PageRenderTime 52ms CodeModel.GetById 15ms RepoModel.GetById 1ms app.codeStats 0ms

/src/ardj/jabberbot.py

https://code.google.com/p/ardj/
Python | 562 lines | 484 code | 36 blank | 42 comment | 36 complexity | 3335463f98d0227867588ec25c26f127 MD5 | raw file
  1. #!/usr/bin/python
  2. # vim: set ts=4 sts=4 sw=4 et fileencoding=utf-8:
  3. # JabberBot: A simple jabber/xmpp bot framework
  4. # Copyright (c) 2007-2010 Thomas Perl <thpinfo.com>
  5. #
  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.
  10. #
  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.
  15. #
  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/>.
  18. #
  19. import logging
  20. import os
  21. import re
  22. 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 logging
  30. import traceback
  31. """A simple jabber/xmpp bot framework"""
  32. __author__ = 'Thomas Perl <thp@thpinfo.com>'
  33. __version__ = '0.10'
  34. __website__ = 'http://thpinfo.com/2007/python-jabberbot/'
  35. __license__ = 'GPLv3 or later'
  36. def botcmd(*args, **kwargs):
  37. """Decorator for bot command functions"""
  38. def decorate(func, hidden=False, name=None, pattern=None):
  39. setattr(func, '_jabberbot_command', True)
  40. setattr(func, '_jabberbot_hidden', hidden)
  41. setattr(func, '_jabberbot_command_name', name or func.__name__)
  42. setattr(func, '_jabberbot_command_re', pattern and re.compile(pattern))
  43. return func
  44. if len(args):
  45. return decorate(args[0], **kwargs)
  46. else:
  47. return lambda func: decorate(func, **kwargs)
  48. class JabberBot(object):
  49. AVAILABLE, AWAY, CHAT, DND, XA, OFFLINE = None, 'away', 'chat', 'dnd', 'xa', 'unavailable'
  50. MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. Authorize my request and I will do the same.'
  51. MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. Access denied.'
  52. PROCESS_MSG_FROM_SELF = False
  53. PROCESS_MSG_FROM_UNSEEN = False
  54. def __init__(self, username, password, res=None, debug=False):
  55. """Initializes the jabber bot and sets up commands."""
  56. self.__debug = debug
  57. self.log = logging.getLogger(__name__)
  58. self.__username = username
  59. self.__password = password
  60. self.jid = xmpp.JID(self.__username)
  61. self.res = (res or self.__class__.__name__)
  62. self.conn = None
  63. self.__finished = False
  64. self.__exitcode = 0
  65. self.__show = None
  66. self.__status = None
  67. self.__seen = {}
  68. self.__threads = {}
  69. self.commands = {}
  70. for name, value in inspect.getmembers(self):
  71. if inspect.ismethod(value) and getattr(value, '_jabberbot_command', False):
  72. name = getattr(value, '_jabberbot_command_name')
  73. if self.__debug:
  74. self.log.debug('Registered command: %s' % name)
  75. self.commands[name] = value
  76. self.roster = None
  77. ################################
  78. def _send_status(self):
  79. self.conn.send(xmpp.dispatcher.Presence(show=self.__show, status=self.__status))
  80. def __set_status(self, value):
  81. if self.__status != value:
  82. self.__status = value
  83. self._send_status()
  84. def __get_status(self):
  85. return self.__status
  86. status_message = property(fget=__get_status, fset=__set_status)
  87. def __set_show(self, value):
  88. if self.__show != value:
  89. self.__show = value
  90. self._send_status()
  91. def __get_show(self):
  92. return self.__show
  93. status_type = property(fget=__get_show, fset=__set_show)
  94. ################################
  95. def connect( self):
  96. if not self.conn:
  97. if self.__debug:
  98. conn = xmpp.Client(self.jid.getDomain())
  99. else:
  100. conn = xmpp.Client(self.jid.getDomain(), debug = [])
  101. conres = conn.connect()
  102. if not conres:
  103. self.log.error('unable to connect to server %s.' % self.jid.getDomain())
  104. return None
  105. if conres<>'tls':
  106. self.log.warning('unable to establish secure connection - TLS failed!')
  107. authres = conn.auth(self.jid.getNode(), self.__password, self.res)
  108. if not authres:
  109. self.log.error('unable to authorize with server.')
  110. return None
  111. if authres<>'sasl':
  112. self.log.warning("unable to perform SASL auth os %s. Old authentication method used!" % self.jid.getDomain())
  113. conn.sendInitPresence()
  114. self.conn = conn
  115. self.roster = self.conn.Roster.getRoster()
  116. roster_items = sorted(self.roster.getItems())
  117. self.log.info('*** roster (%u) ***' % len(roster_items))
  118. for contact in roster_items:
  119. self.log.info(' %s' % contact)
  120. self.log.info('*** roster ***')
  121. self.conn.RegisterHandler('message', self.callback_message)
  122. self.conn.RegisterHandler('presence', self.callback_presence)
  123. return self.conn
  124. def join_room(self, room, username=None):
  125. """Join the specified multi-user chat room"""
  126. if username is None:
  127. username = self.__username.split('@')[0]
  128. my_room_JID = '/'.join((room, username))
  129. self.connect().send(xmpp.Presence(to=my_room_JID))
  130. def quit( self, exitcode=0):
  131. """Stop serving messages and exit.
  132. I find it is handy for development to run the
  133. jabberbot in a 'while true' loop in the shell, so
  134. whenever I make a code change to the bot, I send
  135. the 'reload' command, which I have mapped to call
  136. self.quit(), and my shell script relaunches the
  137. new version.
  138. """
  139. self.__finished = True
  140. self.__exitcode = exitcode
  141. def send_message(self, mess):
  142. """Send an XMPP message"""
  143. self.connect().send(mess)
  144. def send_tune(self, song, debug=False):
  145. """Set information about the currently played tune
  146. Song is a dictionary with keys: file, title, artist, album, pos, track,
  147. length, uri. For details see <http://xmpp.org/protocols/tune/>.
  148. """
  149. NS_TUNE = 'http://jabber.org/protocol/tune'
  150. iq = xmpp.Iq(typ='set')
  151. iq.setFrom(self.jid)
  152. iq.pubsub = iq.addChild('pubsub', namespace = xmpp.NS_PUBSUB)
  153. iq.pubsub.publish = iq.pubsub.addChild('publish', attrs = {'node' : NS_TUNE})
  154. iq.pubsub.publish.item = iq.pubsub.publish.addChild('item', attrs={'id' : 'current'})
  155. tune = iq.pubsub.publish.item.addChild('tune')
  156. tune.setNamespace(NS_TUNE)
  157. title = None
  158. if 'title' in song:
  159. title = song['title']
  160. elif 'file' in song:
  161. title = os.path.splitext(os.path.basename(song['file']))[0]
  162. if title is not None:
  163. tune.addChild('title').addData(title)
  164. if 'artist' in song:
  165. tune.addChild('artist').addData(song['artist'])
  166. if 'album' in song:
  167. tune.addChild('source').addData(song['album'])
  168. if 'pos' in song and song['pos'] > 0:
  169. tune.addChild('track').addData(str(song['pos']))
  170. if 'time' in song:
  171. tune.addChild('length').addData(str(song['time']))
  172. if 'uri' in song:
  173. tune.addChild('uri').addData(song['uri'])
  174. if debug:
  175. print 'Sending tune:', iq.__str__().encode('utf8')
  176. self.conn.send(iq)
  177. def send(self, user, text, in_reply_to=None, message_type='chat'):
  178. """Sends a simple message to the specified user."""
  179. mess = self.build_message(text)
  180. mess.setTo(user)
  181. if in_reply_to:
  182. mess.setThread(in_reply_to.getThread())
  183. mess.setType(in_reply_to.getType())
  184. else:
  185. mess.setThread(self.__threads.get(user, None))
  186. mess.setType(message_type)
  187. self.send_message(mess)
  188. def send_simple_reply(self, mess, text, private=False):
  189. """Send a simple response to a message"""
  190. self.send_message( self.build_reply(mess,text, private) )
  191. def build_reply(self, mess, text=None, private=False):
  192. """Build a message for responding to another message. Message is NOT sent"""
  193. response = self.build_message(text)
  194. if private:
  195. response.setTo(mess.getFrom())
  196. response.setType('chat')
  197. else:
  198. response.setTo(mess.getFrom()) # was: .getStripped() -- why?!
  199. response.setType(mess.getType())
  200. response.setThread(mess.getThread())
  201. return response
  202. def build_message(self, text):
  203. """Builds an xhtml message without attributes."""
  204. text_plain = re.sub(r'<[^>]+>', '', text)
  205. message = xmpp.protocol.Message(body=text_plain)
  206. if text_plain != text:
  207. html = xmpp.Node('html', {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
  208. try:
  209. html.addChild(node=xmpp.simplexml.XML2Node("<body xmlns='http://www.w3.org/1999/xhtml'>" + text.encode('utf-8') + "</body>"))
  210. message.addChild(node=html)
  211. except Exception, e:
  212. # Didn't work, incorrect markup or something.
  213. # print >> sys.stderr, e, text
  214. message = xmpp.protocol.Message(body=text_plain)
  215. return message
  216. def get_sender_username(self, mess):
  217. """Extract the sender's user name from a message"""
  218. type = mess.getType()
  219. jid = mess.getFrom()
  220. if type == "groupchat":
  221. username = jid.getResource()
  222. elif type == "chat":
  223. username = jid.getNode()
  224. else:
  225. username = ""
  226. return username
  227. def status_type_changed(self, jid, new_status_type):
  228. """Callback for tracking status types (available, away, offline, ...)"""
  229. if self.__debug:
  230. self.log.debug('user %s changed status to %s' % (jid, new_status_type))
  231. def status_message_changed(self, jid, new_status_message):
  232. """Callback for tracking status messages (the free-form status text)"""
  233. if self.__debug:
  234. self.log.debug('user %s updated text to %s' % (jid, new_status_message))
  235. def broadcast(self, message, only_available=False):
  236. """Broadcast a message to all users 'seen' by this bot.
  237. If the parameter 'only_available' is True, the broadcast
  238. will not go to users whose status is not 'Available'."""
  239. for jid, (show, status) in self.__seen.items():
  240. if not only_available or show is self.AVAILABLE:
  241. self.send(jid, message)
  242. def callback_presence(self, conn, presence):
  243. jid, type_, show, status = presence.getFrom(), \
  244. presence.getType(), presence.getShow(), \
  245. presence.getStatus()
  246. if self.jid.bareMatch(jid):
  247. # Ignore our own presence messages
  248. return
  249. if type_ is None:
  250. # Keep track of status message and type changes
  251. old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
  252. if old_show != show:
  253. self.status_type_changed(jid, show)
  254. if old_status != status:
  255. self.status_message_changed(jid, status)
  256. self.__seen[jid] = (show, status)
  257. elif type_ == self.OFFLINE and jid in self.__seen:
  258. # Notify of user offline status change
  259. del self.__seen[jid]
  260. self.status_type_changed(jid, self.OFFLINE)
  261. try:
  262. subscription = self.roster.getSubscription(unicode(jid))
  263. except KeyError, e:
  264. # User not on our roster
  265. subscription = None
  266. except AttributeError, e:
  267. # Recieved presence update before roster built
  268. return
  269. if type_ == 'error':
  270. self.log.error('Presence error: %s' % presence.getError())
  271. if self.__debug:
  272. self.log.debug('Got presence: %s (type: %s, show: %s, status: %s, subscription: %s)' % (jid, type_, show, status, subscription))
  273. if type_ == 'subscribe':
  274. # Incoming presence subscription request
  275. if subscription in ('to', 'both', 'from'):
  276. self.roster.Authorize(jid)
  277. self._send_status()
  278. if subscription not in ('to', 'both'):
  279. self.roster.Subscribe(jid)
  280. if subscription in (None, 'none'):
  281. self.send(jid, self.MSG_AUTHORIZE_ME)
  282. elif type_ == 'subscribed':
  283. # Authorize any pending requests for that JID
  284. self.roster.Authorize(jid)
  285. elif type_ == 'unsubscribed':
  286. # Authorization was not granted
  287. self.send(jid, self.MSG_NOT_AUTHORIZED)
  288. self.roster.Unauthorize(jid)
  289. def callback_message( self, conn, mess):
  290. """Messages sent to the bot will arrive here. Command handling + routing is done in this function."""
  291. # Prepare to handle either private chats or group chats
  292. type = mess.getType()
  293. jid = mess.getFrom()
  294. props = mess.getProperties()
  295. text = mess.getBody()
  296. username = self.get_sender_username(mess)
  297. if type not in ("groupchat", "chat"):
  298. self.log.debug("unhandled message type: %s" % type)
  299. return
  300. self.log.debug("*** props = %s" % props)
  301. self.log.debug("*** jid = %s" % jid)
  302. self.log.debug("*** username = %s" % username)
  303. self.log.debug("*** type = %s" % type)
  304. self.log.debug("*** text = %s" % text)
  305. # Ignore messages from before we joined
  306. if xmpp.NS_DELAY in props: return
  307. # Ignore messages from myself
  308. if not self.PROCESS_MSG_FROM_SELF and username == self.__username: return
  309. # If a message format is not supported (eg. encrypted), txt will be None
  310. if not text: return
  311. # Ignore messages from users not seen by this bot
  312. if not self.PROCESS_MSG_FROM_UNSEEN and jid not in self.__seen:
  313. self.log.info('Ignoring message from unseen guest: %s' % jid)
  314. self.log.debug("I've seen: %s" % ["%s" % x for x in self.__seen.keys()])
  315. return
  316. # Remember the last-talked-in thread for replies
  317. self.__threads[jid] = mess.getThread()
  318. handler, command, args = self.parse_command(text)
  319. self.log.debug("*** cmd = %s" % command)
  320. try:
  321. if handler is not None:
  322. reply = handler(mess, args)
  323. else:
  324. # In private chat, it's okay for the bot to always respond.
  325. # In group chat, the bot should silently ignore commands it
  326. # doesn't understand or aren't handled by unknown_command().
  327. default_reply = 'Unknown command: "%s". Type "help" for available commands.' % command
  328. if type == "groupchat": default_reply = None
  329. reply = self.unknown_command(mess, command, args)
  330. if reply is None:
  331. reply = default_reply
  332. except Exception, e:
  333. reply = traceback.format_exc(e)
  334. self.log.exception('An error happened while processing a message ("%s") from %s: %s"' % (text, jid, reply))
  335. if reply:
  336. self.send_simple_reply(mess,reply)
  337. def parse_command(self, text):
  338. # First look for commands with regular expressions.
  339. for command in self.commands:
  340. pattern = getattr(self.commands[command], '_jabberbot_command_re')
  341. if pattern is not None:
  342. res = pattern.match(text)
  343. if res is not None:
  344. return self.commands[command], command, res.groups()
  345. # Find regular commands.
  346. if ' ' in text:
  347. command, args = text.split(' ', 1)
  348. else:
  349. command, args = text, ''
  350. command = command.lower()
  351. # Get the handler function.
  352. handler = command in self.commands and self.commands[command] or None
  353. # If the command has a regexp -- skip it, beacuse it didn't match.
  354. # Also, handlers of such commands expect args to be tuples, and we only
  355. # have a string.
  356. if hasattr(handler, '_jabberbot_command_re') and getattr(handler, '_jabberbot_command_re'):
  357. handler = None
  358. return handler, command, args
  359. def unknown_command(self, mess, cmd, args):
  360. """Default handler for unknown commands
  361. Override this method in derived class if you
  362. want to trap some unrecognized commands. If
  363. 'cmd' is handled, you must return some non-false
  364. value, else some helpful text will be sent back
  365. to the sender.
  366. """
  367. return None
  368. def top_of_help_message(self):
  369. """Returns a string that forms the top of the help message
  370. Override this method in derived class if you
  371. want to add additional help text at the
  372. beginning of the help message.
  373. """
  374. return ""
  375. def bottom_of_help_message(self):
  376. """Returns a string that forms the bottom of the help message
  377. Override this method in derived class if you
  378. want to add additional help text at the end
  379. of the help message.
  380. """
  381. return ""
  382. @botcmd
  383. def help(self, mess, args):
  384. """Returns a help string listing available options.
  385. Automatically assigned to the "help" command."""
  386. if not args:
  387. if self.__doc__:
  388. description = self.__doc__.strip()
  389. else:
  390. description = 'Available commands:'
  391. usage = '\n'.join(sorted([
  392. '%s: %s' % (name, (command.__doc__.strip() or '(undocumented)').split('\n', 1)[0])
  393. for (name, command) in self.commands.iteritems() if name != 'help' and not command._jabberbot_hidden
  394. ]))
  395. usage = usage + '\n\nType help <command name> to get more info about that specific command.'
  396. else:
  397. description = ''
  398. if args in self.commands:
  399. usage = self.commands[args].__doc__.strip() or 'undocumented'
  400. else:
  401. usage = 'That command is not defined.'
  402. top = self.top_of_help_message()
  403. bottom = self.bottom_of_help_message()
  404. if top : top = "%s\n\n" % top
  405. if bottom: bottom = "\n\n%s" % bottom
  406. return '%s%s\n\n%s%s' % ( top, description, usage, bottom )
  407. def idle_proc( self):
  408. """This function will be called in the main loop."""
  409. pass
  410. def shutdown(self):
  411. """This function will be called when we're done serving
  412. Override this method in derived class if you
  413. want to do anything special at shutdown.
  414. """
  415. pass
  416. def serve_forever( self, connect_callback = None, disconnect_callback = None):
  417. """Connects to the server and handles messages."""
  418. conn = self.connect()
  419. if conn:
  420. self.log.info('bot connected. serving forever.')
  421. else:
  422. self.log.warn('could not connect to server - aborting.')
  423. return self.__exitcode
  424. if connect_callback:
  425. connect_callback()
  426. while not self.__finished:
  427. try:
  428. conn.Process(10)
  429. self.idle_proc()
  430. except KeyboardInterrupt:
  431. self.log.info('bot stopped by user request. shutting down.')
  432. break
  433. self.shutdown()
  434. if disconnect_callback:
  435. disconnect_callback()
  436. return self.__exitcode
  437. if __name__ == '__main__':
  438. if len(sys.argv) < 3:
  439. print >>sys.stderr, 'Usage: %s jid password' % os.path.basename(sys.argv[0])
  440. sys.exit(1)
  441. import time
  442. class TestBot(JabberBot):
  443. PING_TIMEOUT = 10
  444. def __init__(self, *args, **kwargs):
  445. self.lastping = time.time()
  446. JabberBot.__init__(self, *args, **kwargs)
  447. @botcmd
  448. def die(self, mess, args):
  449. self.quit()
  450. return 'Ok, bye.'
  451. @botcmd
  452. def check(self, mess, args):
  453. pass
  454. def idle_proc(self):
  455. if time.time() - self.lastping > 5:
  456. self.lastping = time.time()
  457. ping = xmpp.Protocol('iq',typ='get',payload=[xmpp.Node('ping',attrs={'xmlns':'urn:xmpp:ping'})])
  458. res = self.conn.SendAndWaitForResponse(ping, 1)
  459. print 'GOT:', res
  460. bot = TestBot(sys.argv[1], sys.argv[2], res='debug/', debug=True)
  461. bot.serve_forever()
  462. print 'JabberBot exited.'