Skip to content

Dev Decorators

Joshua Haas edited this page Feb 4, 2018 · 8 revisions

In python, decorators are simply a short-hand for modifying functions. In Sibyl, decorators are the basis for its config and plug-in frameworks. Upon initialization, Sibyl searches the directory specified by the cmd_dir config option (default cmds/) for .py files containing decorated functions. It also searches in protocols/ for @botconf only. All decorators can be imported from sibyl.lib.decorators for use in plug-ins. Below is a short listing with brief descriptions. For detailed explanations and examples, click the links or scroll down the page.

Common Decorators

  • botcmd - chat commands
  • botconf - add options to parse from the config file
  • botidle - about once per second
  • botinit - bot initialisation

Others

Detailed Decorator Explanations


botcmd

This decorator makes the function available as a chat command using the name of the function. You can hide the command from the help listing with the hidden kwarg (defaults to False). You can restrict chat commands with the ctrl kwarg (defaults to False), which will disable the command unless the user sets chat_ctrl = True in the config. You can run the command in its own thread by setting thread=True. Finally, you can receive arguments as a raw string rather than a list of space and quote-blocked arguments using raw=True. Below we define two chat commands, foo and explode, as well as some other triggers necessary for the latter.

import time,random
from sibyl.lib.decorators import botcmd,botinit,botidle

@botcmd
def foo(bot,mess,args):
  frm = mess.get_user().get_name()
  return 'Message from %s with args %s' % (frm,args)

@botcmd(name='explode',hidden=True,ctrl=True)
def this_name_doesnt_matter(bot,mess,args):
  bot.explode_time = time.time()+random.randint(0,600)
  bot.fuse_lighter = mess.get_from()

@botinit
def init(bot):
  bot.add_var('explode_time')
  bot.add_var('fuse_lighter')
  random.seed(time.time())

@botidle
def check(bot):
  if not bot.explode_time:
    return
  if bot.explode_time<=time.time():
    bot.send('BOOM!',bot.fuse_lighter)
    bot.quit('Sibyl exploded')

Functions using @botcmd must accept 3 parameters as shown above. The bot parameter is the SibylBot object itself. The mess parameter is a sibyl.lib.protocol.Message object. The args parameter is a list containing the arguments passed in the invoking message, but not including the command name itself (see Commands for details on parsing), unless raw=True, in which case it's a string of the raw args without quote blocking or space delineation. If you return a string from the function, it will be sent as a reply to the user who invoked the command.

These functions (or the value of the name kwarg if set) must be unique across every plug-in. Duplicates will cause the bot to fail. Function names (or the value of the name kwarg) must be alphanumeric plus underscores (e.g. [a-z][A-Z][0-9][_]). If you want to be certain, you can test with name.replace('_','').isalnum(). Command names are forced to lowercase during bot startup.

Be aware that, by default, chat commands are synchronous, and so the bot won't process other chat commands or hooks until it has finished executing the current command. If your command takes a long time to run, you should consider running it in a thread with thread=True. Please read the Race Conditions section of the Threading tutorial.


botfunc

Adds the function as an attribute of the bot itself. This is useful for cases where other plug-ins may want to use a function. Functions using @botfunc are never used by the bot; they will only be called by plug-ins. Note, though, that any plugin can use these, not just the plugin in which thay are defined.

from sibyl.lib.decorators import botfunc,botcmd

@botfunc
def reply(bot,text,mess):
  bot.send(text,mess.get_from())

@botcmd
def test(bot,mess,args):
  bot.reply('It works!',mess)

The bot is always passed as the first parameter, but other than that functions using @botfunc can define whatever additional parameters they like. These functions must have unique names across all plug-ins. Duplicates will be overwritten in no particular order.

When calling the function, do not pass bot explicitly; during plugin loading Sibyl binds @botfunc functions to itself, meaning the SibylBot object will be passed as an implicit first parameter.


botinit

The function will be run during the initialisation of the bot. This is useful for initialising variables or reading data files. These functions are run in __init__ inside sibylbot.py.

from sibyl.lib.decorators import botinit

@botinit
def init(bot):
  bot.add_var('foo')

These functions must accept exactly 1 parameter, which is the bot itself. Also note that @botinit functions are called after config values have already been loaded, so feel free to use bot.opt(). Init functions from multiple plug-ins are not guaranteed to execute in a specific order.


botdown

The function will run when the bot is shutting down (included rebooting). The bot is already disconnected from all chat servers at this point so sending won't work.

from sibyl.lib.decorators import botdown

@botdown
def save_config(bot):
  bot.run_cmd('config',['save','*'])

These functions must accept exactly 1 parameter, which is the bot itself. Multiple @botdown functions are not guaranteed to execute in a specific order.


botcon

The function will run whenever the bot successfully connects to the chat server. These run after successful authentication, so you can send messages or do anything else.

import time
from sibyl.lib.decorators import botinit,botcon,botcmd

@botinit
def init(bot):
  bot.add_var('last_connect',{})

@botcon
def connected(bot,proto):
  bot.last_connect[proto] = time.time()

@botcmd
def alive(bot,mess,args):
  pname = mess.get_protocol().get_name()
  t = time.asctime(time.localtime(bot.last_connect[pname]))
  return 'Connected since '+t

These functions must accept 2 parameters. The first is the bot itself and the second is the name of the protocol that connected. If you need the protocol object itself use bot.get_protocol().


botdiscon

The function will run whenever the bot is disconnected from the chat server. This includes failing a reconnection attempt. We'll call the below example discon.py for purposes of config names.

import time
from sibyl.lib.decorators import botconf,botdiscon

@botconf
def conf(bot):
  return {'name':'file',
          'default':'data/sibyl_discon.log',
          'valid':bot.conf.valid_wfile}

@botdiscon
def log(bot,proto,e):
  with open(bot.opt('discon.file'),'a') as f:
    f.write('%s | %s:%s\n' % (time.asctime(),proto,e.__class__.__name__))

These functions must accept exactly 3 parameters. The first is the bot itself, the second is the name of the protocol that disconnected, and the third is the exception raised by the disconnect. It can be any of: ConnectFailure, PingTimeout, ServerShutdown which can be imported from sibyl.lib.protocol.


botrecon

The function will run whenever the bot attempts to reconnect after having disconnected. It runs just before the bot tries to reconnect. Once the result of the connection attempt is known, @botcon or @botdiscon will run as needed.

from sibyl.lib.decorators import botinit,botrecon,botcon

@botinit
def init(bot):
  bot.add_var('recon_attempts',{})

@botrecon
def count(bot,proto):
  bot.recon_attempts[proto] += 1
  if bot.recon_attempts[proto]>5:
    bot.quit('Protocol %s failed to reconnect after 5 attempts.' % proto)

@botcon
def reset(bot,proto):
  bot.recon_attempts[proto] = 0

These functions must accept 2 parameters. The first is the bot itself and the second is the name of the protocol that is attempting to reconnect.


botrooms

The function is run upon successfully joining a room, whether at bot initialisation, or when running a chat command.

from sibyl.lib.decorators import botrooms

@botrooms
def announce(bot,room):

  bot.send('All systems operational.',room)

These functions must accept exactly 2 parameters. The first is the bot itself, and the second is the sibyl.lib.protocol.Room object that was joined.


botroomf

Same as @botrooms except run when the bot fails to join a room. It also takes a third parameter, error, which is a string describing the reason for failure.

from sibyl.lib.decorators import botroomf

import logging
log = logging.getLogger(__name__)

@botroomf
def room_fail(bot,room,error):

  bot.log.error('Failed to join room "%s" (%s)' % (room,error))

botstatus

The function will be run upon receiving a message with type Message.STATUS. Which should be whenever a user logs on, changes their status, or logs off.

from sibyl.lib.decorators import botstatus
from sibyl.lib.protocol import Message

@botstatus
def hello(bot,mess):
  if mess.get_status()[0]==Message.AVAILABLE:
    name = mess.get_user().get_name()
    bot.send('Welcome back %s!' % name,mess.get_from())

They must accept exactly 2 parameters: bot is the bot itself and mess is a sibyl.lib.protocol.Message object. Note that mess.get_status() returns a tuple of the form (status,msg) where status is any of: Message.UNKNOWN, Message.OFFLINE, Message.EXT_AWAY, Message.AWAY, Message.DND, Message.AVAILABLE and the second field in the tuple, msg, is the user's custom status text (e.g. "Out to lunch").


boterr

The function will be run upon receiving a message with type Message.ERROR. Doing anything significant with these is usually protocol specific.

from sibyl.lib.decorators import boterr

import logging
log = logging.getLogger(__name__)

@boterr
def error(bot,mess):
  log.error('Received ERROR message: "%s"' % mess.get_text())

They must accept exactly 2 parameters: bot is the bot itself and mess is a sibyl.lib.protocol.Message object.


botmsg

The function will be run upon receiving a message of type either Message.PRIVATE or Message.GROUP. It is run before chat commands if the message would invoke one.

from sibyl.lib.decorators import botmsg

@botmsg
def replace(bot,mess,cmd):

  if 'emacs' in mess.get_text().lower():
    bot.send('s/emacs/vi/',mess.get_from())

These functions must accept exactly 3 parameters: bot is the bot itself, mess is a sibyl.lib.protocol.Message object, and cmd is a list containing the command name and args if one will be run (e.g. ['bookmark','set','bob-southpark]). If the message isn't running a command, then cmd will be None. Thus functions can decide not to execute if the invoking message is running a chat command.


botpriv

Just like @botmsg but only activates for Message.PRIVATE messages.


botgroup

Just like @botmsg bot only activates for Message.GROUP messages.


botidle

The function will be called approximately once per second by default (exact frequency not guaranteed). This can be used, for example, to set an alarm by constantly checking the current time. You can specify a frequency for execution with the freq kwarg, although this can never be lower than the idle_freq config option. Finally, the hook will run in its own thread if you set the thread kwarg to True.

import time
from sibyl.lib.decorators import botinit,botidle

@botinit
def init(bot):
  bot.add_var('last_chime')

@botidle
def chime(bot):
  t = time.localtime()
  if t.tm_hour==bot.last_chime:
    return
  if t.tm_min==0:
    bot.last_chime = t.tm_hour
    for proto in bot.protocols.values():
      for room in proto.get_rooms():
        proto.send("Ding! It's %i o'clock!" % t.tm_hour,room)

The function must accept only one parameter, which is the bot itself. Functions using @botidle should not take long to execute, or else the bot may become slow to respond to messages.

Sibyl times botidle hooks to make certain they aren't taking a long time. There is an internal counter for each @botidle hook. If a hook takes longer than the idle_time config option, sibyl increments its counter. If the hook finishes within the limit, the counter is instead decremented (minimum 0). Once the counter for a given hook reaches the idle_count config option, the hook will be deleted. In this way, only hooks that consistently exceed the time limit will be disabled. So, by default, if a given hook takes longer than 0.1 seconds 5 times in a row, it will be disabled.

In general @botidle hooks should only take a fraction of a second to execute; otherwise the bot may become slow to respond. If you have a hook that takes longer than a second, you should consider running it in its own thread by setting thread=True. Please read the Race Conditions sections in the Threading tutorial.


botconf

The function will return a list of config options that Sibyl will then look for in the config file. These functions are called in __init__ inside sibylbot.py. The config values specified by functions using @botconf will all be set to default or read from the config file before @botinit functions are called.

from sibyl.lib.decorators import botconf

@botconf
def conf(bot):
  return [{'name':'foo','default':'bar'},
          {'name':'has_whales','default':False,'parse':bot.conf.parse_bool},
          {'name':'myopt','default':0,'valid':valid,'parse':parse},
          {'name':'primary_color','white':['red','blue','yellow']},
          {'name':'user','black':['root']}]

def valid(conf,val):
  if val<0:
    conf.log('warning','Option "myopt" must be positive')
    return False
  if val>10:
    conf.log('warning','Option "myopt" cannot exceed 10')
    return False
  return True

def parse(conf,opt,val):
  return float(val)

The function must accept exactly 1 parameter which is the bot itself. It must return a list of dictionaries defining config options. Each dictionary must have the key 'name'. Optionally they may also contain 'default', 'req', 'parse', 'valid', 'post', 'white', and 'black'. For more explanation see the config section on the Plug-Ins page.


botsend

The function will be called whenever sibyl sends a message. This hook is used, for example, in the room.bridge functionality to bridge multiple chat rooms.

IMPORTANT: If you call bot.send() inside an @botsend hook, always pass hook=False to prevent infinite looping.

import requests,lxml,re
from sibyl.lib.decorators import botsend

@botsend
def link_echo(bot,mess):

  (text,to) = (mess.get_text(),mess.get_to())
  urls = re.findall(r'(https?://[^\s]+)', text)
  s = ''
  for (i,url) in enumerate(urls):
    r = requests.get(url)
    title = lxml.html.fromstring(r.content).findtext('.//title').strip()
    s += '[%s] %s ' % (i+1,title)
  if s:
    bot.send(s[:-1],to,hook=False)

The function must accept exactly 2 parameters: bot is the bot itself and mess is the Message object being sent.

Also note that this hook will only be called if plug-ins follow convention and call bot.send(); if they call protocol.send() directly, that message will not execute @botsend hooks.

Clone this wiki locally