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. Before continuing, you should make sure that the bot is already in a room. We begin by starting a client. 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> {:ok, pid} = Polyjuice.Client.start_link( ...> "http://localhost:8008", ...> access_token: "access_token", ...> user_id: "@echobot:localhost", ...> storage: Polyjuice.Client.Storage.Ets.open(), ...> handler: self(), ...> sync_filter: Filter.include_presence_types([]) ...> |> Filter.include_ephemeral_types([]) ...> |> Filter.include_state_types(["m.room.member"]) ...> |> Filter.include_timeline_types(["m.room.member"]) ...> ) iex> client = Polyjuice.Client.get_client(pid) 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.`, 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 { :polyjuice_client, :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 { :polyjuice_client, :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", "Welcome"}, ", ", 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 `{:polyjuice_client, :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 `{:polyjuice_client, :initial_sync_completed}` message arrives, then we start sending welcome messages. We will do this by using a variable that tells us if the `{:polyjuice_client, :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 ...> { ...> :polyjuice_client, :state, ...> { ...> _room_id, ...> %{ ...> "state_key" => ^user_id, ...> "type" => "m.room.member" ...> } ...> } ...> } -> ...> # ignore our own room membership ...> loop(client, initial_sync_completed) ...> ...> { ...> :polyjuice_client, :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", "Welcome"}, ", ", user, "!"] ...> |> MsgBuilder.to_message("m.notice") ...> |> (&Polyjuice.Client.Room.send_message(client, room_id, &1)).() ...> end ...> end ...> loop(client, initial_sync_completed) ...> ...> { ...> :polyjuice_client, :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) ...> ...> {:polyjuice_client, :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.