# Copyright 2020 Hubert Chathi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. defmodule Polyjuice.Client.LowLevel do @moduledoc """ A lower-level client than `Polyjuice.Client`. Compared to `Polyjuice.Client`, this module: - does not manage the access token, user ID, or device ID; whenever these change (e.g. after logging in), the application must create a new client to use the new values - does not provide a process for syncing with the server - does not ensure that room requests (such as sending messages) are run in a queue - does not start any processes This module would be suitable, for example, for a bot that only sends messages and does not respond to messages. """ require Logger @type t :: %__MODULE__{ base_url: URI.t(), access_token: String.t(), user_id: String.t(), device_id: String.t(), storage: Polyjuice.Client.Storage.t(), application_service: boolean, test: boolean } @enforce_keys [:base_url] defstruct [ :base_url, :access_token, :user_id, :device_id, :storage, application_service: false, test: false ] defimpl Polyjuice.Client.API do def call( %{ base_url: base_url, access_token: access_token, user_id: user_id, application_service: application_service, test: test }, endpoint ) do %Polyjuice.Client.Endpoint.HttpSpec{ method: method, headers: headers, path: path, query: query, body: body, auth_required: auth_required, stream_response: stream_response } = Polyjuice.Client.Endpoint.Proto.http_spec(endpoint) query = query || [] query = if application_service && user_id, do: [{:user_id, user_id} | query], else: query url = %{ URI.merge(base_url, path) | query: if(query, do: URI.encode_query(query)) } |> to_string() Logger.debug("calling #{method} #{url}") if auth_required and access_token == nil do {:error, :auth_required} else case :hackney.request( # mod_esi doesn't like POST requests to a sub-path, so change POST # to PUT when running tests if(method == :post and test, do: :put, else: method), url, if access_token do [{"Authorization", "Bearer #{access_token}"} | headers] else headers end, body, [] ) do {:ok, status_code, resp_headers, client_ref} -> Logger.debug("status code #{status_code}") Polyjuice.Client.Endpoint.Proto.transform_http_result( endpoint, status_code, resp_headers, if stream_response do Polyjuice.Client.hackney_response_stream(client_ref) else {:ok, body} = :hackney.body(client_ref) body end ) err -> # anything else is an error -- return as-is err end end end def room_queue(_client_api, _room_id, func) do # this client doesn't have a queue. Just run the function. func.() end def transaction_id(_) do "#{Node.self()}_#{:erlang.system_time(:millisecond)}_#{:erlang.unique_integer()}" end def get_user_and_device(%{user_id: user_id, device_id: device_id}) do {user_id, device_id} end def stop(_, _, _) do # don't need to do anything to stop it :ok end end @doc """ Create a client. `opts` may contain: - `access_token`: (required to make calls that require authorization) the access token to use. - `user_id`: (required by some endpoints) the ID of the user - `device_id`: the device ID - `storage`: (required by sync) the storage backend to use (see `Polyjuice.Client.Storage`) - `application_service`: wether to connect as an application service """ @spec create(base_url :: String.t(), opts :: Keyword.t()) :: t() def create(base_url, opts \\ []) when is_binary(base_url) do %__MODULE__{ base_url: if(String.ends_with?(base_url, "/"), do: base_url, else: base_url <> "/") |> URI.parse(), access_token: Keyword.get(opts, :access_token), user_id: Keyword.get(opts, :user_id), device_id: Keyword.get(opts, :device_id), storage: Keyword.get(opts, :storage), application_service: Keyword.get(opts, :application_service, false), test: Keyword.get(opts, :test, false) } end @doc """ Log in with a password. `identifier` may be a single string (in which case it represents a username -- either just the localpart or the full MXID), a tuple of the form `{:email, "email@address"}`, a tuple of the form `{:phone, "country_code", "phone_number"}`, or a map that is passed directly to the login endpoint. `opts` is a keyword list of options: - `device_id:` (string) the device ID to use - `initial_device_display_name:` (string) the display name to use for the device """ @spec log_in_with_password( client :: Polyjuice.Client.LowLevel.t(), identifier :: String.t() | tuple() | map(), password :: String.t(), opts :: list() ) :: {:ok, map()} | any def log_in_with_password(client, identifier, password, opts \\ []) when (is_binary(identifier) or is_tuple(identifier) or is_map(identifier)) and is_binary(password) and is_list(opts) do Polyjuice.Client.API.call( client, %Polyjuice.Client.Endpoint.PostLogin{ type: "m.login.password", identifier: Polyjuice.Client.make_login_identifier(identifier), password: password, device_id: Keyword.get(opts, :device_id), initial_device_display_name: Keyword.get(opts, :initial_device_display_name) } ) end @doc """ Log out an existing session. """ @spec log_out(client :: Polyjuice.Client.LowLevel.t()) :: :ok | any def log_out(client) do Polyjuice.Client.API.call( client, %Polyjuice.Client.Endpoint.PostLogout{} ) end @type register_opts() :: [ kind: :guest | :user, username: String.t() | nil, password: String.t() | nil, device_id: String.t() | nil, initial_device_display_name: String.t() | nil, inhibit_login: boolean() ] @doc """ Register a user. `opts` is a keyword list of options: - `username:` (string) the basis for the localpart of the desired Matrix ID - `password:` (string) the desired password for the account - `device_id:` (string) the device ID to use - `initial_device_display_name:` (string) the display name to use for the device - `inhibit_login:` (boolean) don't login after successful register - `kind:` (atom) kind of account to register. Defaults to user. One of: ["guest", "user"] """ @spec register( client :: Polyjuice.Client.LowLevel.t(), opts :: register_opts() ) :: {:ok, map()} | any def register(client, opts \\ []) do Polyjuice.Client.API.call( client, %Polyjuice.Client.Endpoint.PostRegister{ kind: Keyword.get(opts, :kind, :user), auth: Keyword.get(opts, :auth), type: Keyword.get(opts, :type), username: Keyword.get(opts, :username), password: Keyword.get(opts, :password), device_id: Keyword.get(opts, :device_id), initial_device_display_name: Keyword.get(opts, :initial_device_display_name), inhibit_login: Keyword.get(opts, :inhibit_login, false) } ) end end