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", "[email protected]", "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-reply
event. 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.