summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.formatter.exs4
-rw-r--r--.gitignore24
-rw-r--r--README.md16
-rw-r--r--lib/polyjuice/client.ex204
-rw-r--r--lib/polyjuice/client/endpoint.ex62
-rw-r--r--lib/polyjuice/client/endpoint/post_login.ex91
-rw-r--r--lib/polyjuice/client/endpoint/post_rooms_receipt.ex75
-rw-r--r--lib/polyjuice/client/endpoint/put_rooms_send.ex74
-rw-r--r--lib/polyjuice/client/storage.ex63
-rw-r--r--lib/polyjuice/client/storage/dets.ex65
-rw-r--r--lib/polyjuice/client/storage/ets.ex60
-rw-r--r--lib/polyjuice/client/sync.ex204
-rw-r--r--mix.exs40
-rw-r--r--mix.lock16
-rw-r--r--test/polyjuice/client_test.exs18
-rw-r--r--test/test_helper.exs1
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
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..796acdb
--- /dev/null
+++ b/mix.exs
@@ -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()