summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHubert Chathi <hubert@uhoreg.ca>2019-10-15 19:06:10 +0200
committerHubert Chathi <hubert@uhoreg.ca>2019-10-15 19:06:10 +0200
commita87975ca316c452509f1aa7bf80ecc54e2dba4fe (patch)
treea691757a296254344ab0fcbc4006b4546b99fe04
parentmake update and put in Polyjuice.Client.Filter public (diff)
add another tutorial
-rw-r--r--mix.exs3
-rw-r--r--tutorial_echo.md15
-rw-r--r--tutorial_welcome.md230
3 files changed, 242 insertions, 6 deletions
diff --git a/mix.exs b/mix.exs
index 2c18851..8bee62b 100644
--- a/mix.exs
+++ b/mix.exs
@@ -21,7 +21,8 @@ defmodule PolyjuiceClient.MixProject do
# logo: "path/to/logo.png",
extras: [
"README.md",
- "tutorial_echo.md"
+ "tutorial_echo.md",
+ "tutorial_welcome.md"
]
],
package: [
diff --git a/tutorial_echo.md b/tutorial_echo.md
index 9d01326..69cbfca 100644
--- a/tutorial_echo.md
+++ b/tutorial_echo.md
@@ -1,9 +1,12 @@
-Tutorial: Echo bot
-==================
+Tutorial #1: Echo bot
+=====================
In this tutorial, we will construct a simple echo bot: for every message that
-the echo bot sees, it will repeat that message. The commands in this tutorial
-can be entered interactively into `iex -S mix` to produce a working bot.
+the echo bot sees, it will repeat that message. This tutorial provides a brief
+introduction to using the library.
+
+The commands in this tutorial can be entered interactively into `iex -S mix` to
+produce a working bot.
## Creating a client
@@ -58,7 +61,9 @@ When we receive such a message, we can use the
`Polyjuice.Client.Room.send_message/3` function to respond to the message. We
will use pattern matching to make sure to only respond to `m.text` messages,
and respond with a `m.notice` message, so that we don't create a message loop.
-A `receive` statement to do this would look something like this:
+We can simply take the message contents from the incoming message, change the
+`msgtype`, and pass that to `Polyjuice.Client.Room.send_message/3` to send the
+echo. A `receive` statement to do this would look something like this:
receive do
{:message, room_id, %{"content" => %{"msgtype" => "m.text"} = content}} ->
diff --git a/tutorial_welcome.md b/tutorial_welcome.md
new file mode 100644
index 0000000..d77760f
--- /dev/null
+++ b/tutorial_welcome.md
@@ -0,0 +1,230 @@
+Tutorial #2: Welcome bot
+========================
+
+In this tutorial, we will create a bot that sends a welcome message to users
+when they join a room. This tutorial will illustrate setting filters on the
+sync, constructing messages, and responding to state events.
+
+As before, the commands in this tutorial can be entered interactively into `iex
+-S mix` to produce a working bot. And as before, we start by creating a
+`Polyjuice.Client` struct:
+
+ iex> client = %Polyjuice.Client{
+ ...> base_url: "http://localhost:8008",
+ ...> access_token: "access_token",
+ ...> user_id: "@echobot:localhost",
+ ...> storage: Polyjuice.Client.Storage.Ets.open()
+ ...> }
+
+Before continuing, you should make sure that the bot is already in a room.
+
+## Filtering
+
+We will also start a sync process as we did before. However, this time we will
+apply a filter to the sync so that we will only see the events that we are
+interested in. (We could have applied a filter to our previous bot as well,
+but the goal of the previous tutorial was to just give a quick feel for the
+library.)
+
+Filters can be constructed using the `Polyjuice.Client.Filter` module, which we
+alias to `Filter` for brevity. In this bot, we are only interested in
+`m.room.member` events, as those are the events that will tell us when a user
+has joined the room, so we want to filter out all presence events, and all
+ephemeral events.
+
+We use the `Filter.include_*_types` helper functions to create our filter. By
+default, the sync includes all event types, but if we tell it to include
+certain types of events, it will only include those event types. If we give it
+an empty list, it will give none of the events.
+
+ iex> alias Polyjuice.Client.Filter
+ iex> children = [Polyjuice.Client.API.sync_child_spec(
+ ...> client, self,
+ ...> filter: Filter.include_presence_types([])
+ ...> |> Filter.include_ephemeral_types([])
+ ...> |> Filter.include_state_types(["m.room.member"])
+ ...> |> Filter.include_timeline_types(["m.room.member"])
+ ...> )]
+ iex> {:ok, pid} = Supervisor.start_link(children, strategy: :one_for_one)
+
+Note that we include `m.room.member` events in both `state` and `timeline`.
+This is because these refer to different sections of the sync response, rather
+than to different types of events. `state` refers to the room state before the
+sync result, and `timeline` refers to the events that happened during the time
+period represented by the sync, and so `m.room.member` events can show up in
+both places.
+
+## Membership events
+
+When an `m.room.member` event is received in the sync, the sync process will
+send a message of the form `{:state, room_id, event}`. When a user joins a
+room, the `membership` field will be set to `join`. However, this also happens
+when a user changes their avatar or display name. To determine which situation
+happened, we need to remember the previous room state. There are many ways
+that we could store this, but we will use storage that we have already created:
+the `Polyjuice.Client.Storage` protocol allows us to use the client's storage
+as an arbitrary key-value store.
+
+The Polyjuice Client key-value storage uses a namespace to ensure that
+different things that use the storage will not clash with each other. Storage
+users should ensure that the namespace that they use is unique enough. For
+this tutorial, we will use the namespace `welcomebot.<room_id>`, and for each
+member in the room, we will store a `true` using the user's ID as the key. So
+the code will look something like this:
+
+ receive do
+ {
+ :state,
+ room_id,
+ %{
+ "content" => %{"membership" => "join"} = content,
+ "state_key" => user_id,
+ "type" => "m.room.member"
+ }
+ } ->
+ # join event: if the user was not previously a member, remember that
+ # they are now a member and send them a welcome message
+ namespace = "welcomebot.#{room_id}"
+ unless Polyjuice.Client.Storage.kv_get(client.storage, namespace, user_id) do
+ Polyjuice.Client.Storage.kv_put(client.storage, namespace, user_id, true)
+ # ... send welcome message
+ end
+ {
+ :state,
+ room_id,
+ %{
+ "state_key" => user_id,
+ "type" => "m.room.member"
+ }
+ } ->
+ # any other `m.room.member` event, i.e. not a join event, so
+ # user is not a member of the room: delete their record if it was there
+ namespace = "welcomebot.#{room_id}"
+ Polyjuice.Client.Storage.kv_del(client.storage, namespace, user_id)
+ end
+
+## Sending messages
+
+We omitted the code to actually send the welcome message above. In the
+previous tutorial, we saw that we could send an entire message content object
+to `Polyjuice.Client.Room.send_message/3`. However, building a message content
+can be tedious if we want to add any special formatting, as we must generate
+the plain text and HTML representations of the message. So instead of doing
+that, we will use the `Polyjuice.Client.MsgBuilder` module.
+
+`Polyjuice.Client.Room.send_message/3` allows you to pass in anything that
+implements the `Polyjuice.Client.MsgBuilder.MsgData` protocol. Polyjuice
+Client defines implementations for lists, strings and tuples. So if you want
+to send a simple string, you can just pass a string as the first argument to
+`Polyjuice.Client.Room.send_message/3`, and it will convert to a message
+content object. If you want to send some formatted text, you can pass in a
+2-tuple, where the first element is the text version and the second element is
+the HTML version. And you can pass in a combination of the above using a
+list. `Polyjuice.Client.MsgBuilder` also defines some functions for creating
+other items. Of primary interest to us is
+`Polyjuice.Client.MsgBuilder.mention/2`, which generates a mention of a user.
+
+One slight hitch for bot writers is that if you simply pass a
+`Polyjuice.Client.MsgBuilder.MsgData.t` to
+`Polyjuice.Client.Room.send_message/3`, it will generate a message with
+`msgtype` `m.text`. However, bots are supposed to send `m.notice` messages.
+So we first need to use `Polyjuice.Client.MsgBuilder.to_message/2` to create a
+message object with the correct type. If you are writing a bot that sends many
+messages, you may want to write a wrapper function around
+`Polyjuice.Client.Room.send_message/3`.
+
+Our message sending code will look something like:
+
+ alias Polyjuice.Client.MsgBuilder
+ user = MsgBuilder.mention(user_id, Map.get(content, "displayname", user_id))
+ [{"Welcome", "<i><b>W</b>elcome</i>"}, ", ", user, "!"]
+ |> MsgBuilder.to_message("m.notice")
+ |> (&Polyjuice.Client.Room.send_message(client, room_id, &1)).()
+
+You can change the welcome message as you wish. In a real bot, you may not
+want to add in random formatting; this was only done here for illustration
+purposes.
+
+## Avoiding stale welcome messages
+
+When we first start the bot, there may have already been users in the room; we
+don't want to send welcome messages to these users. The sync process sends a
+message of the form `{:initial_sync_completed}` after the first sync response
+has been processed. So we can start the bot off by simply recording the
+`m.room.member` events, and when the `{:initial_sync_completed}` message
+arrives, then we start sending welcome messages. We will do this by using a
+variable that tells us if the `{:initial_sync_completed}` message was received.
+
+Finally, we want to ignore membership events for the bot itself; it would be a
+bit silly if the bot entered a room and then sent a welcome message to itself.
+
+The full sync processing code would then look like this:
+
+ iex> defmodule WelcomeBot do
+ ...> def loop(client, initial_sync_completed \\ false) do
+ ...> user_id = client.user_id
+ ...> receive do
+ ...> {
+ ...> :state,
+ ...> _room_id,
+ ...> %{
+ ...> "state_key" => ^user_id,
+ ...> "type" => "m.room.member"
+ ...> }
+ ...> } ->
+ ...> # ignore our own room membership
+ ...> loop(client, initial_sync_completed)
+ ...>
+ ...> {
+ ...> :state,
+ ...> room_id,
+ ...> %{
+ ...> "content" => %{"membership" => "join"} = content,
+ ...> "state_key" => user_id,
+ ...> "type" => "m.room.member"
+ ...> }
+ ...> } ->
+ ...> # join event: if the user was not previously a member, remember that
+ ...> # they are now a member and send them a welcome message
+ ...> namespace = "welcomebot.#{room_id}"
+ ...> unless Polyjuice.Client.Storage.kv_get(client.storage, namespace, user_id) do
+ ...> Polyjuice.Client.Storage.kv_put(client.storage, namespace, user_id, true)
+ ...>
+ ...> if initial_sync_completed do
+ ...> alias Polyjuice.Client.MsgBuilder
+ ...> user = MsgBuilder.mention(user_id, Map.get(content, "displayname", user_id))
+ ...> [{"Welcome", "<i><b>W</b>elcome</i>"}, ", ", user, "!"]
+ ...> |> MsgBuilder.to_message("m.notice")
+ ...> |> (&Polyjuice.Client.Room.send_message(client, room_id, &1)).()
+ ...> end
+ ...> end
+ ...> loop(client, initial_sync_completed)
+ ...>
+ ...> {
+ ...> :state,
+ ...> room_id,
+ ...> %{
+ ...> "state_key" => user_id,
+ ...> "type" => "m.room.member"
+ ...> }
+ ...> } ->
+ ...> # any other `m.room.member` event, i.e. not a join event, so
+ ...> # user is not a member of the room: delete their record if it was there
+ ...> namespace = "welcomebot.#{room_id}"
+ ...> Polyjuice.Client.Storage.kv_del(client.storage, namespace, user_id)
+ ...> loop(client, initial_sync_completed)
+ ...>
+ ...> {:initial_sync_completed} ->
+ ...> loop(client, true)
+ ...>
+ ...> _ ->
+ ...> loop(client, initial_sync_completed)
+ ...> end
+ ...> end
+ ...> end
+ iex> WelcomeBot.loop(client)
+
+If you enter the above lines and join a room, the bot should welcome you to the
+room. Note that if you change your display name or avatar, it should not send
+you a message. However, if you leave the room and re-join, then you should
+receive another welcome message.