diff options
author | Hubert Chathi <hubert@uhoreg.ca> | 2019-10-15 19:06:10 +0200 |
---|---|---|
committer | Hubert Chathi <hubert@uhoreg.ca> | 2019-10-15 19:06:10 +0200 |
commit | a87975ca316c452509f1aa7bf80ecc54e2dba4fe (patch) | |
tree | a691757a296254344ab0fcbc4006b4546b99fe04 | |
parent | make update and put in Polyjuice.Client.Filter public (diff) |
add another tutorial
-rw-r--r-- | mix.exs | 3 | ||||
-rw-r--r-- | tutorial_echo.md | 15 | ||||
-rw-r--r-- | tutorial_welcome.md | 230 |
3 files changed, 242 insertions, 6 deletions
@@ -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. |