Skip to content

Dev Protocol

Joshua Haas edited this page Jul 22, 2017 · 6 revisions

If you would like Sibyl to work with another chat protocol, you just have to subclass Protocol, User, amd Room from lib.protocols and define the necessary functions. Some of this information is available in lib/protocols.py itself as comments. Refer to that file for javadoc-like function signatures. Together, the Message, User, Room, and Protocol classes provide an abstract API that allows Sibyl to interface with widely varying chat protocols. You should also take a look at the following explanations and brief API listings:

  • SibylBot for things like add_var() and callbacks
  • Decorators specifically for the @botconf decorator

Some people may learn better via tutorial. You can follow the construction of the mail protocol in the sibyl_mail.py tutorial.

File

In order for Sibyl to use the protocol, the file must be in the protocols/ directory and named sibyl_name.py where name is the name of the protocol. The name should be lowercase, and is what users will specify in the protocols option in the config file. For example, you would use sibyl_xmpp.py with protocols = xmpp. Each file should only have a single Protocol sub class.

You can use protocols/skeleton.py as a base, edit it, and then save it as the correct file name. Most of the information in the comments from lib/protocols.py is also present in protocols/skeleton.py.

Message

You do not need to implement this class, but you will have to use it when coding the others. A Message object is used to represent anything received from the server, be it a text message, status update, or error.

Creating a New Message

A new message object has the following signature:

msg = Message(user,txt,typ=None,status=None,msg=None,room=None,
    to=None,broadcast=False,users=None,hook=True,emote=False)

The first field user must be a sub-class of sibyl.lib.protocol.User and denotes who sent the message. Then txt is the message body, which can be str or unicode.

The next field typ can be any of the following enums listed below, along with their purpose. If not specified, it defaults to Message.PRIVATE.

Message.STATUS    # status updates e.g. 'Available' or 'Away'  
Message.PRIVATE   # private messages between only two people
Message.GROUP     # messages from a chat room
Message.ERROR     # error messages from the server itself

The next two parameters are only used for Message.STATUS messages. The status argument can be any of the following enums listed below, along with their purpose.

Message.UNKNOWN     # unknown or unlisted status type
Message.OFFLINE     # user is offline
Message.EXT_AWAY    # user is gone for a long period
Message.AWAY        # user is idle
Message.DND         # user does not want to be disturbed
Message.AVAILABLE   # user is available

The next argument msg is for custom status text, e.g. Back in 5 minutes or Listening to "Pachelbel's Canon". The final parameter room is used if the message was sent from a Room, and should be a sub-class of the sibyl.lib.protocol.Room class. If the message was sent from a room this parameter must be set.

In general protocol implementers should never need to use the next two, which are used by Sibyl internally when sending Messages. The broadcast argument is a boolean for whether this Message should be broadcast to everyone in a room. The users argument is used by the bridging code to highlight users across the bridge in a broadcast message.

Next we have hook which is set False when sending messages from within @botsend hooks to prevent infinite looping. You shouldn't have to set this directly, as in general it should be done via bot.send or bot.reply.

The final parameter is used for emote messages, e.g. /me is here so they get translated properly between protocols.

Methods

A brief description of each method is given below. For signatures see lib/protocol.py. Also note that type_to_str() is a static method and so can be called with Message.type_to_str(). The second group of functions are used internally by Sibyl. The third group of functions should not be called directly.

get_from()          # returns the Room if it exists else the User
get_protocol()      # return the associated Protocol object
get_room()          # return the Room that the message was sent from (or None)
get_status()        # return a tuple of (status enum, status message str)
get_text()          # return the message body
get_type()          # return the typ enum
get_user()          # return the User that sent the message
get_emote()         # return True if this is an emote (e.g. "/me")
set_text(text)      # set the message body
type_to_str(typ)    # return a human-readable str for the given typ enum

get_to(self)        # return the intended recipient of this Message
get_broadcast(self) # return if this Message should be broadcast to the room
get_users(self)     # return additional users to highlight for a broadcast
get_hook(self)      # return True if this Message should proc @botsend hooks

__getstate__()    # controls the behavior of the pickle module
__init__()        # initialise the object

User

The first class you must override is the User class. It creates a User object used to define who sent a Message. You must override every method prefaced with the @abstractmethod decorator. Equality checking should include resource/device information; if you need to check equality without those, use user1.base_match(user2).

Creating a New User

The signature for a new User is shown below. Note that the User class itself is abstract, and so cannot be directly instantiated. Only sub-classes can create an object, and so this guide will use MyUser as an example sub-class.

usr = MyUser(proto,user,typ=None,real=None)

The first field proto is the Protocol object, which in most cases will just be self when creating a MyUser in your protocol. Next, user can be anything you want (str, dict, etc.) since only your protocol will instantiate user objects directly; the bot itself and plugins will use your protocol's new_user() method to create new users. The third field typ is either Message.PRIVATE or Message.GROUP to denote whether this MyUser is in a chat room or not (defaults to Message.PRIVATE). This is necessary since some protocols like XMPP change how usernames appear in chat rooms. The last field real is a MyUser object representing this user's "real" username (see Real Usernames below for details).

Upon object instantiation, the parse() method is called, which is where you should set up the object. The __init__() method should not be overwritten. You are free to define any additional instance variables you need in the parse() method.

Methods

Upon instantiation of a new MyUser the parse() method should read the object passed in the user argument and setup the object. Then the rest of the methods in the User class can be used to access information about the user. Methods and brief explanations are given below. For signatures see lib/protocol.py. The first group are the methods that must be overriden. The second group are already defined and available for use. The third group are already defined but should not be called directly.

get_base()      # return a str representing this MyUser without Resource/Device
get_name()      # return a human-friendly name for this MyUser
parse(user)     # called automatically on object init
__eq__(obj)     # return True if this MyUser is equal to obj else False
__str__()       # return this MyUser as a str

base_match(obj) # return True if obj is a MyUser and equal (ignoring resource)
get_protocol()  # return the associated Protocol object
get_real()      # return a MyUser respresenting this MyUser's "real" username
get_type()      # return the type either Message.PRIVATE or Message.GROUP
set_real(real)  # set this MyUser's "real" username

__getstate__()  # controls the behavior of the pickle module
__hash__()      # override so MyUser can be dict keys
__init__()      # initialise some fields and call parse()
__ne__(obj)     # override the != operator to return the oposite of ==
__repr__()      # override object representation

Real Usernames

Some protocols allow users to have custom nick names in chat rooms. Thus a message received from a user in a chat room can look like it is from a different person than if the same user had messaged us in private. However, it can be helpful for the bot to know both pieces of information. The nick name in order to reply correctly in the chat room, and the "real" user name for things such as chat command black/white listing. The real variable in a User object should be set whenever applicable. If your protocol implements chat room nick names then code accordingly. Otherwise, you can ignore the real arg, because by default user1.get_real() simply returns a reference to user1.

Room

Next you must sub-class the Room class, which is used to represent the name and associated protocol of a chat room. It also allows specifying a nick name and/or password when trying to join a room.

Creating a New Room

A new room object has the following signature. Note that the Room class itself is abstract, and so cannot be directly instantiated. Only sub-classes can create an object, and so this guide will use MyRoom as an example sub-class.

room = MyRoom(proto,name,nick=None,pword=None)

The first field proto is the associated Protocol object. Next, name is the identifier for the room, which can be any object. As with MyUser, a MyRoom object will only be directly created by you in the protocol class. The bot and plugins will only create new rooms using your protocol's new_room() method.

The next, nick is the bot's nick name in the room as a str. Then pword is the password used to join the room as a str. The nick and pword parameters are only relevant when passing the MyRoom object to join_room().

Methods

A bried description of each method is given below. For signatures see lib/protocol.py.

Upon instantiation of a new MyRoom the parse() method should read the object passed in the name argument and setup the object. Then the rest of the methods in the Room class can be used to access information about the room. Methods and brief explanations are given below. For signatures see lib/protocol.py. The first group are the methods that must be overriden. The second group are already defined and available for use. The third group are already defined, but should not be called directly.

get_name()      # return the name of the room as a string
parse(name)     # called automatically on object init
__eq__(obj)     # return True if this MyRoom is equal to obj else False

get_nick()      # return the nick to use when joining (or None)
get_occupants() # return a list of MyUser who are in this MyRoom
get_password()  # return the password to use when joining (or None)
get_protocol()  # return the associated Protocol object
get_real(nick)  # return the real MyUser behind the specified nick name

__getstate__()  # controls the behavior of the pickle module
__hash__()      # override so MyRoom can be dict keys
__init__()      # initialise some fields and call parse()
__ne__(obj)     # override the != operator to return the oposite of ==
__repr__()      # override object representation
__str__()       # return this MyRoom as a str

Protocol

This is where the real work is done in translating from your chosen protocol to something Sibyl can understand. In most cases this just consists of translating similar but slightly different API calls from an existing chat library into Sibyl's protocols API.

Creating a New Protocol

Although no one should ever need to manually create a Protocol object (the bot does this during start up automatically) it is important to understand how this happens. Only sub-classes can actually be instantiated, and so this guide will use MyProtocol as an example sub-class. As with the User class, you should not override the __init__() method in order to ensure certain things are available. Upon creation, every Protocol sub-class will have the following instance variables:

bot     # the SibylBot object that created this Protocol
log     # the logging.Logger object for use by this Protocol

Instead of overriding __init__() you should override the setup() method for any necessary actions on MyProtocol instantiation. Note that the above instance variables already exist and can be used in the setup() method. For completeness the signature for Protocol objects is given below.

proto = MyProtocol(bot,log)

Where bot is the SibylBot instance and log is a logging.Logger.

Overview

When Sibyl first starts it reads the protocols config option and then creates new Protocol objects of the selected types. After other setup, Sibyl calls the connect() method and checks for success. It then joins any rooms listed in the rooms config option using join_room(). Finally, Sibyl sits in a while loop constantly calling the process() methods of every protocol. When the protocol receives messages it processes them, converting relevant info into Message, User, and Room objects, and finally sending them to sibyl for processing with bot._cb_message().

Sibyl is built expecting the protocol to queue messages asynchronously in the background, which are then retrieved synchronously via process(). If your library is not asynchronous by nature, you will have to use the threading module or similar to achieve this functionality. For details, see the Threading Tutorial and the mail protocol example.

Methods

Protocol setup should be done in the setup() method. If you need to access any config options, you can use the opt() method, for example self.opt('xmpp.resource'). Again, see the lib/protocol.py file for method signatures. The first group are the methods that must be overriden. The second group are already defined and available for use. The third group are already defined but should not be called directly.

broadcast(mess)                 # send Message, highlighting every user in the MyRoom
connect()                       # connect to the server or raise exceptions
get_nick(room)                  # return our nick name in the given MyRoom
get_occupants(room)             # get the users in a room as a list of MyUser
get_real(room,nick)             # return the real MyUser for the given nick
get_user()                      # return our full userid as a MyUser
is_connected()                  # return True if connected to the server else False
join_room(room)                 # join the specified MyRoom
new_room(name,nick,pword)       # return a new MyRoom instance
new_user(user,typ,real)         # return a new MyUser instance
part_room(room)                 # leave the specified MyRoom
process()                       # process any queued messages and do callbacks
send(mess)                      # send a Message to a MyUser or MyRoom
setup()                         # setup the Protocol (bot and log already defined)
shutdown()                      # called by the bot before exiting
_get_rooms(flag)                # return list of MyRoom matching the given flag

get_name()                      # return the name of this protocol as a string
get_rooms(flags)                # return a list of MyRoom matching the flags
in_room(room)                   # return True if we are in the room else False
opt(opt)                        # return the value of the specified config option

__add_rooms(rooms,flags)        # helper method for get_rooms()
__eq__(obj)                     # return True if this MyProtocol is equal to obj else False
__init__()                      # initialise some fields and call setup()
__ne__(obj)                     # override the != operator to return the oposite of ==

Exceptions

There are 4 exceptions defined in lib/protocol.py that should be raised at various points in the Protocol sub-class. So that exceptions keep track of which protocol raised them, include the following in your file (if you're using skeleton.py these lines are already present):

from sibyl.lib.protocol import ProtocolError as SuperProtocolError
from sibyl.lib.protocol import PingTimeout as SuperPingTimeout
from sibyl.lib.protocol import ConnectFailure as SuperConnectFailure
from sibyl.lib.protocol import AuthFailure as SuperAuthFailure
from sibyl.lib.protocol import ServerShutdown as SuperServerShutdown

class ProtocolError(SuperProtocolError):
  def __init__(self):
    self.protocol = __name__.split('_')[-1]

class PingTimeout(SuperPingTimeout,ProtocolError):
  pass

class ConnectFailure(SuperConnectFailure,ProtocolError):
  pass

class AuthFailure(SuperAuthFailure,ProtocolError):
  pass

class ServerShutdown(SuperServerShutdown,ProtocolError):
  pass

Your protocol should never raise an exception other than the ones below. You must catch any exceptions generated by underlying libraries and re-raise them as one of the below. Note also that all these exceptions inherit from ProtocolError in sibyl.lib.protocol and so can all be caught with except ProtocolError:.

PingTimeout       # a ping attempt failed
ConnectFailure    # failed to connect or server disconnected
AuthFailure       # login failed due to bad credentials (don't reconnect)
ServerShutdown    # server shut down (more specific than ConnectFailure)

Callbacks

There are 3 callback functions in SibylBot that need to be called by protocols. They are listed below along with the function that should call them. For more details see the functions in lib/protocol.py and lib/sibylbot.py.

bot._cb_message(mess)                   called by   process()
bot._cb_join_room_success(room)         called by   join_room()
bot._cb_join_room_failure(room,error)   called by   join_room()

Since the bot instance variable is always defined, you can call these easily for example with:

def process(self):
  msg = self.conn.next_msg()
  if msg:
    self.bot._cb_message(self.convert_msg(msg))

Config Options

You can define custom config options using the @botconf decorator as described in this section of the decorators article. For example:

from lib.decorators import botconf

@botconf
def conf(bot):
  return [{'name':'server','default':None,'req':True},
          {'name':'port','default':42}]

One more quick note, the general plugin has the config command, which can be used in chat to view and edit config options. However, any option whose name ends with 'password' will be redacted. Otherwise, consider changing the value to a Password object from lib.password in a parse function.

Persistent Storage

If you need to store something, for example an authentication token or roster, you can use Sibyl's peristent storage. Note that the user can disable persistent storage in the config file. This functions just like in plug-ins, by passing persist=True to the bot.add_var() method. You may only use add_var() in your protocol's setup() method. Note that these variables will be members of the bot, not your protocol.

IMPORTANT: Persistent storage may not work correctly with Message, User, and Room objects

  def setup(self):
    self.bot.add_var('myprotocol_token',persist=True)

  def connect(self):
    if not self.bot.myprotocol_token:
      self.bot.myprotocol_token = self.login()
    else:
      self.login_with_token(self.bot.myprotocol_token)

Logging

Basic logging can be done using the predefined log instance variable, which is a logging.Logger object created by the bot specifically for use by this protocol. Logging a message is as easy as self.log.debug('text'). The available log levels are (use in place of debug in the code snippet):

debug     detailed debug message to help with troubleshooting
info      similar to debug, but might be interesting in general
warning   something went wrong, but you can probably ignore it
error     something went wrong, and it's probably important
critical  something very important happened, possibly killing the bot

In general, a protocol shouldn't need to use the critical level. Unhandled exceptions will be caught in SibylBot and logged. Example uses are given below:

debug     received a presence update
info      successfully joined a room
warning   the server doesn't support encryption
error     kicked from a room
critical  banned from a room
Clone this wiki locally