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.