Add kick and invite for MultiUserChat
[jabberbot/examples.git] / jabberbot.py
blob64cd816e91b33950678b33e350c3f3edd98ab41f
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_command_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, inspect.ismethod):
146 if getattr(value, '_jabberbot_command', False):
147 name = getattr(value, '_jabberbot_command_name')
148 self.log.info('Registered command: %s' % name)
149 self.commands[name] = value
151 self.roster = None
153 ################################
155 def _send_status(self):
156 """Send status to everyone"""
157 self.conn.send(xmpp.dispatcher.Presence(show=self.__show,
158 status=self.__status))
160 def __set_status(self, value):
161 """Set status message.
162 If value remains constant, no presence stanza will be send"""
163 if self.__status != value:
164 self.__status = value
165 self._send_status()
167 def __get_status(self):
168 """Get current status message"""
169 return self.__status
171 status_message = property(fget=__get_status, fset=__set_status)
173 def __set_show(self, value):
174 """Set show (status type like AWAY, DND etc.).
175 If value remains constant, no presence stanza will be send"""
176 if self.__show != value:
177 self.__show = value
178 self._send_status()
180 def __get_show(self):
181 """Get current show (status type like AWAY, DND etc.)."""
182 return self.__show
184 status_type = property(fget=__get_show, fset=__set_show)
186 ################################
188 def connect(self):
189 """Connects the bot to server or returns current connection,
190 send inital presence stanza
191 and registers handlers
193 if not self.conn:
194 #TODO improve debug
195 if self.__debug:
196 conn = xmpp.Client(self.jid.getDomain())
197 else:
198 conn = xmpp.Client(self.jid.getDomain(), debug=[])
200 #connection attempt
201 conres = conn.connect()
202 if not conres:
203 self.log.error('unable to connect to server %s.' %
204 self.jid.getDomain())
205 return None
206 if conres != 'tls':
207 self.log.warning('unable to establish secure connection '\
208 '- TLS failed!')
210 authres = conn.auth(self.jid.getNode(), self.__password, self.res)
211 if not authres:
212 self.log.error('unable to authorize with server.')
213 return None
214 if authres != 'sasl':
215 self.log.warning("unable to perform SASL auth on %s. "\
216 "Old authentication method used!" % self.jid.getDomain())
218 # Connection established - save connection
219 self.conn = conn
221 # Send initial presence stanza (say hello to everyone)
222 self.conn.sendInitPresence()
223 # Save roster and log Items
224 self.roster = self.conn.Roster.getRoster()
225 self.log.info('*** roster ***')
226 for contact in self.roster.getItems():
227 self.log.info(' %s' % contact)
228 self.log.info('*** roster ***')
230 # Register given handlers (TODO move to own function)
231 for (handler, callback) in self.handlers:
232 self.conn.RegisterHandler(handler, callback)
233 self.log.debug('Registered handler: %s' % handler)
235 return self.conn
237 def join_room(self, room, username=None, password=None):
238 """Join the specified multi-user chat room
240 If username is NOT provided fallback to node part of JID"""
241 # TODO fix namespacestrings and history settings
242 NS_MUC = 'http://jabber.org/protocol/muc'
243 if username is None:
244 # TODO use xmpppy function getNode
245 username = self.__username.split('@')[0]
246 my_room_JID = '/'.join((room, username))
247 pres = xmpp.Presence(to=my_room_JID)
248 if password is not None:
249 pres.setTag('x',namespace=NS_MUC).setTagData('password',password)
250 self.connect().send(pres)
252 def kick(self, room, nick, reason=None):
253 """Kicks user from muc
254 Works only with sufficient rights."""
255 NS_MUCADMIN = 'http://jabber.org/protocol/muc#admin'
256 item = xmpp.simplexml.Node('item')
257 item.setAttr('nick', nick)
258 item.setAttr('role', 'none')
259 iq = xmpp.Iq(typ='set',queryNS=NS_MUCADMIN,xmlns=None,to=room,payload={item})
260 if reason is not None:
261 item.setTagData('reason',reason)
262 self.connect().send(iq)
264 def invite(self, room, jid, reason=None):
265 """Invites user to muc.
266 Works only if user has permission to invite to muc"""
267 NS_MUCUSER = 'http://jabber.org/protocol/muc#user'
268 invite = xmpp.simplexml.Node('invite')
269 invite.setAttr('to',jid)
270 if reason is not None:
271 invite.setTagData('reason',reason)
272 mess = xmpp.Message(to=room)
273 mess.setTag('x',namespace=NS_MUCUSER).addChild(node=invite)
274 self.log.error(mess)
275 self.connect().send(mess)
277 def quit(self):
278 """Stop serving messages and exit.
280 I find it is handy for development to run the
281 jabberbot in a 'while true' loop in the shell, so
282 whenever I make a code change to the bot, I send
283 the 'reload' command, which I have mapped to call
284 self.quit(), and my shell script relaunches the
285 new version.
287 self.__finished = True
289 def send_message(self, mess):
290 """Send an XMPP message"""
291 self.connect().send(mess)
293 def send_tune(self, song, debug=False):
294 """Set information about the currently played tune
296 Song is a dictionary with keys: file, title, artist, album, pos, track,
297 length, uri. For details see <http://xmpp.org/protocols/tune/>.
299 NS_TUNE = 'http://jabber.org/protocol/tune'
300 iq = xmpp.Iq(typ='set')
301 iq.setFrom(self.jid)
302 iq.pubsub = iq.addChild('pubsub', namespace=xmpp.NS_PUBSUB)
303 iq.pubsub.publish = iq.pubsub.addChild('publish',
304 attrs={ 'node' : NS_TUNE })
305 iq.pubsub.publish.item = iq.pubsub.publish.addChild('item',
306 attrs={ 'id' : 'current' })
307 tune = iq.pubsub.publish.item.addChild('tune')
308 tune.setNamespace(NS_TUNE)
310 title = None
311 if song.has_key('title'):
312 title = song['title']
313 elif song.has_key('file'):
314 title = os.path.splitext(os.path.basename(song['file']))[0]
315 if title is not None:
316 tune.addChild('title').addData(title)
317 if song.has_key('artist'):
318 tune.addChild('artist').addData(song['artist'])
319 if song.has_key('album'):
320 tune.addChild('source').addData(song['album'])
321 if song.has_key('pos') and song['pos'] > 0:
322 tune.addChild('track').addData(str(song['pos']))
323 if song.has_key('time'):
324 tune.addChild('length').addData(str(song['time']))
325 if song.has_key('uri'):
326 tune.addChild('uri').addData(song['uri'])
328 if debug:
329 self.log.info('Sending tune: %s' % iq.__str__().encode('utf8'))
330 self.conn.send(iq)
332 def send(self, user, text, in_reply_to=None, message_type='chat'):
333 """Sends a simple message to the specified user."""
334 mess = self.build_message(text)
335 mess.setTo(user)
337 if in_reply_to:
338 mess.setThread(in_reply_to.getThread())
339 mess.setType(in_reply_to.getType())
340 else:
341 mess.setThread(self.__threads.get(user, None))
342 mess.setType(message_type)
344 self.send_message(mess)
346 def send_simple_reply(self, mess, text, private=False):
347 """Send a simple response to a message"""
348 self.send_message(self.build_reply(mess, text, private))
350 def build_reply(self, mess, text=None, private=False):
351 """Build a message for responding to another message.
352 Message is NOT sent"""
353 response = self.build_message(text)
354 if private:
355 response.setTo(mess.getFrom())
356 response.setType('chat')
357 else:
358 response.setTo(mess.getFrom().getStripped())
359 response.setType(mess.getType())
360 response.setThread(mess.getThread())
361 return response
363 def build_message(self, text):
364 """Builds an xhtml message without attributes.
365 If input is not valid xhtml-im fallback to normal."""
366 message = None # init message variable
367 # Try to determine if text has xhtml-tags - TODO needs improvement
368 text_plain = re.sub(r'<[^>]+>', '', text)
369 if text_plain != text:
370 # Create body w stripped tags for reciptiens w/o xhtml-abilities
371 # FIXME unescape &quot; etc.
372 message = xmpp.protocol.Message(body=text_plain)
373 # Start creating a xhtml body
374 html = xmpp.Node('html', \
375 {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
376 try:
377 html.addChild(node=xmpp.simplexml.XML2Node( \
378 "<body xmlns='http://www.w3.org/1999/xhtml'>" + \
379 text.encode('utf-8') + "</body>"))
380 message.addChild(node=html)
381 except Exception, e:
382 # Didn't work, incorrect markup or something.
383 self.log.debug('An error while building a xhtml message. '\
384 'Fallback to normal messagebody')
385 # Fallback - don't sanitize invalid input. User is responsible!
386 message = None
387 if message is None:
388 # Normal body
389 message = xmpp.protocol.Message(body=text)
390 return message
392 def get_sender_username(self, mess):
393 """Extract the sender's user name from a message"""
394 type = mess.getType()
395 jid = mess.getFrom()
396 if type == "groupchat":
397 username = jid.getResource()
398 elif type == "chat":
399 username = jid.getNode()
400 else:
401 username = ""
402 return username
404 def get_full_jids(self, jid):
405 """Returns all full jids, which belong to a bare jid
407 Example: A bare jid is bob@jabber.org, with two clients connected,
408 which
409 have the full jids bob@jabber.org/home and bob@jabber.org/work."""
410 for res in self.roster.getResources(jid):
411 full_jid = "%s/%s" % (jid,res)
412 yield full_jid
414 def status_type_changed(self, jid, new_status_type):
415 """Callback for tracking status types (dnd, away, offline, ...)"""
416 self.log.debug('user %s changed status to %s' % (jid, new_status_type))
418 def status_message_changed(self, jid, new_status_message):
419 """Callback for tracking status messages (the free-form status text)"""
420 self.log.debug('user %s updated text to %s' %
421 (jid, new_status_message))
423 def broadcast(self, message, only_available=False):
424 """Broadcast a message to all users 'seen' by this bot.
426 If the parameter 'only_available' is True, the broadcast
427 will not go to users whose status is not 'Available'."""
428 for jid, (show, status) in self.__seen.items():
429 if not only_available or show is self.AVAILABLE:
430 self.send(jid, message)
432 def callback_presence(self, conn, presence):
433 self.__lastping = time.time()
434 jid, type_, show, status = presence.getFrom(), \
435 presence.getType(), presence.getShow(), \
436 presence.getStatus()
438 if self.jid.bareMatch(jid):
439 # update internal status
440 if type_ != self.OFFLINE:
441 self.__status = status
442 self.__show = show
443 else:
444 self.__status = ""
445 self.__show = self.OFFLINE
446 if not self.__acceptownmsgs:
447 # Ignore our own presence messages
448 return
450 if type_ is None:
451 # Keep track of status message and type changes
452 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
453 if old_show != show:
454 self.status_type_changed(jid, show)
456 if old_status != status:
457 self.status_message_changed(jid, status)
459 self.__seen[jid] = (show, status)
460 elif type_ == self.OFFLINE and jid in self.__seen:
461 # Notify of user offline status change
462 del self.__seen[jid]
463 self.status_type_changed(jid, self.OFFLINE)
465 try:
466 subscription = self.roster.getSubscription(unicode(jid.__str__()))
467 except KeyError, e:
468 # User not on our roster
469 subscription = None
470 except AttributeError, e:
471 # Recieved presence update before roster built
472 return
474 if type_ == 'error':
475 self.log.error(presence.getError())
477 self.log.debug('Got presence: %s (type: %s, show: %s, status: %s, '\
478 'subscription: %s)' % (jid, type_, show, status, subscription))
480 # If subscription is private,
481 # disregard anything not from the private domain
482 if self.__privatedomain and type_ in ('subscribe', 'subscribed', \
483 'unsubscribe'):
484 if self.__privatedomain == True:
485 # Use the bot's domain
486 domain = self.jid.getDomain()
487 else:
488 # Use the specified domain
489 domain = self.__privatedomain
491 # Check if the sender is in the private domain
492 user_domain = jid.getDomain()
493 if domain != user_domain:
494 self.log.info('Ignoring subscribe request: %s does not '\
495 'match private domain (%s)' % (user_domain, domain))
496 return
498 if type_ == 'subscribe':
499 # Incoming presence subscription request
500 if subscription in ('to', 'both', 'from'):
501 self.roster.Authorize(jid)
502 self._send_status()
504 if subscription not in ('to', 'both'):
505 self.roster.Subscribe(jid)
507 if subscription in (None, 'none'):
508 self.send(jid, self.MSG_AUTHORIZE_ME)
509 elif type_ == 'subscribed':
510 # Authorize any pending requests for that JID
511 self.roster.Authorize(jid)
512 elif type_ == 'unsubscribed':
513 # Authorization was not granted
514 self.send(jid, self.MSG_NOT_AUTHORIZED)
515 self.roster.Unauthorize(jid)
517 def callback_message(self, conn, mess):
518 """Messages sent to the bot will arrive here.
519 Command handling + routing is done in this function."""
520 self.__lastping = time.time()
522 # Prepare to handle either private chats or group chats
523 type = mess.getType()
524 jid = mess.getFrom()
525 props = mess.getProperties()
526 text = mess.getBody()
527 username = self.get_sender_username(mess)
529 if type not in ("groupchat", "chat"):
530 self.log.debug("unhandled message type: %s" % type)
531 return
533 # Ignore messages from before we joined
534 if xmpp.NS_DELAY in props: return
536 # Ignore messages from myself
537 if self.jid.bareMatch(jid): return
539 self.log.debug("*** props = %s" % props)
540 self.log.debug("*** jid = %s" % jid)
541 self.log.debug("*** username = %s" % username)
542 self.log.debug("*** type = %s" % type)
543 self.log.debug("*** text = %s" % text)
545 # If a message format is not supported (eg. encrypted),
546 # txt will be None
547 if not text: return
549 # Ignore messages from users not seen by this bot
550 if jid not in self.__seen:
551 self.log.info('Ignoring message from unseen guest: %s' % jid)
552 self.log.debug("I've seen: %s" %
553 ["%s" % x for x in self.__seen.keys()])
554 return
556 # Remember the last-talked-in message thread for replies
557 # FIXME i am not threadsafe
558 self.__threads[jid] = mess.getThread()
560 if ' ' in text:
561 command, args = text.split(' ', 1)
562 else:
563 command, args = text, ''
564 cmd = command.lower()
565 self.log.debug("*** cmd = %s" % cmd)
567 if self.commands.has_key(cmd):
568 def execute_and_send():
569 try:
570 reply = self.commands[cmd](mess, args)
571 except Exception, e:
572 self.log.exception('An error happened while processing '\
573 'a message ("%s") from %s: %s"' %
574 (text, jid, traceback.format_exc(e)))
575 reply = self.MSG_ERROR_OCCURRED
576 if reply:
577 self.send_simple_reply(mess, reply)
579 # if command should be executed in a seperate thread do it
580 if self.commands[cmd]._jabberbot_command_thread:
581 thread.start_new_thread(execute_and_send, ())
582 else:
583 execute_and_send()
584 else:
585 # In private chat, it's okay for the bot to always respond.
586 # In group chat, the bot should silently ignore commands it
587 # doesn't understand or aren't handled by unknown_command().
588 if type == 'groupchat':
589 default_reply = None
590 else:
591 default_reply = self.MSG_UNKNOWN_COMMAND % {'command': cmd}
592 reply = self.unknown_command(mess, cmd, args)
593 if reply is None:
594 reply = default_reply
595 if reply:
596 self.send_simple_reply(mess, reply)
598 def unknown_command(self, mess, cmd, args):
599 """Default handler for unknown commands
601 Override this method in derived class if you
602 want to trap some unrecognized commands. If
603 'cmd' is handled, you must return some non-false
604 value, else some helpful text will be sent back
605 to the sender.
607 return None
609 def top_of_help_message(self):
610 """Returns a string that forms the top of the help message
612 Override this method in derived class if you
613 want to add additional help text at the
614 beginning of the help message.
616 return ""
618 def bottom_of_help_message(self):
619 """Returns a string that forms the bottom of the help message
621 Override this method in derived class if you
622 want to add additional help text at the end
623 of the help message.
625 return ""
627 @botcmd
628 def help(self, mess, args):
629 """ Returns a help string listing available options.
631 Automatically assigned to the "help" command."""
632 if not args:
633 if self.__doc__:
634 description = self.__doc__.strip()
635 else:
636 description = 'Available commands:'
638 usage = '\n'.join(sorted([
639 '%s: %s' % (name, (command.__doc__ or \
640 '(undocumented)').strip().split('\n', 1)[0])
641 for (name, command) in self.commands.iteritems() \
642 if name != 'help' \
643 and not command._jabberbot_command_hidden
645 usage = '\n\n'.join(['',usage, self.MSG_HELP_TAIL])
646 else:
647 description = ''
648 if args in self.commands:
649 usage = (self.commands[args].__doc__ or \
650 'undocumented').strip()
651 else:
652 usage = self.MSG_HELP_UNDEFINED_COMMAND
654 top = self.top_of_help_message()
655 bottom = self.bottom_of_help_message()
656 return ''.join([top, description, usage, bottom])
658 def idle_proc(self):
659 """This function will be called in the main loop."""
660 self._idle_ping()
662 def _idle_ping(self):
663 """Pings the server, calls on_ping_timeout() on no response.
665 To enable set self.PING_FREQUENCY to a value higher than zero.
667 if self.PING_FREQUENCY \
668 and time.time() - self.__lastping > self.PING_FREQUENCY:
669 self.__lastping = time.time()
670 #logging.debug('Pinging the server.')
671 ping = xmpp.Protocol('iq', typ='get', \
672 payload=[xmpp.Node('ping', attrs={'xmlns':'urn:xmpp:ping'})])
673 try:
674 res = self.conn.SendAndWaitForResponse(ping, self.PING_TIMEOUT)
675 #logging.debug('Got response: ' + str(res))
676 if res is None:
677 self.on_ping_timeout()
678 except IOError, e:
679 logging.error('Error pinging the server: %s, '\
680 'treating as ping timeout.' % e)
681 self.on_ping_timeout()
683 def on_ping_timeout(self):
684 logging.info('Terminating due to PING timeout.')
685 self.quit()
687 def shutdown(self):
688 """This function will be called when we're done serving
690 Override this method in derived class if you
691 want to do anything special at shutdown.
693 pass
695 def serve_forever(self, connect_callback=None, disconnect_callback=None):
696 """Connects to the server and handles messages."""
697 conn = self.connect()
698 if conn:
699 self.log.info('bot connected. serving forever.')
700 else:
701 self.log.warn('could not connect to server - aborting.')
702 return
704 if connect_callback:
705 connect_callback()
706 self.__lastping = time.time()
708 while not self.__finished:
709 try:
710 conn.Process(1)
711 self.idle_proc()
712 except KeyboardInterrupt:
713 self.log.info('bot stopped by user request. '\
714 'shutting down.')
715 break
717 self.shutdown()
719 if disconnect_callback:
720 disconnect_callback()
722 # vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4