Jabberbot 0.9
[jabberbot/examples.git] / jabberbot.py
blob7d9b56eb539f8a0d4d43a9ba080f3b73a1c047ba
1 #!/usr/bin/python
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/>.
21 import sys
23 try:
24 import xmpp
25 except ImportError:
26 print >>sys.stderr, 'You need to install xmpppy from http://xmpppy.sf.net/.'
27 sys.exit(-1)
28 import inspect
29 import traceback
31 """A simple jabber/xmpp bot framework"""
33 __author__ = 'Thomas Perl <thp@thpinfo.com>'
34 __version__ = '0.9'
35 __website__ = 'http://thpinfo.com/2007/python-jabberbot/'
36 __license__ = 'GPLv3 or later'
38 def botcmd(*args, **kwargs):
39 """Decorator for bot command functions"""
41 def decorate(func, hidden=False):
42 setattr(func, '_jabberbot_command', True)
43 setattr(func, '_jabberbot_hidden', hidden)
44 return func
46 if len(args):
47 return decorate(args[0], **kwargs)
48 else:
49 return lambda func: decorate(func, **kwargs)
52 class JabberBot(object):
53 AVAILABLE, AWAY, CHAT, DND, XA, OFFLINE = None, 'away', 'chat', 'dnd', 'xa', 'unavailable'
55 MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. Authorize my request and I will do the same.'
56 MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. Access denied.'
58 def __init__(self, username, password, res=None, debug=False):
59 """Initializes the jabber bot and sets up commands."""
60 self.__debug = debug
61 self.__username = username
62 self.__password = password
63 self.jid = xmpp.JID(self.__username)
64 self.res = (res or self.__class__.__name__)
65 self.conn = None
66 self.__finished = False
67 self.__show = None
68 self.__status = None
69 self.__seen = {}
70 self.__threads = {}
72 self.commands = {}
73 for name, value in inspect.getmembers(self):
74 if inspect.ismethod(value) and getattr(value, '_jabberbot_command', False):
75 self.debug('Registered command: %s' % name)
76 self.commands[name] = value
78 ################################
80 def _send_status(self):
81 self.conn.send(xmpp.dispatcher.Presence(show=self.__show, status=self.__status))
83 def __set_status(self, value):
84 if self.__status != value:
85 self.__status = value
86 self._send_status()
88 def __get_status(self):
89 return self.__status
91 status_message = property(fget=__get_status, fset=__set_status)
93 def __set_show(self, value):
94 if self.__show != value:
95 self.__show = value
96 self._send_status()
98 def __get_show(self):
99 return self.__show
101 status_type = property(fget=__get_show, fset=__set_show)
103 ################################
105 def debug(self, s):
106 if self.__debug: self.log(s)
108 def log( self, s):
109 """Logging facility, can be overridden in subclasses to log to file, etc.."""
110 print self.__class__.__name__, ':', s
112 def connect( self):
113 if not self.conn:
114 if self.__debug:
115 conn = xmpp.Client(self.jid.getDomain())
116 else:
117 conn = xmpp.Client(self.jid.getDomain(), debug = [])
119 conres = conn.connect()
120 if not conres:
121 self.log( 'unable to connect to server %s.' % self.jid.getDomain())
122 return None
123 if conres<>'tls':
124 self.log("Warning: unable to establish secure connection - TLS failed!")
126 authres = conn.auth(self.jid.getNode(), self.__password, self.res)
127 if not authres:
128 self.log('unable to authorize with server.')
129 return None
130 if authres<>'sasl':
131 self.log("Warning: unable to perform SASL auth os %s. Old authentication method used!" % self.jid.getDomain())
133 conn.RegisterHandler('message', self.callback_message)
134 conn.RegisterHandler('presence', self.callback_presence)
135 conn.sendInitPresence()
136 self.conn = conn
137 self.roster = self.conn.Roster.getRoster()
138 self.log('*** roster ***')
139 for contact in self.roster.getItems():
140 self.log(' ' + str(contact))
141 self.log('*** roster ***')
143 return self.conn
145 def join_room(self, room):
146 """Join the specified multi-user chat room"""
147 my_room_JID = "%s/%s" % (room,self.__username)
148 self.connect().send(xmpp.Presence(to=my_room_JID))
150 def quit( self):
151 """Stop serving messages and exit.
153 I find it is handy for development to run the
154 jabberbot in a 'while true' loop in the shell, so
155 whenever I make a code change to the bot, I send
156 the 'reload' command, which I have mapped to call
157 self.quit(), and my shell script relaunches the
158 new version.
160 self.__finished = True
162 def send_message(self, mess):
163 """Send an XMPP message"""
164 self.connect().send(mess)
166 def send(self, user, text, in_reply_to=None, message_type='chat'):
167 """Sends a simple message to the specified user."""
168 mess = xmpp.Message(user, text)
170 if in_reply_to:
171 mess.setThread(in_reply_to.getThread())
172 mess.setType(in_reply_to.getType())
173 else:
174 mess.setThread(self.__threads.get(user, None))
175 mess.setType(message_type)
177 self.send_message(mess)
179 def send_simple_reply(self, mess, text, private=False):
180 """Send a simple response to a message"""
181 self.send_message( self.build_reply(mess,text, private) )
183 def build_reply(self, mess, text=None, private=False):
184 """Build a message for responding to another message. Message is NOT sent"""
185 if private:
186 to_user = mess.getFrom()
187 type = "chat"
188 else:
189 to_user = mess.getFrom().getStripped()
190 type = mess.getType()
191 response = xmpp.Message(to_user, text)
192 response.setThread(mess.getThread())
193 response.setType(type)
194 return response
196 def get_sender_username(self, mess):
197 """Extract the sender's user name from a message"""
198 type = mess.getType()
199 jid = mess.getFrom()
200 if type == "groupchat":
201 username = jid.getResource()
202 elif type == "chat":
203 username = jid.getNode()
204 else:
205 username = ""
206 return username
208 def status_type_changed(self, jid, new_status_type):
209 """Callback for tracking status types (available, away, offline, ...)"""
210 self.debug('user %s changed status to %s' % (jid, new_status_type))
212 def status_message_changed(self, jid, new_status_message):
213 """Callback for tracking status messages (the free-form status text)"""
214 self.debug('user %s updated text to %s' % (jid, new_status_message))
216 def broadcast(self, message, only_available=False):
217 """Broadcast a message to all users 'seen' by this bot.
219 If the parameter 'only_available' is True, the broadcast
220 will not go to users whose status is not 'Available'."""
221 for jid, (show, status) in self.__seen.items():
222 if not only_available or show is self.AVAILABLE:
223 self.send(jid, message)
225 def callback_presence(self, conn, presence):
226 jid, type_, show, status = presence.getFrom(), \
227 presence.getType(), presence.getShow(), \
228 presence.getStatus()
230 if self.jid.bareMatch(jid):
231 # Ignore our own presence messages
232 return
234 if type_ is None:
235 # Keep track of status message and type changes
236 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
237 if old_show != show:
238 self.status_type_changed(jid, show)
240 if old_status != status:
241 self.status_message_changed(jid, status)
243 self.__seen[jid] = (show, status)
244 elif type_ == self.OFFLINE and jid in self.__seen:
245 # Notify of user offline status change
246 del self.__seen[jid]
247 self.status_type_changed(jid, self.OFFLINE)
249 try:
250 subscription = self.roster.getSubscription(str(jid))
251 except KeyError, ke:
252 # User not on our roster
253 subscription = None
255 if type_ == 'error':
256 self.log(presence.getError())
258 self.debug('Got presence: %s (type: %s, show: %s, status: %s, subscription: %s)' % (jid, type_, show, status, subscription))
260 if type_ == 'subscribe':
261 # Incoming presence subscription request
262 if subscription in ('to', 'both', 'from'):
263 self.roster.Authorize(jid)
264 self._send_status()
266 if subscription not in ('to', 'both'):
267 self.roster.Subscribe(jid)
269 if subscription in (None, 'none'):
270 self.send(jid, self.MSG_AUTHORIZE_ME)
271 elif type_ == 'subscribed':
272 # Authorize any pending requests for that JID
273 self.roster.Authorize(jid)
274 elif type_ == 'unsubscribed':
275 # Authorization was not granted
276 self.send(jid, self.MSG_NOT_AUTHORIZED)
277 self.roster.Unauthorize(jid)
279 def callback_message( self, conn, mess):
280 """Messages sent to the bot will arrive here. Command handling + routing is done in this function."""
282 # Prepare to handle either private chats or group chats
283 type = mess.getType()
284 jid = mess.getFrom()
285 props = mess.getProperties()
286 text = mess.getBody()
287 username = self.get_sender_username(mess)
289 if type not in ("groupchat", "chat"):
290 self.debug("unhandled message type: %s" % type)
291 return
293 self.debug("*** props = %s" % props)
294 self.debug("*** jid = %s" % jid)
295 self.debug("*** username = %s" % username)
296 self.debug("*** type = %s" % type)
297 self.debug("*** text = %s" % text)
299 # Ignore messages from before we joined
300 if xmpp.NS_DELAY in props: return
302 # Ignore messages from myself
303 if username == self.__username: return
305 # If a message format is not supported (eg. encrypted), txt will be None
306 if not text: return
308 # Ignore messages from users not seen by this bot
309 if jid not in self.__seen:
310 self.log('Ignoring message from unseen guest: %s' % jid)
311 self.debug("I've seen: %s" % ["%s" % x for x in self.__seen.keys()])
312 return
314 # Remember the last-talked-in thread for replies
315 self.__threads[jid] = mess.getThread()
317 if ' ' in text:
318 command, args = text.split(' ', 1)
319 else:
320 command, args = text, ''
321 cmd = command.lower()
322 self.debug("*** cmd = %s" % cmd)
324 if self.commands.has_key(cmd):
325 try:
326 reply = self.commands[cmd](mess, args)
327 except Exception, e:
328 reply = traceback.format_exc(e)
329 self.log('An error happened while processing a message ("%s") from %s: %s"' % (text, jid, reply))
330 print reply
331 else:
332 # In private chat, it's okay for the bot to always respond.
333 # In group chat, the bot should silently ignore commands it
334 # doesn't understand or aren't handled by unknown_command().
335 default_reply = 'Unknown command: "%s". Type "help" for available commands.<b>blubb!</b>' % cmd
336 if type == "groupchat": default_reply = None
337 reply = self.unknown_command( mess, cmd, args) or default_reply
338 if reply:
339 self.send_simple_reply(mess,reply)
341 def unknown_command(self, mess, cmd, args):
342 """Default handler for unknown commands
344 Override this method in derived class if you
345 want to trap some unrecognized commands. If
346 'cmd' is handled, you must return some non-false
347 value, else some helpful text will be sent back
348 to the sender.
350 return None
352 def top_of_help_message(self):
353 """Returns a string that forms the top of the help message
355 Override this method in derived class if you
356 want to add additional help text at the
357 beginning of the help message.
359 return ""
361 def bottom_of_help_message(self):
362 """Returns a string that forms the bottom of the help message
364 Override this method in derived class if you
365 want to add additional help text at the end
366 of the help message.
368 return ""
370 @botcmd
371 def help(self, mess, args):
372 """Returns a help string listing available options.
374 Automatically assigned to the "help" command."""
375 if not args:
376 if self.__doc__:
377 description = self.__doc__.strip()
378 else:
379 description = 'Available commands:'
381 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]))
382 usage = usage + '\n\nType help <command name> to get more info about that specific command.'
383 else:
384 description = ''
385 if args in self.commands:
386 usage = self.commands[args].__doc__ or 'undocumented'
387 else:
388 usage = 'That command is not defined.'
390 top = self.top_of_help_message()
391 bottom = self.bottom_of_help_message()
392 if top : top = "%s\n\n" % top
393 if bottom: bottom = "\n\n%s" % bottom
395 return '%s%s\n\n%s%s' % ( top, description, usage, bottom )
397 def idle_proc( self):
398 """This function will be called in the main loop."""
399 pass
401 def shutdown(self):
402 """This function will be called when we're done serving
404 Override this method in derived class if you
405 want to do anything special at shutdown.
407 pass
409 def serve_forever( self, connect_callback = None, disconnect_callback = None):
410 """Connects to the server and handles messages."""
411 conn = self.connect()
412 if conn:
413 self.log('bot connected. serving forever.')
414 else:
415 self.log('could not connect to server - aborting.')
416 return
418 if connect_callback:
419 connect_callback()
421 while not self.__finished:
422 try:
423 conn.Process(1)
424 self.idle_proc()
425 except KeyboardInterrupt:
426 self.log('bot stopped by user request. shutting down.')
427 break
429 self.shutdown()
431 if disconnect_callback:
432 disconnect_callback()