Better logging and stuff (old versions)
[jabberbot.git] / jabberbot.py
blob35668655951af5a7ddf414354d8ae7764e27182b
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/>.
19 # Homepage: http://thpinfo.com/2007/python-jabberbot/
23 import sys
25 try:
26 import xmpp
27 except ImportError:
28 print >>sys.stderr, 'You need to install xmpppy from http://xmpppy.sf.net/.'
29 sys.exit(-1)
30 import inspect
31 import traceback
33 """A simple jabber/xmpp bot framework
35 This is a simple bot framework around the "xmpppy" framework.
36 Copyright (c) 2007-2009 Thomas Perl <thpinfo.com>
37 """
39 __author__ = 'Thomas Perl <thp@thpinfo.com>'
40 __version__ = '0.7'
43 def botcmd(*args, **kwargs):
44 """Decorator for bot command functions"""
46 def decorate(func, hidden=False):
47 setattr(func, '_jabberbot_command', True)
48 setattr(func, '_jabberbot_hidden', hidden)
49 return func
51 if len(args):
52 return decorate(args[0], **kwargs)
53 else:
54 return lambda func: decorate(func, **kwargs)
57 class JabberBot(object):
58 AVAILABLE, AWAY, CHAT, DND, XA, OFFLINE = None, 'away', 'chat', 'dnd', 'xa', 'unavailable'
60 MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. Authorize my request and I will do the same.'
61 MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. Access denied.'
63 def __init__( self, jid, password, res=None):
64 """Initializes the jabber bot and sets up commands."""
65 self.jid = xmpp.JID(jid)
66 self.password = password
67 self.res = (res or self.__class__.__name__)
68 self.conn = None
69 self.__finished = False
70 self.__show = None
71 self.__status = None
72 self.__seen = {}
73 self.__threads = {}
75 self.commands = {}
76 for name, value in inspect.getmembers(self):
77 if inspect.ismethod(value) and getattr(value, '_jabberbot_command', False):
78 self.debug('Registered command: %s' % name)
79 self.commands[name] = value
81 ################################
83 def _send_status(self):
84 self.conn.send(xmpp.dispatcher.Presence(show=self.__show, status=self.__status))
86 def __set_status(self, value):
87 if self.__status != value:
88 self.__status = value
89 self._send_status()
91 def __get_status(self):
92 return self.__status
94 status_message = property(fget=__get_status, fset=__set_status)
96 def __set_show(self, value):
97 if self.__show != value:
98 self.__show = value
99 self._send_status()
101 def __get_show(self):
102 return self.__show
104 status_type = property(fget=__get_show, fset=__set_show)
106 ################################
108 def debug(self, s):
109 self.log(s)
111 def log( self, s):
112 """Logging facility, can be overridden in subclasses to log to file, etc.."""
113 print self.__class__.__name__, ':', s
115 def connect( self):
116 if not self.conn:
117 conn = xmpp.Client(self.jid.getDomain())
119 if not conn.connect():
120 self.log( 'unable to connect to server.')
121 return None
123 if not conn.auth( self.jid.getNode(), self.password, self.res):
124 self.log( 'unable to authorize with server.')
125 return None
127 conn.RegisterHandler('message', self.callback_message)
128 conn.RegisterHandler('presence', self.callback_presence)
129 conn.sendInitPresence()
130 self.conn = conn
131 self.roster = self.conn.Roster.getRoster()
132 self.log('*** roster ***')
133 for contact in self.roster.getItems():
134 self.log(' ' + str(contact))
135 self.log('*** roster ***')
137 return self.conn
139 def quit( self):
140 """Stop serving messages and exit.
142 I find it is handy for development to run the
143 jabberbot in a 'while true' loop in the shell, so
144 whenever I make a code change to the bot, I send
145 the 'reload' command, which I have mapped to call
146 self.quit(), and my shell script relaunches the
147 new version.
149 self.__finished = True
151 def send(self, user, text, in_reply_to=None, message_type='chat'):
152 """Sends a simple message to the specified user."""
153 mess = xmpp.Message(user, text)
155 if in_reply_to:
156 mess.setThread(in_reply_to.getThread())
157 mess.setType(in_reply_to.getType())
158 else:
159 mess.setThread(self.__threads.get(user, None))
160 mess.setType(message_type)
162 self.connect().send(mess)
164 def status_type_changed(self, jid, new_status_type):
165 """Callback for tracking status types (available, away, offline, ...)"""
166 self.debug('user %s changed status to %s' % (jid, new_status_type))
168 def status_message_changed(self, jid, new_status_message):
169 """Callback for tracking status messages (the free-form status text)"""
170 self.debug('user %s updated text to %s' % (jid, new_status_message))
172 def broadcast(self, message, only_available=False):
173 """Broadcast a message to all users 'seen' by this bot.
175 If the parameter 'only_available' is True, the broadcast
176 will not go to users whose status is not 'Available'."""
177 for jid, (show, status) in self.__seen.items():
178 if not only_available or show is self.AVAILABLE:
179 self.send(jid, message)
181 def callback_presence(self, conn, presence):
182 jid, type_, show, status = presence.getFrom(), \
183 presence.getType(), presence.getShow(), \
184 presence.getStatus()
186 if self.jid.bareMatch(jid):
187 # Ignore our own presence messages
188 return
190 if type_ is None:
191 # Keep track of status message and type changes
192 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
193 if old_show != show:
194 self.status_type_changed(jid, show)
196 if old_status != status:
197 self.status_message_changed(jid, status)
199 self.__seen[jid] = (show, status)
200 elif type_ == self.OFFLINE and jid in self.__seen:
201 # Notify of user offline status change
202 del self.__seen[jid]
203 self.status_type_changed(jid, self.OFFLINE)
205 try:
206 subscription = self.roster.getSubscription(str(jid))
207 except KeyError, ke:
208 # User not on our roster
209 subscription = None
211 if type_ == 'error':
212 self.log(presence.getError())
214 self.debug('Got presence: %s (type: %s, show: %s, status: %s, subscription: %s)' % (jid, type_, show, status, subscription))
216 if type_ == 'subscribe':
217 # Incoming presence subscription request
218 if subscription in ('to', 'both', 'from'):
219 self.roster.Authorize(jid)
220 self._send_status()
222 if subscription not in ('to', 'both'):
223 self.roster.Subscribe(jid)
225 if subscription in (None, 'none'):
226 self.send(jid, self.MSG_AUTHORIZE_ME)
227 elif type_ == 'subscribed':
228 # Authorize any pending requests for that JID
229 self.roster.Authorize(jid)
230 elif type_ == 'unsubscribed':
231 # Authorization was not granted
232 self.send(jid, self.MSG_NOT_AUTHORIZED)
233 self.roster.Unauthorize(jid)
235 def callback_message( self, conn, mess):
236 """Messages sent to the bot will arrive here. Command handling + routing is done in this function."""
237 jid, text = mess.getFrom(), mess.getBody()
239 # If a message format is not supported (eg. encrypted), txt will be None
240 if not text:
241 return
243 # Ignore messages from users not seen by this bot
244 if jid not in self.__seen:
245 self.log('Ignoring message from unseen guest: %s' % jid)
246 return
248 # Remember the last-talked-in thread for replies
249 self.__threads[jid] = mess.getThread()
251 if ' ' in text:
252 command, args = text.split(' ', 1)
253 else:
254 command, args = text, ''
256 cmd = command.lower()
258 if self.commands.has_key(cmd):
259 try:
260 reply = self.commands[cmd](mess, args)
261 except Exception, e:
262 reply = traceback.format_exc(e)
263 self.log('An error happened while processing a message ("%s") from %s: %s"' % (text, jid, reply))
264 print reply
265 else:
266 unk_str = 'Unknown command: "%s". Type "help" for available commands.<b>blubb!</b>' % cmd
267 reply = self.unknown_command( mess, cmd, args) or unk_str
268 if reply:
269 self.send(jid, reply, mess)
271 def unknown_command(self, mess, cmd, args):
272 """Default handler for unknown commands
274 Override this method in derived class if you
275 want to trap some unrecognized commands. If
276 'cmd' is handled, you must return some non-false
277 value, else some helpful text will be sent back
278 to the sender.
280 return None
282 @botcmd
283 def help( self, mess, args):
284 """Returns a help string listing available options. Automatically assigned to the "help" command."""
285 usage = '\n'.join(sorted(['%s: %s' % (name, command.__doc__ or '(undocumented)') for (name, command) in self.commands.items() if name != 'help' and not command._jabberbot_hidden]))
287 if self.__doc__:
288 description = self.__doc__.strip()
289 else:
290 description = 'Available commands:'
292 return '%s\n\n%s' % ( description, usage, )
294 def idle_proc( self):
295 """This function will be called in the main loop."""
296 pass
298 def serve_forever( self, connect_callback = None, disconnect_callback = None):
299 """Connects to the server and handles messages."""
300 conn = self.connect()
301 if conn:
302 self.log('bot connected. serving forever.')
303 else:
304 self.log('could not connect to server - aborting.')
305 return
307 if connect_callback:
308 connect_callback()
310 while not self.__finished:
311 try:
312 conn.Process(1)
313 self.idle_proc()
314 except KeyboardInterrupt:
315 self.log('bot stopped by user request. shutting down.')
316 break
318 if disconnect_callback:
319 disconnect_callback()