diff options
Diffstat (limited to 'lib/polyjuice')
-rw-r--r-- | lib/polyjuice/client.ex | 183 | ||||
-rw-r--r-- | lib/polyjuice/client/application.ex | 29 | ||||
-rw-r--r-- | lib/polyjuice/client/handler.ex | 65 | ||||
-rw-r--r-- | lib/polyjuice/client/sync.ex | 66 |
4 files changed, 225 insertions, 118 deletions
diff --git a/lib/polyjuice/client.ex b/lib/polyjuice/client.ex index 81415d1..2a15d28 100644 --- a/lib/polyjuice/client.ex +++ b/lib/polyjuice/client.ex @@ -16,8 +16,8 @@ defmodule Polyjuice.Client do @moduledoc """ Matrix client functions. - To create a client, use `start/2`, and to destroy it, use - `stop/1`. + To start a client, use `start_link/2`, and to stop it, use + `stop/3`. The struct in this module, or any struct that implements the `Polyjuice.Client.API` protocol, can be used to connect to a Matrix server @@ -28,8 +28,8 @@ defmodule Polyjuice.Client do - `Polyjuice.Client.Media`: use the media repository - `Polyjuice.Client.MsgBuilder`: build message contents - To sync with the homeserver, start a process using the child spec returned by - `Polyjuice.Client.API.sync_child_spec/3`. + The client defined in this module should work for most cases. If you want + more control, you can use `Polyjuice.Client.LowLevel` instead. """ require Logger @@ -37,25 +37,28 @@ defmodule Polyjuice.Client do @typedoc """ Matrix client. - This struct is created by `start/1`. + This struct is created by `start_link/2`. """ # - `base_url`: Required. The base URL for the homeserver. - # - `pid`: PID for the agent that contains the state. + # - `id`: An ID for the client. # - `storage`: Required for some endpoints and for sync. Storage for the client. # - `test`: if the client is used for a unit test (converts POST requests to PUT, # to make mod_esi happy) @opaque t :: %__MODULE__{ base_url: String.t(), - pid: pid, + id: integer, storage: Polyjuice.Client.Storage.t(), + handler: Polyjuice.Client.Handler.t(), test: boolean } - @enforce_keys [:base_url, :pid] + @enforce_keys [:base_url, :id] defstruct [ :base_url, - :pid, + :id, :storage, + :handler, + sync: true, test: false ] @@ -67,57 +70,84 @@ defmodule Polyjuice.Client do 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 + - `storage`: (required for sync) the storage backend to use (see `Polyjuice.Client.Storage`) + - `handler`: (required for sync) an event handler (see `Polyjuice.Client.Handler`) + - `sync`: whether to start a sync process (defaults to true). The sync process + will not start if there is no `storage` or `handler` provided. + - `sync_filter`: the filter to use for the sync. Defaults to no filter. """ - @spec start(base_url :: String.t(), opts :: Keyword.t()) :: t() - def start(base_url, opts \\ []) when is_binary(base_url) do - {:ok, pid} = - Agent.start(fn -> - %{ - access_token: Keyword.get(opts, :access_token), - user_id: Keyword.get(opts, :user_id), - device_id: Keyword.get(opts, :device_id) - } - end) + @spec start_link(base_url :: String.t(), opts :: Keyword.t()) :: t() + def start_link(base_url, opts \\ []) when is_binary(base_url) and is_list(opts) do + base_url = + if(String.ends_with?(base_url, "/"), do: base_url, else: base_url <> "/") + |> URI.parse() + + client_id = Agent.get_and_update(Polyjuice.Client.ID, fn id -> {id, id + 1} end) + + access_token = Keyword.get(opts, :access_token) + user_id = Keyword.get(opts, :user_id) + device_id = Keyword.get(opts, :device_id) + sync = Keyword.get(opts, :sync, true) + storage = Keyword.get(opts, :storage) + handler = Keyword.get(opts, :handler) + + children = [ + %{ + id: Polyjuice.Client, + start: + {Agent, :start_link, + [ + fn -> + %{ + access_token: access_token, + user_id: user_id, + device_id: device_id + } + end, + [name: process_name(client_id, :state)] + ]} + } + | if(sync and access_token != nil and handler != nil and storage != nil, + do: [sync_child_spec(base_url, client_id, opts)], + else: [] + ) + ] + + {:ok, _pid} = + Supervisor.start_link(children, + strategy: :rest_for_one, + name: process_name(client_id, :supervisor) + ) %__MODULE__{ - base_url: - if(String.ends_with?(base_url, "/"), do: base_url, else: base_url <> "/") - |> URI.parse(), - pid: pid, - storage: Keyword.get(opts, :storage), + base_url: base_url, + id: client_id, + storage: storage, + handler: handler, + sync: sync, test: Keyword.get(opts, :test, false) } end @doc """ - Start a client, linking the client process to the calling process. - - See `start/2`. + Stop a client. """ - @spec start_link(base_url :: String.t(), opts :: Keyword.t()) :: t() - def start_link(base_url, opts \\ []) when is_binary(base_url) do - {:ok, pid} = - Agent.start_link(fn -> - %{ - access_token: Keyword.get(opts, :access_token), - user_id: Keyword.get(opts, :user_id) - } - end) + @spec stop(Polyjuice.Client.t(), reason :: term, timeout()) :: :ok + def stop(%__MODULE__{id: id}, reason \\ :normal, timeout \\ :infinity) do + Supervisor.stop(process_name(id, :supervisor), reason, timeout) + end - %__MODULE__{ - base_url: - if(String.ends_with?(base_url, "/"), do: base_url, else: base_url <> "/") - |> URI.parse(), - pid: pid, - storage: Keyword.get(opts, :storage), - test: Keyword.get(opts, :test, false) - } + @doc false + def process_name(id, process) do + {:via, Registry, {Polyjuice.Client, {id, process}}} end - def stop(%__MODULE__{pid: pid}) do - Process.exit(pid, :normal) + defp sync_child_spec(base_url, client_id, opts) do + %{ + id: Polyjuice.Client.Sync, + start: {Task, :start_link, [Polyjuice.Client.Sync, :sync, [base_url, client_id, opts]]} + } end @doc "The r0 client URL prefix" @@ -149,36 +179,10 @@ defmodule Polyjuice.Client do """ @spec transaction_id(client_api :: Polyjuice.Client.API.t()) :: String.t() def transaction_id(client_api) - - @doc """ - Get the child spec for the sync process. - - `listener` will receive messages with the sync results. Messages include: - - - `{:connected}`: the process has connected to the homeserver - - `{:disconnected}`: the process has been disconnected from the homeserver - - `{:initial_sync_completed}`: the first sync has completed - - `{:limited, room_id, prev_batch}`: a room's timeline has been limited. - Previous messages can be fetched using the `prev_batch` - - `{:message, room_id, event}`: a message event has been received - - `{:state, room_id, event}`: a state event has been received - - `{:invite, room_id, inviter, invite_state}`: the user was invited to a room - - `{:left, room_id}`: the user left a room - - `opts` is a keyword list of options: - - - `filter:` (string or map) the filter to use with the sync - """ - @spec sync_child_spec( - client_api :: Polyjuice.Client.API.t(), - listener :: pid(), - opts :: list() - ) :: map() - def sync_child_spec(client_api, listener, opts \\ []) end defimpl Polyjuice.Client.API do - def call(%{base_url: base_url, pid: pid, test: test}, endpoint) do + def call(%{base_url: base_url, id: id, test: test}, endpoint) do %Polyjuice.Client.Endpoint.HttpSpec{ method: method, headers: headers, @@ -192,7 +196,9 @@ defmodule Polyjuice.Client do access_token = if auth_required do - Agent.get(pid, fn %{access_token: access_token} -> access_token end) + Agent.get(Polyjuice.Client.process_name(id, :state), fn %{access_token: access_token} -> + access_token + end) else nil end @@ -238,10 +244,6 @@ defmodule Polyjuice.Client do def transaction_id(_) do "#{Node.self()}_#{:erlang.system_time(:millisecond)}_#{:erlang.unique_integer()}" end - - def sync_child_spec(client, listener, opts \\ []) do - Polyjuice.Client.Sync.child_spec([client, listener | opts]) - end end @doc false @@ -260,9 +262,9 @@ defmodule Polyjuice.Client do @doc """ Synchronize messages from the server. - Normally, you should create a sync process using - `Polyjuice.Client.API.sync_child_spec/3` rather than calling this function, but - this function may be used where more control is needed. + Normally, you should use `Polyjuice.Client`'s built-in sync process rather + than calling this function, but this function may be used where more control + is needed. `opts` is a keyword list of options: @@ -291,6 +293,7 @@ defmodule Polyjuice.Client do ) end + @doc false def make_login_identifier(identifier) do case identifier do x when is_binary(x) -> @@ -353,10 +356,18 @@ defmodule Polyjuice.Client do } ) - Agent.cast(client.pid, fn state -> + Agent.cast(process_name(client.id, :state), fn state -> %{state | access_token: access_token, user_id: user_id, device_id: device_id} end) + if client.handler do + Polyjuice.Client.Handler.handle( + client.handler, + :logged_in, + {user_id, device_id, Map.drop(ret, ["user_id", "device_id"])} + ) + end + ret end @@ -371,7 +382,11 @@ defmodule Polyjuice.Client do %Polyjuice.Client.Endpoint.PostLogout{} ) - Agent.cast(client.pid, fn state -> %{state | access_token: nil} end) + Agent.cast(process_name(client.id, :state), fn state -> %{state | access_token: nil} end) + + if client.handler do + Polyjuice.Client.Handler.handle(client.handler, :logged_out) + end {:ok} end diff --git a/lib/polyjuice/client/application.ex b/lib/polyjuice/client/application.ex new file mode 100644 index 0000000..b6c87cc --- /dev/null +++ b/lib/polyjuice/client/application.ex @@ -0,0 +1,29 @@ +# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca> +# +# 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.Application do + use Application + + def start(_type, _args) do + children = [ + {Registry, keys: :unique, name: Polyjuice.Client}, + %{ + id: Polyjuice.Client.ID, + start: {Agent, :start_link, [fn -> 0 end, [name: Polyjuice.Client.ID]]} + } + ] + + Supervisor.start_link(children, strategy: :one_for_one) + end +end diff --git a/lib/polyjuice/client/handler.ex b/lib/polyjuice/client/handler.ex new file mode 100644 index 0000000..e58952c --- /dev/null +++ b/lib/polyjuice/client/handler.ex @@ -0,0 +1,65 @@ +# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca> +# +# 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. + +defprotocol Polyjuice.Client.Handler do + @moduledoc """ + Protocol for client event handlers. + """ + + @doc """ + Handle an event from the client. + + Events (in the form `type, args`) may include: + + - `:logged_in`, `{user_id, device_id, login_info}`: the user was logged in. + - `:logged_out`, `{soft_logout}`: the user was logged out. + - `:sync_connected`, `nil`: the sync process has connected to the homeserver + - `:sync_disconnected`, `nil`: the sync process has been disconnected from + the homeserver + - `:initial_sync_completed`, `nil`: the first sync after login has completed + - `:limited`, `{room_id, prev_batch}` a room's timeline has been limited. + Previous messages can be fetched using the `prev_batch` + - `:message`, `{room_id, event}`: a message event has been received + - `:state`, `{room_id, event}`: a state event has been received + - `:invite`, `{room_id, inviter, invite_state}`: the user was invited to a room + - `:left`, `{room_id}`: the user left a room + + If a PID is used as a handler, the process will be sent a message of the form + `{:polyjuice_client, type}`, if `args` is `nil`, or `{:polyjuice_client, type, + args}` otherwise. + + If a function is used as a handler, it must have arity 2, and will be given + `type` and `args` as its arguments. + + """ + @spec handle(handler :: Polyjuice.Client.Handler.t(), event_type :: atom, args :: tuple | nil) :: + any + def handle(handler, event_type, args \\ nil) +end + +defimpl Polyjuice.Client.Handler, for: PID do + def handle(pid, event_type, nil) do + send(pid, {:polyjuice_client, event_type}) + end + + def handle(pid, event_type, args) do + send(pid, {:polyjuice_client, event_type, args}) + end +end + +defimpl Polyjuice.Client.Handler, for: Function do + def handle(func, event_type, args) do + apply(func, [event_type, args]) + end +end diff --git a/lib/polyjuice/client/sync.ex b/lib/polyjuice/client/sync.ex index df1e13b..1f6e95b 100644 --- a/lib/polyjuice/client/sync.ex +++ b/lib/polyjuice/client/sync.ex @@ -21,17 +21,11 @@ defmodule Polyjuice.Client.Sync do @doc """ Start a sync task. """ - @spec start_link([...]) :: {:ok, pid} - def start_link([client, listener | opts]) - when (is_pid(listener) or is_function(listener)) and is_list(opts) do - Task.start_link(__MODULE__, :sync, [client, listener, opts]) - end - - @enforce_keys [:send, :pid, :homeserver_url, :uri, :storage] + @enforce_keys [:client_state, :homeserver_url, :uri, :storage] defstruct [ - :send, + :handler, :conn_ref, - :pid, + :client_state, :homeserver_url, :uri, :storage, @@ -49,22 +43,19 @@ defmodule Polyjuice.Client.Sync do @doc false def sync( - %Polyjuice.Client{ - pid: pid, - base_url: homeserver_url, - storage: storage, - test: test - }, - listener, + homeserver_url, + id, opts ) do - homeserver_url = homeserver_url + storage = Keyword.fetch!(opts, :storage) + handler = Keyword.fetch!(opts, :handler) + test = Keyword.get(opts, :test, false) # Figure out how to handle the filter (if any): can we pass it in straight # to the query, or do we need to get its ID. And if we get its ID, do we # already have it, or do we need to send it to the server? {filter, set_filter} = - case Keyword.get(opts, :filter) do + case Keyword.get(opts, :sync_filter) do nil -> {nil, nil} @@ -97,8 +88,8 @@ defmodule Polyjuice.Client.Sync do uri = URI.merge(homeserver_url, @sync_path) connect(%__MODULE__{ - send: if(is_function(listener), do: listener, else: &send(listener, &1)), - pid: pid, + handler: handler, + client_state: Polyjuice.Client.process_name(id, :state), homeserver_url: homeserver_url, uri: uri, query_params: query_params, @@ -122,7 +113,7 @@ defmodule Polyjuice.Client.Sync do |> :hackney.connect(options) do {:ok, conn_ref} -> Logger.info("Connected to sync") - state.send.({:connected}) + Polyjuice.Client.Handler.handle(state.handler, :sync_connected) if state.set_filter do set_filter(%{state | conn_ref: conn_ref, backoff: nil}) @@ -146,9 +137,12 @@ defmodule Polyjuice.Client.Sync do e = &URI.encode_www_form/1 {access_token, user_id} = - Agent.get(state.pid, fn %{access_token: access_token, user_id: user_id} -> - {access_token, user_id} - end) + Agent.get( + state.client_state, + fn %{access_token: access_token, user_id: user_id} -> + {access_token, user_id} + end + ) path = URI.merge( @@ -209,7 +203,7 @@ defmodule Polyjuice.Client.Sync do backoff = calc_backoff(state.backoff) Logger.error("Set filter error: closed; retrying in #{backoff} seconds.") connect(%{state | backoff: backoff, conn_ref: nil}) - state.send.({:disconnected}) + Polyjuice.Client.Handler.handle(state.handler, :sync_disconnected) # FIXME: what other error codes do we need to handle? {:error, err} -> @@ -223,7 +217,11 @@ defmodule Polyjuice.Client.Sync do defp do_sync(state) do if state.backoff, do: :timer.sleep(state.backoff * 1000) - access_token = Agent.get(state.pid, fn %{access_token: access_token} -> access_token end) + access_token = + Agent.get( + state.client_state, + fn %{access_token: access_token} -> access_token end + ) headers = [ {"Accept", "application/json"}, @@ -249,7 +247,7 @@ defmodule Polyjuice.Client.Sync do Polyjuice.Client.Storage.set_sync_token(state.storage, next_batch) if not state.initial_done do - state.send.({:initial_sync_completed}) + Polyjuice.Client.Handler.handle(state.handler, :initial_sync_completed) end do_sync(%{state | since: next_batch, backoff: nil, initial_done: true}) @@ -276,7 +274,7 @@ defmodule Polyjuice.Client.Sync do backoff = calc_backoff(state.backoff) Logger.error("Sync error: closed; retrying in #{backoff} seconds.") connect(%{state | backoff: backoff, conn_ref: nil}) - state.send.({:disconnected}) + Polyjuice.Client.Handler.handle(state.handler, :sync_disconnected) # FIXME: what other error codes do we need to handle? {:error, err} -> @@ -302,7 +300,7 @@ defmodule Polyjuice.Client.Sync do |> Map.get("leave", []) |> Enum.each(fn {k, v} -> process_room(k, v, state) - state.send.({:left, k}) + Polyjuice.Client.Handler.handle(state.handler, :left, {k}) end) end @@ -311,7 +309,7 @@ defmodule Polyjuice.Client.Sync do if Map.get(timeline, "limited", false) do with {:ok, prev_batch} <- Map.fetch(timeline, "prev_batch") do - state.send.({:limited, roomname, prev_batch}) + Polyjuice.Client.Handler.handle(state.handler, :limited, {roomname, prev_batch}) end end @@ -332,7 +330,7 @@ defmodule Polyjuice.Client.Sync do roomname, state ) do - state.send.({:state, roomname, event}) + Polyjuice.Client.Handler.handle(state.handler, :state, {roomname, event}) state end @@ -342,7 +340,7 @@ defmodule Polyjuice.Client.Sync do roomname, state ) do - state.send.({:message, roomname, event}) + Polyjuice.Client.Handler.handle(state.handler, :message, {roomname, event}) state end @@ -373,7 +371,7 @@ defmodule Polyjuice.Client.Sync do end ) - user_id = Agent.get(state.pid, fn %{user_id: user_id} -> user_id end) + user_id = Agent.get(state.client_state, fn %{user_id: user_id} -> user_id end) inviter = invite_state @@ -382,7 +380,7 @@ defmodule Polyjuice.Client.Sync do |> Map.get("sender") if inviter do - state.send.({:invite, roomname, inviter, invite_state}) + Polyjuice.Client.Handler.handle(state.handler, :invite, {roomname, inviter, invite_state}) end state |