Control of linebreak in help back in users hand
[jabberbot.git] / jabberbot.py
blob0c2ac01d5f413ab7445e1db83459a7162ad56f9c
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
34 import thread
36 try:
37 import xmpp
38 except ImportError:
39 print >> sys.stderr, """
40 You need to install xmpppy from http://xmpppy.sf.net/.
41 On Debian-based systems, install the python-xmpp package.
42 """
43 sys.exit(-1)
45 import time
46 import inspect
47 import logging
48 import traceback
50 # Will be parsed by setup.py to determine package metadata
51 __author__ = 'Thomas Perl <m@thp.io>'
52 __version__ = '0.14'
53 __website__ = 'http://thp.io/2007/python-jabberbot/'
54 __license__ = 'GNU General Public License version 3 or later'
56 def botcmd(*args, **kwargs):
57 """Decorator for bot command functions"""
59 def decorate(func, hidden=False, name=None, thread=False):
60 setattr(func, '_jabberbot_command', True)
61 setattr(func, '_jabberbot_hidden', hidden)
62 setattr(func, '_jabberbot_command_name', name or func.__name__)
63 setattr(func, '_jabberbot_command_thread', thread)
64 return func
66 if len(args):
67 return decorate(args[0], **kwargs)
68 else:
69 return lambda func: decorate(func, **kwargs)
72 class JabberBot(object):
73 # Show types for presence
74 AVAILABLE, AWAY, CHAT = None, 'away', 'chat'
75 DND, XA, OFFLINE = 'dnd', 'xa', 'unavailable'
77 # UI-messages (overwrite to change content)
78 MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. ' \
79 'Authorize my request and I will do the same.'
80 MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. '\
81 'Access denied.'
82 MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
83 'Type "help" for available commands.'
84 MSG_HELP_TAIL = 'Type help <command name> to get more info '\
85 'about that specific command.'
86 MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'
87 MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
88 'An unexpected error occurred.'
90 PING_FREQUENCY = 0 # Set to the number of seconds, e.g. 60.
91 PING_TIMEOUT = 2 # Seconds to wait for a response.
93 def __init__(self, username, password, res=None, debug=False,
94 privatedomain=False, acceptownmsgs=False, handlers=None):
95 """Initializes the jabber bot and sets up commands.
97 username and password should be clear ;)
99 If res provided, res will be ressourcename,
100 otherwise it defaults to classname of childclass
102 If debug is True log messages of xmpppy will be printed to console.
103 Logging of Jabberbot itself is NOT affected.
105 If privatedomain is provided, it should be either
106 True to only allow subscriptions from the same domain
107 as the bot or a string that describes the domain for
108 which subscriptions are accepted (e.g. 'jabber.org').
110 If acceptownmsgs it set to True, this bot will accept
111 messages from the same JID that the bot itself has. This
112 is useful when using JabberBot with a single Jabber account
113 and multiple instances that want to talk to each other.
115 If handlers are provided, default handlers won't be enabled.
116 Usage like: [('stanzatype1', function1), ('stanzatype2', function2)]
117 Signature of function should be callback_xx(self, conn, stanza),
118 where conn is the connection and stanza the current stanza in process.
119 First handler in list will be served first.
120 Don't forget to raise exception xmpp.NodeProcessed to stop
121 processing in other handlers (see callback_presence)
123 # TODO sort this initialisation thematically
124 self.__debug = debug
125 self.log = logging.getLogger(__name__)
126 self.__username = username
127 self.__password = password
128 self.jid = xmpp.JID(self.__username)
129 self.res = (res or self.__class__.__name__)
130 self.conn = None
131 self.__finished = False
132 self.__show = None
133 self.__status = None
134 self.__seen = {}
135 self.__threads = {}
136 self.__lastping = time.time()
137 self.__privatedomain = privatedomain
138 self.__acceptownmsgs = acceptownmsgs
140 self.handlers = (handlers or [('message', self.callback_message),
141 ('presence', self.callback_presence)])
143 # Collect commands from source
144 self.commands = {}
145 for name, value in inspect.getmembers(self):
146 if inspect.ismethod(value) and getattr(value, \
147 '_jabberbot_command', False):
148 name = getattr(value, '_jabberbot_command_name')
149 self.log.info('Registered command: %s' % name)
150 self.commands[name] = value
152 self.roster = None
154 ################################
156 def _send_status(self):
157 """Send status to everyone"""
158 self.conn.send(xmpp.dispatcher.Presence(show=self.__show,
159 status=self.__status))
161 def __set_status(self, value):
162 """Set status message.
163 If value remains constant, no presence stanza will be send"""
164 if self.__status != value:
165 self.__status = value
166 self._send_status()
168 def __get_status(self):
169 """Get current status message"""
170 return self.__status
172 status_message = property(fget=__get_status, fset=__set_status)
174 def __set_show(self, value):
175 """Set show (status type like AWAY, DND etc.).
176 If value remains constant, no presence stanza will be send"""
177 if self.__show != value:
178 self.__show = value
179 self._send_status()
181 def __get_show(self):
182 """Get current show (status type like AWAY, DND etc.)."""
183 return self.__show
185 status_type = property(fget=__get_show, fset=__set_show)
187 ################################
189 def connect(self):
190 """Connects the bot to server or returns current connection,
191 send inital presence stanza
192 and registers handlers
194 if not self.conn:
195 #TODO improve debug
196 if self.__debug:
197 conn = xmpp.Client(self.jid.getDomain())
198 else:
199 conn = xmpp.Client(self.jid.getDomain(), debug=[])
201 #connection attempt
202 conres = conn.connect()
203 if not conres:
204 self.log.error('unable to connect to server %s.' %
205 self.jid.getDomain())
206 return None
207 if conres != 'tls':
208 self.log.warning('unable to establish secure connection '\
209 '- TLS failed!')
211 authres = conn.auth(self.jid.getNode(), self.__password, self.res)
212 if not authres:
213 self.log.error('unable to authorize with server.')
214 return None
215 if authres != 'sasl':
216 self.log.warning("unable to perform SASL auth on %s. "\
217 "Old authentication method used!" % self.jid.getDomain())
219 # Connection established - save connection
220 self.conn = conn
222 # Send initial presence stanza (say hello to everyone)
223 self.conn.sendInitPresence()
224 # Save roster and log Items
225 self.roster = self.conn.Roster.getRoster()
226 self.log.info('*** roster ***')
227 for contact in self.roster.getItems():
228 self.log.info(' %s' % contact)
229 self.log.info('*** roster ***')
231 # Register given handlers (TODO move to own function)
232 for (handler, callback) in self.handlers:
233 self.conn.RegisterHandler(handler, callback)
234 self.log.debug('Registered handler: %s' % handler)
236 return self.conn
238 def join_room(self, room, username=None, password=None):
239 """Join the specified multi-user chat room
241 If username is NOT provided fallback to node part of JID"""
242 # TODO fix namespacestrings and history settings
243 NS_MUC = 'http://jabber.org/protocol/muc'
244 if username is None:
245 # TODO use xmpppy function getNode
246 username = self.__username.split('@')[0]
247 my_room_JID = '/'.join((room, username))
248 pres = xmpp.Presence(to=my_room_JID)
249 if password is not None:
250 pres.setTag('x',namespace=NS_MUC).setTagData('password',password)
251 self.connect().send(pres)
253 def quit(self):
254 """Stop serving messages and exit.
256 I find it is handy for development to run the
257 jabberbot in a 'while true' loop in the shell, so
258 whenever I make a code change to the bot, I send
259 the 'reload' command, which I have mapped to call
260 self.quit(), and my shell script relaunches the
261 new version.
263 self.__finished = True
265 def send_message(self, mess):
266 """Send an XMPP message"""
267 self.connect().send(mess)
269 def send_tune(self, song, debug=False):
270 """Set information about the currently played tune
272 Song is a dictionary with keys: file, title, artist, album, pos, track,
273 length, uri. For details see <http://xmpp.org/protocols/tune/>.
275 NS_TUNE = 'http://jabber.org/protocol/tune'
276 iq = xmpp.Iq(typ='set')
277 iq.setFrom(self.jid)
278 iq.pubsub = iq.addChild('pubsub', namespace=xmpp.NS_PUBSUB)
279 iq.pubsub.publish = iq.pubsub.addChild('publish',
280 attrs={ 'node' : NS_TUNE })
281 iq.pubsub.publish.item = iq.pubsub.publish.addChild('item',
282 attrs={ 'id' : 'current' })
283 tune = iq.pubsub.publish.item.addChild('tune')
284 tune.setNamespace(NS_TUNE)
286 title = None
287 if song.has_key('title'):
288 title = song['title']
289 elif song.has_key('file'):
290 title = os.path.splitext(os.path.basename(song['file']))[0]
291 if title is not None:
292 tune.addChild('title').addData(title)
293 if song.has_key('artist'):
294 tune.addChild('artist').addData(song['artist'])
295 if song.has_key('album'):
296 tune.addChild('source').addData(song['album'])
297 if song.has_key('pos') and song['pos'] > 0:
298 tune.addChild('track').addData(str(song['pos']))
299 if song.has_key('time'):
300 tune.addChild('length').addData(str(song['time']))
301 if song.has_key('uri'):
302 tune.addChild('uri').addData(song['uri'])
304 if debug:
305 self.log.info('Sending tune: %s' % iq.__str__().encode('utf8'))
306 self.conn.send(iq)
308 def send(self, user, text, in_reply_to=None, message_type='chat'):
309 """Sends a simple message to the specified user."""
310 mess = self.build_message(text)
311 mess.setTo(user)
313 if in_reply_to:
314 mess.setThread(in_reply_to.getThread())
315 mess.setType(in_reply_to.getType())
316 else:
317 mess.setThread(self.__threads.get(user, None))
318 mess.setType(message_type)
320 self.send_message(mess)
322 def send_simple_reply(self, mess, text, private=False):
323 """Send a simple response to a message"""
324 self.send_message(self.build_reply(mess, text, private))
326 def build_reply(self, mess, text=None, private=False):
327 """Build a message for responding to another message.
328 Message is NOT sent"""
329 response = self.build_message(text)
330 if private:
331 response.setTo(mess.getFrom())
332 response.setType('chat')
333 else:
334 response.setTo(mess.getFrom().getStripped())
335 response.setType(mess.getType())
336 response.setThread(mess.getThread())
337 return response
339 def build_message(self, text):
340 """Builds an xhtml message without attributes.
341 If input is not valid xhtml-im fallback to normal."""
342 message = None # init message variable
343 # Try to determine if text has xhtml-tags - TODO needs improvement
344 text_plain = re.sub(r'<[^>]+>', '', text)
345 if text_plain != text:
346 # Create body w stripped tags for reciptiens w/o xhtml-abilities
347 # FIXME unescape &quot; etc.
348 message = xmpp.protocol.Message(body=text_plain)
349 # Start creating a xhtml body
350 html = xmpp.Node('html', \
351 {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
352 try:
353 html.addChild(node=xmpp.simplexml.XML2Node( \
354 "<body xmlns='http://www.w3.org/1999/xhtml'>" + \
355 text.encode('utf-8') + "</body>"))
356 message.addChild(node=html)
357 except Exception, e:
358 # Didn't work, incorrect markup or something.
359 self.log.debug('An error while building a xhtml message. '\
360 'Fallback to normal messagebody')
361 # Fallback - don't sanitize invalid input. User is responsible!
362 message = None
363 if message is None:
364 # Normal body
365 message = xmpp.protocol.Message(body=text)
366 return message
368 def get_sender_username(self, mess):
369 """Extract the sender's user name from a message"""
370 type = mess.getType()
371 jid = mess.getFrom()
372 if type == "groupchat":
373 username = jid.getResource()
374 elif type == "chat":
375 username = jid.getNode()
376 else:
377 username = ""
378 return username
380 def get_full_jids(self, jid):
381 """Returns all full jids, which belong to a bare jid
383 Example: A bare jid is bob@jabber.org, with two clients connected,
384 which
385 have the full jids bob@jabber.org/home and bob@jabber.org/work."""
386 for res in self.roster.getResources(jid):
387 full_jid = "%s/%s" % (jid,res)
388 yield full_jid
390 def status_type_changed(self, jid, new_status_type):
391 """Callback for tracking status types (dnd, away, offline, ...)"""
392 self.log.debug('user %s changed status to %s' % (jid, new_status_type))
394 def status_message_changed(self, jid, new_status_message):
395 """Callback for tracking status messages (the free-form status text)"""
396 self.log.debug('user %s updated text to %s' %
397 (jid, new_status_message))
399 def broadcast(self, message, only_available=False):
400 """Broadcast a message to all users 'seen' by this bot.
402 If the parameter 'only_available' is True, the broadcast
403 will not go to users whose status is not 'Available'."""
404 for jid, (show, status) in self.__seen.items():
405 if not only_available or show is self.AVAILABLE:
406 self.send(jid, message)
408 def callback_presence(self, conn, presence):
409 self.__lastping = time.time()
410 jid, type_, show, status = presence.getFrom(), \
411 presence.getType(), presence.getShow(), \
412 presence.getStatus()
414 if self.jid.bareMatch(jid):
415 # update internal status
416 if type_ != self.OFFLINE:
417 self.__status = status
418 self.__show = show
419 else:
420 self.__status = ""
421 self.__show = self.OFFLINE
422 if not self.__acceptownmsgs:
423 # Ignore our own presence messages
424 return
426 if type_ is None:
427 # Keep track of status message and type changes
428 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
429 if old_show != show:
430 self.status_type_changed(jid, show)
432 if old_status != status:
433 self.status_message_changed(jid, status)
435 self.__seen[jid] = (show, status)
436 elif type_ == self.OFFLINE and jid in self.__seen:
437 # Notify of user offline status change
438 del self.__seen[jid]
439 self.status_type_changed(jid, self.OFFLINE)
441 try:
442 subscription = self.roster.getSubscription(unicode(jid.__str__()))
443 except KeyError, e:
444 # User not on our roster
445 subscription = None
446 except AttributeError, e:
447 # Recieved presence update before roster built
448 return
450 if type_ == 'error':
451 self.log.error(presence.getError())
453 self.log.debug('Got presence: %s (type: %s, show: %s, status: %s, '\
454 'subscription: %s)' % (jid, type_, show, status, subscription))
456 # If subscription is private,
457 # disregard anything not from the private domain
458 if self.__privatedomain and type_ in ('subscribe', 'subscribed', \
459 'unsubscribe'):
460 if self.__privatedomain == True:
461 # Use the bot's domain
462 domain = self.jid.getDomain()
463 else:
464 # Use the specified domain
465 domain = self.__privatedomain
467 # Check if the sender is in the private domain
468 user_domain = jid.getDomain()
469 if domain != user_domain:
470 self.log.info('Ignoring subscribe request: %s does not '\
471 'match private domain (%s)' % (user_domain, domain))
472 return
474 if type_ == 'subscribe':
475 # Incoming presence subscription request
476 if subscription in ('to', 'both', 'from'):
477 self.roster.Authorize(jid)
478 self._send_status()
480 if subscription not in ('to', 'both'):
481 self.roster.Subscribe(jid)
483 if subscription in (None, 'none'):
484 self.send(jid, self.MSG_AUTHORIZE_ME)
485 elif type_ == 'subscribed':
486 # Authorize any pending requests for that JID
487 self.roster.Authorize(jid)
488 elif type_ == 'unsubscribed':
489 # Authorization was not granted
490 self.send(jid, self.MSG_NOT_AUTHORIZED)
491 self.roster.Unauthorize(jid)
493 def callback_message(self, conn, mess):
494 """Messages sent to the bot will arrive here.
495 Command handling + routing is done in this function."""
496 self.__lastping = time.time()
498 # Prepare to handle either private chats or group chats
499 type = mess.getType()
500 jid = mess.getFrom()
501 props = mess.getProperties()
502 text = mess.getBody()
503 username = self.get_sender_username(mess)
505 if type not in ("groupchat", "chat"):
506 self.log.debug("unhandled message type: %s" % type)
507 return
509 # Ignore messages from before we joined
510 if xmpp.NS_DELAY in props: return
512 # Ignore messages from myself
513 if self.jid.bareMatch(jid): return
515 self.log.debug("*** props = %s" % props)
516 self.log.debug("*** jid = %s" % jid)
517 self.log.debug("*** username = %s" % username)
518 self.log.debug("*** type = %s" % type)
519 self.log.debug("*** text = %s" % text)
521 # If a message format is not supported (eg. encrypted),
522 # txt will be None
523 if not text: return
525 # Ignore messages from users not seen by this bot
526 if jid not in self.__seen:
527 self.log.info('Ignoring message from unseen guest: %s' % jid)
528 self.log.debug("I've seen: %s" %
529 ["%s" % x for x in self.__seen.keys()])
530 return
532 # Remember the last-talked-in message thread for replies
533 # FIXME i am not threadsafe
534 self.__threads[jid] = mess.getThread()
536 if ' ' in text:
537 command, args = text.split(' ', 1)
538 else:
539 command, args = text, ''
540 cmd = command.lower()
541 self.log.debug("*** cmd = %s" % cmd)
543 if self.commands.has_key(cmd):
544 def execute_and_send():
545 try:
546 reply = self.commands[cmd](mess, args)
547 except Exception, e:
548 self.log.exception('An error happened while processing '\
549 'a message ("%s") from %s: %s"' %
550 (text, jid, traceback.format_exc(e)))
551 reply = self.MSG_ERROR_OCCURRED
552 if reply:
553 self.send_simple_reply(mess, reply)
555 # if command should be executed in a seperate thread do it
556 if getattr(self.commands[cmd], '_jabberbot_command_thread'):
557 thread.start_new_thread(execute_and_send, ())
558 else:
559 execute_and_send()
560 else:
561 # In private chat, it's okay for the bot to always respond.
562 # In group chat, the bot should silently ignore commands it
563 # doesn't understand or aren't handled by unknown_command().
564 if type == 'groupchat':
565 default_reply = None
566 else:
567 default_reply = self.MSG_UNKNOWN_COMMAND % {'command': cmd}
568 reply = self.unknown_command(mess, cmd, args)
569 if reply is None:
570 reply = default_reply
571 if reply:
572 self.send_simple_reply(mess, reply)
574 def unknown_command(self, mess, cmd, args):
575 """Default handler for unknown commands
577 Override this method in derived class if you
578 want to trap some unrecognized commands. If
579 'cmd' is handled, you must return some non-false
580 value, else some helpful text will be sent back
581 to the sender.
583 return None
585 def top_of_help_message(self):
586 """Returns a string that forms the top of the help message
588 Override this method in derived class if you
589 want to add additional help text at the
590 beginning of the help message.
592 return ""
594 def bottom_of_help_message(self):
595 """Returns a string that forms the bottom of the help message
597 Override this method in derived class if you
598 want to add additional help text at the end
599 of the help message.
601 return ""
603 @botcmd
604 def help(self, mess, args):
605 """ Returns a help string listing available options.
607 Automatically assigned to the "help" command."""
608 if not args:
609 if self.__doc__:
610 description = self.__doc__.strip()
611 else:
612 description = 'Available commands:'
614 usage = '\n'.join(sorted([
615 '%s: %s' % (name, (command.__doc__ or \
616 '(undocumented)').strip().split('\n', 1)[0])
617 for (name, command) in self.commands.iteritems() \
618 if name != 'help' \
619 and not command._jabberbot_hidden
621 usage = '\n\n'.join(['',usage, self.MSG_HELP_TAIL])
622 else:
623 description = ''
624 if args in self.commands:
625 usage = (self.commands[args].__doc__ or \
626 'undocumented').strip()
627 else:
628 usage = self.MSG_HELP_UNDEFINED_COMMAND
630 top = self.top_of_help_message()
631 bottom = self.bottom_of_help_message()
632 return ''.join([top, description, usage, bottom])
634 def idle_proc(self):
635 """This function will be called in the main loop."""
636 self._idle_ping()
638 def _idle_ping(self):
639 """Pings the server, calls on_ping_timeout() on no response.
641 To enable set self.PING_FREQUENCY to a value higher than zero.
643 if self.PING_FREQUENCY \
644 and time.time() - self.__lastping > self.PING_FREQUENCY:
645 self.__lastping = time.time()
646 #logging.debug('Pinging the server.')
647 ping = xmpp.Protocol('iq', typ='get', \
648 payload=[xmpp.Node('ping', attrs={'xmlns':'urn:xmpp:ping'})])
649 try:
650 res = self.conn.SendAndWaitForResponse(ping, self.PING_TIMEOUT)
651 #logging.debug('Got response: ' + str(res))
652 if res is None:
653 self.on_ping_timeout()
654 except IOError, e:
655 logging.error('Error pinging the server: %s, '\
656 'treating as ping timeout.' % e)
657 self.on_ping_timeout()
659 def on_ping_timeout(self):
660 logging.info('Terminating due to PING timeout.')
661 self.quit()
663 def shutdown(self):
664 """This function will be called when we're done serving
666 Override this method in derived class if you
667 want to do anything special at shutdown.
669 pass
671 def serve_forever(self, connect_callback=None, disconnect_callback=None):
672 """Connects to the server and handles messages."""
673 conn = self.connect()
674 if conn:
675 self.log.info('bot connected. serving forever.')
676 else:
677 self.log.warn('could not connect to server - aborting.')
678 return
680 if connect_callback:
681 connect_callback()
682 self.__lastping = time.time()
684 while not self.__finished:
685 try:
686 conn.Process(1)
687 self.idle_proc()
688 except KeyboardInterrupt:
689 self.log.info('bot stopped by user request. '\
690 'shutting down.')
691 break
693 self.shutdown()
695 if disconnect_callback:
696 disconnect_callback()
698 # vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4