3 # JabberBot: A simple jabber/xmpp bot framework
4 # Copyright (c) 2007-2009 Thomas Perl <thpinfo.com>
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
28 print >>sys
.stderr
, 'You need to install xmpppy from http://xmpppy.sf.net/.'
33 """A simple jabber/xmpp bot framework"""
35 __author__
= 'Thomas Perl <thp@thpinfo.com>'
37 __website__
= 'http://thpinfo.com/2007/python-jabberbot/'
38 __license__
= 'GPLv3 or later'
40 def botcmd(*args
, **kwargs
):
41 """Decorator for bot command functions"""
43 def decorate(func
, hidden
=False, name
=None):
44 setattr(func
, '_jabberbot_command', True)
45 setattr(func
, '_jabberbot_hidden', hidden
)
46 setattr(func
, '_jabberbot_command_name', name
or func
.__name
__)
50 return decorate(args
[0], **kwargs
)
52 return lambda func
: decorate(func
, **kwargs
)
55 class JabberBot(object):
56 AVAILABLE
, AWAY
, CHAT
, DND
, XA
, OFFLINE
= None, 'away', 'chat', 'dnd', 'xa', 'unavailable'
58 MSG_AUTHORIZE_ME
= 'Hey there. You are not yet on my roster. Authorize my request and I will do the same.'
59 MSG_NOT_AUTHORIZED
= 'You did not authorize my subscription request. Access denied.'
61 def __init__(self
, username
, password
, res
=None, debug
=False):
62 """Initializes the jabber bot and sets up commands."""
64 self
.__username
= username
65 self
.__password
= password
66 self
.jid
= xmpp
.JID(self
.__username
)
67 self
.res
= (res
or self
.__class
__.__name
__)
69 self
.__finished
= False
76 for name
, value
in inspect
.getmembers(self
):
77 if inspect
.ismethod(value
) and getattr(value
, '_jabberbot_command', False):
78 name
= getattr(value
, '_jabberbot_command_name')
79 self
.debug('Registered command: %s' % name
)
80 self
.commands
[name
] = value
82 ################################
84 def _send_status(self
):
85 self
.conn
.send(xmpp
.dispatcher
.Presence(show
=self
.__show
, status
=self
.__status
))
87 def __set_status(self
, value
):
88 if self
.__status
!= value
:
92 def __get_status(self
):
95 status_message
= property(fget
=__get_status
, fset
=__set_status
)
97 def __set_show(self
, value
):
98 if self
.__show
!= value
:
102 def __get_show(self
):
105 status_type
= property(fget
=__get_show
, fset
=__set_show
)
107 ################################
110 if self
.__debug
: self
.log(s
)
113 """Logging facility, can be overridden in subclasses to log to file, etc.."""
114 print self
.__class
__.__name
__, ':', s
119 conn
= xmpp
.Client(self
.jid
.getDomain())
121 conn
= xmpp
.Client(self
.jid
.getDomain(), debug
= [])
123 conres
= conn
.connect()
125 self
.log( 'unable to connect to server %s.' % self
.jid
.getDomain())
128 self
.log("Warning: unable to establish secure connection - TLS failed!")
130 authres
= conn
.auth(self
.jid
.getNode(), self
.__password
, self
.res
)
132 self
.log('unable to authorize with server.')
135 self
.log("Warning: unable to perform SASL auth os %s. Old authentication method used!" % self
.jid
.getDomain())
137 conn
.RegisterHandler('message', self
.callback_message
)
138 conn
.RegisterHandler('presence', self
.callback_presence
)
139 conn
.sendInitPresence()
141 self
.roster
= self
.conn
.Roster
.getRoster()
142 self
.log('*** roster ***')
143 for contact
in self
.roster
.getItems():
144 self
.log(' ' + str(contact
))
145 self
.log('*** roster ***')
149 def join_room(self
, room
, username
=None):
150 """Join the specified multi-user chat room"""
152 username
= self
.__username
.split('@')[0]
153 my_room_JID
= '/'.join(room
, username
)
154 self
.connect().send(xmpp
.Presence(to
=my_room_JID
))
157 """Stop serving messages and exit.
159 I find it is handy for development to run the
160 jabberbot in a 'while true' loop in the shell, so
161 whenever I make a code change to the bot, I send
162 the 'reload' command, which I have mapped to call
163 self.quit(), and my shell script relaunches the
166 self
.__finished
= True
168 def send_message(self
, mess
):
169 """Send an XMPP message"""
170 self
.connect().send(mess
)
172 def send_tune(self
, song
, debug
=False):
173 """Set information about the currently played tune
175 Song is a dictionary with keys: file, title, artist, album, pos, track,
176 length, uri. For details see <http://xmpp.org/protocols/tune/>.
178 NS_TUNE
= 'http://jabber.org/protocol/tune'
179 iq
= xmpp
.Iq(typ
='set')
181 iq
.pubsub
= iq
.addChild('pubsub', namespace
= xmpp
.NS_PUBSUB
)
182 iq
.pubsub
.publish
= iq
.pubsub
.addChild('publish', attrs
= { 'node' : NS_TUNE
})
183 iq
.pubsub
.publish
.item
= iq
.pubsub
.publish
.addChild('item', attrs
= { 'id' : 'current' })
184 tune
= iq
.pubsub
.publish
.item
.addChild('tune')
185 tune
.setNamespace(NS_TUNE
)
188 if song
.has_key('title'):
189 title
= song
['title']
190 elif song
.has_key('file'):
191 title
= os
.path
.splitext(os
.path
.basename(song
['file']))[0]
192 if title
is not None:
193 tune
.addChild('title').addData(title
)
194 if song
.has_key('artist'):
195 tune
.addChild('artist').addData(song
['artist'])
196 if song
.has_key('album'):
197 tune
.addChild('source').addData(song
['album'])
198 if song
.has_key('pos') and song
['pos'] > 0:
199 tune
.addChild('track').addData(str(song
['pos']))
200 if song
.has_key('time'):
201 tune
.addChild('length').addData(str(song
['time']))
202 if song
.has_key('uri'):
203 tune
.addChild('uri').addData(song
['uri'])
206 print 'Sending tune:', iq
.__str
__().encode('utf8')
209 def send(self
, user
, text
, in_reply_to
=None, message_type
='chat'):
210 """Sends a simple message to the specified user."""
211 mess
= self
.build_message(text
)
215 mess
.setThread(in_reply_to
.getThread())
216 mess
.setType(in_reply_to
.getType())
218 mess
.setThread(self
.__threads
.get(user
, None))
219 mess
.setType(message_type
)
221 self
.send_message(mess
)
223 def send_simple_reply(self
, mess
, text
, private
=False):
224 """Send a simple response to a message"""
225 self
.send_message( self
.build_reply(mess
,text
, private
) )
227 def build_reply(self
, mess
, text
=None, private
=False):
228 """Build a message for responding to another message. Message is NOT sent"""
229 response
= self
.build_message(text
)
231 response
.setTo(mess
.getFrom())
232 response
.setType('chat')
234 response
.setTo(mess
.getFrom().getStripped())
235 response
.setType(mess
.getType())
236 response
.setThread(mess
.getThread())
239 def build_message(self
, text
):
240 """Builds an xhtml message without attributes."""
241 text_plain
= re
.sub(r
'<[^>]+>', '', text
)
242 message
= xmpp
.protocol
.Message(body
=text_plain
)
243 if text_plain
!= text
:
244 html
= xmpp
.Node('html', {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
246 html
.addChild(node
=xmpp
.simplexml
.XML2Node("<body xmlns='http://www.w3.org/1999/xhtml'>" + text
.encode('utf-8') + "</body>"))
247 message
.addChild(node
=html
)
249 # Didn't work, incorrect markup or something.
250 # print >> sys.stderr, e, text
251 message
= xmpp
.protocol
.Message(body
=text_plain
)
254 def get_sender_username(self
, mess
):
255 """Extract the sender's user name from a message"""
256 type = mess
.getType()
258 if type == "groupchat":
259 username
= jid
.getResource()
261 username
= jid
.getNode()
266 def status_type_changed(self
, jid
, new_status_type
):
267 """Callback for tracking status types (available, away, offline, ...)"""
268 self
.debug('user %s changed status to %s' % (jid
, new_status_type
))
270 def status_message_changed(self
, jid
, new_status_message
):
271 """Callback for tracking status messages (the free-form status text)"""
272 self
.debug('user %s updated text to %s' % (jid
, new_status_message
))
274 def broadcast(self
, message
, only_available
=False):
275 """Broadcast a message to all users 'seen' by this bot.
277 If the parameter 'only_available' is True, the broadcast
278 will not go to users whose status is not 'Available'."""
279 for jid
, (show
, status
) in self
.__seen
.items():
280 if not only_available
or show
is self
.AVAILABLE
:
281 self
.send(jid
, message
)
283 def callback_presence(self
, conn
, presence
):
284 jid
, type_
, show
, status
= presence
.getFrom(), \
285 presence
.getType(), presence
.getShow(), \
288 if self
.jid
.bareMatch(jid
):
289 # Ignore our own presence messages
293 # Keep track of status message and type changes
294 old_show
, old_status
= self
.__seen
.get(jid
, (self
.OFFLINE
, None))
296 self
.status_type_changed(jid
, show
)
298 if old_status
!= status
:
299 self
.status_message_changed(jid
, status
)
301 self
.__seen
[jid
] = (show
, status
)
302 elif type_
== self
.OFFLINE
and jid
in self
.__seen
:
303 # Notify of user offline status change
305 self
.status_type_changed(jid
, self
.OFFLINE
)
308 subscription
= self
.roster
.getSubscription(str(jid
))
310 # User not on our roster
314 self
.log(presence
.getError())
316 self
.debug('Got presence: %s (type: %s, show: %s, status: %s, subscription: %s)' % (jid
, type_
, show
, status
, subscription
))
318 if type_
== 'subscribe':
319 # Incoming presence subscription request
320 if subscription
in ('to', 'both', 'from'):
321 self
.roster
.Authorize(jid
)
324 if subscription
not in ('to', 'both'):
325 self
.roster
.Subscribe(jid
)
327 if subscription
in (None, 'none'):
328 self
.send(jid
, self
.MSG_AUTHORIZE_ME
)
329 elif type_
== 'subscribed':
330 # Authorize any pending requests for that JID
331 self
.roster
.Authorize(jid
)
332 elif type_
== 'unsubscribed':
333 # Authorization was not granted
334 self
.send(jid
, self
.MSG_NOT_AUTHORIZED
)
335 self
.roster
.Unauthorize(jid
)
337 def callback_message( self
, conn
, mess
):
338 """Messages sent to the bot will arrive here. Command handling + routing is done in this function."""
340 # Prepare to handle either private chats or group chats
341 type = mess
.getType()
343 props
= mess
.getProperties()
344 text
= mess
.getBody()
345 username
= self
.get_sender_username(mess
)
347 if type not in ("groupchat", "chat"):
348 self
.debug("unhandled message type: %s" % type)
351 self
.debug("*** props = %s" % props
)
352 self
.debug("*** jid = %s" % jid
)
353 self
.debug("*** username = %s" % username
)
354 self
.debug("*** type = %s" % type)
355 self
.debug("*** text = %s" % text
)
357 # Ignore messages from before we joined
358 if xmpp
.NS_DELAY
in props
: return
360 # Ignore messages from myself
361 if username
== self
.__username
: return
363 # If a message format is not supported (eg. encrypted), txt will be None
366 # Ignore messages from users not seen by this bot
367 if jid
not in self
.__seen
:
368 self
.log('Ignoring message from unseen guest: %s' % jid
)
369 self
.debug("I've seen: %s" % ["%s" % x
for x
in self
.__seen
.keys()])
372 # Remember the last-talked-in thread for replies
373 self
.__threads
[jid
] = mess
.getThread()
376 command
, args
= text
.split(' ', 1)
378 command
, args
= text
, ''
379 cmd
= command
.lower()
380 self
.debug("*** cmd = %s" % cmd
)
382 if self
.commands
.has_key(cmd
):
384 reply
= self
.commands
[cmd
](mess
, args
)
386 reply
= traceback
.format_exc(e
)
387 self
.log('An error happened while processing a message ("%s") from %s: %s"' % (text
, jid
, reply
))
390 # In private chat, it's okay for the bot to always respond.
391 # In group chat, the bot should silently ignore commands it
392 # doesn't understand or aren't handled by unknown_command().
393 default_reply
= 'Unknown command: "%s". Type "help" for available commands.<b>blubb!</b>' % cmd
394 if type == "groupchat": default_reply
= None
395 reply
= self
.unknown_command( mess
, cmd
, args
)
397 reply
= default_reply
399 self
.send_simple_reply(mess
,reply
)
401 def unknown_command(self
, mess
, cmd
, args
):
402 """Default handler for unknown commands
404 Override this method in derived class if you
405 want to trap some unrecognized commands. If
406 'cmd' is handled, you must return some non-false
407 value, else some helpful text will be sent back
412 def top_of_help_message(self
):
413 """Returns a string that forms the top of the help message
415 Override this method in derived class if you
416 want to add additional help text at the
417 beginning of the help message.
421 def bottom_of_help_message(self
):
422 """Returns a string that forms the bottom of the help message
424 Override this method in derived class if you
425 want to add additional help text at the end
431 def help(self
, mess
, args
):
432 """Returns a help string listing available options.
434 Automatically assigned to the "help" command."""
437 description
= self
.__doc
__.strip()
439 description
= 'Available commands:'
441 usage
= '\n'.join(sorted(['%s: %s' % (name
, (command
.__doc
__ or '(undocumented)').split('\n', 1)[0]) for (name
, command
) in self
.commands
.items() if name
!= 'help' and not command
._jabberbot
_hidden
]))
442 usage
= usage
+ '\n\nType help <command name> to get more info about that specific command.'
445 if args
in self
.commands
:
446 usage
= self
.commands
[args
].__doc
__ or 'undocumented'
448 usage
= 'That command is not defined.'
450 top
= self
.top_of_help_message()
451 bottom
= self
.bottom_of_help_message()
452 if top
: top
= "%s\n\n" % top
453 if bottom
: bottom
= "\n\n%s" % bottom
455 return '%s%s\n\n%s%s' % ( top
, description
, usage
, bottom
)
457 def idle_proc( self
):
458 """This function will be called in the main loop."""
462 """This function will be called when we're done serving
464 Override this method in derived class if you
465 want to do anything special at shutdown.
469 def serve_forever( self
, connect_callback
= None, disconnect_callback
= None):
470 """Connects to the server and handles messages."""
471 conn
= self
.connect()
473 self
.log('bot connected. serving forever.')
475 self
.log('could not connect to server - aborting.')
481 while not self
.__finished
:
485 except KeyboardInterrupt:
486 self
.log('bot stopped by user request. shutting down.')
491 if disconnect_callback
:
492 disconnect_callback()