-
Notifications
You must be signed in to change notification settings - Fork 6
Dev email
Here we will be writing a protocol so sibyl can talk over e-mail. This will be a
simplified version of protocols/sibyl_email.py
that we will develop step by
step. This version will be missing some config options and checks present in the
full version.
We want to control Sibyl via e-mail. Users should be able to send a command to Sibyl and get a reply. This will be handy to have for library searches that respond with a wall of text, or for users who don't have a supported chat app on their phone but still want to issue Sibyl commands from it.
We will be editing a file called sibyl_mail.py
which will be located in the
protocols/
directory. In order to write a protocol, we need to sub-class
Protocol
, User
, and Room
from the sibyl.lib.protocol
module. To make
that easier, you should start from protocols/skeleton.py
, which you should
copy and rename to protocols/sibyl_mail.py
. For more details see the coding
protocols page.
First, this protocol requires an external e-mail account with remote SMTP and IMAP enabled. This is easy to do with pretty much any provider, including the major ones like Gmail or Hotmail. Making a new account for sibyl's exclusive use is probably a good idea. Sending e-mail with our protocol is done using SMTP via the smtplib module. Doing so is straightforward; we just have to build a message, connect to the server, send it, and disconnect.
For receiving messages, we'll by using IMAP from the imaplib module. We could just poll the server and check for new messages every minute or so, but then messages sent to Sibyl via e-mail would take forever to execute. Further, many IMAP servers do not allow you to connect more often than once a minute. However, rather than check for new messages every so often, we can open an IMAP connection and ask the server to notify us as soon as new mail arrives. Doing so halts execution, so we'll have to run the IMAP listener in its own thread.
We'll need to raise exceptions at several points in our protocol so sibyl can
deal with disconnects and other important events correctly. If you weren't
editing skeleton.py
you would still need a few lines of code from it.
Specifically, the imports and class definitions that make exceptions protocol
specific. When you raise one of these exceptions, it will then be initialised
with the name of your protocol so Sibyl knows where it came from.
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
Protocols can add config options just like plug-ins, using the @botconf
decorator. For more details see the Adding Config Options
section on the
Plug-Ins page. We'll need a few config options:
-
address
- the e-mail address for sibyl to use (required) -
password
- the password to login to the account (required) -
imap
- the IMAP server to use (default inferred fromaddress
) -
smtp
- the SMTP server to use (default inferred fromaddress
)
from sibyl.lib.decorators import botconf
@botconf
def conf(bot):
return [
{'name':'address','req':True},
{'name':'password','req':True},
{'name':'imap'},
{'name':'smtp'},
]
We'll need to make our own User
sub-class for holding e-mail addresses. Other
protocols may need to care about nick names and resources, but for us the only
piece of information that matters is the e-mail address itself. We need to
override every method in skeleton.py
, which corresponds to overriding every
@abstractmethod
in protocol.py
.
from sibyl.lib.protocol import User
# here we make our MailUser class inherit methods from User
class MailUser(User):
# in our case, the user is just a str, so we can just store it
def parse(self,user):
self.user = user
# the "name" of an e-mail address is always just the address itself
def get_name(self):
return self.user
# e-mail doesn't keep track of resources or separate devices
def get_base(self):
return self.user
# MailUsers are equal if their user variables are equal
def __eq__(self,other):
if not isinstance(other,MailUser):
return False
return self.user==other.user
# again, the string version of a MailUser is just its user
def __str__(self):
return self.user
Very similar to our User
class, except e-mail doesn't have rooms. We still
need to have a working Room
sub-class so Sibyl and plug-ins work correctly,
but the room-related methods in our Protocol
sub-class won't actually do
anything meaningful.
from sibyl.lib.protocol import Room
# make our MailRoom class inherit from the Room class
class MailRoom(Room):
# we'll just store the name like we did with MailUser
def parse(self,name):
self.name = name
# just return the stored name
def get_name(self):
return self.name
# two MailRooms are only equal if their name variables are equal
def __eq__(self,other):
if not isinstance(other,MailRoom):
return False
return self.name==other.name
Before we get to our Protocol
sub-class, let's create the separate thread for
listening for new messages we mentioned in the Overview. If you aren't familiar
with threading, you should check out the Threading Tutorial. We'll be
sub-classing the Thread
class from the threading
module. First, we need to
keep track of a few things:
-
self.proto
- theProtocol
object that created the thread -
self.imap
- our IMAP object created with theimaplib
module -
self.msgs
- a thread-safeQueue
for storing/getting messages asynchronously
from threading import Thread
from Queue import Queue
class IMAPThread(Thread):
def __init__(self,proto):
# we have to call the original __init__() method from the Thread class first
super(IMAPThread,self).__init__()
# this settings means this thread will exit if the main thread does
self.daemon = True
# instance variables explained above
self.proto = proto
self.imap = None
self.msgs = Queue()
We'll be using the IMAP IDLE
functionality as described in the IMAP
specification (rfc2177). The python adaptation below was created using
info from this StackOverflow answer. In order to make this easier, we'll
want a function for sending IMAP commands to the remote server.
def cmd(self,s):
self.imap.send("%s %s\r\n"%(self.imap._new_tag(),s))
Next let's define a connect()
method; our protocol will have to call this
method to login to the e-mail account and wait for new messages. At the end of
this function, we'll issue an IMAP IDLE
command to tell the server we want to
be notified when new mail arrives.
import imaplib
def connect(self):
# create a new IMAP connection and try to login
self.imap = imaplib.IMAP4_SSL(self.proto.imap_serv)
self.imap.login(self.proto.opt('mail.address'),self.proto.opt('mail.password'))
# we need to choose an inbox before anything else; we'll pick the default
self.imap.select()
# tell the server to notify us when new mail arrives
self.cmd('IDLE')
However, we're missing something important here; what happens if we can't
connect to the server, or our password is wrong? Our calls to imaplib
will
raise exceptions that we need to catch and translate into exceptions Sibyl
understands. Now, if we can't connect to the server we'll raise a
ConnectFailure
exception, and if login fails we'll raise AuthFailure
.
import imaplib
def connect(self):
# try to connect to the IMAP server
try:
self.imap = imaplib.IMAP4_SSL(self.proto.imap_serv)
except:
raise ConnectFailure
# try to login with our password
try:
self.imap.login(self.proto.opt('mail.address'),
self.proto.opt('mail.password'))
except:
raise AuthFailure
self.imap.select()
self.cmd('IDLE')
The main logic for our thread will be in its run()
method, which will be
called when we do IMAPThread().start()
to run it asynchronously. This is just
going to be a big while
loop that runs forever, waiting for new mail to
arrive. When it receives a new message, it will put it in self.msgs
, for later
retrieval by our Protocol
sub-class.
We'll call self.imap.readline()
which will block until the server sends us a
response that will be stored in line
. If line
has the string 'EXISTS'
in
it, then new mail has arrived. Any other messages from the server we'll just
discard. If line
is blank, it means our connection timed out. This is normal
when using IMAP-IDLE, since connections only have a lifespan of 30 minutes. So
if this happens, we'll just try to reconnect.
import email
def run(self):
while True:
# wait for the server to send us a new notification
line = self.imap.readline().strip()
# if the line is blank, the server closed the connection
if not line:
self.connect()
# if the line ends with "EXISTS" then there is a new message waiting
elif line.endswith('EXISTS'):
# to end the IDLE state and actually get the message we issue "DONE"
self.cmd('DONE')
# find all new messages (those flagged as 'UNSEEN')
(status,nums) = self.imap.search('utf8','UNSEEN')
# we have a list of message IDs, now we need to get their content
for n in nums[0].split(' '):
# we fetch the message from the server, in the RFC822 format
msg = self.imap.fetch(n,'(RFC822)')[1][0][1]
# then convert it to an email.Message object and store it in our Queue
self.msgs.put(email.message_from_string(msg))
# after we get the new message(s) and Queue them, we enter IDLE again
self.cmd('IDLE')
It's also possible that a blank line
is due to an actualy connection issue
rather than a simple timeout. In that case, since we're calling connect()
in
here, we need to handle any exceptions it might raise. If we don't, they'll
likely kill the thread without letting our protocol know. As explained in the
Threading Tutorial, in python the main thread never sees exceptions
raised in other threads. So instead, we'll catch any exceptions and just add
them to self.msgs
. Then in our Protocol
sub-class we'll check to see if we
get a Message
or Exception
when we access the Queue
and handle it
accordingly.
import email
def run(self):
while True:
line = self.imap.readline().strip()
if not line:
# catch ConnectFailure and AuthFailure when attempting to connect
try:
self.connect()
except ProtocolError as e:
self.msgs.put(e)
elif line.endswith('EXISTS'):
self.cmd('DONE')
(status,nums) = self.imap.search('utf8','UNSEEN')
for n in nums[0].split(' '):
msg = self.imap.fetch(n,'(RFC822)')[1][0][1]
self.msgs.put(email.message_from_string(msg))
self.cmd('IDLE')
Finally, let's have our Protocol
class handle reconnection by calling our
thread's connect()
method. When we catch an Exception in run()
we'll also
set self.imap = None
. When our Protocol
tries to reconnect and calls our
thread's connect()
method and it succeeds, self.imap
will then contain the
imaplib.IMAP4_SSL
object. This means that if our thread disconnects, it will
sit idle in time.sleep()
until our protocol reconnects it.
import email,time
def run(self):
while True:
# wait for reconnection if needed
if not self.imap:
time.sleep(1)
continue
line = self.imap.readline().strip()
if not line:
try:
self.connect()
except ProtocolError as e:
self.msgs.put(e)
# update self.imap to wait for reconnection
self.imap = None
elif line.endswith('EXISTS'):
self.cmd('DONE')
(status,nums) = self.imap.search('utf8','UNSEEN')
for n in nums[0].split(' '):
msg = self.imap.fetch(n,'(RFC822)')[1][0][1]
self.msgs.put(email.message_from_string(msg))
self.cmd('IDLE')
Now we need to sub-class Protocol
which is what Sibyl is actually going to
instantiate and call methods from. During bot startup, Sibyl will call the
setup()
method shortly followed by the connect()
method. Then the bot sits
in a loop calling process()
every so often to handle new messages.
First we'll do the setup()
method and define some variables. We also need to
create an IMAPThread
and start it with start()
. Because of the idling logic
we put at the beginning of the thread's run()
method, it won't try to do
anything before we call connect()
on it.
-
self.smtp_serv
- server address to use for SMTP -
self.imap_serv
- server address to use for IMAP -
self.smtp
- our SMTP object fromsmtplib
-
self.thread
- ourIMAPThread
instance
from sibyl.lib.protocol import Protocol
class MailProtocol(Protocol):
def setup(self):
# create default values if needed for SMTP and IMAP servers
server = self.opt('mail.address').split('@')[-1]
self.smtp_serv = (self.opt('mail.smtp') or ('smtp.'+server))
self.imap_serv = (self.opt('mail.imap') or ('imap.'+server))
self.smtp = None
# create a new IMAPThread and start it
self.thread = IMAPThread(self)
self.thread.start()
Next we'll do connect()
, where we'll attempt to connect to SMTP and IMAP and
raise exceptions as needed. We should also have some helpful log messages so if
something goes wrong the user (or a developer) has a chance to figure it out. To
make things easier in the future, we'll give connecting to SMTP its own function
called _connect_smtp()
. The leading underscore is just for convenience; in
this case it just helps us remember it's our own function and not required by
sibyl.lib.protocol.Protocol
.
def connect(self):
# our thread's connect() method already catches and re-raises exceptions
self.log.debug('Attempting IMAP connection')
self.thread.connect()
self.log.info('IMAP successful')
self.log.debug('Attempting SMTP connection')
self._connect_smtp()
self.log.info('SMTP successful')
def _connect_smtp(self):
# we have to catch and re-raise exceptions here
try:
self.smtp = smtplib.SMTP(self.smtp_serv,port=587)
# we'll use TLS for security and encryption
self.smtp.starttls()
self.smtp.ehlo()
except:
raise ConnectFailure
# if the protocol raises AuthFailure, Sibyl will never try to reconnect
try:
self.smtp.login(self.opt('mail.address'),self.opt('mail.password'))
except:
raise AuthFailure
Next a few easy ones. For is_connected()
we'll just use the state of our
IMAP thread's imap object. The shutdown()
method is called by Sibyl when
the bot is quitting, but in our case we don't need it for anything.
def is_connected(self):
return self.thread.imap is not None
def shutdown(self):
pass
Now we're at the most important function, process()
, which Sibyl calls about
once a second to process new messages and execute commands. Here we need to
check if there are any new messages in our thread's Queue
, translate them into
sibyl.lib.protocol.Message
objects, and send them on to Sibyl. We also need to
check if items we get from the Queue
are exceptions because of how we
implemented that in our IMAPThread
class.
from sibyl.lib.protocol import Message
def process(self):
# go through every message in our thread's Queue
while not self.thread.msgs.empty():
mail = self.thread.msgs.get()
# if the message is actually an exception, raise it
if isinstance(mail,Exception):
raise mail
# get the sender
frm = email.utils.parseaddr(mail['From'])[1]
user = MailUser(self,frm)
# get the body, throwing out everything except the first line
body = mail.get_payload().split('\n')[0].strip()
# create the message and send it to Sibyl
msg = Message(user,body)
self.log.debug('Got mail from "%s"' % frm)
self.bot._cb_message(msg)
Equally important is the send()
methods, which lets Sibyl reply. The only real
caveat here is that we need to check if our SMTP connection is still open, since
like IMAP it has a timeout.
from email.mime.text import MIMEText
def send(self,mess):
# test our SMTP connection using noop()
try:
status = self.smtp.noop()[0]
except:
status = -1
# a code of 250 means everything is good; otherwise reconnect
if status!=250:
self._connect_smtp()
# build the message using the email module and send it over SMTP
msg = MIMEText(mess.get_text())
msg['Subject'] = 'Sibyl reply'
msg['From'] = self.opt('mail.address')
msg['To'] = str(mess.get_to())
self.smtp.sendmail(msg['From'],msg['To'],msg.as_string())
Now we've got a series of functions that deal specifically with rooms. Since
e-mail doesn't have rooms, most of these will just be pass
. For those that
need to return something, it will be some sane empty value, for example an
empty list. Notable is join_room()
, which always notifies sibyl of failure
using _cb_join_room_failure()
.
def broadcast(self,mess):
pass
def join_room(self,room):
bot._cb_join_room_failure(room,'Not supported')
def part_room(self,room):
pass
def _get_rooms(self,flag):
return []
def get_occupants(self,room):
return []
def get_nick(self,room):
return ''
def get_real(self,room,nick):
return nick
Next we need a way for Sibyl or its plug-ins to get our username.
def get_user(self):
return MailUser(self,self.opt('mail.address'))
Finally, Sibyl and its plug-ins need a way to create MailUser
and MailRoom
.
These methods almost always look the same in every protocol; they're necessary
because the Protocol
super-class doesn't have access to our e-mail
sub-classes.
def new_user(self,user,typ=None,real=None):
return MailUser(self,user,typ,real)
def new_room(self,name,nick=None,pword=None):
return MailRoom(self,name,nick,pword)
One final note: if you haven't done so already, you should keep all of your imports at the top of your file for organization and clarity. Here is a compiled list of the imports to make it easier:
import imaplib,smtplib,email,time
from email.mime.text import MIMEText
from threading import Thread
from Queue import Queue
from sibyl.lib.protocol import User,Room,Protocol,Message
from sibyl.lib.decorators import botconf
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
If you want to test your comprehension, try going through
example/sibyl_mail.py
and writing a comment for every line. If you really know
what everything is doing, you or your comments should be able to explain it to
another python coder.
And now we're done! Just copy example/sibyl_mail.py
or the file you've been
working on to the protocols/
directory and add mail
to your list of enabled
protocols. (You could even have email
and mail
enabled at the same time if
you wanted.) Then set the config options, remembering they need to start with
mail.
(e.g. mail.password
) and start Sibyl. Of course, you also need a valid
e-mail address for sibyl to use.