Back to index

moin  1.9.0~rc2
xmppbot.py
Go to the documentation of this file.
00001 # -*- coding: iso-8859-1 -*-
00002 """
00003     MoinMoin - jabber bot
00004 
00005     @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
00006     @license: GNU GPL, see COPYING for details.
00007 """
00008 
00009 import logging, time, Queue
00010 from threading import Thread
00011 from datetime import timedelta
00012 
00013 from pyxmpp.cache import Cache
00014 from pyxmpp.cache import CacheItem
00015 from pyxmpp.client import Client
00016 from pyxmpp.jid import JID
00017 from pyxmpp.streamtls import TLSSettings
00018 from pyxmpp.message import Message
00019 from pyxmpp.presence import Presence
00020 from pyxmpp.iq import Iq
00021 import pyxmpp.jabber.dataforms as forms
00022 import libxml2
00023 
00024 import jabberbot.commands as cmd
00025 import jabberbot.i18n as i18n
00026 import jabberbot.oob as oob
00027 import jabberbot.capat as capat
00028 
00029 
00030 class Contact:
00031     """Abstraction of a roster item / contact
00032 
00033     This class handles some logic related to keeping track of
00034     contact availability, status, etc."""
00035 
00036     # Default Time To Live of a contact. If there are no registered
00037     # resources for that period of time, the contact should be removed
00038     default_ttl = 3600 * 24 # default of one day
00039 
00040     def __init__(self, jid, resource, priority, show, language=None):
00041         self.jid = jid
00042         self.resources = {resource: {'show': show, 'priority': priority, 'supports': []}}
00043         self.language = language
00044 
00045         # The last time when this contact was seen online.
00046         # This value has meaning for offline contacts only.
00047         self.last_online = None
00048 
00049         # Queued messages, waiting for contact to change its "show"
00050         # status to something different than "dnd". The messages should
00051         # also be sent when contact becomes "unavailable" directly from
00052         # "dnd", as we can't guarantee, that the bot will be up and running
00053         # the next time she becomes "available".
00054         self.messages = []
00055 
00056     def is_valid(self, current_time):
00057         """Check if this contact entry is still valid and should be kept
00058 
00059         @param time: current time in seconds
00060 
00061         """
00062         # No resources == offline
00063         return self.resources or current_time < self.last_online + self.default_ttl
00064 
00065     def add_resource(self, resource, show, priority):
00066         """Adds information about a connected resource
00067 
00068         @param resource: resource name
00069         @param show: a show presence property, as defined in XMPP
00070         @param priority: priority of the given resource
00071 
00072         """
00073         self.resources[resource] = {'show': show, 'priority': priority, 'supports': []}
00074         self.last_online = None
00075 
00076     def set_supports(self, resource, extension):
00077         """Flag a given resource as supporting a particular extension"""
00078         self.resources[resource]['supports'].append(extension)
00079 
00080     def supports(self, resource, extension):
00081         """Check if a given resource supports a particular extension
00082 
00083         If no resource is specified, check the resource with the highest
00084         priority among currently connected.
00085 
00086         """
00087         if resource and resource in self.resources:
00088             return extension in self.resources[resource]['supports']
00089         else:
00090             resource = self.max_prio_resource()
00091             return resource and extension in resource['supports']
00092 
00093     def max_prio_resource(self):
00094         """Returns the resource (dict) with the highest priority
00095 
00096         @return: highest priority resource or None if contacts is offline
00097         @rtype: dict or None
00098 
00099         """
00100         if not self.resources:
00101             return None
00102 
00103         # Priority can't be lower than -128
00104         max_prio = -129
00105         selected = None
00106 
00107         for resource in self.resources.itervalues():
00108             # TODO: check RFC for behaviour of 2 resources with the same priority
00109             if resource['priority'] > max_prio:
00110                 max_prio = resource['priority']
00111                 selected = resource
00112 
00113         return selected
00114 
00115     def remove_resource(self, resource):
00116         """Removes information about a connected resource
00117 
00118         @param resource: resource name
00119 
00120         """
00121         if self.resources.has_key(resource):
00122             del self.resources[resource]
00123         else:
00124             raise ValueError("No such resource!")
00125 
00126         if not self.resources:
00127             self.last_online = time.time()
00128 
00129     def is_dnd(self):
00130         """Checks if contact is DoNotDisturb
00131 
00132         The contact is DND if its resource with the highest priority is DND
00133 
00134         """
00135         max_prio_res = self.max_prio_resource()
00136 
00137         # If there are no resources the contact is offline, not dnd
00138         if max_prio_res:
00139             return max_prio_res['show'] == u"dnd"
00140         else:
00141             return False
00142 
00143     def set_show(self, resource, show):
00144         """Sets show property for a given resource
00145 
00146         @param resource: resource to alter
00147         @param show: new value of the show property
00148         @raise ValueError: no resource with given name has been found
00149 
00150         """
00151         if self.resources.has_key(resource):
00152             self.resources[resource]['show'] = show
00153         else:
00154             raise ValueError("There's no such resource")
00155 
00156     def uses_resource(self, resource):
00157         """Checks if contact uses a given resource"""
00158         return self.resources.has_key(resource)
00159 
00160     def __str__(self):
00161         retval = "%s (%s) has %d queued messages"
00162         res = ", ".join([name + " is " + res['show'] for name, res in self.resources.items()])
00163         return retval % (self.jid.as_unicode(), res, len(self.messages))
00164 
00165 
00166 class XMPPBot(Client, Thread):
00167     """A simple XMPP bot"""
00168 
00169     def __init__(self, config, from_commands, to_commands):
00170         """A constructor
00171 
00172         @param from_commands: a Queue object used to send commands to other (xmlrpc) threads
00173         @param to_commands: a Queue object used to receive commands from other threads
00174 
00175         """
00176         Thread.__init__(self)
00177 
00178         self.from_commands = from_commands
00179         self.to_commands = to_commands
00180 
00181         self.config = config
00182         self.log = logging.getLogger(__name__)
00183         self.jid = JID(node_or_jid=config.xmpp_node, domain=config.xmpp_server)
00184         self.tlsconfig = TLSSettings(require = True, verify_peer=False)
00185 
00186         # A dictionary of contact objects, ordered by bare JID
00187         self.contacts = {}
00188 
00189         # The last time when contacts were checked for expiration, in seconds
00190         self.last_expiration = time.time()
00191 
00192         # How often should the contacts be checked for expiration, in seconds
00193         self.contact_check = 600
00194         self.stopping = False
00195 
00196         self.known_xmlrpc_cmds = [cmd.GetPage, cmd.GetPageHTML, cmd.GetPageList, cmd.GetPageInfo, cmd.Search, cmd.RevertPage]
00197         self.internal_commands = ["ping", "help", "searchform"]
00198 
00199         self.xmlrpc_commands = {}
00200         for command, name in [(command, command.__name__) for command in self.known_xmlrpc_cmds]:
00201             self.xmlrpc_commands[name.lower()] = command
00202 
00203         Client.__init__(self, self.jid, config.xmpp_password, config.xmpp_server, tls_settings=self.tlsconfig)
00204 
00205         # Setup message handlers
00206 
00207         self._msg_handlers = {cmd.NotificationCommand: self._handle_notification,
00208                               cmd.NotificationCommandI18n: self._handle_notification,
00209                               cmd.AddJIDToRosterCommand: self._handle_add_contact,
00210                               cmd.RemoveJIDFromRosterCommand: self._handle_remove_contact,
00211                               cmd.GetPage: self._handle_get_page,
00212                               cmd.GetPageHTML: self._handle_get_page,
00213                               cmd.GetPageList: self._handle_get_page_list,
00214                               cmd.GetPageInfo: self._handle_get_page_info,
00215                               cmd.GetUserLanguage: self._handle_get_language,
00216                               cmd.Search: self._handle_search}
00217 
00218         # cache for service discovery results ( (ver, algo) : Capabilities = libxml2.xmlNode)
00219         self.disco_cache = Cache(max_items=config.disco_cache_size, default_purge_period=0)
00220 
00221         # dictionary of jids waiting for service discovery results
00222         # ( (ver, algo) : (timeout=datetime.timedelta, [list_of_jids=pyxmpp.jid]) )
00223         self.disco_wait = {}
00224 
00225         # temporary dictionary ( pyxmpp.jid:  (ver, algo) )
00226         self.disco_temp = {}
00227 
00228     def run(self):
00229         """Start the bot - enter the event loop"""
00230 
00231         self.log.info("Starting the jabber bot.")
00232         self.connect()
00233         self.loop()
00234 
00235     def stop(self):
00236         """Stop the thread"""
00237         self.stopping = True
00238 
00239     def loop(self, timeout=1):
00240         """Main event loop - stream and command handling"""
00241 
00242         while True:
00243             if self.stopping:
00244                 break
00245 
00246             stream = self.get_stream()
00247             if not stream:
00248                 break
00249 
00250             act = stream.loop_iter(timeout)
00251             if not act:
00252                 # Process all available commands
00253                 while self.poll_commands(): pass
00254                 self.idle()
00255 
00256     def idle(self):
00257         """Do some maintenance"""
00258 
00259         Client.idle(self)
00260 
00261         current_time = time.time()
00262         if self.last_expiration + self.contact_check < current_time:
00263             self.expire_contacts(current_time)
00264             self.last_expiration = current_time
00265 
00266         self.disco_cache.tick()
00267         self.check_disco_delays()
00268 
00269     def session_started(self):
00270         """Handle session started event.
00271         Requests the user's roster and sends the initial presence with
00272         a <c> child as described in XEP-0115 (Entity Capabilities)
00273 
00274         """
00275         self.request_roster()
00276         pres = capat.create_presence(self.jid)
00277         self.stream.set_iq_get_handler("query", "http://jabber.org/protocol/disco#info", self.handle_disco_query)
00278         self.stream.send(pres)
00279 
00280     def expire_contacts(self, current_time):
00281         """Check which contats have been offline for too long and should be removed
00282 
00283         @param current_time: current time in seconds
00284 
00285         """
00286         for jid, contact in self.contacts.items():
00287             if not contact.is_valid(current_time):
00288                 del self.contacts[jid]
00289 
00290     def get_text(self, jid):
00291         """Returns a gettext function (_) for the given JID
00292 
00293         @param jid: bare Jabber ID of the user we're going to communicate with
00294         @type jid: str or pyxmpp.jid.JID
00295 
00296         """
00297         language = "en"
00298         if isinstance(jid, str) or isinstance(jid, unicode):
00299             jid = JID(jid).bare().as_unicode()
00300         else:
00301             jid = jid.bare().as_unicode()
00302 
00303         if jid in self.contacts:
00304             language = self.contacts[jid].language
00305 
00306         return lambda text: i18n.get_text(text, lang=language)
00307 
00308     def poll_commands(self):
00309         """Checks for new commands in the input queue and executes them
00310 
00311         @return: True if any command has been executed, False otherwise.
00312 
00313         """
00314         try:
00315             command = self.to_commands.get_nowait()
00316             self.handle_command(command)
00317             return True
00318         except Queue.Empty:
00319             return False
00320 
00321     def handle_command(self, command, ignore_dnd=False):
00322         """Excecutes commands from other components
00323 
00324         @param command: a command to execute
00325         @type command: any class defined in commands.py (FIXME?)
00326         @param ignore_dnd: if command results in user interaction, should DnD be ignored?
00327 
00328         """
00329 
00330         cmd_cls = command.__class__
00331 
00332         try:
00333             handler = self._msg_handlers[cmd_cls]
00334         except KeyError:
00335             self.log.debug("No such command: " + cmd_cls.__name__)
00336             return
00337 
00338         # NOTE: handler is a method, so it takes self as a hidden arg
00339         handler(command, ignore_dnd)
00340 
00341     def handle_changed_action(self, cmd_data, jid, contact):
00342         """Handles a notification command with 'page_changed' action
00343 
00344         @param cmd_data: notification command data
00345         @param jid: jid to send the notification to
00346         @param contact: a roster contact
00347         @type cmd_data: dict
00348         @type jid: pyxmpp.jid.JID
00349         @type contact: Contact
00350 
00351         """
00352         if contact and contact.supports(jid.resource, u"jabber:x:data"):
00353             self.send_change_form(jid.as_unicode(), cmd_data)
00354             return
00355         else:
00356             self.send_change_text(jid.as_unicode(), cmd_data)
00357 
00358     def handle_deleted_action(self, cmd_data, jid, contact):
00359         """Handles a notification cmd_data with 'page_deleted' action
00360 
00361         @param cmd_data: notification cmd_data
00362         @param jid: jid to send the notification to
00363         @param contact: a roster contact
00364         @type cmd_data: dict
00365         @type jid: pyxmpp.jid.JID
00366         @type contact: Contact
00367 
00368         """
00369         if contact and contact.supports(jid.resource, u"jabber:x:data"):
00370             self.send_deleted_form(jid.as_unicode(), cmd_data)
00371             return
00372         else:
00373             self.send_deleted_text(jid.as_unicode(), cmd_data)
00374 
00375     def handle_attached_action(self, cmd_data, jid, contact):
00376         """Handles a notification cmd_data with 'file_attached' action
00377 
00378         @param cmd_data: notification cmd_data
00379         @param jid: jid to send the notification to
00380         @param contact: a roster contact
00381         @type cmd_data: dict
00382         @type jid: pyxmpp.jid.JID
00383         @type contact: Contact
00384 
00385         """
00386         if contact and contact.supports(jid.resource, u"jabber:x:data"):
00387             self.send_attached_form(jid.as_unicode(), cmd_data)
00388             return
00389         else:
00390             self.send_attached_text(jid.as_unicode(), cmd_data)
00391 
00392     def handle_renamed_action(self, cmd_data, jid, contact):
00393         """Handles a notification cmd_data with 'page_renamed' action
00394 
00395         @param cmd_data: notification cmd_data
00396         @param jid: jid to send the notification to
00397         @param contact: a roster contact
00398         @type cmd_data: dict
00399         @type jid: pyxmpp.jid.JID
00400         @type contact: Contact
00401 
00402         """
00403         if contact and contact.supports(jid.resource, u"jabber:x:data"):
00404             self.send_renamed_form(jid.as_unicode(), cmd_data)
00405             return
00406         else:
00407             self.send_renamed_text(jid.as_unicode(), cmd_data)
00408 
00409     def handle_user_created_action(self, cmd_data, jid, contact):
00410         """Handles a notification cmd_data with 'user_created' action
00411 
00412         @param cmd_data: notification cmd_data
00413         @param jid: jid to send the notification to
00414         @param contact: a roster contact
00415         @type cmd_data: dict
00416         @type jid: pyxmpp.jid.JID
00417         @type contact: Contact
00418 
00419         """
00420         # TODO: send as form if user-client supports it
00421         self.send_user_created_text(jid.as_unicode(), cmd_data)
00422 
00423     def ask_for_subscription(self, jid):
00424         """Sends a <presence/> stanza with type="subscribe"
00425 
00426         Bot tries to subscribe to every contact's presence, so that
00427         it can honor special cases, like DoNotDisturb setting.
00428 
00429         @param jid: Jabber ID of entity we're subscribing to
00430         @type jid: pyxmpp.jid.JID
00431 
00432         """
00433         stanza = Presence(to_jid=jid, stanza_type="subscribe")
00434         self.get_stream().send(stanza)
00435 
00436     def remove_subscription(self, jid):
00437         """Sends a <presence/> stanza with type="unsubscribed
00438 
00439         @param jid: Jabber ID of entity whose subscription we cancel
00440         @type jid: JID
00441 
00442         """
00443         stanza = Presence(to_jid=jid, stanza_type="unsubscribed")
00444         self.get_stream().send(stanza)
00445 
00446     def send_message(self, jid_text, data, msg_type=u"chat"):
00447         """Sends a message
00448 
00449         @param jid_text: JID to send the message to
00450         @param data: dictionary containing notification data
00451         @param msg_type: message type, as defined in RFC
00452         @type jid_text: unicode
00453 
00454         """
00455         use_oob = False
00456         subject = data.get('subject', '')
00457         jid = JID(jid_text)
00458 
00459         if data.has_key('url_list') and data['url_list']:
00460             jid_bare = jid.bare().as_unicode()
00461             contact = self.contacts.get(jid_bare, None)
00462             if contact and contact.supports(jid.resource, u'jabber:x:oob'):
00463                 use_oob = True
00464             else:
00465                 url_strings = ['%s - %s' % (entry['url'], entry['description']) for entry in data['url_list']]
00466 
00467                 # Insert a newline, so that the list of URLs doesn't start in the same
00468                 # line as the rest of message text
00469                 url_strings.insert(0, '\n')
00470                 data['text'] = data['text'] + '\n'.join(url_strings)
00471 
00472         message = Message(to_jid=jid, body=data['text'], stanza_type=msg_type, subject=subject)
00473 
00474         if use_oob:
00475             oob.add_urls(message, data['url_list'])
00476 
00477         self.get_stream().send(message)
00478 
00479     def send_form(self, jid, form, subject, url_list=[]):
00480         """Send a data form
00481 
00482         @param jid: jid to send the form to (full)
00483         @param form: the form to send
00484         @param subject: subject of the message
00485         @param url_list: list of urls to use with OOB
00486         @type jid: unicode
00487         @type form: pyxmpp.jabber.dataforms.Form
00488         @type subject: unicode
00489         @type url_list: list
00490 
00491         """
00492         if not isinstance(form, forms.Form):
00493             raise ValueError("The 'form' argument must be of type pyxmpp.jabber.dataforms.Form!")
00494 
00495         _ = self.get_text(JID(jid).bare().as_unicode())
00496 
00497         message = Message(to_jid=jid, subject=subject)
00498         message.add_content(form)
00499 
00500         if url_list:
00501             oob.add_urls(message, url_list)
00502 
00503         self.get_stream().send(message)
00504 
00505     def send_search_form(self, jid):
00506         _ = self.get_text(jid)
00507 
00508         # These encode()s may look weird, but due to some pyxmpp oddness we have
00509         # to provide an utf-8 string instead of unicode. Bug reported, patches submitted...
00510         form_title = _("Wiki search").encode("utf-8")
00511         help_form = _("Submit this form to perform a wiki search").encode("utf-8")
00512         search_type1 = _("Title search")
00513         search_type2 = _("Full-text search")
00514         search_label = _("Search type")
00515         search_label2 = _("Search text")
00516         case_label = _("Case-sensitive search")
00517         regexp_label = _("Treat terms as regular expressions")
00518         forms_warn = _("If you see this, your client probably doesn't support Data Forms.")
00519 
00520         title_search = forms.Option("t", search_type1)
00521         full_search = forms.Option("f", search_type2)
00522 
00523         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=help_form)
00524         form.add_field(name="action", field_type="hidden", value="search")
00525         form.add_field(name="case", field_type="boolean", label=case_label)
00526         form.add_field(name="regexp", field_type="boolean", label=regexp_label)
00527         form.add_field(name="search_type", options=[title_search, full_search], field_type="list-single", label=search_label)
00528         form.add_field(name="search", field_type="text-single", label=search_label2)
00529 
00530         self.send_form(jid, form, _("Wiki search"))
00531 
00532     def send_change_form(self, jid, msg_data):
00533         """Sends a page change notification using Data Forms
00534 
00535         @param jid: a Jabber ID to send the notification to
00536         @type jid: unicode
00537         @param msg_data: dictionary with notification data
00538         @type msg_data: dict
00539 
00540         """
00541         _ = self.get_text(jid)
00542 
00543         form_title = _("Page changed notification").encode("utf-8")
00544         instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
00545         action_label = _("What to do next")
00546 
00547         action1 = _("Do nothing")
00548         action2 = _("Revert change")
00549         action3 = _("View page info")
00550         action4 = _("Perform a search")
00551 
00552         do_nothing = forms.Option("n", action1)
00553         revert = forms.Option("r", action2)
00554         view_info = forms.Option("v", action3)
00555         search = forms.Option("s", action4)
00556 
00557         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
00558         form.add_field(name='revision', field_type='hidden', value=msg_data['revision'])
00559         form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
00560         form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
00561         form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
00562 
00563         # Add lines of text as separate values, as recommended in XEP
00564         diff_lines = msg_data['diff'].split('\n')
00565         form.add_field(name="diff", field_type="text-multi", values=diff_lines, label=("Diff"))
00566 
00567         full_jid = JID(jid)
00568         bare_jid = full_jid.bare().as_unicode()
00569         resource = full_jid.resource
00570 
00571         # Add URLs as OOB data if it's supported and as separate fields otherwise
00572         if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
00573             url_list = msg_data['url_list']
00574         else:
00575             url_list = []
00576 
00577             for number, url in enumerate(msg_data['url_list']):
00578                 field_name = "url%d" % (number, )
00579                 form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
00580 
00581         # Selection of a following action
00582         form.add_field(name="options", field_type="list-single", options=[do_nothing, revert, view_info, search], label=action_label)
00583 
00584         self.send_form(jid, form, _("Page change notification"), url_list)
00585 
00586     def send_change_text(self, jid, msg_data):
00587         """Sends a simple, text page change notification
00588 
00589         @param jid: a Jabber ID to send the notification to
00590         @type jid: unicode
00591         @param msg_data: dictionary with notification data
00592         @type msg_data: dict
00593 
00594         """
00595         _ = self.get_text(jid)
00596         separator = '-' * 78
00597         urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
00598         message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(diff)s\n%(separator)s\n%(links)s") % {
00599                     'preamble': msg_data['text'],
00600                     'separator': separator,
00601                     'diff': msg_data['diff'],
00602                     'comment': msg_data.get('comment', _('no comment')),
00603                     'links': urls_text,
00604                   }
00605 
00606         data = {'text': message, 'subject': msg_data.get('subject', '')}
00607         self.send_message(jid, data, u"normal")
00608 
00609     def send_deleted_form(self, jid, msg_data):
00610         """Sends a page deleted notification using Data Forms
00611 
00612         @param jid: a Jabber ID to send the notification to
00613         @type jid: unicode
00614         @param msg_data: dictionary with notification data
00615         @type msg_data: dict
00616 
00617         """
00618         _ = self.get_text(jid)
00619 
00620         form_title = _("Page deletion notification").encode("utf-8")
00621         instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
00622         action_label = _("What to do next")
00623 
00624         action1 = _("Do nothing")
00625         action2 = _("Perform a search")
00626 
00627         do_nothing = forms.Option("n", action1)
00628         search = forms.Option("s", action2)
00629 
00630         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
00631         form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
00632         form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
00633 
00634         full_jid = JID(jid)
00635         bare_jid = full_jid.bare().as_unicode()
00636         resource = full_jid.resource
00637 
00638         # Add URLs as OOB data if it's supported and as separate fields otherwise
00639         if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
00640             url_list = msg_data['url_list']
00641         else:
00642             url_list = []
00643 
00644             for number, url in enumerate(msg_data['url_list']):
00645                 field_name = "url%d" % (number, )
00646                 form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
00647 
00648         # Selection of a following action
00649         form.add_field(name="options", field_type="list-single", options=[do_nothing, search], label=action_label)
00650 
00651         self.send_form(jid, form, _("Page deletion notification"), url_list)
00652 
00653     def send_deleted_text(self, jid, msg_data):
00654         """Sends a simple, text page deletion notification
00655 
00656         @param jid: a Jabber ID to send the notification to
00657         @type jid: unicode
00658         @param msg_data: dictionary with notification data
00659         @type msg_data: dict
00660 
00661         """
00662         _ = self.get_text(jid)
00663         separator = '-' * 78
00664         urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
00665         message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(links)s") % {
00666                     'preamble': msg_data['text'],
00667                     'separator': separator,
00668                     'comment': msg_data.get('comment', _('no comment')),
00669                     'links': urls_text,
00670                   }
00671 
00672         data = {'text': message, 'subject': msg_data.get('subject', '')}
00673         self.send_message(jid, data, u"normal")
00674 
00675     def send_attached_form(self, jid, msg_data):
00676         """Sends a new attachment notification using Data Forms
00677 
00678         @param jid: a Jabber ID to send the notification to
00679         @type jid: unicode
00680         @param msg_data: dictionary with notification data
00681         @type msg_data: dict
00682 
00683         """
00684         _ = self.get_text(jid)
00685 
00686         form_title = _("File attached notification").encode("utf-8")
00687         instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
00688         action_label = _("What to do next")
00689 
00690         action1 = _("Do nothing")
00691         action2 = _("View page info")
00692         action3 = _("Perform a search")
00693 
00694         do_nothing = forms.Option("n", action1)
00695         view_info = forms.Option("v", action2)
00696         search = forms.Option("s", action3)
00697 
00698         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
00699         form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
00700         form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
00701         form.add_field(name='page', field_type='text-single', value=msg_data['page_name'], label=_("Page name"))
00702         form.add_field(name='name', field_type='text-single', value=msg_data['attach_name'], label=_("File name"))
00703         form.add_field(name='size', field_type='text-single', value=msg_data['attach_size'], label=_("File size"))
00704 
00705         full_jid = JID(jid)
00706         bare_jid = full_jid.bare().as_unicode()
00707         resource = full_jid.resource
00708 
00709         # Add URLs as OOB data if it's supported and as separate fields otherwise
00710         if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
00711             url_list = msg_data['url_list']
00712         else:
00713             url_list = []
00714 
00715             for number, url in enumerate(msg_data['url_list']):
00716                 field_name = "url%d" % (number, )
00717                 form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
00718 
00719         # Selection of a following action
00720         form.add_field(name="options", field_type="list-single", options=[do_nothing, view_info, search], label=action_label)
00721 
00722         self.send_form(jid, form, _("File attached notification"), url_list)
00723 
00724     def send_attached_text(self, jid, msg_data):
00725         """Sends a simple, text page deletion notification
00726 
00727         @param jid: a Jabber ID to send the notification to
00728         @type jid: unicode
00729         @param msg_data: dictionary with notification data
00730         @type msg_data: dict
00731 
00732         """
00733         _ = self.get_text(jid)
00734         separator = '-' * 78
00735         urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
00736         message = _("%(preamble)s\n%(separator)s\n%(links)s") % {
00737                     'preamble': msg_data['text'],
00738                     'separator': separator,
00739                     'links': urls_text,
00740                   }
00741 
00742         data = {'text': message, 'subject': msg_data['subject']}
00743         self.send_message(jid, data, u"normal")
00744 
00745     def send_renamed_form(self, jid, msg_data):
00746         """Sends a page rename notification using Data Forms
00747 
00748         @param jid: a Jabber ID to send the notification to
00749         @type jid: unicode
00750         @param msg_data: dictionary with notification data
00751         @type msg_data: dict
00752 
00753         """
00754         _ = self.get_text(jid)
00755 
00756         form_title = _("Page rename notification").encode("utf-8")
00757         instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
00758         action_label = _("What to do next")
00759 
00760         action1 = _("Do nothing")
00761         action2 = _("Revert change")
00762         action3 = _("View page info")
00763         action4 = _("Perform a search")
00764 
00765         do_nothing = forms.Option("n", action1)
00766         revert = forms.Option("r", action2)
00767         view_info = forms.Option("v", action3)
00768         search = forms.Option("s", action4)
00769 
00770         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
00771         form.add_field(name='revision', field_type='hidden', value=msg_data['revision'])
00772         form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
00773         form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
00774         form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
00775         form.add_field(name='old', field_type='text-single', value=msg_data['old_name'], label=_("Old name"))
00776         form.add_field(name='new', field_type='text-single', value=msg_data['page_name'], label=_("New name"))
00777 
00778         full_jid = JID(jid)
00779         bare_jid = full_jid.bare().as_unicode()
00780         resource = full_jid.resource
00781 
00782         # Add URLs as OOB data if it's supported and as separate fields otherwise
00783         if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
00784             url_list = msg_data['url_list']
00785         else:
00786             url_list = []
00787 
00788             for number, url in enumerate(msg_data['url_list']):
00789                 field_name = "url%d" % (number, )
00790                 form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
00791 
00792         # Selection of a following action
00793         form.add_field(name="options", field_type="list-single", options=[do_nothing, revert, view_info, search], label=action_label)
00794 
00795         self.send_form(jid, form, _("Page rename notification"), url_list)
00796 
00797     def send_renamed_text(self, jid, msg_data):
00798         """Sends a simple, text page rename notification
00799 
00800         @param jid: a Jabber ID to send the notification to
00801         @type jid: unicode
00802         @param msg_data: dictionary with notification data
00803         @type msg_data: dict
00804 
00805         """
00806         _ = self.get_text(jid)
00807         separator = '-' * 78
00808         urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
00809         message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(links)s") % {
00810                     'preamble': msg_data['text'],
00811                     'separator': separator,
00812                     'comment': msg_data.get('comment', _('no comment')),
00813                     'links': urls_text,
00814                   }
00815 
00816         data = {'text': message, 'subject': msg_data['subject']}
00817         self.send_message(jid, data, u"normal")
00818 
00819     def send_user_created_text(self, jid, msg_data):
00820         """Sends a simple, text page user-created-notification
00821 
00822         @param jid: a Jabber ID to send the notification to
00823         @type jid: unicode
00824         @param msg_data: dictionary with notification data
00825         @type msg_data: dict
00826 
00827         """
00828         _ = self.get_text(jid)
00829         message = _("%(text)s") % {'text': msg_data['text']}
00830 
00831         data = {'text': message, 'subject': msg_data['subject']}
00832         self.send_message(jid, data, u"normal")
00833 
00834     def handle_page_info(self, command):
00835         """Handles GetPageInfo commands
00836 
00837         @param command: a command instance
00838         @type command: jabberbot.commands.GetPageInfo
00839 
00840         """
00841         # Process command data first so it can be directly usable
00842         if command.data['author'].startswith("Self:"):
00843             command.data['author'] = command.data['author'][5:]
00844 
00845         datestr = str(command.data['lastModified'])
00846         command.data['lastModified'] = u"%(year)s-%(month)s-%(day)s at %(time)s" % {
00847                     'year': datestr[:4],
00848                     'month': datestr[4:6],
00849                     'day': datestr[6:8],
00850                     'time': datestr[9:17],
00851         }
00852 
00853         if command.presentation == u"text":
00854             self.send_pageinfo_text(command)
00855         elif command.presentation == u"dataforms":
00856             self.send_pageinfo_form(command)
00857 
00858         else:
00859             raise ValueError("presentation value '%s' is not supported!" % (command.presentation, ))
00860 
00861     def send_pageinfo_text(self, command):
00862         """Sends detailed page info with plain text
00863 
00864         @param command: command with detailed data
00865         @type command: jabberbot.command.GetPageInfo
00866 
00867         """
00868         _ = self.get_text(command.jid)
00869 
00870         intro = _("""Following detailed information on page "%(pagename)s" \
00871 is available:""")
00872 
00873         msg = _("""Last author: %(author)s
00874 Last modification: %(modification)s
00875 Current version: %(version)s""") % {
00876          'author': command.data['author'],
00877          'modification': command.data['lastModified'],
00878          'version': command.data['version'],
00879         }
00880 
00881         self.send_message(command.jid, {'text': intro % {'pagename': command.pagename}})
00882         self.send_message(command.jid, {'text': msg})
00883 
00884     def send_pageinfo_form(self, command):
00885         """Sends page info using Data Forms
00886 
00887 
00888         """
00889         _ = self.get_text(command.jid)
00890         data = command.data
00891 
00892         form_title = _("Detailed page information").encode("utf-8")
00893         instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
00894         action_label = _("What to do next")
00895 
00896         action1 = _("Do nothing")
00897         action2 = _("Get page contents")
00898         action3 = _("Get page contents (HTML)")
00899         action4 = _("Perform a search")
00900 
00901         do_nothing = forms.Option("n", action1)
00902         get_content = forms.Option("c", action2)
00903         get_content_html = forms.Option("h", action3)
00904         search = forms.Option("s", action4)
00905 
00906         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
00907         form.add_field(name='pagename', field_type='text-single', value=command.pagename, label=_("Page name"))
00908         form.add_field(name="changed", field_type='text-single', value=data['lastModified'], label=_("Last changed"))
00909         form.add_field(name='editor', field_type='text-single', value=data['author'], label=_("Last editor"))
00910         form.add_field(name='version', field_type='text-single', value=data['version'], label=_("Current version"))
00911 
00912 #        full_jid = JID(jid)
00913 #        bare_jid = full_jid.bare().as_unicode()
00914 #        resource = full_jid.resource
00915 
00916         # Add URLs as OOB data if it's supported and as separate fields otherwise
00917 #        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
00918 #            url_list = msg_data['url_list']
00919 #        else:
00920 #            url_list = []
00921 #
00922 #            for number, url in enumerate(msg_data['url_list']):
00923 #                field_name = "url%d" % (number, )
00924 #                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
00925 
00926         # Selection of a following action
00927         form.add_field(name="options", field_type="list-single", options=[do_nothing, get_content, get_content_html, search], label=action_label)
00928 
00929         self.send_form(command.jid, form, _("Detailed page information"))
00930 
00931     def is_internal(self, command):
00932         """Check if a given command is internal
00933 
00934         @type command: unicode
00935 
00936         """
00937         for internal_cmd in self.internal_commands:
00938             if internal_cmd.lower() == command:
00939                 return True
00940 
00941         return False
00942 
00943     def is_xmlrpc(self, command):
00944         """Checks if a given commands requires interaction via XMLRPC
00945 
00946         @type command: unicode
00947 
00948         """
00949         for xmlrpc_cmd in self.xmlrpc_commands:
00950             if xmlrpc_cmd.lower() == command:
00951                 return True
00952 
00953         return False
00954 
00955     def contains_form(self, message):
00956         """Checks if passed message stanza contains a submitted form and parses it
00957 
00958         @param message: message stanza
00959         @type message: pyxmpp.message.Message
00960         @return: xml node with form data if found, or None
00961 
00962         """
00963         if not isinstance(message, Message):
00964             raise ValueError("The 'message' parameter must be of type pyxmpp.message.Message!")
00965 
00966         payload = message.get_node()
00967         form = message.xpath_eval('/ns:message/data:x', {'data': 'jabber:x:data'})
00968 
00969         if form:
00970             return form[0]
00971         else:
00972             return None
00973 
00974     def handle_form(self, jid, form_node):
00975         """Handles a submitted data form
00976 
00977         @param jid: jid that submitted the form (full jid)
00978         @type jid: pyxmpp.jid.JID
00979         @param form_node: a xml node with data form
00980         @type form_node: libxml2.xmlNode
00981 
00982         """
00983         if not isinstance(form_node, libxml2.xmlNode):
00984             raise ValueError("The 'form' parameter must be of type libxml2.xmlNode!")
00985 
00986         if not isinstance(jid, JID):
00987             raise ValueError("The 'jid' parameter must be of type jid!")
00988 
00989         _ = self.get_text(jid.bare().as_unicode())
00990 
00991         form = forms.Form(form_node)
00992 
00993         if form.type != u"submit":
00994             return
00995 
00996         if "action" in form:
00997             action = form["action"].value
00998             if action == u"search":
00999                 self.handle_search_form(jid, form)
01000             else:
01001                 data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
01002                 self.send_message(jid.as_unicode(), data, u"normal")
01003         elif "options" in form:
01004             option = form["options"].value
01005 
01006             # View page info
01007             if option == "v":
01008                 command = cmd.GetPageInfo(jid.as_unicode(), form["page_name"].value, presentation="dataforms")
01009                 self.from_commands.put_nowait(command)
01010 
01011             # Perform an another search
01012             elif option == "s":
01013                 self.handle_internal_command(jid, ["searchform"])
01014 
01015             # Revert a change
01016             elif option == "r":
01017                 revision = int(form["revision"].value)
01018 
01019                 # We can't really revert creation of a page, right?
01020                 if revision == 1:
01021                     return
01022 
01023                 self.handle_xmlrpc_command(jid, ["revertpage", form["page_name"].value, "%d" % (revision - 1, )])
01024 
01025     def handle_search_form(self, jid, form):
01026         """Handles a search form
01027 
01028         @param jid: jid that submitted the form
01029         @type jid: pyxmpp.jid.JID
01030         @param form: a form object
01031         @type form_node: pyxmpp.jabber.dataforms.Form
01032 
01033         """
01034         required_fields = ["case", "regexp", "search_type", "search"]
01035         jid_text = jid.bare().as_unicode()
01036         _ = self.get_text(jid_text)
01037 
01038         for field in required_fields:
01039             if field not in form:
01040                 data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
01041                 self.send_message(jid.as_unicode(), data, u"normal")
01042 
01043         case_sensitive = form['case'].value
01044         regexp_terms = form['regexp'].value
01045         if form['search_type'].value == 't':
01046             search_type = 'title'
01047         else:
01048             search_type = 'text'
01049 
01050         command = cmd.Search(jid.as_unicode(), search_type, form["search"].value, case=form['case'].value,
01051                              regexp=form['regexp'].value, presentation='dataforms')
01052         self.from_commands.put_nowait(command)
01053 
01054     def handle_message(self, message):
01055         """Handles incoming messages
01056 
01057         @param message: a message stanza to parse
01058         @type message: pyxmpp.message.Message
01059 
01060         """
01061         if self.config.verbose:
01062             msg = "Message from %s." % (message.get_from_jid().as_unicode(), )
01063             self.log.debug(msg)
01064 
01065         form = self.contains_form(message)
01066         if form:
01067             self.handle_form(message.get_from_jid(), form)
01068             return
01069 
01070         text = message.get_body()
01071         sender = message.get_from_jid()
01072         if text:
01073             command = text.split()
01074             command[0] = command[0].lower()
01075         else:
01076             return
01077 
01078         if self.is_internal(command[0]):
01079             response = self.handle_internal_command(sender, command)
01080         elif self.is_xmlrpc(command[0]):
01081             response = self.handle_xmlrpc_command(sender, command)
01082         else:
01083             response = self.reply_help(sender)
01084 
01085         if response:
01086             self.send_message(sender, {'text': response})
01087 
01088     def handle_internal_command(self, sender, command):
01089         """Handles internal commands, that can be completed by the XMPP bot itself
01090 
01091         @param command: list representing a command
01092         @param sender: JID of sender
01093         @type sender: pyxmpp.jid.JID
01094 
01095         """
01096         _ = self.get_text(sender)
01097 
01098         if command[0] == "ping":
01099             return "pong"
01100         elif command[0] == "help":
01101             if len(command) == 1:
01102                 return self.reply_help(sender)
01103             else:
01104                 return self.help_on(sender, command[1])
01105         elif command[0] == "searchform":
01106             jid = sender.bare().as_unicode()
01107             resource = sender.resource
01108 
01109             # Assume that outsiders know what they are doing. Clients that don't support
01110             # data forms should display a warning passed in message <body>.
01111             if jid not in self.contacts or self.contacts[jid].supports(resource, u"jabber:x:data"):
01112                 self.send_search_form(sender)
01113             else:
01114                 msg = {'text': _("This command requires a client supporting Data Forms.")}
01115                 self.send_message(sender, msg, u"")
01116         else:
01117             # For unknown command return a generic help message
01118             return self.reply_help(sender)
01119 
01120     def do_search(self, jid, search_type, presentation, *args):
01121         """Performs a Wiki search of term
01122 
01123         @param jid: Jabber ID of user performing a search
01124         @type jid: pyxmpp.jid.JID
01125         @param term: term to search for
01126         @type term: unicode
01127         @param search_type: type of search; either "text" or "title"
01128         @type search_type: unicode
01129         @param presentation: how to present the results; "text" or "dataforms"
01130         @type presentation: unicode
01131 
01132         """
01133         search = cmd.Search(jid, search_type, presentation=presentation, *args)
01134         self.from_commands.put_nowait(search)
01135 
01136     def help_on(self, jid, command):
01137         """Returns a help message on a given topic
01138 
01139         @param command: a command to describe in a help message
01140         @type command: str or unicode
01141         @return: a help message
01142 
01143         """
01144         _ = self.get_text(jid)
01145 
01146         if command == "help":
01147             return _("""The "help" command prints a short, helpful message \
01148 about a given topic or function.\n\nUsage: help [topic_or_function]""")
01149 
01150         elif command == "ping":
01151             return _("""The "ping" command returns a "pong" message as soon \
01152 as it's received.""")
01153 
01154         elif command == "searchform":
01155             return _("""searchform - perform a wiki search using a form""")
01156 
01157         # Here we have to deal with help messages of external (xmlrpc) commands
01158         else:
01159             if command in self.xmlrpc_commands:
01160                 classobj = self.xmlrpc_commands[command]
01161                 help_str = _(u"%(command)s - %(description)s\n\nUsage: %(command)s %(params)s")
01162                 return help_str % {'command': command,
01163                                    'description': classobj.description,
01164                                    'params': classobj.parameter_list,
01165                                   }
01166             else:
01167                 return _("""Unknown command "%s" """) % (command, )
01168 
01169     def handle_xmlrpc_command(self, sender, command):
01170         """Creates a command object, and puts it the command queue
01171 
01172         @param command: a valid name of available xmlrpc command
01173         @type command: list representing a command, name and parameters
01174 
01175         """
01176         _ = self.get_text(sender)
01177         command_class = self.xmlrpc_commands[command[0]]
01178 
01179         # Add sender's JID to the argument list
01180         command.insert(1, sender.as_unicode())
01181 
01182         try:
01183             instance = command_class.__new__(command_class)
01184             instance.__init__(*command[1:])
01185             self.from_commands.put_nowait(instance)
01186 
01187         # This happens when user specifies wrong parameters
01188         except TypeError:
01189             msg = _("You've specified a wrong parameter list. \
01190 The call should look like:\n\n%(command)s %(params)s")
01191 
01192             return msg % {'command': command[0], 'params': command_class.parameter_list}
01193 
01194     def handle_unsubscribed_presence(self, stanza):
01195         """Handles unsubscribed presence stanzas"""
01196 
01197         # FiXME: what policy should we adopt in this case?
01198         pass
01199 
01200     def handle_subscribe_presence(self, stanza):
01201         """Handles subscribe presence stanzas (requests)"""
01202 
01203         # FIXME: Let's just accept all subscribtion requests for now
01204         response = stanza.make_accept_response()
01205         self.get_stream().send(response)
01206 
01207     def handle_unavailable_presence(self, stanza):
01208         """Handles unavailable presence stanzas
01209 
01210         @type stanza: pyxmpp.presence.Presence
01211 
01212         """
01213         self.log.debug("Handling unavailable presence.")
01214 
01215         jid = stanza.get_from_jid()
01216         bare_jid = jid.bare().as_unicode()
01217 
01218         # If we get presence, this contact should already be known
01219         if bare_jid in self.contacts:
01220             contact = self.contacts[bare_jid]
01221 
01222             if self.config.verbose:
01223                 self.log.debug("%s, going OFFLINE." % contact)
01224 
01225             # check if we are waiting for disco#info from this jid
01226             self.check_if_waiting(jid)
01227             del self.disco_temp[jid]
01228 
01229             try:
01230                 # Send queued messages now, as we can't guarantee to be
01231                 # alive the next time this contact becomes available.
01232                 if len(contact.resources) == 1:
01233                     self.send_queued_messages(contact, ignore_dnd=True)
01234                     contact.remove_resource(jid.resource)
01235                 else:
01236                     contact.remove_resource(jid.resource)
01237 
01238                     # The highest-priority resource, which used to be DnD might
01239                     # have gone offline. If so, try to deliver messages now.
01240                     if not contact.is_dnd():
01241                         self.send_queued_messages(contact)
01242 
01243             except ValueError:
01244                 self.log.error("Unknown contact (resource) going offline...")
01245 
01246         else:
01247             self.log.error("Unavailable presence from unknown contact.")
01248 
01249         # Confirm that we've handled this stanza
01250         return True
01251 
01252     def handle_available_presence(self, presence):
01253         """Handles available presence stanzas
01254 
01255         @type presence: pyxmpp.presence.Presence
01256 
01257         """
01258         self.log.debug("Handling available presence.")
01259 
01260         show = presence.get_show()
01261         if show is None:
01262             show = u'available'
01263 
01264         priority = presence.get_priority()
01265         jid = presence.get_from_jid()
01266         bare_jid = jid.bare().as_unicode()
01267 
01268         if bare_jid in self.contacts:
01269             contact = self.contacts[bare_jid]
01270 
01271             # The resource is already known, so update it
01272             if contact.uses_resource(jid.resource):
01273                 contact.set_show(jid.resource, show)
01274 
01275             # Unknown resource, add it to the list
01276             else:
01277                 contact.add_resource(jid.resource, show, priority)
01278 
01279                 # Discover capabilities of the newly connected client
01280                 self.service_discovery(jid, presence)
01281 
01282             if self.config.verbose:
01283                 self.log.debug(contact)
01284 
01285             # Either way check, if we can deliver queued messages now
01286             if not contact.is_dnd():
01287                 self.send_queued_messages(contact)
01288 
01289         else:
01290             self.contacts[bare_jid] = Contact(jid, jid.resource, priority, show)
01291             self.service_discovery(jid, presence)
01292             self.get_user_language(bare_jid)
01293             self.log.debug(self.contacts[bare_jid])
01294 
01295         # Confirm that we've handled this stanza
01296         return True
01297 
01298     def get_user_language(self, jid):
01299         """Request user's language setting from the wiki
01300 
01301         @param jid: bare Jabber ID of the user to query for
01302         @type jid: unicode
01303         """
01304         request = cmd.GetUserLanguage(jid)
01305         self.from_commands.put_nowait(request)
01306 
01307     def handle_disco_query(self, stanza):
01308         """Handler for <Iq /> service discovery query
01309 
01310         @param stanza: received query stanza (pyxmpp.iq.Iq)
01311         """
01312         response = capat.create_response(stanza)
01313         self.get_stream().send(response)
01314 
01315     def service_discovery(self, jid, presence):
01316         """General handler for XEP-0115 (Entity Capabilities)
01317 
01318         @param jid: whose capabilities to discover (pyxmpp.jid.JID)
01319         @param presence: received presence stanza (pyxmpp.presence.Presence)
01320         """
01321         ver_algo = self.check_presence(presence)
01322         self.disco_temp[jid] = ver_algo
01323 
01324         if ver_algo is None:
01325             # legacy client - send disco#info query
01326             self.send_disco_query(jid)
01327         else:
01328             # check if we have this (ver,algo) already cached
01329             cache_item = self.disco_cache.get_item(ver_algo, state='stale')
01330 
01331             if cache_item is None:
01332                 # add to disco_wait
01333                 self.add_to_disco_wait(ver_algo, jid)
01334             else:
01335                 # use cached capabilities
01336                 self.log.debug(u"%s: using cached capabilities." % jid.as_unicode())
01337                 payload = cache_item.value
01338                 self.set_support(jid, payload)
01339 
01340     def check_presence(self, presence):
01341         """Search received presence for a <c> child with 'ver' and 'algo' attributes
01342         return (ver, algo) or None if no 'ver' found.
01343         (no 'algo' attribute defaults to 'sha-1', as described in XEP-0115)
01344 
01345         @param presence: received presence stanza (pyxmpp.presence.Presence)
01346         @return type: tuple of (str, str) or None
01347         """
01348         # TODO: <c> could be found directly using more appropriate xpath
01349         tags = presence.xpath_eval('child::*')
01350         for tag in tags:
01351             if tag.name == 'c':
01352                 ver = tag.xpathEval('@ver')
01353                 algo = tag.xpathEval('@algo')
01354                 if ver:
01355                     if algo:
01356                         ver_algo = (ver[0].children.content, algo[0].children.content)
01357                     else:
01358                         # no algo attribute defaults to 'sha-1'
01359                         ver_algo = (ver[0].children.content, 'sha-1')
01360 
01361                     return ver_algo
01362                 else:
01363                     #self.log.debug(u"%s: presence with <c> but without 'ver' attribute." % jid.as_unicode())
01364                     return None
01365                 break
01366         else:
01367             #self.log.debug(u"%s: presence without a <c> tag." % jid.as_unicode())
01368             return None
01369 
01370     def send_disco_query(self, jid):
01371         """Sends disco#info query to a given jid
01372 
01373         @type jid: pyxmpp.jid.JID
01374         """
01375         query = Iq(to_jid=jid, stanza_type="get")
01376         query.new_query("http://jabber.org/protocol/disco#info")
01377         self.get_stream().set_response_handlers(query, self.handle_disco_result, None)
01378         self.get_stream().send(query)
01379 
01380     def add_to_disco_wait(self, ver_algo, jid):
01381         """Adds given jid to the list of contacts waiting for service
01382         discovery results.
01383 
01384         @param ver_algo: 'ver' and 'algo' attributes of the given jid
01385         @type ver_algo: tuple of (str, str)
01386         @type jid: pyxmpp.jid.JID
01387         """
01388         if ver_algo in self.disco_wait:
01389             # query already sent, add to the end of waiting list
01390             self.disco_wait[ver_algo][1].append(jid)
01391         else:
01392             # send a query and create a new entry
01393             self.send_disco_query(jid)
01394             timeout = time.time() + self.config.disco_answering_timeout
01395             self.disco_wait[ver_algo] = (timeout, [jid])
01396 
01397     def handle_disco_result(self, stanza):
01398         """Handler for <iq> service discovery results
01399         check if contact is still available and if 'ver' matches the capabilities' hash
01400 
01401         @param stanza: a received result stanza (pyxmpp.iq.Iq)
01402         """
01403         jid = stanza.get_from_jid()
01404         bare_jid = jid.bare().as_unicode()
01405         payload = stanza.get_query()
01406 
01407         if bare_jid in self.contacts:
01408             ver_algo = self.disco_temp[jid]
01409 
01410             if ver_algo is not None:
01411                 ver, algo = ver_algo
01412                 payload_hash = capat.hash_iq(stanza, algo)
01413 
01414                 if payload_hash == ver:
01415                     # we can trust this 'ver' string
01416                     self.disco_result_right(ver_algo, payload)
01417                 else:
01418                     self.log.debug(u"%s: 'ver' and hash do not match! (legacy client?)" % jid.as_unicode())
01419                     self.disco_result_wrong(ver_algo)
01420 
01421             self.set_support(jid, payload)
01422 
01423         else:
01424             self.log.debug(u"%s is unavailable but sends service discovery response." % jid.as_unicode())
01425             # such situation is handled by check_if_waiting
01426 
01427     def disco_result_right(self, ver_algo, payload):
01428         """We received a correct service discovery response so we can safely cache it
01429         for future use and apply to every waiting contact from the list (first one is already done)
01430 
01431         @param ver_algo: 'ver' and 'algo' attributes matching received capabilities
01432         @param payload: received capabilities
01433         @type ver_algo: tuple of (str, str)
01434         @type payload: libxml2.xmlNode
01435         """
01436         delta = timedelta(0)
01437         cache_item = CacheItem(ver_algo, payload, delta, delta, delta)
01438         self.disco_cache.add_item(cache_item)
01439 
01440         timeout, jid_list = self.disco_wait[ver_algo]
01441         for jid in jid_list[1:]:
01442             if jid.bare().as_unicode() in self.contacts:
01443                 self.set_support(jid, payload)
01444         del self.disco_wait[ver_algo]
01445 
01446     def disco_result_wrong(self, ver_algo):
01447         """First jid from the list returned wrong response
01448         if it is possible try to ask the second one
01449 
01450         @param ver_algo: 'ver' and 'algo' attributes for which we received an inappropriate response
01451         @type ver_algo: tuple of (str, str)
01452         """
01453         timeout, jid_list = self.disco_wait[ver_algo]
01454         jid_list = jid_list[1:]
01455         if jid_list:
01456             self.send_disco_query(jid_list[0])
01457             timeout = time.time() + self.config.disco_answering_timeout
01458             self.disco_wait[ver_algo] = (timeout, jid_list)
01459         else:
01460             del self.disco_wait[ver_algo]
01461 
01462     def check_disco_delays(self):
01463         """Called when idle to check if some contacts haven't answered in allowed time"""
01464         for item in self.disco_wait:
01465             timeout, jid_list = self.disco_wait[item]
01466             if timeout < time.time():
01467                 self.disco_result_wrong(item)
01468 
01469     def check_if_waiting(self, jid):
01470         """Check if we were waiting for disco#info reply from client that
01471         has just become unavailable. If so, ask next candidate.
01472 
01473         @param jid: jid that has just gone unavailable
01474         @type jid: pyxmpp.jid.JID
01475         """
01476         ver_algo = self.disco_temp[jid]
01477         if ver_algo in self.disco_wait:
01478             timeout, jid_list = self.disco_wait[ver_algo]
01479             if jid_list:
01480                 if jid == jid_list[0]:
01481                     self.disco_result_wrong(ver_algo)
01482             else:
01483                 # this should never happen
01484                 self.log.debug(u"disco_wait: keeping empty entry at (%s, %s) !" % ver_algo)
01485 
01486     def set_support(self, jid, payload):
01487         """Searches service discovery results for support for
01488         Out Of Band Data (XEP-066) and Data Forms (XEP-004)
01489         and applies it to newly created Contact.
01490 
01491         @param jid: client's jabber ID (pyxmpp.jid.JID)
01492         @param payload: client's capabilities (libxml2.xmlNode)
01493         """
01494         supports = payload.xpathEval('//*[@var="jabber:x:oob"]')
01495         if supports:
01496             self.contacts[jid.bare().as_unicode()].set_supports(jid.resource, u"jabber:x:oob")
01497 
01498         supports = payload.xpathEval('//*[@var="jabber:x:data"]')
01499         if supports:
01500             self.contacts[jid.bare().as_unicode()].set_supports(jid.resource, u"jabber:x:data")
01501 
01502     def send_queued_messages(self, contact, ignore_dnd=False):
01503         """Sends messages queued for the contact
01504 
01505         @param contact: a contact whose queued messages are to be sent
01506         @type contact: jabberbot.xmppbot.Contact
01507         @param ignore_dnd: should contact's DnD status be ignored?
01508 
01509         """
01510         for command in contact.messages:
01511             self.handle_command(command, ignore_dnd)
01512 
01513     def reply_help(self, jid):
01514         """Constructs a generic help message
01515 
01516         It's sent in response to an uknown message or the "help" command.
01517 
01518         """
01519         _ = self.get_text(jid)
01520 
01521         msg = _("Hello there! I'm a MoinMoin Notification Bot. Available commands:\
01522 \n\n%(internal)s\n%(xmlrpc)s")
01523         internal = ", ".join(self.internal_commands)
01524         xmlrpc = ", ".join(self.xmlrpc_commands.keys())
01525 
01526         return msg % {'internal': internal, 'xmlrpc': xmlrpc}
01527 
01528     def authenticated(self):
01529         """Called when authentication succeedes"""
01530         self.log.info("Authenticated.")
01531 
01532     def authorized(self):
01533         """Called when authorization succeedes"""
01534 
01535         self.log.info("Authorized.")
01536 
01537         stream = self.get_stream()
01538         stream.set_message_handler("normal", self.handle_message)
01539         stream.set_presence_handler("available", self.handle_available_presence)
01540         stream.set_presence_handler("unavailable", self.handle_unavailable_presence)
01541         stream.set_presence_handler("unsubscribed", self.handle_unsubscribed_presence)
01542         stream.set_presence_handler("subscribe", self.handle_subscribe_presence)
01543 
01544         self.request_session()
01545 
01546     def connected(self):
01547         """Called when connections has been established"""
01548         self.log.info("Connected.")
01549 
01550     def disconnected(self):
01551         """Called when disconnection occurs"""
01552         self.log.info("Disconnected.")
01553 
01554     def roster_updated(self, item=None):
01555         """Called when roster gets updated"""
01556         self.log.debug("Updating roster.")
01557 
01558     def stream_closed(self, stream):
01559         """Called when stream closes"""
01560         self.log.debug("Stream closed.")
01561 
01562     def stream_created(self, stream):
01563         """Called when stream gets created"""
01564         self.log.debug("Stream created.")
01565 
01566     def stream_error(self, error):
01567         """Called when stream error gets received"""
01568         self.log.error("Received a stream error.")
01569 
01570     # Message handlers
01571 
01572     def _handle_notification(self, command, ignore_dnd):
01573         cmd_data = command.notification
01574         original_text = cmd_data.get('text', '')
01575         original_subject = cmd_data.get('subject', '')
01576 
01577         for recipient in command.jids:
01578             jid = JID(recipient)
01579             jid_text = jid.bare().as_unicode()
01580 
01581             if isinstance(command, cmd.NotificationCommandI18n):
01582                 # Translate&interpolate the message with data
01583                 gettext_func = self.get_text(jid_text)
01584                 text, subject = command.translate(gettext_func)
01585                 cmd_data['text'] = text
01586                 cmd_data['subject'] = subject
01587             else:
01588                 cmd_data['text'] = original_text
01589                 cmd_data['subject'] = original_subject
01590 
01591             # Check if contact is DoNotDisturb.
01592             # If so, queue the message for delayed delivery.
01593             contact = self.contacts.get(jid_text, '')
01594             if contact:
01595                 if command.async and contact.is_dnd() and not ignore_dnd:
01596                     contact.messages.append(command)
01597                     return
01598 
01599             action = cmd_data.get('action', '')
01600             if action == u'page_changed':
01601                 self.handle_changed_action(cmd_data, jid, contact)
01602             elif action == u'page_deleted':
01603                 self.handle_deleted_action(cmd_data, jid, contact)
01604             elif action == u'file_attached':
01605                 self.handle_attached_action(cmd_data, jid, contact)
01606             elif action == u'page_renamed':
01607                 self.handle_renamed_action(cmd_data, jid, contact)
01608             elif action == u'user_created':
01609                 self.handle_user_created_action(cmd_data, jid, contact)
01610             else:
01611                 self.send_message(jid, cmd_data, command.msg_type)
01612 
01613     def _handle_search(self, command, ignore_dnd):
01614         warnings = []
01615         _ = self.get_text(command.jid)
01616 
01617         if not command.data:
01618             warnings.append(_("There are no pages matching your search criteria!"))
01619 
01620         # This hardcoded limitation relies on (mostly correct) assumption that Jabber
01621         # servers have rather tight traffic limits. Sending more than 25 results is likely
01622         # to take a second or two - users should not have to wait longer (+search time!).
01623         elif len(command.data) > 25:
01624             warnings.append(_("There are too many results (%(number)s). Limiting to first 25 entries.") % {'number': str(len(command.data))})
01625             command.data = command.data[:25]
01626 
01627         results = [{'description': result[0], 'url': result[2]} for result in command.data]
01628 
01629         if command.presentation == u"text":
01630             for warning in warnings:
01631                 self.send_message(command.jid, {'text': warning})
01632 
01633             if not results:
01634                 return
01635 
01636             data = {'text': _('Following pages match your search criteria:'), 'url_list': results}
01637             self.send_message(command.jid, data, u"chat")
01638         else:
01639             form_title = _("Search results").encode("utf-8")
01640             help_form = _("Submit this form to perform a wiki search").encode("utf-8")
01641             form = forms.Form(xmlnode_or_type="result", title=form_title, instructions=help_form)
01642 
01643             action_label = _("What to do next")
01644             do_nothing = forms.Option("n", _("Do nothing"))
01645             search_again = forms.Option("s", _("Search again"))
01646 
01647             for no, warning in enumerate(warnings):
01648                 form.add_field(name="warning", field_type="fixed", value=warning)
01649 
01650             for no, result in enumerate(results):
01651                 field_name = "url%d" % (no, )
01652                 form.add_field(name=field_name, value=unicode(result["url"]), label=result["description"].encode("utf-8"), field_type="text-single")
01653 
01654             # Selection of a following action
01655             form.add_field(name="options", field_type="list-single", options=[do_nothing, search_again], label=action_label)
01656 
01657             self.send_form(command.jid, form, _("Search results"))
01658 
01659     def _handle_add_contact(self, command, ignore_dnd):
01660         jid = JID(node_or_jid = command.jid)
01661         self.ask_for_subscription(jid)
01662 
01663     def _handle_remove_contact(self, command, ignore_dnd):
01664         jid = JID(node_or_jid = command.jid)
01665         self.remove_subscription(jid)
01666 
01667     def _handle_get_page(self, command, ignore_dnd):
01668         _ = self.get_text(command.jid)
01669         msg = _(u"""Here's the page "%(pagename)s" that you've requested:\n\n%(data)s""")
01670 
01671         cmd_data = {'text': msg % {'pagename': command.pagename, 'data': command.data}}
01672         self.send_message(command.jid, cmd_data)
01673 
01674     def _handle_get_page_list(self, command, ignore_dnd):
01675         _ = self.get_text(command.jid)
01676         msg = _("That's the list of pages accesible to you:\n\n%s")
01677         pagelist = u"\n".join(command.data)
01678 
01679         self.send_message(command.jid, {'text': msg % (pagelist, )})
01680 
01681     def _handle_get_page_info(self, command, ignore_dnd):
01682         self.handle_page_info(command)
01683 
01684     def _handle_get_language(self, command, ignore_dnd):
01685         if command.jid in self.contacts:
01686             self.contacts[command.jid].language = command.language