diff options
-rw-r--r-- | .formatter.exs | 4 | ||||
-rw-r--r-- | .gitignore | 24 | ||||
-rw-r--r-- | README.md | 16 | ||||
-rw-r--r-- | lib/polyjuice/client.ex | 204 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint.ex | 62 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_login.ex | 91 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_rooms_receipt.ex | 75 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/put_rooms_send.ex | 74 | ||||
-rw-r--r-- | lib/polyjuice/client/storage.ex | 63 | ||||
-rw-r--r-- | lib/polyjuice/client/storage/dets.ex | 65 | ||||
-rw-r--r-- | lib/polyjuice/client/storage/ets.ex | 60 | ||||
-rw-r--r-- | lib/polyjuice/client/sync.ex | 204 | ||||
-rw-r--r-- | mix.exs | 40 | ||||
-rw-r--r-- | mix.lock | 16 | ||||
-rw-r--r-- | test/polyjuice/client_test.exs | 18 | ||||
-rw-r--r-- | test/test_helper.exs | 1 |
16 files changed, 1017 insertions, 0 deletions
diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6c83f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +polyjuice_client-*.tar + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c75e058 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Polyjuice Client + +Polyjuice Client is a [Matrix](https://matrix.org/) client library. + +## Installation + +The package can be installed by adding `polyjuice_client` to your list of +dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:polyjuice_client, "~> 0.1.0"} + ] +end +``` diff --git a/lib/polyjuice/client.ex b/lib/polyjuice/client.ex new file mode 100644 index 0000000..7778020 --- /dev/null +++ b/lib/polyjuice/client.ex @@ -0,0 +1,204 @@ +# Copyright 2019 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 do + @moduledoc """ + Matrix client functions. + """ + require Logger + + @type t :: %__MODULE__{ + base_url: String.t(), + access_token: String.t() + } + + @enforce_keys [:base_url] + defstruct [ + :base_url, + :access_token + ] + + def prefix_r0, do: "/_matrix/client/r0" + def prefix_unstable, do: "/_matrix/client/unstable" + + defprotocol API do + @moduledoc """ + Protocol for calling the Matrix client API. + """ + + @doc """ + Call a Matrix client API. This is a lower-level function; generally, clients + will want to call one of the higher-level functions from `Polyjuice.Client`. + """ + @spec call( + client_api :: Polyjuice.Client.API.t(), + endpoint :: Polyjuice.Client.Endpoint.Proto.t() + ) :: Any + def call(client_api, endpoint) + + @doc """ + Get the child spec for the sync process. + """ + @spec sync_child_spec( + client_api :: Polyjuice.Client.API.t(), + listener :: pid() | fun(), + storage :: Polyjuice.Client.Storage.Proto.t(), + opts :: list() + ) :: map() + def sync_child_spec(client_api, listener, storage, opts \\ []) + end + + defimpl Polyjuice.Client.API do + def call(%{base_url: base_url, access_token: access_token}, endpoint) do + %Polyjuice.Client.Endpoint.HttpSpec{ + method: method, + headers: headers, + url: url, + body: body, + transform: transform, + auth_required: auth_required + } = Polyjuice.Client.Endpoint.Proto.http_spec(endpoint, base_url) + + Logger.debug("calling #{url}") + + if auth_required and access_token == nil do + {:error, :auth_required} + else + {:ok, status_code, resp_headers, body} = + :hackney.request( + method, + url, + if access_token do + [{"Authorization", "Bearer #{access_token}"} | headers] + else + headers + end, + body, + [:with_body] + ) + + Logger.debug("status code #{status_code}") + transform.(status_code, resp_headers, body) + end + end + + def sync_child_spec(client, listener, storage, opts \\ []) do + Polyjuice.Client.Sync.child_spec([client, listener, storage | opts]) + end + end + + @doc """ + Send a message to a room. + + `msg` can either be a string (which will be sent as a text message), a + 2-tuple (the first element will be the text version and the second element + will be the HTML version), or a map (which specifies the full message + content). + """ + @spec send_message( + client_api :: Polyjuice.Client.API.t(), + msg :: String.t() | {String.t(), String.t()} | map, + room :: String.t() + ) :: String.t() + def send_message(client_api, msg, room) + when is_binary(msg) and is_binary(room) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PutRoomsSend{ + room: room, + event_type: "m.room.message", + message: %{"msgtype" => "m.text", "body" => msg} + } + ) + end + + def send_message(client_api, {text, html}, room) + when is_binary(room) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PutRoomsSend{ + room: room, + event_type: "m.room.message", + message: %{ + "msgtype" => "m.text", + "body" => text, + "format" => "org.matrix.custom.html", + "formatted_body" => html + } + } + ) + end + + def send_message(client_api, msg, room) + when is_map(msg) and is_binary(room) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PutRoomsSend{ + room: room, + event_type: "m.room.message", + message: msg + } + ) + end + + @doc """ + Send an event to a room. + """ + @spec send_event( + client_api :: Polyjuice.Client.API.t(), + event_type :: String.t(), + event :: map, + room :: String.t() + ) :: String.t() + def send_event(client_api, event_type, event, room) + when is_binary(event_type) and is_map(event) and is_binary(room) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PutRoomsSend{ + room: room, + event_type: event_type, + message: event + } + ) + end + + @doc """ + Update the client's read receipt (of the given type) to the given message in the + given room. + """ + @spec update_read_receipt( + client_api :: Polyjuice.Client.API.t(), + room :: String.t(), + event_id :: String.t(), + receipt_type :: String.t() + ) :: Any + def update_read_receipt(client_api, room, event_id, receipt_type \\ "m.read") + when is_binary(room) and is_binary(event_id) and is_binary(receipt_type) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PostRoomsReceipt{ + room: room, + event_id: event_id, + receipt_type: receipt_type + } + ) + end + + @doc """ + Generate a unique transaction ID. + """ + def transaction_id do + "#{Node.self()}_#{:erlang.system_time(:millisecond)}_#{:erlang.unique_integer()}" + end +end diff --git a/lib/polyjuice/client/endpoint.ex b/lib/polyjuice/client/endpoint.ex new file mode 100644 index 0000000..82f9e11 --- /dev/null +++ b/lib/polyjuice/client/endpoint.ex @@ -0,0 +1,62 @@ +# Copyright 2019 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.Endpoint do + @moduledoc false + + defmodule HttpSpec do + @typedoc """ + The description of how to handle the endpoint. + + - `method` is the HTTP verb + - `headers` is a list of the HTTP headers + - `url` is the URL to call + - `body` is the HTTP body (if any) + - `transform` is a function to transform the result (status code, headers, content) to a return value + - `auth_required` indicates whether the end point requires authentication + """ + @type t :: %__MODULE__{ + method: atom, + headers: [{String.t(), String.t()}], + url: String.t(), + body: String.t(), + transform: (integer, [{String.t(), String.t()}, ...], String.t() -> any), + auth_required: true | false + } + @enforce_keys [:method, :headers, :url, :transform] + defstruct [ + :method, + :headers, + :url, + :body, + :transform, + auth_required: true + ] + end + + defprotocol Proto do + @moduledoc """ + Matrix client endpoint. + """ + + @doc """ + Generate the spec for calling the endpoint via HTTP. + """ + @spec http_spec( + endpoint_args :: __MODULE__.t(), + base_url :: String.t() + ) :: Polyjuice.Client.Endpoint.HttpSpec.t() + def http_spec(endpoint_args, base_url) + end +end diff --git a/lib/polyjuice/client/endpoint/post_login.ex b/lib/polyjuice/client/endpoint/post_login.ex new file mode 100644 index 0000000..8f5c7d5 --- /dev/null +++ b/lib/polyjuice/client/endpoint/post_login.ex @@ -0,0 +1,91 @@ +# Copyright 2019 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.Endpoint.PostLogin do + @moduledoc """ + Log in a user + + https://matrix.org/docs/spec/client_server/r0.5.0#post-matrix-client-r0-login + """ + + @type t :: %__MODULE__{ + type: String.t(), + identifier: map | Poison.Encoder.t(), + password: String.t(), + token: String.t(), + device_id: String.t(), + initial_device_display_name: String.t() + } + + @enforce_keys [:type, :identifier] + + defstruct [ + :type, + :identifier, + :password, + :token, + :device_id, + :initial_device_display_name + ] + + defimpl Polyjuice.Client.Endpoint.Proto do + def http_spec( + %Polyjuice.Client.Endpoint.PostLogin{ + type: type, + identifier: identifier, + password: password, + token: token, + device_id: device_id, + initial_device_display_name: initial_device_display_name + }, + base_url + ) + when is_binary(base_url) do + body = + Poison.encode(%{ + "type" => type, + "identifier" => identifier, + "password" => password, + "token" => token, + "device_id" => device_id, + "initial_device_display_name" => initial_device_display_name + }) + + %Polyjuice.Client.Endpoint.HttpSpec{ + method: :post, + headers: [], + url: + URI.merge( + base_url, + "#{Polyjuice.Client.prefix_r0()}/login" + ) + |> to_string(), + body: body, + transform: &Polyjuice.Client.Endpoint.PutRoomsSend.transform/3 + } + end + end + + @doc false + def transform(status_code, _resp_headers, body) do + case status_code do + 200 -> + {:ok, Poison.decode!(body)} + {:ok} + + _ -> + {:error, status_code, body} + end + end +end diff --git a/lib/polyjuice/client/endpoint/post_rooms_receipt.ex b/lib/polyjuice/client/endpoint/post_rooms_receipt.ex new file mode 100644 index 0000000..d6c85c3 --- /dev/null +++ b/lib/polyjuice/client/endpoint/post_rooms_receipt.ex @@ -0,0 +1,75 @@ +# Copyright 2019 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.Endpoint.PostRoomsReceipt do + @moduledoc """ + Update the user's read receipt. + + https://matrix.org/docs/spec/client_server/r0.5.0#post-matrix-client-r0-rooms-roomid-receipt-receipttype-eventid + """ + + @type t :: %__MODULE__{ + room: String.t(), + event_id: String.t(), + receipt_type: String.t() + } + + @enforce_keys [:room, :event_id] + + defstruct [ + :room, + :event_id, + receipt_type: "m.read" + ] + + defimpl Polyjuice.Client.Endpoint.Proto do + def http_spec( + %Polyjuice.Client.Endpoint.PostRoomsReceipt{ + room: room, + event_id: event_id, + receipt_type: receipt_type + }, + base_url + ) + when is_binary(base_url) do + e = &URI.encode_www_form/1 + + %Polyjuice.Client.Endpoint.HttpSpec{ + method: :post, + headers: [], + url: + URI.merge( + base_url, + "#{Polyjuice.Client.prefix_r0()}/rooms/#{e.(room)}/receipt/#{e.(receipt_type)}/#{ + e.(event_id) + }" + ) + |> to_string(), + body: "{}", + transform: &Polyjuice.Client.Endpoint.PostRoomsReceipt.transform/3 + } + end + end + + @doc false + def transform(status_code, _resp_headers, body) do + case status_code do + 200 -> + {:ok} + + _ -> + {:error, status_code, body} + end + end +end diff --git a/lib/polyjuice/client/endpoint/put_rooms_send.ex b/lib/polyjuice/client/endpoint/put_rooms_send.ex new file mode 100644 index 0000000..07a45a0 --- /dev/null +++ b/lib/polyjuice/client/endpoint/put_rooms_send.ex @@ -0,0 +1,74 @@ +# Copyright 2019 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.Endpoint.PutRoomsSend do + @moduledoc """ + Send a message event to a room. + + https://matrix.org/docs/spec/client_server/r0.5.0#put-matrix-client-r0-rooms-roomid-send-eventtype-txnid + """ + + @type t :: %__MODULE__{ + room: String.t(), + event_type: String.t(), + message: map + } + + @enforce_keys [:room] + defstruct [ + :room, + event_type: "m.room.message", + message: "Hello world" + ] + + defimpl Polyjuice.Client.Endpoint.Proto do + def http_spec( + %Polyjuice.Client.Endpoint.PutRoomsSend{ + room: room, + event_type: event_type, + message: message + }, + base_url + ) do + e = &URI.encode_www_form/1 + txn_id = Polyjuice.Client.transaction_id() + body = Poison.encode!(message) + + %Polyjuice.Client.Endpoint.HttpSpec{ + method: :put, + headers: [], + url: + URI.merge( + base_url, + "#{Polyjuice.Client.prefix_r0()}/rooms/#{e.(room)}/send/#{e.(event_type)}/#{ + e.(txn_id) + }" + ) + |> to_string(), + body: body, + transform: &Polyjuice.Client.Endpoint.PutRoomsSend.transform/3 + } + end + end + + def transform(status_code, _resp_headers, body) do + case status_code do + 200 -> + {:ok, body |> Poison.decode!() |> Map.get("event_id")} + + _ -> + {:error, status_code, body} + end + end +end diff --git a/lib/polyjuice/client/storage.ex b/lib/polyjuice/client/storage.ex new file mode 100644 index 0000000..6ca4ffa --- /dev/null +++ b/lib/polyjuice/client/storage.ex @@ -0,0 +1,63 @@ +# Copyright 2019 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.Storage do + @moduledoc """ + Persistent storage for the Matrix client + """ + + @typedoc """ + Safe values to use for key-value storage. All storage must support at least + these types. + """ + @type value() :: + true + | false + | nil + | String.t() + | float() + | [value(), ...] + | %{optional(String.t()) => value()} + + @doc """ + Close the storage. + """ + @spec get_sync_token(storage :: __MODULE__.t()) :: any + def close(storage) + + @doc """ + Get the latest sync token, or `nil` if no sync token has been set. + """ + @spec get_sync_token(storage :: __MODULE__.t()) :: String.t() | nil + def get_sync_token(storage) + + @doc """ + Set the sync token. + """ + @spec set_sync_token(storage :: __MODULE__.t(), token :: String.t()) :: any + def set_sync_token(storage, token) + + @doc """ + Store data for a specific key. + """ + @spec kv_put(storage :: __MODULE__.t(), key :: String, value :: __MODULE__.value()) :: any + def kv_put(storage, key, value) + + @doc """ + Get the data for a specific key. + """ + @spec kv_get(storage :: __MODULE__.t(), key :: String, default :: __MODULE__.value()) :: + __MODULE__.value() + def kv_get(storage, key, default \\ nil) +end diff --git a/lib/polyjuice/client/storage/dets.ex b/lib/polyjuice/client/storage/dets.ex new file mode 100644 index 0000000..edea241 --- /dev/null +++ b/lib/polyjuice/client/storage/dets.ex @@ -0,0 +1,65 @@ +# Copyright 2019 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.Storage.Dets do + @moduledoc """ + Storage using Erlang [dets](http://erlang.org/doc/man/dets.html). + """ + + defstruct [ + :table + ] + + @doc """ + Create a new dets storage. + + `file` is the filename for the dets file. + """ + def open(file) when is_binary(file) do + table = make_ref() + :dets.open_file(table, file: to_charlist(file), auto_save: 10000) + + %__MODULE__{ + table: table + } + end + + defimpl Polyjuice.Client.Storage, for: __MODULE__ do + def close(%{table: table}) do + :dets.close(table) + end + + def get_sync_token(%{table: table}) do + case :dets.lookup(table, :sync_token) do + [sync_token: token] -> token + _ -> nil + end + end + + def set_sync_token(%{table: table}, token) do + :dets.insert(table, {:sync_token, token}) + end + + def kv_put(%{table: table}, key, value) when is_binary(key) do + :dets.insert(table, {"kv_" <> key, value}) + end + + def kv_get(%{table: table}, key, default \\ nil) when is_binary(key) do + case :dets.lookup(table, "kv_" <> key) do + [{_, value}] -> value + _ -> default + end + end + end +end diff --git a/lib/polyjuice/client/storage/ets.ex b/lib/polyjuice/client/storage/ets.ex new file mode 100644 index 0000000..dfeae72 --- /dev/null +++ b/lib/polyjuice/client/storage/ets.ex @@ -0,0 +1,60 @@ +# Copyright 2019 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.Storage.Ets do + @moduledoc """ + Storage using Erlang [ets](http://erlang.org/doc/man/ets.html). This should + only be used for testing. + """ + + defstruct [ + :table + ] + + def open() do + table = :ets.new(:polyjuice_client, []) + + %__MODULE__{ + table: table + } + end + + defimpl Polyjuice.Client.Storage, for: __MODULE__ do + def close(%{table: table}) do + :ets.delete_all_objects(table) + end + + def get_sync_token(%{table: table}) do + case :ets.lookup(table, :sync_token) do + [sync_token: token] -> token + _ -> nil + end + end + + def set_sync_token(%{table: table}, token) do + :ets.insert(table, {:sync_token, token}) + end + + def kv_put(%{table: table}, key, value) when is_binary(key) do + :dets.insert(table, {"kv_" <> key, value}) + end + + def kv_get(%{table: table}, key, default \\ nil) when is_binary(key) do + case :dets.lookup(table, "kv_" <> key) do + [{_, value}] -> value + _ -> default + end + end + end +end diff --git a/lib/polyjuice/client/sync.ex b/lib/polyjuice/client/sync.ex new file mode 100644 index 0000000..b12913c --- /dev/null +++ b/lib/polyjuice/client/sync.ex @@ -0,0 +1,204 @@ +# Copyright 2019 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.Sync do + # Matrix sync worker. Calls /sync and sends messages to the listener. + @moduledoc false + use Task, restart: :permanent + require Logger + + @doc """ + Start a sync task. + """ + @spec start_link([...]) :: {:ok, pid} + def start_link([ + %Polyjuice.Client{access_token: access_token, base_url: homeserver_url}, + listener, + storage | opts + ]) + when is_binary(access_token) and is_binary(homeserver_url) and is_pid(listener) and + is_list(opts) do + Task.start_link(__MODULE__, :sync, [access_token, homeserver_url, listener, storage, opts]) + end + + @enforce_keys [:listener, :access_token, :uri, :storage] + defstruct [ + :listener, + :conn_ref, + :access_token, + :uri, + :storage, + :since, + query_params: "", + backoff: nil + ] + + @sync_path "_matrix/client/r0/sync" + @sync_timeout 30000 + @buffer_timeout 10000 + + @doc false + def sync(access_token, homeserver_url, listener, storage, opts) do + query_params = + URI.encode_query( + Enum.reduce( + opts, + [{"timeout", @sync_timeout}], + fn + {:filter, filter}, acc -> acc ++ [{"filter", filter}] + {:full_state, full_state}, acc -> acc ++ [{"full_state", full_state}] + {:set_presence, set_presence}, acc -> acc ++ [{"set_presence", set_presence}] + _, acc -> acc + end + ) + ) + + uri = URI.merge(homeserver_url, @sync_path) + + connect(%__MODULE__{ + listener: listener, + access_token: access_token, + uri: uri, + query_params: query_params, + storage: storage, + since: Polyjuice.Client.Storage.get_sync_token(storage) + }) + end + + defp calc_backoff(backoff), do: if(backoff, do: min(backoff * 2, 30), else: 1) + + defp connect(state) do + if state.backoff, do: :timer.sleep(state.backoff * 1000) + + uri = state.uri + options = [recv_timeout: @sync_timeout + @buffer_timeout] + + case :hackney.connect( + URI.to_string(%URI{scheme: uri.scheme, host: uri.host, port: uri.port}), + options + ) do + {:ok, conn_ref} -> + Logger.info("Connected to sync") + do_sync(%{state | conn_ref: conn_ref, backoff: nil}) + + # FIXME: what errors do we need to handle differently? + {:error, err} -> + backoff = calc_backoff(state.backoff) + Logger.error("Sync error: #{err}; retrying in #{backoff} seconds.") + connect(%{state | backoff: backoff}) + end + end + + defp do_sync(state) do + if state.backoff, do: :timer.sleep(state.backoff * 1000) + + headers = [ + {"Authorization", "Bearer #{state.access_token}"} + ] + + path = + state.uri.path <> + "?" <> + state.query_params <> + if state.since, do: "&since=" <> URI.encode_www_form(state.since), else: "" + + case :hackney.send_request(state.conn_ref, {:get, path, headers, ""}) do + {:ok, status_code, _resp_headers, client_ref} -> + case status_code do + 200 -> + if state.backoff, do: Logger.info("Sync resumed") + {:ok, body} = :hackney.body(client_ref) + json_body = Poison.decode!(body) + process_body(json_body, state) + %{"next_batch" => next_batch} = json_body + Polyjuice.Client.Storage.set_sync_token(state.storage, next_batch) + do_sync(%{state | since: next_batch, backoff: nil}) + + # FIXME: other status codes/error messages + _ -> + backoff = calc_backoff(state.backoff) + Logger.error("Unexpected status code #{status_code}; retrying in #{backoff} seconds") + do_sync(%{state | backoff: backoff}) + end + + # if the request timed out, try again + {:error, :timeout} -> + Logger.info("sync timed out") + do_sync(%{state | backoff: nil}) + + {:error, :closed} -> + backoff = calc_backoff(state.backoff) + Logger.error("Sync error: closed; retrying in #{backoff} seconds.") + connect(%{state | backoff: backoff, conn_ref: nil}) + + # FIXME: what other error codes do we need to handle? + {:error, err} -> + # for other errors, we retry with exponential backoff + backoff = calc_backoff(state.backoff) + Logger.error("Sync error: #{err}; retrying in #{backoff} seconds.") + do_sync(%{state | backoff: backoff}) + end + end + + defp process_body(body, state) do + body + |> Map.get("rooms", %{}) + |> Map.get("join", []) + |> Enum.each(fn {k, v} -> process_room(k, v, state) end) + end + + defp process_room(roomname, room, state) do + room + |> Map.get("state", %{}) + |> Map.get("events", []) + |> Enum.each(&process_event(&1, roomname, state)) + + room + |> Map.get("timeline", %{}) + |> Map.get("events", []) + |> Enum.each(&process_event(&1, roomname, state)) + end + + defp process_event( + %{ + "state_key" => _state_key + } = event, + roomname, + state + ) do + send( + state.listener, + {:state, roomname, event} + ) + + state + end + + defp process_event( + %{} = event, + roomname, + state + ) do + send( + state.listener, + {:message, roomname, event} + ) + + state + end + + defp process_event(_, _, state) do + state + end +end @@ -0,0 +1,40 @@ +defmodule PolyjuiceClient.MixProject do + use Mix.Project + + def project do + [ + app: :polyjuice_client, + version: "0.1.0", + elixir: "~> 1.7", + start_permanent: Mix.env() == :prod, + deps: deps(), + + # Docs + name: "Polyjuice Client", + source_url: "https://gitlab.com/uhoreg/polyjuice_client", + # homepage_url: "https://www.uhoreg.ca/programming/matrix/polyjuice", + docs: [ + # The main page in the docs + main: "readme", + # logo: "path/to/logo.png", + extras: ["README.md"] + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:hackney, "~> 1.12"}, + {:poison, "~> 4.0"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..b794a82 --- /dev/null +++ b/mix.lock @@ -0,0 +1,16 @@ +%{ + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/test/polyjuice/client_test.exs b/test/polyjuice/client_test.exs new file mode 100644 index 0000000..ef80593 --- /dev/null +++ b/test/polyjuice/client_test.exs @@ -0,0 +1,18 @@ +# Copyright 2019 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.ClientTest do + use ExUnit.Case + doctest Polyjuice.Client +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() |