Writing Loquitor

At the heart of Loquitor, it runs by signals. When a message is posted, a signal is sent which runs a function. If that function determines that the text in the message is a command, that’s another event. The Command signal is sent and also a more specific signal such as Command-test. It’s usually the more specific one that has a function attached to it which handles the command.

The original ChatExchange library has only one function to handle signals: Room.watch in the rooms module. Only one function can watch (I think), and it handles every signal. I wasn’t satisfied with that little customization, so I wrote the skeleton module to create a subclass with more advanced signal-handling. As a GTK+ fan, I imitated their scheme.

The skeleton module

How to use

When entering a room, you first need to be logged in. This is accomplished with the chatexchange.Client class, something like this:

from chatexchange import Client
        
client = Client("stackoverflow.com", "myuser@myemailprovider.com", "mYP@sSw0rd")

The host that is passed must be one of stackoverflow.com, meta.stackexchange.com, or stackexchange.com. If you don’t know which one to use, you should probably use stackexchange.com. That client is then passed to skeleton.room along with a room ID like this:

from Loquitor.skeleton import Room
 
room = Room(1, client) # 1 is the ID of the Sandbox

Once that is done, you can connect to the room’s signals. To see which ones are available, you can do this:

from Loquitor.skeleton import Events

print(Events.events.keys()) 

You can mostly guess which ones are available by looking at the subclasses of chatexchange.events.Event (available by running pydoc3 chatexchange.events). You can then connect to those events with something like this:

def echo(event, room, client):
    event.message.reply(event.content)

room.connect('message-posted', echo)

Note that event.content is the HTML-encoded version; you might want to use html.unescape() to decode it. That would be a very annoying function to do because your bot would now repeat anything that anybody said. To help with that, I created the main bot module.

How it works

The skeleton module adds two new classes: a subclass of chatexchange.rooms.Room and an Events class. The Room class works with the Events class to allow connecting to signals more specifically than just a watch for them all.

The Events class

The Events class is a way of keeping track of which events mean what. It has the events dictionary, which maps names to IDs. To add something to it, the register class method is used. It is given an event name and a class. It then gets an ID from the class by checking the class’s type_id attribute. Next, it registers the class with chatexchange.events. Finally, it makes sure that all Room instances have the event registered. This is because each room keeps track of which events go to which functions with a dictionary. If the event wasn’t in existence until after the creation of the room, the event’ddddddddD will not be a key in the dictionary and trying to connect to it would result in an error.

The Room class

The Room class is just like chatexchange.rooms.Room except that it has the connect, disconnect, and emit methods. The connect and disconnect methods are merely easy ways of modifying a dictionary. The emit method is where the action happens.

Connecting an event to a function is fairly simple, but I just had to make it more complicated. Well, it also happens to be useful. Instead of just a simple event_id: function dictionary, a single entry might be as complicated as this:

12: {
    0: {
        0: (some_function, (1,)),
        1: (some_function, ()),
    },
    20: {
        2: (important_function, ()),
    }
}

The 12 is the ID of the event. In that ID, there is a dictionary of priorities to functions. For the default priority (0), we have two functions to call. “Why the 0 and 1?” you might ask, “and why is some_function there twice?” Each time you use room.connect(), a new connection ID is created. The 0 and 1 are IDs. In this way, a single function could be called twice (as is the case here). In our case, the function is called as some_function(1) and then is called again as just some_function(). “But you could just make a list. Why do you need IDs?” When an ID is used, it can then be reverted later. A second dictionary is being maintained also that keeps track of where these IDs are. For our example, it would look like this:

0: (12,0),
1: (12,0),
2: (12,20),

The disconnect() method can now take an ID, say 2. It checks the ID dictionary and finds that it is at 12 and then 20. It looks at the events dictionary at the 12 key. It then looks at the 20 key of that dictionary. The result is our dictionary of connection IDs to functions. It can then remove the ID that we are looking at (2), and that function will no longer be called when the 12 event happens.

A little more on priorities, in case you missed it. The some_function and important_function are in separate sections. When the emit method is looking through 12’s dictionary, it looks at the highest numbers first (higher priority). It therefore looks at 20 before it looks at 0 and calls the functions in this order: important_function(), some_function(1), some_function().

Now the emit method is what actually uses this dictionary. At the end of Room.__init__, we use watch to send all events to emit (with multithreading). Any other uses of emit are done manually. The emit method is given an event (an instance of a class with a type_id attribute) and a logged-in client. It then checks the event ID with the events dictionary. For each priority (in descending order), it calls each function with the following arguments: the event, the room, the client, and then whatever extra arguments were specified in connect(). If, at any point, one of the functions returns True, the loop breaks and no other functions are called. This could be helpful, for example, if one wanted to prevent the bot from responding to messages containing indecent language. You could connect the message_posted event to a function with high priority and then return True if it contained indecent language. No further functions would be called, and it would be just as if the message hadn’t been posted.

The bot module

How to use

The bot module offers the Bot class and the Command class. The Command class is mostly meant to be subclassed. It is used in bot.register (explained below). The Bot class is a way of converting message-posted events into Command events.

The Bot arguments are as follows:

  • room: a skeleton.Room instance
  • client: a chatexchange.Client instance
  • config_dir: a directory path in which command configuration files will be stored
  • no_input: a boolean (True or False) that indicates if keyboard input can be accepted

Once you have a bot, you can then register commands with the register method. It is given a command name, a function, and (optionally) some help text. In addition to registering commands, you can also register replies to bot messages. For example, the pause command runs like this:

user: >>pause 5 minutes
bot : (reply ^) No more messages will be received for 5 minutes. Reply `cancel` to cancel.
user: (reply ^) cancel
bot : I will now receive messages.

To register a reply, you will need the ID of the original message posted by the user (>>pause 5 minutes). This is because I don’t currently know how to get the ID of the message the bot posted. This can be used something like this:

def cancel(event, room, client):
    event.message.reply("Nah, never mind. You can't cancel.")

def pause_command(event, room, client, bot):
    message_id = event.data['message_id']
    bot.register_response(message_id, on_cancel)
    # Do something about the pause command

How it works

Command registration

The command registration is handled by the Bot.register method. It is given the name of the command, a function to call, and (optionally) the help text. It then creates a new subclass of bot.Command with the name Command-commandname and registers that class with skeleton.Events. Next, it uses skeleton.Room.connect() to register the function with the newly-created signal. The help attribute of the new class is set, and the command name and class are added to the commands dictionary.

When a message is posted

In the bot module, I create a new kind of event. It is called Command. If you want to, you can have a function called whenever a command is given. I connect the message-posted signal to a method called on_message. In there, the message is parsed to see if it is a command. If it is a command, the command is checked against our dictionary of commands. If nothing is found, the user who posted the command is informed that no such command exists. Otherwise, we do a couple things. First, we do some better HTML parsing. This is done with html.unescape(). Next, we define the query as the text after the name of the command, or 5 minutes. The query of event.data is set appropriately. We then try to get a list of arguments from the query string. This is done with csv.reader(). We define the csv separator as a space, but the csv module is still helpful because it listens to the presence of quotation marks. This sly trick was taken from somebody on Stack Overflow. I don’t remember his name. Once the event is all setup with attributes, we use room.emit to call the function associated with this command. For error handling, that call is put in a try block and any exception is sent to the calling user with Oops. That's an error: prepended to it.

When there is a reply

In addition to the message-posted event, we listen to the message-replyevent. In on_reply(), we find the parent of the parent of the message. This is because the parent of a reply to the bot would be the message of the bot. The parent of that is the original message posted by the user. To get this grandparent, we use event.message.parent._parent_message_id. We then check this against our dictionary. If it exists, we do the same query parsing as mentioned above and then call the function registered for this response. If no function is registered, we simply ignore the message.

Written on March 13, 2017