2 # -*- coding: utf-8 -*-
4 # JabberBot: A simple jabber/xmpp bot framework
5 # Copyright (c) 2007-2011 Thomas Perl <thp.io/about>
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/>.
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.
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.
50 # Will be parsed by setup.py to determine package metadata
51 __author__
= 'Thomas Perl <m@thp.io>'
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
) # Experimental!
67 return decorate(args
[0], **kwargs
)
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. '\
82 MSG_UNKNOWN_COMMAND
= 'Unknown command: "%(command)s". '\
83 'Type "%(helpcommand)s" for available commands.'
84 MSG_HELP_TAIL
= 'Type %(helpcommand)s <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,
96 """Initializes the jabber bot and sets up commands.
98 username and password should be clear ;)
100 If res provided, res will be ressourcename,
101 otherwise it defaults to classname of childclass
103 If debug is True log messages of xmpppy will be printed to console.
104 Logging of Jabberbot itself is NOT affected.
106 If privatedomain is provided, it should be either
107 True to only allow subscriptions from the same domain
108 as the bot or a string that describes the domain for
109 which subscriptions are accepted (e.g. 'jabber.org').
111 If acceptownmsgs it set to True, this bot will accept
112 messages from the same JID that the bot itself has. This
113 is useful when using JabberBot with a single Jabber account
114 and multiple instances that want to talk to each other.
116 If handlers are provided, default handlers won't be enabled.
117 Usage like: [('stanzatype1', function1), ('stanzatype2', function2)]
118 Signature of function should be callback_xx(self, conn, stanza),
119 where conn is the connection and stanza the current stanza in process.
120 First handler in list will be served first.
121 Don't forget to raise exception xmpp.NodeProcessed to stop
122 processing in other handlers (see callback_presence)
124 If command_prefix is set to a string different from '' (the empty
125 string), it will require the commands to be prefixed with this text,
126 e.g. command_prefix = '!' means: Type "!info" for the "info" command.
128 # TODO sort this initialisation thematically
130 self
.log
= logging
.getLogger(__name__
)
131 self
.__username
= username
132 self
.__password
= password
133 self
.jid
= xmpp
.JID(self
.__username
)
134 self
.res
= (res
or self
.__class
__.__name
__)
136 self
.__finished
= False
141 self
.__lastping
= time
.time()
142 self
.__privatedomain
= privatedomain
143 self
.__acceptownmsgs
= acceptownmsgs
144 self
.__command
_prefix
= command_prefix
146 self
.handlers
= (handlers
or [('message', self
.callback_message
),
147 ('presence', self
.callback_presence
)])
149 # Collect commands from source
151 for name
, value
in inspect
.getmembers(self
, inspect
.ismethod
):
152 if getattr(value
, '_jabberbot_command', False):
153 name
= getattr(value
, '_jabberbot_command_name')
154 self
.log
.info('Registered command: %s' % name
)
155 self
.commands
[self
.__command
_prefix
+ name
] = value
159 ################################
161 def _send_status(self
):
162 """Send status to everyone"""
163 self
.conn
.send(xmpp
.dispatcher
.Presence(show
=self
.__show
,
164 status
=self
.__status
))
166 def __set_status(self
, value
):
167 """Set status message.
168 If value remains constant, no presence stanza will be send"""
169 if self
.__status
!= value
:
170 self
.__status
= value
173 def __get_status(self
):
174 """Get current status message"""
177 status_message
= property(fget
=__get_status
, fset
=__set_status
)
179 def __set_show(self
, value
):
180 """Set show (status type like AWAY, DND etc.).
181 If value remains constant, no presence stanza will be send"""
182 if self
.__show
!= value
:
186 def __get_show(self
):
187 """Get current show (status type like AWAY, DND etc.)."""
190 status_type
= property(fget
=__get_show
, fset
=__set_show
)
192 ################################
195 """Connects the bot to server or returns current connection,
196 send inital presence stanza
197 and registers handlers
202 conn
= xmpp
.Client(self
.jid
.getDomain())
204 conn
= xmpp
.Client(self
.jid
.getDomain(), debug
=[])
207 conres
= conn
.connect()
209 self
.log
.error('unable to connect to server %s.' %
210 self
.jid
.getDomain())
213 self
.log
.warning('unable to establish secure connection '\
216 authres
= conn
.auth(self
.jid
.getNode(), self
.__password
, self
.res
)
218 self
.log
.error('unable to authorize with server.')
220 if authres
!= 'sasl':
221 self
.log
.warning("unable to perform SASL auth on %s. "\
222 "Old authentication method used!" % self
.jid
.getDomain())
224 # Connection established - save connection
227 # Send initial presence stanza (say hello to everyone)
228 self
.conn
.sendInitPresence()
229 # Save roster and log Items
230 self
.roster
= self
.conn
.Roster
.getRoster()
231 self
.log
.info('*** roster ***')
232 for contact
in self
.roster
.getItems():
233 self
.log
.info(' %s' % contact
)
234 self
.log
.info('*** roster ***')
236 # Register given handlers (TODO move to own function)
237 for (handler
, callback
) in self
.handlers
:
238 self
.conn
.RegisterHandler(handler
, callback
)
239 self
.log
.debug('Registered handler: %s' % handler
)
243 def join_room(self
, room
, username
=None, password
=None):
244 """Join the specified multi-user chat room
246 If username is NOT provided fallback to node part of JID"""
247 # TODO fix namespacestrings and history settings
248 NS_MUC
= 'http://jabber.org/protocol/muc'
250 # TODO use xmpppy function getNode
251 username
= self
.__username
.split('@')[0]
252 my_room_JID
= '/'.join((room
, username
))
253 pres
= xmpp
.Presence(to
=my_room_JID
)
254 if password
is not None:
255 pres
.setTag('x',namespace
=NS_MUC
).setTagData('password',password
)
256 self
.connect().send(pres
)
258 def kick(self
, room
, nick
, reason
=None):
259 """Kicks user from muc
260 Works only with sufficient rights."""
261 NS_MUCADMIN
= 'http://jabber.org/protocol/muc#admin'
262 item
= xmpp
.simplexml
.Node('item')
263 item
.setAttr('nick', nick
)
264 item
.setAttr('role', 'none')
265 iq
= xmpp
.Iq(typ
='set',queryNS
=NS_MUCADMIN
,xmlns
=None,to
=room
,payload
=set([item
]))
266 if reason
is not None:
267 item
.setTagData('reason',reason
)
268 self
.connect().send(iq
)
270 def invite(self
, room
, jid
, reason
=None):
271 """Invites user to muc.
272 Works only if user has permission to invite to muc"""
273 NS_MUCUSER
= 'http://jabber.org/protocol/muc#user'
274 invite
= xmpp
.simplexml
.Node('invite')
275 invite
.setAttr('to',jid
)
276 if reason
is not None:
277 invite
.setTagData('reason',reason
)
278 mess
= xmpp
.Message(to
=room
)
279 mess
.setTag('x',namespace
=NS_MUCUSER
).addChild(node
=invite
)
281 self
.connect().send(mess
)
284 """Stop serving messages and exit.
286 I find it is handy for development to run the
287 jabberbot in a 'while true' loop in the shell, so
288 whenever I make a code change to the bot, I send
289 the 'reload' command, which I have mapped to call
290 self.quit(), and my shell script relaunches the
293 self
.__finished
= True
295 def send_message(self
, mess
):
296 """Send an XMPP message"""
297 self
.connect().send(mess
)
299 def send_tune(self
, song
, debug
=False):
300 """Set information about the currently played tune
302 Song is a dictionary with keys: file, title, artist, album, pos, track,
303 length, uri. For details see <http://xmpp.org/protocols/tune/>.
305 NS_TUNE
= 'http://jabber.org/protocol/tune'
306 iq
= xmpp
.Iq(typ
='set')
308 iq
.pubsub
= iq
.addChild('pubsub', namespace
=xmpp
.NS_PUBSUB
)
309 iq
.pubsub
.publish
= iq
.pubsub
.addChild('publish',
310 attrs
={ 'node' : NS_TUNE
})
311 iq
.pubsub
.publish
.item
= iq
.pubsub
.publish
.addChild('item',
312 attrs
={ 'id' : 'current' })
313 tune
= iq
.pubsub
.publish
.item
.addChild('tune')
314 tune
.setNamespace(NS_TUNE
)
317 if song
.has_key('title'):
318 title
= song
['title']
319 elif song
.has_key('file'):
320 title
= os
.path
.splitext(os
.path
.basename(song
['file']))[0]
321 if title
is not None:
322 tune
.addChild('title').addData(title
)
323 if song
.has_key('artist'):
324 tune
.addChild('artist').addData(song
['artist'])
325 if song
.has_key('album'):
326 tune
.addChild('source').addData(song
['album'])
327 if song
.has_key('pos') and song
['pos'] > 0:
328 tune
.addChild('track').addData(str(song
['pos']))
329 if song
.has_key('time'):
330 tune
.addChild('length').addData(str(song
['time']))
331 if song
.has_key('uri'):
332 tune
.addChild('uri').addData(song
['uri'])
335 self
.log
.info('Sending tune: %s' % iq
.__str
__().encode('utf8'))
338 def send(self
, user
, text
, in_reply_to
=None, message_type
='chat'):
339 """Sends a simple message to the specified user."""
340 mess
= self
.build_message(text
)
344 mess
.setThread(in_reply_to
.getThread())
345 mess
.setType(in_reply_to
.getType())
347 mess
.setThread(self
.__threads
.get(user
, None))
348 mess
.setType(message_type
)
350 self
.send_message(mess
)
352 def send_simple_reply(self
, mess
, text
, private
=False):
353 """Send a simple response to a message"""
354 self
.send_message(self
.build_reply(mess
, text
, private
))
356 def build_reply(self
, mess
, text
=None, private
=False):
357 """Build a message for responding to another message.
358 Message is NOT sent"""
359 response
= self
.build_message(text
)
361 response
.setTo(mess
.getFrom())
362 response
.setType('chat')
364 response
.setTo(mess
.getFrom().getStripped())
365 response
.setType(mess
.getType())
366 response
.setThread(mess
.getThread())
369 def build_message(self
, text
):
370 """Builds an xhtml message without attributes.
371 If input is not valid xhtml-im fallback to normal."""
372 message
= None # init message variable
373 # Try to determine if text has xhtml-tags - TODO needs improvement
374 text_plain
= re
.sub(r
'<[^>]+>', '', text
)
375 if text_plain
!= text
:
376 # Create body w stripped tags for reciptiens w/o xhtml-abilities
377 # FIXME unescape " etc.
378 message
= xmpp
.protocol
.Message(body
=text_plain
)
379 # Start creating a xhtml body
380 html
= xmpp
.Node('html', \
381 {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
383 html
.addChild(node
=xmpp
.simplexml
.XML2Node( \
384 "<body xmlns='http://www.w3.org/1999/xhtml'>" + \
385 text
.encode('utf-8') + "</body>"))
386 message
.addChild(node
=html
)
388 # Didn't work, incorrect markup or something.
389 self
.log
.debug('An error while building a xhtml message. '\
390 'Fallback to normal messagebody')
391 # Fallback - don't sanitize invalid input. User is responsible!
395 message
= xmpp
.protocol
.Message(body
=text
)
398 def get_sender_username(self
, mess
):
399 """Extract the sender's user name from a message"""
400 type = mess
.getType()
402 if type == "groupchat":
403 username
= jid
.getResource()
405 username
= jid
.getNode()
410 def get_full_jids(self
, jid
):
411 """Returns all full jids, which belong to a bare jid
413 Example: A bare jid is bob@jabber.org, with two clients connected,
415 have the full jids bob@jabber.org/home and bob@jabber.org/work."""
416 for res
in self
.roster
.getResources(jid
):
417 full_jid
= "%s/%s" % (jid
,res
)
420 def status_type_changed(self
, jid
, new_status_type
):
421 """Callback for tracking status types (dnd, away, offline, ...)"""
422 self
.log
.debug('user %s changed status to %s' % (jid
, new_status_type
))
424 def status_message_changed(self
, jid
, new_status_message
):
425 """Callback for tracking status messages (the free-form status text)"""
426 self
.log
.debug('user %s updated text to %s' %
427 (jid
, new_status_message
))
429 def broadcast(self
, message
, only_available
=False):
430 """Broadcast a message to all users 'seen' by this bot.
432 If the parameter 'only_available' is True, the broadcast
433 will not go to users whose status is not 'Available'."""
434 for jid
, (show
, status
) in self
.__seen
.items():
435 if not only_available
or show
is self
.AVAILABLE
:
436 self
.send(jid
, message
)
438 def callback_presence(self
, conn
, presence
):
439 jid
, type_
, show
, status
= presence
.getFrom(), \
440 presence
.getType(), presence
.getShow(), \
443 if self
.jid
.bareMatch(jid
):
444 # update internal status
445 if type_
!= self
.OFFLINE
:
446 self
.__status
= status
450 self
.__show
= self
.OFFLINE
451 if not self
.__acceptownmsgs
:
452 # Ignore our own presence messages
456 # Keep track of status message and type changes
457 old_show
, old_status
= self
.__seen
.get(jid
, (self
.OFFLINE
, None))
459 self
.status_type_changed(jid
, show
)
461 if old_status
!= status
:
462 self
.status_message_changed(jid
, status
)
464 self
.__seen
[jid
] = (show
, status
)
465 elif type_
== self
.OFFLINE
and jid
in self
.__seen
:
466 # Notify of user offline status change
468 self
.status_type_changed(jid
, self
.OFFLINE
)
471 subscription
= self
.roster
.getSubscription(unicode(jid
.__str
__()))
473 # User not on our roster
475 except AttributeError, e
:
476 # Recieved presence update before roster built
480 self
.log
.error(presence
.getError())
482 self
.log
.debug('Got presence: %s (type: %s, show: %s, status: %s, '\
483 'subscription: %s)' % (jid
, type_
, show
, status
, subscription
))
485 # If subscription is private,
486 # disregard anything not from the private domain
487 if self
.__privatedomain
and type_
in ('subscribe', 'subscribed', \
489 if self
.__privatedomain
== True:
490 # Use the bot's domain
491 domain
= self
.jid
.getDomain()
493 # Use the specified domain
494 domain
= self
.__privatedomain
496 # Check if the sender is in the private domain
497 user_domain
= jid
.getDomain()
498 if domain
!= user_domain
:
499 self
.log
.info('Ignoring subscribe request: %s does not '\
500 'match private domain (%s)' % (user_domain
, domain
))
503 if type_
== 'subscribe':
504 # Incoming presence subscription request
505 if subscription
in ('to', 'both', 'from'):
506 self
.roster
.Authorize(jid
)
509 if subscription
not in ('to', 'both'):
510 self
.roster
.Subscribe(jid
)
512 if subscription
in (None, 'none'):
513 self
.send(jid
, self
.MSG_AUTHORIZE_ME
)
514 elif type_
== 'subscribed':
515 # Authorize any pending requests for that JID
516 self
.roster
.Authorize(jid
)
517 elif type_
== 'unsubscribed':
518 # Authorization was not granted
519 self
.send(jid
, self
.MSG_NOT_AUTHORIZED
)
520 self
.roster
.Unauthorize(jid
)
522 def callback_message(self
, conn
, mess
):
523 """Messages sent to the bot will arrive here.
524 Command handling + routing is done in this function."""
526 # Prepare to handle either private chats or group chats
527 type = mess
.getType()
529 props
= mess
.getProperties()
530 text
= mess
.getBody()
531 username
= self
.get_sender_username(mess
)
533 if type not in ("groupchat", "chat"):
534 self
.log
.debug("unhandled message type: %s" % type)
537 # Ignore messages from before we joined
538 if xmpp
.NS_DELAY
in props
: return
540 # Ignore messages from myself
541 if self
.jid
.bareMatch(jid
): return
543 self
.log
.debug("*** props = %s" % props
)
544 self
.log
.debug("*** jid = %s" % jid
)
545 self
.log
.debug("*** username = %s" % username
)
546 self
.log
.debug("*** type = %s" % type)
547 self
.log
.debug("*** text = %s" % text
)
549 # If a message format is not supported (eg. encrypted),
553 # Ignore messages from users not seen by this bot
554 if jid
not in self
.__seen
:
555 self
.log
.info('Ignoring message from unseen guest: %s' % jid
)
556 self
.log
.debug("I've seen: %s" %
557 ["%s" % x
for x
in self
.__seen
.keys()])
560 # Remember the last-talked-in message thread for replies
561 # FIXME i am not threadsafe
562 self
.__threads
[jid
] = mess
.getThread()
565 command
, args
= text
.split(' ', 1)
567 command
, args
= text
, ''
568 cmd
= command
.lower()
569 self
.log
.debug("*** cmd = %s" % cmd
)
571 if self
.commands
.has_key(cmd
):
572 def execute_and_send():
574 reply
= self
.commands
[cmd
](mess
, args
)
576 self
.log
.exception('An error happened while processing '\
577 'a message ("%s") from %s: %s"' %
578 (text
, jid
, traceback
.format_exc(e
)))
579 reply
= self
.MSG_ERROR_OCCURRED
581 self
.send_simple_reply(mess
, reply
)
583 # if command should be executed in a seperate thread do it
584 if self
.commands
[cmd
]._jabberbot
_command
_thread
:
585 thread
.start_new_thread(execute_and_send
, ())
589 # In private chat, it's okay for the bot to always respond.
590 # In group chat, the bot should silently ignore commands it
591 # doesn't understand or aren't handled by unknown_command().
592 if type == 'groupchat':
595 default_reply
= self
.MSG_UNKNOWN_COMMAND
% {
597 'helpcommand': self
.__command
_prefix
+ 'help',
599 reply
= self
.unknown_command(mess
, cmd
, args
)
601 reply
= default_reply
603 self
.send_simple_reply(mess
, reply
)
605 def unknown_command(self
, mess
, cmd
, args
):
606 """Default handler for unknown commands
608 Override this method in derived class if you
609 want to trap some unrecognized commands. If
610 'cmd' is handled, you must return some non-false
611 value, else some helpful text will be sent back
616 def top_of_help_message(self
):
617 """Returns a string that forms the top of the help message
619 Override this method in derived class if you
620 want to add additional help text at the
621 beginning of the help message.
625 def bottom_of_help_message(self
):
626 """Returns a string that forms the bottom of the help message
628 Override this method in derived class if you
629 want to add additional help text at the end
635 def help(self
, mess
, args
):
636 """ Returns a help string listing available options.
638 Automatically assigned to the "help" command."""
641 description
= self
.__doc
__.strip()
643 description
= 'Available commands:'
645 usage
= '\n'.join(sorted([
646 '%s: %s' % (name
, (command
.__doc
__ or \
647 '(undocumented)').strip().split('\n', 1)[0])
648 for (name
, command
) in self
.commands
.iteritems() \
649 if name
!= (self
.__command
_prefix
+ 'help') \
650 and not command
._jabberbot
_command
_hidden
652 usage
= '\n\n' + '\n\n'.join(filter(None, [usage
, self
.MSG_HELP_TAIL
% {'helpcommand': self
.__command
_prefix
+ 'help'}]))
655 if (args
not in self
.commands
and
656 (self
.__command
_prefix
+ args
) in self
.commands
):
657 # Automatically add prefix if it's missing
658 args
= self
.__command
_prefix
+ args
659 if args
in self
.commands
:
660 usage
= (self
.commands
[args
].__doc
__ or \
661 'undocumented').strip()
663 usage
= self
.MSG_HELP_UNDEFINED_COMMAND
665 top
= self
.top_of_help_message()
666 bottom
= self
.bottom_of_help_message()
667 return ''.join(filter(None, [top
, description
, usage
, bottom
]))
670 """This function will be called in the main loop."""
673 def _idle_ping(self
):
674 """Pings the server, calls on_ping_timeout() on no response.
676 To enable set self.PING_FREQUENCY to a value higher than zero.
678 if self
.PING_FREQUENCY \
679 and time
.time() - self
.__lastping
> self
.PING_FREQUENCY
:
680 self
.__lastping
= time
.time()
681 #logging.debug('Pinging the server.')
682 ping
= xmpp
.Protocol('iq', typ
='get', \
683 payload
=[xmpp
.Node('ping', attrs
={'xmlns':'urn:xmpp:ping'})])
685 res
= self
.conn
.SendAndWaitForResponse(ping
, self
.PING_TIMEOUT
)
686 #logging.debug('Got response: ' + str(res))
688 self
.on_ping_timeout()
690 logging
.error('Error pinging the server: %s, '\
691 'treating as ping timeout.' % e
)
692 self
.on_ping_timeout()
694 def on_ping_timeout(self
):
695 logging
.info('Terminating due to PING timeout.')
699 """This function will be called when we're done serving
701 Override this method in derived class if you
702 want to do anything special at shutdown.
706 def serve_forever(self
, connect_callback
=None, disconnect_callback
=None):
707 """Connects to the server and handles messages."""
708 conn
= self
.connect()
710 self
.log
.info('bot connected. serving forever.')
712 self
.log
.warn('could not connect to server - aborting.')
717 self
.__lastping
= time
.time()
719 while not self
.__finished
:
723 except KeyboardInterrupt:
724 self
.log
.info('bot stopped by user request. '\
730 if disconnect_callback
:
731 disconnect_callback()
733 # vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4