2 # -*- coding: utf-8 -*-
4 # JabberBot: A simple jabber/xmpp bot framework
5 # Copyright (c) 2007-2011 Thomas Perl <thp.io/about>
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 """A simple framework for creating Jabber/XMPP bots and services"""
30 print >> sys
.stderr
, 'You need to install xmpppy from http://xmpppy.sf.net/.'
38 __author__
= 'Thomas Perl <m@thp.io>'
40 __website__
= 'http://thp.io/2007/python-jabberbot/'
41 __license__
= 'GPLv3 or later'
43 def botcmd(*args
, **kwargs
):
44 """Decorator for bot command functions"""
46 def decorate(func
, hidden
=False, name
=None):
47 setattr(func
, '_jabberbot_command', True)
48 setattr(func
, '_jabberbot_hidden', hidden
)
49 setattr(func
, '_jabberbot_command_name', name
or func
.__name
__)
53 return decorate(args
[0], **kwargs
)
55 return lambda func
: decorate(func
, **kwargs
)
58 class JabberBot(object):
59 AVAILABLE
, AWAY
, CHAT
, DND
, XA
, OFFLINE
= None, 'away', 'chat', 'dnd', 'xa', 'unavailable'
61 MSG_AUTHORIZE_ME
= 'Hey there. You are not yet on my roster. Authorize my request and I will do the same.'
62 MSG_NOT_AUTHORIZED
= 'You did not authorize my subscription request. Access denied.'
64 PING_FREQUENCY
= 0 # Set to the number of seconds, e.g. 60.
65 PING_TIMEOUT
= 2 # Seconds to wait for a response.
67 def __init__(self
, username
, password
, res
=None, debug
=False,
68 privatedomain
=False, acceptownmsgs
=False):
69 """Initializes the jabber bot and sets up commands.
71 If privatedomain is provided, it should be either
72 True to only allow subscriptions from the same domain
73 as the bot or a string that describes the domain for
74 which subscriptions are accepted (e.g. 'jabber.org').
76 If acceptownmsgs it set to True, this bot will accept
77 messages from the same JID that the bot itself has. This
78 is useful when using JabberBot with a single Jabber account
79 and multiple instances that want to talk to each other.
82 self
.log
= logging
.getLogger(__name__
)
83 self
.__username
= username
84 self
.__password
= password
85 self
.jid
= xmpp
.JID(self
.__username
)
86 self
.res
= (res
or self
.__class
__.__name
__)
88 self
.__finished
= False
93 self
.__lastping
= time
.time()
94 self
.__privatedomain
= privatedomain
95 self
.__acceptownmsgs
= acceptownmsgs
97 self
.custom_message_handler
= None
100 for name
, value
in inspect
.getmembers(self
):
101 if inspect
.ismethod(value
) and getattr(value
, '_jabberbot_command', False):
102 name
= getattr(value
, '_jabberbot_command_name')
103 self
.log
.debug('Registered command: %s' % name
)
104 self
.commands
[name
] = value
108 ################################
110 def _send_status(self
):
111 self
.conn
.send(xmpp
.dispatcher
.Presence(show
=self
.__show
, status
=self
.__status
))
113 def __set_status(self
, value
):
114 if self
.__status
!= value
:
115 self
.__status
= value
118 def __get_status(self
):
121 status_message
= property(fget
=__get_status
, fset
=__set_status
)
123 def __set_show(self
, value
):
124 if self
.__show
!= value
:
128 def __get_show(self
):
131 status_type
= property(fget
=__get_show
, fset
=__set_show
)
133 ################################
138 conn
= xmpp
.Client(self
.jid
.getDomain())
140 conn
= xmpp
.Client(self
.jid
.getDomain(), debug
=[])
142 conres
= conn
.connect()
144 self
.log
.error('unable to connect to server %s.' % self
.jid
.getDomain())
147 self
.log
.warning('unable to establish secure connection - TLS failed!')
149 authres
= conn
.auth(self
.jid
.getNode(), self
.__password
, self
.res
)
151 self
.log
.error('unable to authorize with server.')
153 if authres
!= 'sasl':
154 self
.log
.warning("unable to perform SASL auth os %s. Old authentication method used!" % self
.jid
.getDomain())
156 conn
.sendInitPresence()
158 self
.roster
= self
.conn
.Roster
.getRoster()
159 self
.log
.info('*** roster ***')
160 for contact
in self
.roster
.getItems():
161 self
.log
.info(' %s' % contact
)
162 self
.log
.info('*** roster ***')
163 self
.conn
.RegisterHandler('message', self
.callback_message
)
164 self
.conn
.RegisterHandler('presence', self
.callback_presence
)
168 def join_room(self
, room
, username
=None):
169 """Join the specified multi-user chat room"""
171 username
= self
.__username
.split('@')[0]
172 my_room_JID
= '/'.join((room
, username
))
173 self
.connect().send(xmpp
.Presence(to
=my_room_JID
))
176 """Stop serving messages and exit.
178 I find it is handy for development to run the
179 jabberbot in a 'while true' loop in the shell, so
180 whenever I make a code change to the bot, I send
181 the 'reload' command, which I have mapped to call
182 self.quit(), and my shell script relaunches the
185 self
.__finished
= True
187 def send_message(self
, mess
):
188 """Send an XMPP message"""
189 self
.connect().send(mess
)
191 def send_tune(self
, song
, debug
=False):
192 """Set information about the currently played tune
194 Song is a dictionary with keys: file, title, artist, album, pos, track,
195 length, uri. For details see <http://xmpp.org/protocols/tune/>.
197 NS_TUNE
= 'http://jabber.org/protocol/tune'
198 iq
= xmpp
.Iq(typ
='set')
200 iq
.pubsub
= iq
.addChild('pubsub', namespace
=xmpp
.NS_PUBSUB
)
201 iq
.pubsub
.publish
= iq
.pubsub
.addChild('publish', attrs
={ 'node' : NS_TUNE
})
202 iq
.pubsub
.publish
.item
= iq
.pubsub
.publish
.addChild('item', attrs
={ 'id' : 'current' })
203 tune
= iq
.pubsub
.publish
.item
.addChild('tune')
204 tune
.setNamespace(NS_TUNE
)
207 if song
.has_key('title'):
208 title
= song
['title']
209 elif song
.has_key('file'):
210 title
= os
.path
.splitext(os
.path
.basename(song
['file']))[0]
211 if title
is not None:
212 tune
.addChild('title').addData(title
)
213 if song
.has_key('artist'):
214 tune
.addChild('artist').addData(song
['artist'])
215 if song
.has_key('album'):
216 tune
.addChild('source').addData(song
['album'])
217 if song
.has_key('pos') and song
['pos'] > 0:
218 tune
.addChild('track').addData(str(song
['pos']))
219 if song
.has_key('time'):
220 tune
.addChild('length').addData(str(song
['time']))
221 if song
.has_key('uri'):
222 tune
.addChild('uri').addData(song
['uri'])
225 print 'Sending tune:', iq
.__str
__().encode('utf8')
228 def send(self
, user
, text
, in_reply_to
=None, message_type
='chat'):
229 """Sends a simple message to the specified user."""
230 mess
= self
.build_message(text
)
234 mess
.setThread(in_reply_to
.getThread())
235 mess
.setType(in_reply_to
.getType())
237 mess
.setThread(self
.__threads
.get(user
, None))
238 mess
.setType(message_type
)
240 self
.send_message(mess
)
242 def send_simple_reply(self
, mess
, text
, private
=False):
243 """Send a simple response to a message"""
244 self
.send_message(self
.build_reply(mess
, text
, private
))
246 def build_reply(self
, mess
, text
=None, private
=False):
247 """Build a message for responding to another message. Message is NOT sent"""
248 response
= self
.build_message(text
)
250 response
.setTo(mess
.getFrom())
251 response
.setType('chat')
253 response
.setTo(mess
.getFrom().getStripped())
254 response
.setType(mess
.getType())
255 response
.setThread(mess
.getThread())
258 def build_message(self
, text
):
259 """Builds an xhtml message without attributes."""
260 text_plain
= re
.sub(r
'<[^>]+>', '', text
)
261 message
= xmpp
.protocol
.Message(body
=text_plain
)
262 if text_plain
!= text
:
263 html
= xmpp
.Node('html', {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
265 html
.addChild(node
=xmpp
.simplexml
.XML2Node("<body xmlns='http://www.w3.org/1999/xhtml'>" + text
.encode('utf-8') + "</body>"))
266 message
.addChild(node
=html
)
268 # Didn't work, incorrect markup or something.
269 # print >> sys.stderr, e, text
270 message
= xmpp
.protocol
.Message(body
=text_plain
)
273 def get_sender_username(self
, mess
):
274 """Extract the sender's user name from a message"""
275 type = mess
.getType()
277 if type == "groupchat":
278 username
= jid
.getResource()
280 username
= jid
.getNode()
285 def status_type_changed(self
, jid
, new_status_type
):
286 """Callback for tracking status types (available, away, offline, ...)"""
287 self
.log
.debug('user %s changed status to %s' % (jid
, new_status_type
))
289 def status_message_changed(self
, jid
, new_status_message
):
290 """Callback for tracking status messages (the free-form status text)"""
291 self
.log
.debug('user %s updated text to %s' % (jid
, new_status_message
))
293 def broadcast(self
, message
, only_available
=False):
294 """Broadcast a message to all users 'seen' by this bot.
296 If the parameter 'only_available' is True, the broadcast
297 will not go to users whose status is not 'Available'."""
298 for jid
, (show
, status
) in self
.__seen
.items():
299 if not only_available
or show
is self
.AVAILABLE
:
300 self
.send(jid
, message
)
302 def callback_presence(self
, conn
, presence
):
303 self
.__lastping
= time
.time()
304 jid
, type_
, show
, status
= presence
.getFrom(), \
305 presence
.getType(), presence
.getShow(), \
308 if self
.jid
.bareMatch(jid
) and not self
.__acceptownmsgs
:
309 # Ignore our own presence messages
313 # Keep track of status message and type changes
314 old_show
, old_status
= self
.__seen
.get(jid
, (self
.OFFLINE
, None))
316 self
.status_type_changed(jid
, show
)
318 if old_status
!= status
:
319 self
.status_message_changed(jid
, status
)
321 self
.__seen
[jid
] = (show
, status
)
322 elif type_
== self
.OFFLINE
and jid
in self
.__seen
:
323 # Notify of user offline status change
325 self
.status_type_changed(jid
, self
.OFFLINE
)
328 subscription
= self
.roster
.getSubscription(unicode(jid
.__str
__()))
330 # User not on our roster
332 except AttributeError, e
:
333 # Recieved presence update before roster built
337 self
.log
.error(presence
.getError())
339 self
.log
.debug('Got presence: %s (type: %s, show: %s, status: %s, subscription: %s)' % (jid
, type_
, show
, status
, subscription
))
341 # If subscription is private, disregard anything not from the private domain
342 if self
.__privatedomain
and type_
in ('subscribe', 'subscribed', 'unsubscribe'):
343 if self
.__privatedomain
== True:
344 # Use the bot's domain
345 domain
= self
.jid
.getDomain()
347 # Use the specified domain
348 domain
= self
.__privatedomain
350 # Check if the sender is in the private domain
351 user_domain
= jid
.getDomain()
352 if domain
!= user_domain
:
353 self
.log
.info('Ignoring subscribe request: %s does not match private domain (%s)' % (user_domain
, domain
))
356 if type_
== 'subscribe':
357 # Incoming presence subscription request
358 if subscription
in ('to', 'both', 'from'):
359 self
.roster
.Authorize(jid
)
362 if subscription
not in ('to', 'both'):
363 self
.roster
.Subscribe(jid
)
365 if subscription
in (None, 'none'):
366 self
.send(jid
, self
.MSG_AUTHORIZE_ME
)
367 elif type_
== 'subscribed':
368 # Authorize any pending requests for that JID
369 self
.roster
.Authorize(jid
)
370 elif type_
== 'unsubscribed':
371 # Authorization was not granted
372 self
.send(jid
, self
.MSG_NOT_AUTHORIZED
)
373 self
.roster
.Unauthorize(jid
)
375 def callback_message(self
, conn
, mess
):
376 """Messages sent to the bot will arrive here. Command handling + routing is done in this function."""
377 self
.__lastping
= time
.time()
379 # Prepare to handle either private chats or group chats
380 type = mess
.getType()
382 props
= mess
.getProperties()
383 text
= mess
.getBody()
384 username
= self
.get_sender_username(mess
)
386 if type not in ("groupchat", "chat"):
387 self
.log
.debug("unhandled message type: %s" % type)
390 self
.log
.debug("*** props = %s" % props
)
391 self
.log
.debug("*** jid = %s" % jid
)
392 self
.log
.debug("*** username = %s" % username
)
393 self
.log
.debug("*** type = %s" % type)
394 self
.log
.debug("*** text = %s" % text
)
396 # Ignore messages from before we joined
397 if xmpp
.NS_DELAY
in props
: return
399 # Ignore messages from myself
400 if username
== self
.__username
: return
402 # If a message format is not supported (eg. encrypted), txt will be None
405 # Ignore messages from users not seen by this bot
406 if jid
not in self
.__seen
:
407 self
.log
.info('Ignoring message from unseen guest: %s' % jid
)
408 self
.log
.debug("I've seen: %s" % ["%s" % x
for x
in self
.__seen
.keys()])
411 # Remember the last-talked-in thread for replies
412 self
.__threads
[jid
] = mess
.getThread()
415 command
, args
= text
.split(' ', 1)
417 command
, args
= text
, ''
418 cmd
= command
.lower()
419 self
.log
.debug("*** cmd = %s" % cmd
)
421 if self
.custom_message_handler
is not None:
422 # Try the custom handler first. It can return None
423 # if you want JabberBot to fall back to the default.
424 reply
= self
.custom_message_handler(mess
, text
)
428 if reply
is None and self
.commands
.has_key(cmd
):
430 reply
= self
.commands
[cmd
](mess
, args
)
432 reply
= traceback
.format_exc(e
)
433 self
.log
.exception('An error happened while processing a message ("%s") from %s: %s"' % (text
, jid
, reply
))
435 # In private chat, it's okay for the bot to always respond.
436 # In group chat, the bot should silently ignore commands it
437 # doesn't understand or aren't handled by unknown_command().
438 default_reply
= 'Unknown command: "%s". Type "help" for available commands.<b>blubb!</b>' % cmd
439 if type == "groupchat": default_reply
= None
440 reply
= self
.unknown_command(mess
, cmd
, args
)
442 reply
= default_reply
444 self
.send_simple_reply(mess
, reply
)
446 def unknown_command(self
, mess
, cmd
, args
):
447 """Default handler for unknown commands
449 Override this method in derived class if you
450 want to trap some unrecognized commands. If
451 'cmd' is handled, you must return some non-false
452 value, else some helpful text will be sent back
457 def top_of_help_message(self
):
458 """Returns a string that forms the top of the help message
460 Override this method in derived class if you
461 want to add additional help text at the
462 beginning of the help message.
466 def bottom_of_help_message(self
):
467 """Returns a string that forms the bottom of the help message
469 Override this method in derived class if you
470 want to add additional help text at the end
476 def help(self
, mess
, args
):
477 """Returns a help string listing available options.
479 Automatically assigned to the "help" command."""
482 description
= self
.__doc
__.strip()
484 description
= 'Available commands:'
486 usage
= '\n'.join(sorted([
487 '%s: %s' % (name
, (command
.__doc
__.strip() or '(undocumented)').split('\n', 1)[0])
488 for (name
, command
) in self
.commands
.iteritems() if name
!= 'help' and not command
._jabberbot
_hidden
490 usage
= usage
+ '\n\nType help <command name> to get more info about that specific command.'
493 if args
in self
.commands
:
494 usage
= self
.commands
[args
].__doc
__.strip() or 'undocumented'
496 usage
= 'That command is not defined.'
498 top
= self
.top_of_help_message()
499 bottom
= self
.bottom_of_help_message()
500 if top
: top
= "%s\n\n" % top
501 if bottom
: bottom
= "\n\n%s" % bottom
503 return '%s%s\n\n%s%s' % (top
, description
, usage
, bottom
)
506 """This function will be called in the main loop."""
509 def _idle_ping(self
):
510 """Pings the server, calls on_ping_timeout() on no response.
512 To enable set self.PING_FREQUENCY to a value higher than zero.
514 if self
.PING_FREQUENCY
and time
.time() - self
.__lastping
> self
.PING_FREQUENCY
:
515 self
.__lastping
= time
.time()
516 #logging.debug('Pinging the server.')
517 ping
= xmpp
.Protocol('iq', typ
='get', payload
=[xmpp
.Node('ping', attrs
={'xmlns':'urn:xmpp:ping'})])
519 res
= self
.conn
.SendAndWaitForResponse(ping
, self
.PING_TIMEOUT
)
520 #logging.debug('Got response: ' + str(res))
522 self
.on_ping_timeout()
524 logging
.error('Error pinging the server: %s, treating as ping timeout.' % e
)
525 self
.on_ping_timeout()
527 def on_ping_timeout(self
):
528 logging
.info('Terminating due to PING timeout.')
532 """This function will be called when we're done serving
534 Override this method in derived class if you
535 want to do anything special at shutdown.
539 def serve_forever(self
, connect_callback
=None, disconnect_callback
=None):
540 """Connects to the server and handles messages."""
541 conn
= self
.connect()
543 self
.log
.info('bot connected. serving forever.')
545 self
.log
.warn('could not connect to server - aborting.')
550 self
.__lastping
= time
.time()
552 while not self
.__finished
:
556 except KeyboardInterrupt:
557 self
.log
.info('bot stopped by user request. shutting down.')
562 if disconnect_callback
:
563 disconnect_callback()