Replacement for custom_message_handler.
[jabberbot.git] / jabberbot.py
blobc11e781946a5230fd169fbd10f8e3da3c146bbf5
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
4 # JabberBot: A simple jabber/xmpp bot framework
5 # Copyright (c) 2007-2011 Thomas Perl <thp.io/about>
6 # $Id$
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 """
23 A framework for writing Jabber/XMPP bots and services
25 The JabberBot framework allows you to easily write bots
26 that use the XMPP protocol. You can create commands by
27 decorating functions in your subclass or customize the
28 bot's operation completely. MUCs are also supported.
29 """
31 import os
32 import re
33 import sys
35 try:
36 import xmpp
37 except ImportError:
38 print >> sys.stderr, """
39 You need to install xmpppy from http://xmpppy.sf.net/.
40 On Debian-based systems, install the python-xmpp package.
41 """
42 sys.exit(-1)
44 import time
45 import inspect
46 import logging
47 import traceback
49 # Will be parsed by setup.py to determine package metadata
50 __author__ = 'Thomas Perl <m@thp.io>'
51 __version__ = '0.14'
52 __website__ = 'http://thp.io/2007/python-jabberbot/'
53 __license__ = 'GNU General Public License version 3 or later'
55 def botcmd(*args, **kwargs):
56 """Decorator for bot command functions"""
58 def decorate(func, hidden=False, name=None):
59 setattr(func, '_jabberbot_command', True)
60 setattr(func, '_jabberbot_hidden', hidden)
61 setattr(func, '_jabberbot_command_name', name or func.__name__)
62 return func
64 if len(args):
65 return decorate(args[0], **kwargs)
66 else:
67 return lambda func: decorate(func, **kwargs)
70 class JabberBot(object):
71 # Show types for presence
72 AVAILABLE, AWAY, CHAT = None, 'away', 'chat'
73 DND, XA, OFFLINE = 'dnd', 'xa', 'unavailable'
75 # UI-messages (overwrite to change content)
76 MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. ' \
77 'Authorize my request and I will do the same.'
78 MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. '\
79 'Access denied.'
80 MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
81 'Type "help" for available commands.'
82 MSG_HELP_TAIL = 'Type help <command name> to get more info '\
83 'about that specific command.'
84 MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'
85 MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
86 'An unexpected error occurred.'
88 PING_FREQUENCY = 0 # Set to the number of seconds, e.g. 60.
89 PING_TIMEOUT = 2 # Seconds to wait for a response.
91 def __init__(self, username, password, res=None, debug=False,
92 privatedomain=False, acceptownmsgs=False, handlers=None):
93 """Initializes the jabber bot and sets up commands.
95 username and password should be clear ;)
97 If res provided, res will be ressourcename,
98 otherwise it defaults to classname of childclass
100 If debug is True log messages of xmpppy will be printed to console.
101 Logging of Jabberbot itself is NOT affected.
103 If privatedomain is provided, it should be either
104 True to only allow subscriptions from the same domain
105 as the bot or a string that describes the domain for
106 which subscriptions are accepted (e.g. 'jabber.org').
108 If acceptownmsgs it set to True, this bot will accept
109 messages from the same JID that the bot itself has. This
110 is useful when using JabberBot with a single Jabber account
111 and multiple instances that want to talk to each other.
113 If handlers are provided, default handlers won't be enabled.
114 Usage like: [('stanzatype1', function1), ('stanzatype2', function2)]
115 Signature of function should be callback_xx(self, conn, stanza),
116 where conn is the connection and stanza the current stanza in process.
117 First handler in list will be served first.
118 Don't forget to raise exception xmpp.NodeProcessed to stop
119 processing in other handlers (see callback_presence)
121 # TODO sort this initialisation thematically
122 self.__debug = debug
123 self.log = logging.getLogger(__name__)
124 self.__username = username
125 self.__password = password
126 self.jid = xmpp.JID(self.__username)
127 self.res = (res or self.__class__.__name__)
128 self.conn = None
129 self.__finished = False
130 self.__show = None
131 self.__status = None
132 self.__seen = {}
133 self.__threads = {}
134 self.__lastping = time.time()
135 self.__privatedomain = privatedomain
136 self.__acceptownmsgs = acceptownmsgs
138 self.handlers = (handlers or [('message', self.callback_message),
139 ('presence', self.callback_presence)])
141 # Collect commands from source
142 self.commands = {}
143 for name, value in inspect.getmembers(self):
144 if inspect.ismethod(value) and getattr(value, \
145 '_jabberbot_command', False):
146 name = getattr(value, '_jabberbot_command_name')
147 self.log.info('Registered command: %s' % name)
148 self.commands[name] = value
150 self.roster = None
152 ################################
154 def _send_status(self):
155 """Send status to everyone"""
156 self.conn.send(xmpp.dispatcher.Presence(show=self.__show,
157 status=self.__status))
159 def __set_status(self, value):
160 """Set status message.
161 If value remains constant, no presence stanza will be send"""
162 if self.__status != value:
163 self.__status = value
164 self._send_status()
166 def __get_status(self):
167 """Get current status message"""
168 return self.__status
170 status_message = property(fget=__get_status, fset=__set_status)
172 def __set_show(self, value):
173 """Set show (status type like AWAY, DND etc.).
174 If value remains constant, no presence stanza will be send"""
175 if self.__show != value:
176 self.__show = value
177 self._send_status()
179 def __get_show(self):
180 """Get current show (status type like AWAY, DND etc.)."""
181 return self.__show
183 status_type = property(fget=__get_show, fset=__set_show)
185 ################################
187 def connect(self):
188 """Connects the bot to server or returns current connection,
189 send inital presence stanza
190 and registers handlers
192 if not self.conn:
193 #TODO improve debug
194 if self.__debug:
195 conn = xmpp.Client(self.jid.getDomain())
196 else:
197 conn = xmpp.Client(self.jid.getDomain(), debug=[])
199 #connection attempt
200 conres = conn.connect()
201 if not conres:
202 self.log.error('unable to connect to server %s.' %
203 self.jid.getDomain())
204 return None
205 if conres != 'tls':
206 self.log.warning('unable to establish secure connection '\
207 '- TLS failed!')
209 authres = conn.auth(self.jid.getNode(), self.__password, self.res)
210 if not authres:
211 self.log.error('unable to authorize with server.')
212 return None
213 if authres != 'sasl':
214 self.log.warning("unable to perform SASL auth on %s. "\
215 "Old authentication method used!" % self.jid.getDomain())
217 # Connection established - save connection
218 self.conn = conn
220 # Send initial presence stanza (say hello to everyone)
221 self.conn.sendInitPresence()
222 # Save roster and log Items
223 self.roster = self.conn.Roster.getRoster()
224 self.log.info('*** roster ***')
225 for contact in self.roster.getItems():
226 self.log.info(' %s' % contact)
227 self.log.info('*** roster ***')
229 # Register given handlers (TODO move to own function)
230 for (handler, callback) in self.handlers:
231 self.conn.RegisterHandler(handler, callback)
232 self.log.debug('Registered handler: %s' % handler)
234 return self.conn
236 def join_room(self, room, username=None, password=None):
237 """Join the specified multi-user chat room
239 If username is NOT provided fallback to node part of JID"""
240 # TODO fix namespacestrings and history settings
241 NS_MUC = 'http://jabber.org/protocol/muc'
242 if username is None:
243 # TODO use xmpppy function getNode
244 username = self.__username.split('@')[0]
245 my_room_JID = '/'.join((room, username))
246 pres = xmpp.Presence(to=my_room_JID)
247 if password is not None:
248 pres.setTag('x',namespace=NS_MUC).setTagData('password',password)
249 self.connect().send(pres)
251 def quit(self):
252 """Stop serving messages and exit.
254 I find it is handy for development to run the
255 jabberbot in a 'while true' loop in the shell, so
256 whenever I make a code change to the bot, I send
257 the 'reload' command, which I have mapped to call
258 self.quit(), and my shell script relaunches the
259 new version.
261 self.__finished = True
263 def send_message(self, mess):
264 """Send an XMPP message"""
265 self.connect().send(mess)
267 def send_tune(self, song, debug=False):
268 """Set information about the currently played tune
270 Song is a dictionary with keys: file, title, artist, album, pos, track,
271 length, uri. For details see <http://xmpp.org/protocols/tune/>.
273 NS_TUNE = 'http://jabber.org/protocol/tune'
274 iq = xmpp.Iq(typ='set')
275 iq.setFrom(self.jid)
276 iq.pubsub = iq.addChild('pubsub', namespace=xmpp.NS_PUBSUB)
277 iq.pubsub.publish = iq.pubsub.addChild('publish',
278 attrs={ 'node' : NS_TUNE })
279 iq.pubsub.publish.item = iq.pubsub.publish.addChild('item',
280 attrs={ 'id' : 'current' })
281 tune = iq.pubsub.publish.item.addChild('tune')
282 tune.setNamespace(NS_TUNE)
284 title = None
285 if song.has_key('title'):
286 title = song['title']
287 elif song.has_key('file'):
288 title = os.path.splitext(os.path.basename(song['file']))[0]
289 if title is not None:
290 tune.addChild('title').addData(title)
291 if song.has_key('artist'):
292 tune.addChild('artist').addData(song['artist'])
293 if song.has_key('album'):
294 tune.addChild('source').addData(song['album'])
295 if song.has_key('pos') and song['pos'] > 0:
296 tune.addChild('track').addData(str(song['pos']))
297 if song.has_key('time'):
298 tune.addChild('length').addData(str(song['time']))
299 if song.has_key('uri'):
300 tune.addChild('uri').addData(song['uri'])
302 if debug:
303 self.log.info('Sending tune: %s' % iq.__str__().encode('utf8'))
304 self.conn.send(iq)
306 def send(self, user, text, in_reply_to=None, message_type='chat'):
307 """Sends a simple message to the specified user."""
308 mess = self.build_message(text)
309 mess.setTo(user)
311 if in_reply_to:
312 mess.setThread(in_reply_to.getThread())
313 mess.setType(in_reply_to.getType())
314 else:
315 mess.setThread(self.__threads.get(user, None))
316 mess.setType(message_type)
318 self.send_message(mess)
320 def send_simple_reply(self, mess, text, private=False):
321 """Send a simple response to a message"""
322 self.send_message(self.build_reply(mess, text, private))
324 def build_reply(self, mess, text=None, private=False):
325 """Build a message for responding to another message.
326 Message is NOT sent"""
327 response = self.build_message(text)
328 if private:
329 response.setTo(mess.getFrom())
330 response.setType('chat')
331 else:
332 response.setTo(mess.getFrom().getStripped())
333 response.setType(mess.getType())
334 response.setThread(mess.getThread())
335 return response
337 def build_message(self, text):
338 """Builds an xhtml message without attributes.
339 If input is not valid xhtml-im fallback to normal."""
340 message = None # init message variable
341 # Try to determine if text has xhtml-tags - TODO needs improvement
342 text_plain = re.sub(r'<[^>]+>', '', text)
343 if text_plain != text:
344 # Create body w stripped tags for reciptiens w/o xhtml-abilities
345 # FIXME unescape &quot; etc.
346 message = xmpp.protocol.Message(body=text_plain)
347 # Start creating a xhtml body
348 html = xmpp.Node('html', \
349 {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
350 try:
351 html.addChild(node=xmpp.simplexml.XML2Node( \
352 "<body xmlns='http://www.w3.org/1999/xhtml'>" + \
353 text.encode('utf-8') + "</body>"))
354 message.addChild(node=html)
355 except Exception, e:
356 # Didn't work, incorrect markup or something.
357 self.log.debug('An error while building a xhtml message. '\
358 'Fallback to normal messagebody')
359 # Fallback - don't sanitize invalid input. User is responsible!
360 message = None
361 if message is None:
362 # Normal body
363 message = xmpp.protocol.Message(body=text)
364 return message
366 def get_sender_username(self, mess):
367 """Extract the sender's user name from a message"""
368 type = mess.getType()
369 jid = mess.getFrom()
370 if type == "groupchat":
371 username = jid.getResource()
372 elif type == "chat":
373 username = jid.getNode()
374 else:
375 username = ""
376 return username
378 def get_full_jids(self, jid):
379 """Returns all full jids, which belong to a bare jid
381 Example: A bare jid is bob@jabber.org, with two clients connected,
382 which
383 have the full jids bob@jabber.org/home and bob@jabber.org/work."""
384 for res in self.roster.getResources(jid):
385 full_jid = "%s/%s" % (jid,res)
386 yield full_jid
388 def status_type_changed(self, jid, new_status_type):
389 """Callback for tracking status types (dnd, away, offline, ...)"""
390 self.log.debug('user %s changed status to %s' % (jid, new_status_type))
392 def status_message_changed(self, jid, new_status_message):
393 """Callback for tracking status messages (the free-form status text)"""
394 self.log.debug('user %s updated text to %s' %
395 (jid, new_status_message))
397 def broadcast(self, message, only_available=False):
398 """Broadcast a message to all users 'seen' by this bot.
400 If the parameter 'only_available' is True, the broadcast
401 will not go to users whose status is not 'Available'."""
402 for jid, (show, status) in self.__seen.items():
403 if not only_available or show is self.AVAILABLE:
404 self.send(jid, message)
406 def callback_presence(self, conn, presence):
407 self.__lastping = time.time()
408 jid, type_, show, status = presence.getFrom(), \
409 presence.getType(), presence.getShow(), \
410 presence.getStatus()
412 if self.jid.bareMatch(jid):
413 # update internal status
414 if type_ != self.OFFLINE:
415 self.__status = status
416 self.__show = show
417 else:
418 self.__status = ""
419 self.__show = self.OFFLINE
420 if not self.__acceptownmsgs:
421 # Ignore our own presence messages
422 return
424 if type_ is None:
425 # Keep track of status message and type changes
426 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
427 if old_show != show:
428 self.status_type_changed(jid, show)
430 if old_status != status:
431 self.status_message_changed(jid, status)
433 self.__seen[jid] = (show, status)
434 elif type_ == self.OFFLINE and jid in self.__seen:
435 # Notify of user offline status change
436 del self.__seen[jid]
437 self.status_type_changed(jid, self.OFFLINE)
439 try:
440 subscription = self.roster.getSubscription(unicode(jid.__str__()))
441 except KeyError, e:
442 # User not on our roster
443 subscription = None
444 except AttributeError, e:
445 # Recieved presence update before roster built
446 return
448 if type_ == 'error':
449 self.log.error(presence.getError())
451 self.log.debug('Got presence: %s (type: %s, show: %s, status: %s, '\
452 'subscription: %s)' % (jid, type_, show, status, subscription))
454 # If subscription is private,
455 # disregard anything not from the private domain
456 if self.__privatedomain and type_ in ('subscribe', 'subscribed', \
457 'unsubscribe'):
458 if self.__privatedomain == True:
459 # Use the bot's domain
460 domain = self.jid.getDomain()
461 else:
462 # Use the specified domain
463 domain = self.__privatedomain
465 # Check if the sender is in the private domain
466 user_domain = jid.getDomain()
467 if domain != user_domain:
468 self.log.info('Ignoring subscribe request: %s does not '\
469 'match private domain (%s)' % (user_domain, domain))
470 return
472 if type_ == 'subscribe':
473 # Incoming presence subscription request
474 if subscription in ('to', 'both', 'from'):
475 self.roster.Authorize(jid)
476 self._send_status()
478 if subscription not in ('to', 'both'):
479 self.roster.Subscribe(jid)
481 if subscription in (None, 'none'):
482 self.send(jid, self.MSG_AUTHORIZE_ME)
483 elif type_ == 'subscribed':
484 # Authorize any pending requests for that JID
485 self.roster.Authorize(jid)
486 elif type_ == 'unsubscribed':
487 # Authorization was not granted
488 self.send(jid, self.MSG_NOT_AUTHORIZED)
489 self.roster.Unauthorize(jid)
491 def callback_message(self, conn, mess):
492 """Messages sent to the bot will arrive here.
493 Command handling + routing is done in this function."""
494 self.__lastping = time.time()
496 # Prepare to handle either private chats or group chats
497 type = mess.getType()
498 jid = mess.getFrom()
499 props = mess.getProperties()
500 text = mess.getBody()
501 username = self.get_sender_username(mess)
503 if type not in ("groupchat", "chat"):
504 self.log.debug("unhandled message type: %s" % type)
505 return
507 # Ignore messages from before we joined
508 if xmpp.NS_DELAY in props: return
510 # Ignore messages from myself
511 if self.jid.bareMatch(jid): return
513 self.log.debug("*** props = %s" % props)
514 self.log.debug("*** jid = %s" % jid)
515 self.log.debug("*** username = %s" % username)
516 self.log.debug("*** type = %s" % type)
517 self.log.debug("*** text = %s" % text)
519 # If a message format is not supported (eg. encrypted),
520 # txt will be None
521 if not text: return
523 # Ignore messages from users not seen by this bot
524 if jid not in self.__seen:
525 self.log.info('Ignoring message from unseen guest: %s' % jid)
526 self.log.debug("I've seen: %s" %
527 ["%s" % x for x in self.__seen.keys()])
528 return
530 # Remember the last-talked-in thread for replies
531 self.__threads[jid] = mess.getThread()
533 if ' ' in text:
534 command, args = text.split(' ', 1)
535 else:
536 command, args = text, ''
537 cmd = command.lower()
538 self.log.debug("*** cmd = %s" % cmd)
540 if self.commands.has_key(cmd):
541 try:
542 reply = self.commands[cmd](mess, args)
543 except Exception, e:
544 self.log.exception('An error happened while processing '\
545 'a message ("%s") from %s: %s"' %
546 (text, jid, traceback.format_exc(e)))
547 reply = self.MSG_ERROR_OCCURRED
548 else:
549 # In private chat, it's okay for the bot to always respond.
550 # In group chat, the bot should silently ignore commands it
551 # doesn't understand or aren't handled by unknown_command().
552 if type == 'groupchat':
553 default_reply = None
554 else:
555 default_reply = self.MSG_UNKNOWN_COMMAND % {'command': cmd}
556 reply = self.unknown_command(mess, cmd, args)
557 if reply is None:
558 reply = default_reply
559 if reply:
560 self.send_simple_reply(mess, reply)
562 def unknown_command(self, mess, cmd, args):
563 """Default handler for unknown commands
565 Override this method in derived class if you
566 want to trap some unrecognized commands. If
567 'cmd' is handled, you must return some non-false
568 value, else some helpful text will be sent back
569 to the sender.
571 return None
573 def top_of_help_message(self):
574 """Returns a string that forms the top of the help message
576 Override this method in derived class if you
577 want to add additional help text at the
578 beginning of the help message.
580 return ""
582 def bottom_of_help_message(self):
583 """Returns a string that forms the bottom of the help message
585 Override this method in derived class if you
586 want to add additional help text at the end
587 of the help message.
589 return ""
591 @botcmd
592 def help(self, mess, args):
593 """ Returns a help string listing available options.
595 Automatically assigned to the "help" command."""
596 if not args:
597 if self.__doc__:
598 description = self.__doc__.strip()
599 else:
600 description = 'Available commands:'
602 usage = '\n'.join(sorted([
603 '%s: %s' % (name, (command.__doc__ or \
604 '(undocumented)').strip().split('\n', 1)[0])
605 for (name, command) in self.commands.iteritems() \
606 if name != 'help' \
607 and not command._jabberbot_hidden
609 usage = '\n\n'.join([usage, self.MSG_HELP_TAIL])
610 else:
611 description = ''
612 if args in self.commands:
613 usage = (self.commands[args].__doc__ or \
614 'undocumented').strip()
615 else:
616 usage = self.MSG_HELP_UNDEFINED_COMMAND
618 top = self.top_of_help_message()
619 bottom = self.bottom_of_help_message()
620 return '\n\n'.join([top, description, usage, bottom])
622 def idle_proc(self):
623 """This function will be called in the main loop."""
624 self._idle_ping()
626 def _idle_ping(self):
627 """Pings the server, calls on_ping_timeout() on no response.
629 To enable set self.PING_FREQUENCY to a value higher than zero.
631 if self.PING_FREQUENCY \
632 and time.time() - self.__lastping > self.PING_FREQUENCY:
633 self.__lastping = time.time()
634 #logging.debug('Pinging the server.')
635 ping = xmpp.Protocol('iq', typ='get', \
636 payload=[xmpp.Node('ping', attrs={'xmlns':'urn:xmpp:ping'})])
637 try:
638 res = self.conn.SendAndWaitForResponse(ping, self.PING_TIMEOUT)
639 #logging.debug('Got response: ' + str(res))
640 if res is None:
641 self.on_ping_timeout()
642 except IOError, e:
643 logging.error('Error pinging the server: %s, '\
644 'treating as ping timeout.' % e)
645 self.on_ping_timeout()
647 def on_ping_timeout(self):
648 logging.info('Terminating due to PING timeout.')
649 self.quit()
651 def shutdown(self):
652 """This function will be called when we're done serving
654 Override this method in derived class if you
655 want to do anything special at shutdown.
657 pass
659 def serve_forever(self, connect_callback=None, disconnect_callback=None):
660 """Connects to the server and handles messages."""
661 conn = self.connect()
662 if conn:
663 self.log.info('bot connected. serving forever.')
664 else:
665 self.log.warn('could not connect to server - aborting.')
666 return
668 if connect_callback:
669 connect_callback()
670 self.__lastping = time.time()
672 while not self.__finished:
673 try:
674 conn.Process(1)
675 self.idle_proc()
676 except KeyboardInterrupt:
677 self.log.info('bot stopped by user request. '\
678 'shutting down.')
679 break
681 self.shutdown()
683 if disconnect_callback:
684 disconnect_callback()
686 # vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4