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/>.
26 print >>sys
.stderr
, 'You need to install xmpppy from http://xmpppy.sf.net/.'
31 """A simple jabber/xmpp bot framework"""
33 __author__
= 'Thomas Perl <thp@thpinfo.com>'
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
)
47 return decorate(args
[0], **kwargs
)
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."""
61 self
.__username
= username
62 self
.__password
= password
63 self
.jid
= xmpp
.JID(self
.__username
)
64 self
.res
= (res
or self
.__class
__.__name
__)
66 self
.__finished
= False
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
:
88 def __get_status(self
):
91 status_message
= property(fget
=__get_status
, fset
=__set_status
)
93 def __set_show(self
, value
):
94 if self
.__show
!= value
:
101 status_type
= property(fget
=__get_show
, fset
=__set_show
)
103 ################################
106 if self
.__debug
: self
.log(s
)
109 """Logging facility, can be overridden in subclasses to log to file, etc.."""
110 print self
.__class
__.__name
__, ':', s
115 conn
= xmpp
.Client(self
.jid
.getDomain())
117 conn
= xmpp
.Client(self
.jid
.getDomain(), debug
= [])
119 conres
= conn
.connect()
121 self
.log( 'unable to connect to server %s.' % self
.jid
.getDomain())
124 self
.log("Warning: unable to establish secure connection - TLS failed!")
126 authres
= conn
.auth(self
.jid
.getNode(), self
.__password
, self
.res
)
128 self
.log('unable to authorize with server.')
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()
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 ***')
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
))
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
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
)
171 mess
.setThread(in_reply_to
.getThread())
172 mess
.setType(in_reply_to
.getType())
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"""
186 to_user
= mess
.getFrom()
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)
196 def get_sender_username(self
, mess
):
197 """Extract the sender's user name from a message"""
198 type = mess
.getType()
200 if type == "groupchat":
201 username
= jid
.getResource()
203 username
= jid
.getNode()
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(), \
230 if self
.jid
.bareMatch(jid
):
231 # Ignore our own presence messages
235 # Keep track of status message and type changes
236 old_show
, old_status
= self
.__seen
.get(jid
, (self
.OFFLINE
, None))
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
247 self
.status_type_changed(jid
, self
.OFFLINE
)
250 subscription
= self
.roster
.getSubscription(str(jid
))
252 # User not on our roster
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
)
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()
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)
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
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()])
314 # Remember the last-talked-in thread for replies
315 self
.__threads
[jid
] = mess
.getThread()
318 command
, args
= text
.split(' ', 1)
320 command
, args
= text
, ''
321 cmd
= command
.lower()
322 self
.debug("*** cmd = %s" % cmd
)
324 if self
.commands
.has_key(cmd
):
326 reply
= self
.commands
[cmd
](mess
, args
)
328 reply
= traceback
.format_exc(e
)
329 self
.log('An error happened while processing a message ("%s") from %s: %s"' % (text
, jid
, reply
))
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
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
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.
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
371 def help(self
, mess
, args
):
372 """Returns a help string listing available options.
374 Automatically assigned to the "help" command."""
377 description
= self
.__doc
__.strip()
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.'
385 if args
in self
.commands
:
386 usage
= self
.commands
[args
].__doc
__ or 'undocumented'
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."""
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.
409 def serve_forever( self
, connect_callback
= None, disconnect_callback
= None):
410 """Connects to the server and handles messages."""
411 conn
= self
.connect()
413 self
.log('bot connected. serving forever.')
415 self
.log('could not connect to server - aborting.')
421 while not self
.__finished
:
425 except KeyboardInterrupt:
426 self
.log('bot stopped by user request. shutting down.')
431 if disconnect_callback
:
432 disconnect_callback()