Skip to content

Dev alarm

Joshua Haas edited this page Jun 24, 2017 · 4 revisions

Here we will be writing a Sibly plug-in that allows users to set custom alarms. Development will be step-by-step and detailed. Many common decorators and API calls will be used and briefly explained.

Goal

We want a chat command that allows users to set an alarm that will trigger the bot to send a message at some point in the future. We would also like to create a config option to disable alarms in chat rooms so you can only use them in private chat.

File and Overview

We will be editing a file called alarm.py which will be located in the cmds/custom/ directory. We have to import the @botcmd decorator in order to flag our function as a chat command. For more info on decorators click here. As long as you start Sibyl using run.py the sibyl directory will be on your python path, allowing for easy imports.

As shown below, our command currently has Sibyl reply with Not implemented! when invoked in chat. For more details on Plug-Ins click here. Note that mess is the Message object that triggered the command, and args contains anything said in the same message after the command itself as a list.

from sibyl.lib.decorators import botcmd

@botcmd                       # this tells sibyl our function is a chat command
def alarm(bot,mess,args):     # our function receives some info about who called it
  return 'Not implemented!'   # returned text is sent as a response to the user

User Interface

We will keep the interface to our alarm command very simple. In chat, a user can say alarm TIME to set a new alarm for the specified time. This is easy to use in the function. If a user says sibyl alarm 9:00 in a chat room, then Sibyl will automatically call our alarm function and pass it the remaining arguments. In the above example, args would be ['9:00']. If the user said something like sibyl alarm in 30 minutes then args would be ['in','30','minutes']. For the purpose of this tutorial we will only be implementing the first case.

Help

We will require the user to specify a time as H:MM in 24-hour format. To let users know how to use our command, we can add a short help string that will then be used by Sibyl's built-in help command.

def alarm(self,mess,args):
  """set an alarm to go off later - alarm H:MM"""
  return 'Not implemented!'

Parsing Input

We'll use python's convenient datetime module to handle the time math. Visit the python docs for more info. We need to convert the user-specified time from a string into hour and minute, then use those to create a datetime.datetime object. This will make later logic much easier.

import datetime
from sibyl.lib.decorators import botcmd

@botcmd
def alarm(bot,mess,args):
  """set an alarm to go off later - alarm H:MM"""

  # args will look like ['9:00'] so take the first item in the list and split it
  (hr,mi) = args[0].split(':')

  # get the current date and change its time to match the target time
  now = datetime.datetime.now()
  target = now.replace(hour=int(hr),minute=int(mi),second=0,microsecond=0)

  # if the specified time is actually tomorrow, add 1 day to our target
  if target<now:
    target += datetime.timedelta(1)

Storing Alarms

Currently, when our function exits, we have no way to remember when the alarm was. We need a place to store our alarms so we can access them even after this function returns. The easiest way to do this is to define a new instance variable in the SibylBot instance which will persist even after our alarm function ends.

Creating a Variable

In order to play nice between plug-ins, the correct way to create a new variable is with bot.add_var(). It makes certain that plug-ins don't accidentally overwrite each others' variables. In order to use this, we need the @botinit decorator. We add the variable alarms which will be initialised to [] (an empty list).

from sibyl.lib.decorators import botinit

@botinit                      # this line tells sibyl to run the function during startup
def init(bot):                # the only info our function gets is the bot itself
  bot.add_var('alarms',[])    # we create a variable that we can access as bot.alarms

Storing an Alarm

We actually need to store two things. Of course we need to store the time the alarm should go off, but we also need to store who set the alarm so we know who we should send a message to when it does. Now we simply have to add a few lines to the end of our alarm function to store the new alarm and the message that created it. We simply add the Message object and the alarm time to the alarms list as a tuple. While we're at it, let's add some user feedback too.

  bot.alarms.append((mess,target))    # store the alarm and the Message that created it
  return 'Alarm added'                # respond to the user letting them know success

Triggering an Alarm

Of course, we need a way to trigger alarms when it's time. Although this isn't guaranteed to activate the exact moment the current time equals the target time, we can use @botidle to get pretty close. Functions using @botidle are called about once per second by default. We simply iterate over every stored alarm and check if its time has passed. If it has, we send a message to the user who originally set the alarm. If it hasn't, we add it back to bot.alarms to be checked again in the future.

from sibyl.lib.decorators import botidle

@botidle
def idle(bot):

  # get the current time
  now = datetime.datetime.now()

  # create a new list where we will put alarms that didn't trigger
  not_triggered = []

  # look through all our alarms
  for (mess,target) in bot.alarms:

    # if the alarm time has passed, send the user who sent it a message
    if target<=now:

      # get the display name of the user from the stored Message object
      name = mess.get_user().get_name()

      # get the sender from the stored Message object
      frm = mess.get_from()

      # send a message that looks like "Bob: ALARM!"
      bot.send(name+': ALARM!',frm)

    # if the alarm time hasn't passed, keep the alarm stored and waiting
    else:

      # add the alarm to our list of ones to keep
      not_triggered.append((mess,target))

  # replace our stored alarms with the updated list
  bot.alarms = not_triggered

Using Config Options

It's pretty easy to add custom config options to Sibyl. It just involves another decorated function, but for more info visit this page. This is a great way to let users customize how commands behave.

Adding an Option

Finally we need one more decorator @botconf in order to define a custom config option. If the user sets alarm.allow_rooms = False in the config file, then users will only be able to set alarms in private chats. Note that even though we specify our option's name as only allow_rooms sibyl automatically adds the file name to the front, so in order to use the option you have to use alarm.allow_rooms. We also have to parse the option from a str to a bool.

from sibyl.lib.decorators import botconf

@botconf
def conf(bot):
  return {'name':    'allow_rooms',         # the name of our option
          'default': True,                  # its default value
          'parse':   bot.conf.parse_bool}   # how to interpret it in the config file

Using the Option

Now we have to edit our alarm function to check what the value of the config option is. We simply add the below to the start of the function. We also need another import in order to check if the message was private or from a room. To get the value of a config option, simply use bot.opt(name). Note that you must specify the full name, including the name of the plugin.

from sibyl.lib.protocol import Message

@botcmd
def alarm(bot,mess,args):
  """set an alarm to go off later - alarm H:MM"""

  # check if the Message is from a room but the user has disabled allow_rooms
  if mess.get_type()==Message.GROUP and not bot.opt('alarm.allow_rooms'):

    # let the user know why their alarm wasn't created and exit the command
    return 'Alarms are disabled in chat rooms'

Exception Handling

We made some assumptions that might not always work out. For example, if a user says sibyl alarm foo bar then our input parsing will fail and python will throw an exception. We have the option of accounting for that or letting Sibyl handle it. Any un-caught exceptions raised during the execution of a chat command are caught by SibylBot. However, it's nice to give users feedback, so instead let's add a try/except clause in the alarm function.

  # if anything inside the try block encounters an error, we'll go to the except block
  try:
    (hr,mi) = args[0].split(':')
    now = datetime.datetime.now()
    target = now.replace(hour=int(hr),minute=int(mi),second=0,microsecond=0)

  # let the user know why their alarm wasn't created and exit the command
  except ValueError:
    return 'Time must be in the format H:MM'

Putting It All Together

You can find the final code below, as well as in the examples/alarm.py file. You can test the version you've been editing, or just copy the aforementioned file to cmds/custom/ and then restart sibyl. You can test the behavior of the config option without restarting sibyl using the config chat command:

  • sibyl config set alarm.allow_rooms False
  • sibyl config set alarm.allow_rooms True

You can also check that sibyl help alarm returns our help text. And finally the full script:

import datetime
from sibyl.lib.protocol import Message
from sibyl.lib.decorators import botinit,botconf,botcmd,botidle

@botinit
def init(bot):
  bot.add_var('alarms',[])

@botconf
def conf(bot):
  return {'name':    'allow_rooms',
          'default': True,
          'parse':   bot.conf.parse_bool}

@botcmd
def alarm(bot,mess,args):
  """set an alarm to go off later - alarm H:MM"""

  if mess.get_type()==Message.GROUP and not bot.opt('alarm.allow_rooms'):
    return 'Alarms are disabled in chat rooms'

  try:
    (hr,mi) = args[0].split(':')
    now = datetime.datetime.now()
    target = now.replace(hour=int(hr),minute=int(mi),second=0,microsecond=0)
  except ValueError:
    return 'Time must be in the format H:MM'

  if target<now:
    target += datetime.timedelta(1)
  bot.alarms.append((mess,target))
  return 'Alarm added'

@botidle
def idle(bot):
  now = datetime.datetime.now()
  not_triggered = []
  for (mess,target) in bot.alarms:
    if target<=now:
      name = mess.get_user().get_name()
      frm = mess.get_from()
      bot.send(name+': ALARM!',frm)
    else:
      not_triggered.append(alarm)
  bot.alarms = not_triggered
Clone this wiki locally