changeset 2732:b185b5b80d1b

merged main
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Sun, 19 Aug 2007 19:51:56 +0200
parents a62313ef3473 (current diff) 248489d28118 (diff)
children cdda42a9d8a8
files MoinMoin/config/multiconfig.py MoinMoin/parser/text_creole.py MoinMoin/script/migration/_tests/test_conv160_wiki.py MoinMoin/theme/__init__.py jabberbot/main.py
diffstat 22 files changed, 1059 insertions(+), 312 deletions(-) [+]
line wrap: on
line diff
--- a/MoinMoin/_tests/test_sourcecode.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/_tests/test_sourcecode.py	Sun Aug 19 19:51:56 2007 +0200
@@ -23,6 +23,7 @@
     '/wiki/htdocs/applets/FCKeditor', # 3rd party GUI editor
     '/tests/wiki', # this is our test wiki
     '/wiki/htdocs', # this is our dist static stuff
+    '/wiki/data/pages', # wiki pages, there may be .py attachments
 ]
 
 TRAILING_SPACES = 'nochange' # 'nochange' or 'fix'
@@ -49,9 +50,15 @@
         f = file(path, 'rb')
         data = f.read()
         f.close()
-        data = FIX_TS_RE.sub('', data)
+        fixed = FIX_TS_RE.sub('', data)
+
+        # Don't write files if there's no need for that,
+        # as altering timestamps can be annoying with some tools.
+        if fixed == data:
+            return
+
         f = file(path, 'wb')
-        f.write(data)
+        f.write(fixed)
         f.close()
     # Please read and follow PEP8 - rerun this test until it does not fail any more,
     # any type of error is only reported ONCE (even if there are multiple).
--- a/MoinMoin/action/SubscribeUser.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/action/SubscribeUser.py	Sun Aug 19 19:51:56 2007 +0200
@@ -62,7 +62,7 @@
             realusers.append(userobj.name)
             if userobj.isSubscribedTo([pagename]):
                 success = True
-            elif not userobj.email:
+            elif not userobj.email and not userobj.jid:
                 success = False
             elif userobj.subscribe(pagename):
                 success = True
--- a/MoinMoin/action/revert.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/action/revert.py	Sun Aug 19 19:51:56 2007 +0200
@@ -46,4 +46,6 @@
         e = PageRevertedEvent(request, pagename, request.rev, revstr)
         send_event(e)
 
-    pg.send_page(msg=msg)
+    if request.action != "xmlrpc":
+        pg.send_page(msg=msg)
+
--- a/MoinMoin/action/subscribe.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/action/subscribe.py	Sun Aug 19 19:51:56 2007 +0200
@@ -18,16 +18,16 @@
         msg = _("You are not allowed to subscribe to a page you can't read.")
 
     # Check if mail is enabled
-    elif not cfg.mail_enabled:
-        msg = _("This wiki is not enabled for mail processing.")
+    elif not cfg.mail_enabled and not cfg.jabber_enabled:
+        msg = _("This wiki is not enabled for mail/Jabber processing.")
 
     # Suggest visitors to login
     elif not request.user.valid:
         msg = _("You must log in to use subscriptions.")
 
     # Suggest users without email to add their email address
-    elif not request.user.email:
-        msg = _("Add your email address in your UserPreferences to use subscriptions.")
+    elif not request.user.email and not request.user.jid:
+        msg = _("Add your email address or Jabber ID in your UserPreferences to use subscriptions.")
 
     elif request.user.isSubscribedTo([pagename]):
         # Try to unsubscribe
@@ -46,3 +46,4 @@
             msg = _('You could not get subscribed to this page.')
 
     Page(request, pagename).send_page(msg=msg)
+
--- a/MoinMoin/config/multiconfig.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/config/multiconfig.py	Sun Aug 19 19:51:56 2007 +0200
@@ -775,10 +775,6 @@
         if self.url_prefix_local is None:
             self.url_prefix_local = self.url_prefix_static
 
-        # Register a list of available event handlers - this has to stay at the
-        # end, because loading plugins depends on having a config object
-        self.event_handlers = events.get_handlers(self)
-
 
     def load_meta_dict(self):
         """ The meta_dict contains meta data about the wiki instance. """
@@ -803,8 +799,21 @@
             if getattr(self, "_subscribable_events", None) is None:
                 self._subscribable_events = events.get_subscribable_events()
             return getattr(self, "_subscribable_events")
+
+        def setter(self, new_handlers):
+            self._event_handlers = new_handlers
+
+        return property(getter, setter)
+    subscribable_events = make_subscribable_events_prop()
+
+    # lazily create a list of event handlers
+    def make_event_handlers_prop():
+        def getter(self):
+            if getattr(self, "_event_handlers", None) is None:
+                self._event_handlers = events.get_handlers(self)
+            return getattr(self, "_event_handlers")
         return property(getter)
-    subscribable_events = make_subscribable_events_prop()
+    event_handlers = make_event_handlers_prop()
 
     def load_IWID(self):
         """ Loads the InterWikiID of this instance. It is used to identify the instance
--- a/MoinMoin/events/__init__.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/events/__init__.py	Sun Aug 19 19:51:56 2007 +0200
@@ -30,18 +30,26 @@
 
 class Event(object):
     """A class handling information common to all events."""
+
+    # NOTE: each Event subclass must have a unique name attribute
+    name = u"Event"
+
     def __init__(self, request):
         self.request = request
 
 
 class PageEvent(Event):
     """An event related to a page change"""
+
+    name = u"PageEvent"
+
     def __init__(self, request):
         Event.__init__(self, request)
 
 
 class PageChangedEvent(PageEvent):
 
+    name = u"PageChangedEvent"
     description = _(u"""Page has been modified""")
     req_superuser = False
 
@@ -53,6 +61,7 @@
 
 class TrivialPageChangedEvent(PageEvent):
 
+    name = u"TrivialPageChangedEvent"
     description = _(u"Page has been modified in a trivial fashion")
     req_superuser = False
 
@@ -64,6 +73,7 @@
 
 class PageRenamedEvent(PageEvent):
 
+    name = u"PageRenamedEvent"
     description = _(u"""Page has been renamed""")
     req_superuser = False
 
@@ -76,6 +86,7 @@
 
 class PageDeletedEvent(PageEvent):
 
+    name = u"PageDeletedEvent"
     description = _(u"""Page has been deleted""")
     req_superuser = False
 
@@ -87,6 +98,7 @@
 
 class PageCopiedEvent(PageEvent):
 
+    name = u"PageCopiedEvent"
     description = _(u"""Page has been copied""")
     req_superuser = False
 
@@ -99,19 +111,21 @@
 
 class FileAttachedEvent(PageEvent):
 
+    name = u"FileAttachedEvent"
     description = _(u"""A new attachment has been added""")
     req_superuser = False
 
-    def __init__(self, request, pagename, name, size):
+    def __init__(self, request, pagename, filename, size):
         PageEvent.__init__(self, request)
         self.request = request
         self.pagename = pagename
-        self.name = name
+        self.filename = filename
         self.size = size
 
 
 class PageRevertedEvent(PageEvent):
 
+    name = u"PageRevertedEvent"
     description = _(u"""A page has been reverted to a previous state""")
     req_superuser = False
 
@@ -124,6 +138,7 @@
 
 class SubscribedToPageEvent(PageEvent):
 
+    name = u"SubscribedToPageEvent"
     description = _(u"""A user has subscribed to a page""")
     req_superuser = True
 
@@ -154,6 +169,7 @@
 class UserCreatedEvent(Event):
     """ Sent when a new user has been created """
 
+    name = u"UserCreatedEvent"
     description = _(u"""A new account has been created""")
     req_superuser = True
 
@@ -168,6 +184,9 @@
     if handler returns
 
     """
+
+    name = u"PagePreSaveEvent"
+
     def __init__(self, request, page_editor, new_text):
         Event.__init__(self, request)
         self.page_editor = page_editor
@@ -243,9 +262,9 @@
     subscribable_events = {}
 
     for ev in defs.values():
-        if type(ev) is type and issubclass(ev, Event) and ev.__dict__.has_key("description"):
-            subscribable_events[ev.__name__] = {'desc': ev.description,
-                                                 'superuser': ev.req_superuser}
+        if type(ev) is type and issubclass(ev, Event) and ev.__dict__.has_key("description") and ev.__dict__.has_key("name"):
+            subscribable_events[ev.name] = {'desc': ev.description, 'superuser': ev.req_superuser}
+
     return subscribable_events
 
 # Get rid of the dummy getText so that it doesn't get imported with *
--- a/MoinMoin/events/emailnotify.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/events/emailnotify.py	Sun Aug 19 19:51:56 2007 +0200
@@ -14,6 +14,8 @@
 from MoinMoin.mail import sendmail
 from MoinMoin.support.python_compatibility import set
 from MoinMoin.user import User, getUserList
+from MoinMoin.action.AttachFile import getAttachUrl
+
 import MoinMoin.events as ev
 import MoinMoin.events.notification as notification
 
@@ -63,7 +65,6 @@
     @return: message, indicating success or errors.
 
     """
-    _ = request.getText
     subscribers = page.getSubscribers(request, return_users=1, trivial=trivial)
     mail_from = page.cfg.mail_from
 
@@ -76,7 +77,7 @@
         # send email to all subscribers
         for lang in subscribers:
             users = [u for u in subscribers[lang]
-                     if ev.PageChangedEvent.__name__ in u.email_subscribed_events]
+                     if ev.PageChangedEvent.name in u.email_subscribed_events]
             emails = [u.email for u in users]
             names = [u.name for u in users]
             data = prep_page_changed_mail(request, page, comment, lang, revisions, trivial)
@@ -94,7 +95,7 @@
     emails = []
     _ = event.request.getText
     user_ids = getUserList(event.request)
-    event_name = event.__class__.__name__
+    event_name = event.name
 
     from_address = event.request.cfg.mail_from
     email = event.user.email or u"NOT SET"
@@ -117,27 +118,35 @@
     """Sends an email to super users that have subscribed to this event type"""
 
     names = set()
-    _ = event.request.getText
-    event_name = event.__class__.__name__
+    event_name = event.name
     from_address = event.request.cfg.mail_from
     request = event.request
     page = Page(request, event.pagename)
 
     subscribers = page.getSubscribers(request, return_users=1)
     notification.filter_subscriber_list(event, subscribers, False)
-    data = notification.attachment_added(request, event.pagename, event.name, event.size)
+    recipients = []
+
+    for lang in subscribers:
+        recipients.extend(subscribers[lang])
+
+    attachlink = request.getBaseURL() + getAttachUrl(event.pagename, event.filename, request)
+    pagelink = request.getQualifiedURL(page.url(request, {}, relative=False))
 
     for lang in subscribers:
         emails = []
+        _ = lambda text: request.getText(text, lang=lang, formatted=False)
 
-        for usr in subscribers[lang]:
-            if usr.email and event_name in usr.email_subscribed_events:
-                emails.append(usr.email)
-            else:
-                continue
+        links = _("Attachment link: %(attach)s\n" \
+                  "Page link: %(page)s\n") % {'attach': attachlink, 'page': pagelink}
 
-            if send_notification(request, from_address, emails, data):
-                names.update(usr.name)
+        data = notification.attachment_added(request, _, event.pagename, event.filename, event.size)
+        data['body'] = data['body'] + links
+
+        emails = [usr.email for usr in subscribers[lang]]
+
+        if send_notification(request, from_address, emails, data):
+            names.update(recipients)
 
     return notification.Success(names)
 
--- a/MoinMoin/events/jabbernotify.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/events/jabbernotify.py	Sun Aug 19 19:51:56 2007 +0200
@@ -14,6 +14,8 @@
 from MoinMoin.Page import Page
 from MoinMoin.user import User, getUserList
 from MoinMoin.support.python_compatibility import set
+from MoinMoin.action.AttachFile import getAttachUrl
+
 import MoinMoin.events.notification as notification
 import MoinMoin.events as ev
 
@@ -68,23 +70,27 @@
     names = set()
     request = event.request
     page = Page(request, event.pagename)
-    event_name = event.__class__.__name__
-
+    event_name = event.name
     subscribers = page.getSubscribers(request, return_users=1)
     notification.filter_subscriber_list(event, subscribers, True)
+    recipients = []
+
+    for lang in subscribers:
+        recipients.extend(subscribers[lang])
+
+    attachlink = request.getBaseURL() + getAttachUrl(event.pagename, event.filename, request)
+    pagelink = request.getQualifiedURL(page.url(request, {}, relative=False))
 
     for lang in subscribers.keys():
-        jids = []
-        data = notification.attachment_added(request, event.pagename, event.name, event.size)
+        _ = lambda text: request.getText(text, lang=lang, formatted=False)
+        data = notification.attachment_added(request, _, event.pagename, event.filename, event.size)
+        links = [{'url': attachlink, 'description': _("Attachment link")},
+                  {'url': pagelink, 'description': _("Page link")}]
 
-        for usr in subscribers[lang]:
-            if usr.jid and event_name in usr.jabber_subscribed_events:
-                jids.append(usr.jid)
-            else:
-                continue
+        jids = [usr.jid for usr in subscribers[lang]]
 
-            if send_notification(request, jids, data['body'], data['subject']):
-                names.update(usr.name)
+        if send_notification(request, jids, data['body'], data['subject'], links, "file_attached"):
+            names.update(recipients)
 
     return notification.Success(names)
 
@@ -127,7 +133,7 @@
 
     jids = []
     user_ids = getUserList(event.request)
-    event_name = event.__class__.__name__
+    event_name = event.name
 
     email = event.user.email or u"NOT SET"
     sitename = event.request.cfg.sitename
@@ -142,7 +148,7 @@
         if usr.isSuperUser() and usr.jid and event_name in usr.jabber_subscribed_events:
             jids.append(usr.jid)
 
-    send_notification(event.request, jids, data['body'], data['subject'])
+    send_notification(event.request, jids, data['body'], data['subject'], "user_created")
 
 
 def page_change(change_type, request, page, subscribers, **kwargs):
@@ -157,7 +163,9 @@
             jids = [u.jid for u in subscribers[lang] if u.jid]
             names = [u.name for u in subscribers[lang] if u.jid]
             msg = notification.page_change_message(change_type, request, page, lang, **kwargs)
-            result = send_notification(request, jids, msg)
+            page_url = request.getQualifiedURL(page.url(request, relative=False))
+            url = {'url': page_url, 'description': _("Changed page")}
+            result = send_notification(request, jids, msg, _("Page changed"), [url], "page_changed")
 
             if result:
                 recipients.update(names)
@@ -165,17 +173,25 @@
         if recipients:
             return notification.Success(recipients)
 
-def send_notification(request, jids, message, subject=""):
+def send_notification(request, jids, message, subject="", url_list=[], action=""):
     """ Send notifications for a single language.
 
-    @param comment: editor's comment given when saving the page
     @param jids: an iterable of Jabber IDs to send the message to
+    @param message: message text
+    @param subject: subject of the message, makes little sense for chats
+    @param url_list: a list of dicts containing URLs and their descriptions
+    @type url_list: list
+
     """
     _ = request.getText
     server = request.cfg.notification_server
 
+    if type(url_list) != list:
+        raise ValueError("url_list must be of type list!")
+
     try:
-        server.send_notification(request.cfg.secret, jids, message, subject)
+        cmd_data = {'text': message, 'subject': subject, 'url_list': url_list, 'action': action}
+        server.send_notification(request.cfg.secret, jids, cmd_data)
         return True
     except xmlrpclib.Error, err:
         ev.logger.error(_("XML RPC error: %s"), str(err))
--- a/MoinMoin/events/notification.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/events/notification.py	Sun Aug 19 19:51:56 2007 +0200
@@ -46,6 +46,17 @@
     """ Used to signal an invalid page change event """
     pass
 
+def page_link(request, page, querystr):
+    """Create an absolute url to a given page with optional action
+
+    @param page: a page to link to
+    @type page: MoinMoin.Page.Page
+    @param querystr: a dict passed to wikiutil.makeQueryString
+
+    """
+    query = wikiutil.makeQueryString(querystr, True)
+    return request.getQualifiedURL(page.url(request, query, relative=False))
+
 def page_change_message(msgtype, request, page, lang, **kwargs):
     """Prepare a notification text for a page change of given type
 
@@ -70,15 +81,14 @@
                     'rev2': str(revisions[0]),
                     'rev1': str(revisions[1])}
 
-    pagelink = request.getQualifiedURL(page.url(request, querystr, relative=False))
+    pagelink = page_link(request, page, querystr)
 
     if msgtype == "page_changed":
         msg_body = _("Dear Wiki user,\n\n"
         'You have subscribed to a wiki page or wiki category on "%(sitename)s" for change notification.\n\n'
-        "The following page has been changed by %(editor)s:\n"
-        "%(pagelink)s\n\n", formatted=False) % {
+        'The "%(pagename)s" page has been changed by %(editor)s:\n\n', formatted=False) % {
+            'pagename': page.page_name,
             'editor': page.uid_override or user.getUserIdentification(request),
-            'pagelink': pagelink,
             'sitename': page.cfg.sitename or request.getBaseURL(),
         }
 
@@ -98,20 +108,19 @@
     elif msgtype == "page_deleted":
         msg_body = _("Dear wiki user,\n\n"
             'You have subscribed to a wiki page "%(sitename)s" for change notification.\n\n'
-            "The following page has been deleted by %(editor)s:\n"
-            "%(pagelink)s\n\n", formatted=False) % {
+            'The page "%(pagename)" has been deleted by %(editor)s:\n\n', formatted=False) % {
+                'pagename': page.page_name,
                 'editor': page.uid_override or user.getUserIdentification(request),
-                'pagelink': pagelink,
                 'sitename': page.cfg.sitename or request.getBaseURL(),
         }
 
     elif msgtype == "page_renamed":
         msg_body = _("Dear wiki user,\n\n"
             'You have subscribed to a wiki page "%(sitename)s" for change notification.\n\n'
-            "The following page has been renamed from %(oldname)s by %(editor)s:\n"
-            "%(pagelink)s\n\n", formatted=False) % {
+            'The page "%(pagename)" has been renamed from %(oldname)s by %(editor)s:\n',
+            formatted=False) % {
                 'editor': page.uid_override or user.getUserIdentification(request),
-                'pagelink': pagelink,
+                'pagename': page.page_name,
                 'sitename': page.cfg.sitename or request.getBaseURL(),
                 'oldname': kwargs['old_name']
         }
@@ -120,7 +129,8 @@
 
     if 'comment' in kwargs and kwargs['comment']:
         msg_body = msg_body + \
-            _("The comment on the change is:\n%(comment)s", formatted=False) % {'comment': kwargs['comment']}
+            _("The comment on the change is:\n%(comment)s",
+              formatted=False) % {'comment': kwargs['comment']}
 
     return msg_body
 
@@ -141,17 +151,14 @@
 
     return {'subject': subject, 'body': body}
 
-def attachment_added(request, page_name, attach_name, attach_size):
+def attachment_added(request, _, page_name, attach_name, attach_size):
     """Formats a message used to notify about new attachments
 
+    @param _: a gettext function
     @return: a dict containing message body and subject
-    """
-    from MoinMoin.action.AttachFile import getAttachUrl
 
-    _ = request.getText
+    """
     page = Page(request, page_name)
-    attachlink = request.getBaseURL() + getAttachUrl(page_name, attach_name, request)
-    pagelink = request.getQualifiedURL(page.url(request, {}, relative=False))
 
     subject = _("New attachment added to page %(pagename)s on %(sitename)s") % {
                 'pagename': page_name,
@@ -163,14 +170,11 @@
     "An attachment has been added to that page by %(editor)s. "
     "Following detailed information is available:\n\n"
     "Attachment name: %(attach_name)s\n"
-    "Attachment size: %(attach_size)s\n"
-    "Download link: %(attach_get)s", formatted=False) % {
+    "Attachment size: %(attach_size)s\n") % {
         'editor': user.getUserIdentification(request),
-        'pagelink': pagelink,
         'page_name': page_name,
         'attach_name': attach_name,
         'attach_size': attach_size,
-        'attach_get': attachlink,
     }
 
     return {'body': body, 'subject': subject}
@@ -185,7 +189,7 @@
     @type subscribers: dict
 
     """
-    event_name = event.__class__.__name__
+    event_name = event.name
 
     # Filter the list by removing users who don't want to receive
     # notifications about this type of event
--- a/MoinMoin/parser/text_creole.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/parser/text_creole.py	Sun Aug 19 19:51:56 2007 +0200
@@ -104,7 +104,7 @@
 
     # For splitting table cells:
     cell_rule = r'\|\s* ( (?P<head> [=][^|]+) | (?P<cell> ((%(link)s) |(%(macro)s) |(%(image)s) |(%(code)s) | [^|] )+)  )\s*' % inline_tab
-    cell_re = re.compile(cell_rule, re.X|re.I|re.U)
+    cell_re = re.compile(cell_rule, re.X|re.U)
 
     # For link descriptions:
     link_rules = r'|'.join([
@@ -112,11 +112,11 @@
             _get_rule('break', inline_tab),
             _get_rule('char', inline_tab),
     ])
-    link_re = re.compile(link_rules, re.X|re.I|re.U)
+    link_re = re.compile(link_rules, re.X|re.U)
 
     # For lists:
     item_rule = r'(?P<item> ^\s* (?P<item_head> [\#*]+ ) \s* (?P<item_text>.*?) $)'
-    item_re = re.compile(item_rule, re.X|re.I|re.U|re.M)
+    item_re = re.compile(item_rule, re.X|re.U|re.M)
 
     # For block elements:
     block_rules = '|'.join([
--- a/MoinMoin/theme/__init__.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/theme/__init__.py	Sun Aug 19 19:51:56 2007 +0200
@@ -1190,7 +1190,7 @@
         @rtype: unicode
         @return: subscribe or unsubscribe link
         """
-        if not (self.cfg.mail_enabled and self.request.user.valid):
+        if not (self.cfg.mail_enabled or self.cfg.jabber_enabled and self.request.user.valid):
             return ''
 
         _ = self.request.getText
--- a/MoinMoin/theme/classic.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/theme/classic.py	Sun Aug 19 19:51:56 2007 +0200
@@ -73,7 +73,7 @@
                 if icon == "up":
                     if d['page_parent_page']:
                         iconbar.append('<li>%s</li>\n' % self.make_iconlink(icon, d))
-                elif icon == "subscribe" and self.cfg.mail_enabled:
+                elif icon == "subscribe" and (self.cfg.mail_enabled or self.cfg.jabber_enabled):
                     iconbar.append('<li>%s</li>\n' % self.make_iconlink(
                         ["subscribe", "unsubscribe"][self.request.user.isSubscribedTo([d['page_name']])], d))
                 else:
--- a/MoinMoin/xmlrpc/__init__.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/MoinMoin/xmlrpc/__init__.py	Sun Aug 19 19:51:56 2007 +0200
@@ -556,6 +556,28 @@
 
         return xmlrpclib.Boolean(1)
 
+    def xmlrpc_revertPage(self, pagename, revision):
+        """Revert a page to previous revision
+
+        This is mainly intended to be used by the jabber bot.
+
+        @param pagename: the page name (unicode or utf-8)
+        @param revision: revision to revert to
+        @rtype bool
+        @return true on success
+
+        """
+        if not self.request.user.may.write(pagename):
+            return xmlrpclib.Fault(1, "You are not allowed to edit this page")
+
+        from MoinMoin.action import revert
+        self.request.rev = int(self._instr(revision))
+        msg = revert.execute(pagename, self.request)
+        if msg:
+            return xmlrpclib.Fault(1, "Revert failed: %s" % (msg, ))
+        else:
+            return xmlrpclib.Boolean(1)
+
     def xmlrpc_searchPages(self, query_string):
         """ Searches pages for query_string.
             Returns a list of tuples (foundpagename, context)
@@ -568,6 +590,36 @@
                  self._outstr(results.formatContext(hit, 180, 1)))
                 for hit in results.hits]
 
+    def xmlrpc_searchPagesEx(self, query_string, search_type, length, case, mtime, regexp):
+        """ Searches pages for query_string - extended version for compatibility
+
+        This function, in contrary to searchPages(), doesn't return HTML-formatted data.
+
+        @param query_string: term to search for
+        @param search_type: "text" or "title" search
+        @param length: length of context preview (in characters)
+        @param case: should the search be case sensitive?
+        @param mtime: only output pages modified after mtime
+        @param regexp: should the query_string be treates as a regular expression?
+        @return: (page name, context preview, page url)
+
+        """
+        from MoinMoin import search
+        from MoinMoin.formatter.text_plain import Formatter
+
+        kwargs = {"sort": "page_name", "case": case, "regex": regexp}
+        if search_type == "title":
+            kwargs["titlesearch"] = True
+
+        results = search.searchPages(self.request, query_string, **kwargs)
+        results.formatter = Formatter(self.request)
+        results.request = self.request
+
+        return [(self._outstr(hit.page_name),
+                 self._outstr(results.formatContext(hit, length, 1)),
+                 self.request.getQualifiedURL(hit.page.url(self.request, {}, relative=False)))
+                for hit in results.hits]
+
     def xmlrpc_getMoinVersion(self):
         """ Returns a tuple of the MoinMoin version:
             (project, release, revision)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/CHANGES.jabber	Sun Aug 19 19:51:56 2007 +0200
@@ -0,0 +1,87 @@
+MoinMoin Version History - Jabber Functionality
+===============================================
+
+Please completely read CHANGES text until you reach the version you were
+using until now. Otherwise you might miss some important upgrading and
+configuration hints.
+
+Overview:
+===============================================
+   MoinMoin 1.7 includes a brand new notification system based on
+   a separate process running a Jabber/XMPP notification bot. See
+   http://www.jabber.org and http://www.xmpp.org for more information
+   on this protocol.
+
+   The bot can be used to send notifications about various events
+   occuring in your Wiki, or to work with the Wiki interactively.
+
+   As it's a separate process, it doesn't block waiting for all
+   notifications to be sent, to this solution should be suitable for
+   large sites that have many users subscribed to particular changes.
+
+Features:
+===============================================
+  * Notification sent when pages are changed in various ways (content
+    change, page rename, deletion, page copy), users being created
+    (visible to super user only!), attachments being added and users
+    subscribing to pages...
+
+  * Users can choose which events they're interested in being notified
+    about. This applied both to (old) email and jabber notifications.
+
+  * Interactive Jabber bot allows to perform various simple operations
+    on a Wiki from within your IM client (possibly in response to
+    received notification). This includes getting raw and html-formatted
+    page contents, querying detailed page information (last author, 
+    revision, date of the last change...), getting a list of pages,
+    performing searches and reverts.
+
+    The bot uses Data Forms (XEP-004) and Out of Band Data (XEP-066)
+    extensions if they're supported by the client to further extend
+    available communication options
+    
+Getting help:
+===============================================
+  There's a sample wikiconfig in MOINDIR/wiki/config/more_examples
+
+  You can read more about the notification bot on following pages:
+    * http://moinmo.in/JabberSupport
+    * http://moinmo.in/MoinMoinTodo/Release_1.7/HelpOnNotification
+
+Known main issues with jabber bot:
+===============================================
+  * You need a development version of pyxmpp, 1.0 won't work. You can
+    get it directly from svn repository with:
+
+    svn checkout http://pyxmpp.jajcus.net/svn/pyxmpp/trunk pyxmpp
+
+    Add the resulting `pyxmpp` directory to your PYTHONPATH or perform
+    a "full installation" as described on http://pyxmpp.jajcus.net/:
+
+    To build the package just invoke:
+    python setup.py build
+
+    To install it:
+    python setup.py install
+
+    If you had some older version of PyXMPP it is better to uninstall it 
+    (delete pyxmpp subdirectory os your site-packages directory) before 
+    installing this one or things may not work correctly.
+
+    You may also try:
+    make
+
+    and:
+    make install
+
+    instead.
+
+  * Jabber servers usually have rather tight data rate limits, so if
+    your site generates a lot of traffic, the notification bot may become
+    unstable and/or unusable. If such condition occurs, you should
+    consider running your own Jabber/XMPP server with relaxed limits.
+
+Changes
+===============================================
+  Version 1.7.current:
+    The first version that supports jabber notifications.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jabberbot/_tests/test_xmppbot.py	Sun Aug 19 19:51:56 2007 +0200
@@ -0,0 +1,87 @@
+# -*- coding: iso-8859-1 -*-
+"""
+    MoinMoin - tests for the XMPP component
+
+    @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
+    @license: GNU GPL, see COPYING for details.
+"""
+
+import py.test
+from Queue import Queue
+
+try:
+    import pyxmpp
+except ImportError:
+    py.test.skip("Skipping jabber bot tests - pyxmpp is not installed")
+
+from jabberbot.xmppbot import XMPPBot
+import jabberbot.commands as commands
+from jabberbot.config import BotConfig
+
+class TestXMPPBotCommands:
+    """Various tests for the XMPP bot receiving commands from Wiki"""
+
+    def setup_class(self):
+        self.from_xmlrpc = Queue()
+        self.to_xmlrpc = Queue()
+        self.bot = XMPPBot(BotConfig, self.from_xmlrpc, self.to_xmlrpc)
+
+    def setup_method(self, method):
+        self.called = False
+        self.bot.send_message = self.dummy_method
+        self.bot.ask_for_subscription = self.dummy_method
+        self.bot.remove_subscription = self.dummy_method
+
+    def dummy_method(self, *args, **kwargs):
+        self.called = True
+
+    def testNotificationCommand(self):
+        """Check if send_message is triggered for tested commands"""
+
+        data = {'text': 'Some notification', 'subject': 'It is optional', 'url_list': []}
+        cmds = []
+        cmds.append(commands.NotificationCommand(["dude@example.com"], data, True))
+        cmds.append(commands.NotificationCommandI18n(["dude@example.com"], data, True))
+        cmds.append(commands.GetPage("dude@example.com", "TestPage"))
+        cmds.append(commands.GetPageHTML("dude@example.com", "TestPage"))
+
+        tmp_cmd = commands.GetPageList("dude@example.com")
+        tmp_cmd.data = ""
+        cmds.append(tmp_cmd)
+
+        tmp_cmd = commands.GetPageInfo("dude@example.com", "TestPage")
+        tmp_cmd.data = {'author': 'dude', 'lastModified': '200708060T34350', 'version': 42}
+        cmds.append(tmp_cmd)
+
+        for cmd in cmds:
+            self.called = False
+            self.bot.handle_command(cmd)
+            if not self.called:
+                print "The bot should send a notification when %s arrives!" % (cmd.__class__.__name__, )
+                raise Exception()
+
+    def testRosterCommands(self):
+        """Test if appropriate functions are called for (Add|Remove)JIDFromRosterCommand"""
+
+        command = commands.AddJIDToRosterCommand("dude@example.com")
+        self.bot.handle_command(command)
+
+        if not self.called:
+            print "The bot should do something when AddJIDToRosterCommand arrives!"
+            raise Exception()
+
+        self.called = False
+        command = commands.RemoveJIDFromRosterCommand("dude@example.com")
+        self.bot.handle_command(command)
+
+        if not self.called:
+            print "The bot should do something when RemoveJIDFromRosterCommand arrives!"
+            raise Exception()
+
+    def testInternalHelp(self):
+        """Check if there's help for every known command"""
+
+        commands = self.bot.internal_commands + self.bot.xmlrpc_commands.values()
+        for cmd in commands:
+            print "There should be help on %s command!" % (cmd, )
+            assert self.bot.help_on("dude@example.com", cmd)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jabberbot/bot.py	Sun Aug 19 19:51:56 2007 +0200
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+"""
+    MoinMoin - jabber bot main file
+
+    @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
+    @license: GNU GPL, see COPYING for details.
+"""
+
+import logging, os, sys, time
+from Queue import Queue
+
+from jabberbot.config import BotConfig
+from jabberbot.i18n import init_i18n
+from jabberbot.xmppbot import XMPPBot
+from jabberbot.xmlrpcbot import XMLRPCServer, XMLRPCClient
+
+
+def _check_xmpp_version():
+    """Checks if available version of pyxmpp is recent enough
+
+    Since __revision__ is broken in current trunk, we can't rely on it.
+    Therefore a simple check for known problems is used to determine if
+    we can start the bot with it.
+
+    """
+    import pyxmpp
+
+    msg = pyxmpp.message.Message()
+    form = pyxmpp.jabber.dataforms.Form()
+
+    try:
+        msg.add_content(form)
+    except TypeError:
+        print 'Your version of pyxmpp is too old!'
+        print 'You need a least revision 665 to run this bot. Exiting...'
+        sys.exit(1)
+
+def main():
+    """Starts the jabber bot"""
+
+    _check_xmpp_version()
+
+    args = sys.argv
+    if "--help" in args:
+        print """MoinMoin notification bot
+
+        Usage: %(myname)s [--server server] [--xmpp_port port] [--user user] [--resource resource] [--password pass] [--xmlrpc_host host] [--xmlrpc_port port]
+        """ % {"myname": os.path.basename(args[0])}
+
+        raise SystemExit
+
+    log = logging.getLogger("log")
+    log.setLevel(logging.DEBUG)
+    log.addHandler(logging.StreamHandler())
+
+    init_i18n(BotConfig)
+
+    # TODO: actually accept options from the help string
+    commands_from_xmpp = Queue()
+    commands_to_xmpp = Queue()
+
+    xmpp_bot = None
+    xmlrpc_client = None
+    xmlrpc_server = None
+
+    while True:
+        try:
+            if not xmpp_bot or not xmpp_bot.isAlive():
+                log.info("(Re)starting XMPP thread...")
+                xmpp_bot = XMPPBot(BotConfig, commands_from_xmpp, commands_to_xmpp)
+                xmpp_bot.setDaemon(True)
+                xmpp_bot.start()
+
+            if not xmlrpc_client or not xmlrpc_client.isAlive():
+                log.info("(Re)starting XMLRPC client thread...")
+                xmlrpc_client = XMLRPCClient(BotConfig, commands_from_xmpp, commands_to_xmpp)
+                xmlrpc_client.setDaemon(True)
+                xmlrpc_client.start()
+
+            if not xmlrpc_server or not xmlrpc_server.isAlive():
+                log.info("(Re)starting XMLRPC server thread...")
+                xmlrpc_server = XMLRPCServer(BotConfig, commands_to_xmpp)
+                xmlrpc_server.setDaemon(True)
+                xmlrpc_server.start()
+
+            time.sleep(5)
+
+        except KeyboardInterrupt, i:
+            xmpp_bot.stop()
+            xmlrpc_client.stop()
+
+            log.info("Stopping XMPP bot thread, please wait...")
+            xmpp_bot.join(5)
+            log.info("Stopping XMLRPC client thread, please wait...")
+            xmlrpc_client.join(5)
+
+            sys.exit(0)
+
+
+if __name__ == "__main__":
+    main()
--- a/jabberbot/commands.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/jabberbot/commands.py	Sun Aug 19 19:51:56 2007 +0200
@@ -9,24 +9,51 @@
     @license: GNU GPL, see COPYING for details.
 """
 
+from pyxmpp.jid import JID
+
 # First, XML RPC -> XMPP commands
 class NotificationCommand:
     """Class representing a notification request"""
-    def __init__(self, jids, text, subject="", async=True):
+    def __init__(self, jids, notification, msg_type=u"message", async=True):
         """A constructor
 
         @param jids: a list of jids to sent this message to
+        @param notification: dictionary with notification data
+        @param async: async notifications get queued if contact is DnD
         @type jids: list
-        @param async: async notifications get queued if contact is DnD
 
         """
         if type(jids) != list:
             raise Exception("jids argument must be a list!")
 
+        self.notification = notification
         self.jids = jids
-        self.text = text
-        self.subject = subject
         self.async = async
+        self.msg_type = msg_type
+
+class NotificationCommandI18n(NotificationCommand):
+    """Notification request that should be translated by the XMPP bot"""
+    def __init__(self, jids, notification, msg_type="message", async=True):
+        """A constructor
+
+        Params as in NotificationCommand.
+
+        """
+        NotificationCommand.__init__(self, jids, notification, msg_type, async)
+
+    def translate(self, gettext_func):
+        """Translate the message using a provided gettext function
+
+        @param gettext_func: a unary gettext function
+        @return: translated message and subject
+        @rtype: tuple
+        """
+        if self.notification.has_key('data'):
+            msg =  gettext_func(self.notification['text']) % self.notification['data']
+        else:
+            msg = gettext_func(self.notification['text'])
+
+        return (msg, gettext_func(self.notification.get('subject', '')))
 
 class AddJIDToRosterCommand:
     """Class representing a request to add a new jid to roster"""
@@ -105,10 +132,30 @@
     description = u"perform a wiki search"
     parameter_list = u"{title|text} term"
 
-    def __init__(self, jid, term, search_type):
+    def __init__(self, jid, search_type, *args, **kwargs):
         BaseDataCommand.__init__(self, jid)
-        self.term = term
+
+        if not JID(jid).resource:
+            raise ValueError("The jid argument must be a full jabber id!")
+
+        self.term = ' '.join(args)
         self.search_type = search_type
+        self.presentation = kwargs.get('presentation', 'text') # "text" or "dataforms"
+        self.case = kwargs.get('case', False)
+        self.mtime = None
+        self.regexp = kwargs.get('regexp', False)
+
+
+class RevertPage(BaseDataCommand):
+
+    description = u"revert a page to previous revision"
+    parameter_list = u"page_name revision"
+
+    def __init__(self, jid, pagename, revision):
+        BaseDataCommand.__init__(self, jid)
+        self.pagename = pagename
+        self.revision = revision
+
 
 class GetUserLanguage:
     """Request user's language information from wiki"""
@@ -119,3 +166,4 @@
         """
         self.jid = jid
         self.language = None
+
--- a/jabberbot/i18n.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/jabberbot/i18n.py	Sun Aug 19 19:51:56 2007 +0200
@@ -6,6 +6,7 @@
     @license: GNU GPL, see COPYING for details.
 """
 import logging, xmlrpclib
+from jabberbot.config import BotConfig
 
 TRANSLATIONS = None
 
@@ -20,7 +21,7 @@
 
     global TRANSLATIONS
     if not TRANSLATIONS:
-        init_i18n()
+        init_i18n(BotConfig)
 
     try:
         return TRANSLATIONS[lang][original]
--- a/jabberbot/main.py	Sun Aug 19 19:29:40 2007 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,54 +0,0 @@
-# -*- coding: iso-8859-1 -*-
-"""
-    MoinMoin - jabber bot main file
-
-    @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
-    @license: GNU GPL, see COPYING for details.
-"""
-
-import logging, os, sys
-from Queue import Queue
-
-from jabberbot.config import BotConfig
-from jabberbot.i18n import init_i18n
-from jabberbot.xmppbot import XMPPBot
-from jabberbot.xmlrpcbot import XMLRPCServer, XMLRPCClient
-
-
-def main():
-    args = sys.argv
-
-    if "--help" in args:
-        print """MoinMoin notification bot
-
-        Usage: %(myname)s [--server server] [--xmpp_port port] [--user user] [--resource resource] [--password pass] [--xmlrpc_host host] [--xmlrpc_port port]
-        """ % {"myname": os.path.basename(args[0])}
-
-        raise SystemExit
-
-    log = logging.getLogger("log")
-    log.setLevel(logging.DEBUG)
-    log.addHandler(logging.StreamHandler())
-
-    init_i18n(BotConfig)
-
-    # TODO: actually accept options from the help string
-    commands_from_xmpp = Queue()
-    commands_to_xmpp = Queue()
-
-    try:
-        xmpp_bot = XMPPBot(BotConfig, commands_from_xmpp, commands_to_xmpp)
-        xmlrpc_client = XMLRPCClient(BotConfig, commands_from_xmpp, commands_to_xmpp)
-        xmlrpc_server = XMLRPCServer(BotConfig, commands_to_xmpp)
-
-        xmpp_bot.start()
-        xmlrpc_client.start()
-        xmlrpc_server.start()
-
-    except KeyboardInterrupt, i:
-        print i
-        sys.exit(0)
-
-
-if __name__ == "__main__":
-    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jabberbot/oob.py	Sun Aug 19 19:51:56 2007 +0200
@@ -0,0 +1,35 @@
+# -*- coding: iso-8859-1 -*-
+"""
+    MoinMoin - Out Of Band Data (XEP-066) implementation
+
+    This is used by the xmpp thread to send URIs to clients
+    in a structured manner.
+
+    @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
+    @license: GNU GPL, see COPYING for details.
+"""
+
+from pyxmpp.message import Message
+from pyxmpp.presence import Presence
+
+def add_urls(stanza, data):
+    """Adds a URL to a message or presence stanza
+
+    Adds an <x> element qualified by the jabber:x:oob namespace
+    to the stanza's payload
+
+    @param stanza: message or presence stanza to add the URL info to
+    @type stanza: pyxmpp.message.Message or pyxmpp.presence.Presence
+    @param data: a list of dictionaries containing (url, description), as unicode
+    @type data: list
+
+    """
+    if not (isinstance(stanza, Presence) or isinstance(stanza, Message)):
+        raise TypeError("Stanza must be either of type Presence or Message!")
+
+    for piece in data:
+        x_elem = stanza.add_new_content(u"jabber:x:oob", u"x")
+        url = x_elem.newChild(None, u"url", None)
+        desc = x_elem.newChild(None, u"desc", None)
+        url.addContent(piece['url'].encode("utf-8"))
+        desc.addContent(piece['description'].encode("utf-8"))
--- a/jabberbot/xmlrpcbot.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/jabberbot/xmlrpcbot.py	Sun Aug 19 19:51:56 2007 +0200
@@ -12,15 +12,58 @@
 
 import jabberbot.commands as cmd
 from jabberbot.multicall import MultiCall
-from jabberbot.i18n import get_text
 
-_ = get_text
 
 class ConfigurationError(Exception):
     def __init__(self, message):
-        Exception.__init__()
+        Exception.__init__(self)
         self.message = message
 
+def _xmlrpc_decorator(function):
+    """A decorator function, which adds some maintenance code
+
+    This function takes care of preparing a MultiCall object and
+    an authentication token, and deleting them at the end.
+
+    """
+    def wrapped_func(self, command):
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
+
+        self.token = None
+        self.multicall = MultiCall(self.connection)
+        jid = command.jid
+        if type(jid) is not list:
+            jid = [jid]
+
+        try:
+            try:
+                self.get_auth_token(command.jid)
+                if self.token:
+                    self.multicall.applyAuthToken(self.token)
+
+                function(self, command)
+                self.commands_out.put_nowait(command)
+
+            except xmlrpclib.Fault, fault:
+                msg = _("Your request has failed. The reason is:\n%(error)s")
+                self.log.error(str(fault))
+                self.report_error(jid, msg, {'error': fault.faultString})
+            except xmlrpclib.Error, err:
+                msg = _("A serious error occured while processing your request:\n%(error)s")
+                self.log.error(str(err))
+                self.report_error(jid, msg, {'error': str(err)})
+            except Exception, exc:
+                msg = _("An internal error has occured, please contact the administrator.")
+                self.log.critical(str(exc))
+                self.report_error(jid, msg)
+
+        finally:
+            del self.token
+            del self.multicall
+
+    return wrapped_func
+
 class XMLRPCClient(Thread):
     """XMLRPC Client
 
@@ -39,7 +82,7 @@
         self.log = logging.getLogger("log")
 
         if not config.secret:
-            error = _("You must set a (long) secret string!")
+            error = "You must set a (long) secret string!"
             self.log.critical(error)
             raise ConfigurationError(error)
 
@@ -50,16 +93,24 @@
         self.connection = self.create_connection()
         self.token = None
         self.multicall = None
+        self.stopping = False
 
     def run(self):
         """Starts the server / thread"""
         while True:
+            if self.stopping:
+                break
+
             try:
                 command = self.commands_in.get(True, 2)
                 self.execute_command(command)
             except Queue.Empty:
                 pass
 
+    def stop(self):
+        """Stop the thread"""
+        self.stopping = True
+
     def create_connection(self):
         return xmlrpclib.ServerProxy(self.url, allow_none=True, verbose=self.config.verbose)
 
@@ -77,9 +128,17 @@
             self.get_page_info(command)
         elif isinstance(command, cmd.GetUserLanguage):
             self.get_language_by_jid(command)
+        elif isinstance(command, cmd.Search):
+            self.do_search(command)
+        elif isinstance(command, cmd.RevertPage):
+            self.do_revert(command)
 
-    def report_error(self, jid, text):
-        report = cmd.NotificationCommand(jid, text, _("Error"), async=False)
+    def report_error(self, jid, text, data={}):
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
+
+        cmddata = {'text': text, 'data': data}
+        report = cmd.NotificationCommandI18n(jid, cmddata, msg_type=u"chat", async=False)
         self.commands_out.put_nowait(report)
 
     def get_auth_token(self, jid):
@@ -93,66 +152,45 @@
         if token:
             self.token = token
 
-    def _xmlrpc_decorator(function):
-        """A decorator function, which adds some maintenance code
+    def warn_no_credentials(self, jid):
+        """Warn a given JID that credentials check failed
 
-        This function takes care of preparing a MultiCall object and
-        an authentication token, and deleting them at the end.
+        @param jid: full JID to notify about failure
+        @type jid: str
 
         """
-        def wrapped_func(self, command):
-            self.token = None
-            self.multicall = MultiCall(self.connection)
-            jid = command.jid
-            if type(jid) is not list:
-                jid = [jid]
-
-            try:
-                try:
-                    self.get_auth_token(command.jid)
-                    if self.token:
-                        self.multicall.applyAuthToken(self.token)
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
 
-                    function(self, command)
-                    self.commands_out.put_nowait(command)
-                except xmlrpclib.Fault, fault:
-                    msg = _("Your request has failed. The reason is:\n%s")
-                    self.log.error(str(fault))
-                    self.report_error(jid, msg % (fault.faultString, ))
-                except xmlrpclib.Error, err:
-                    msg = _("A serious error occured while processing your request:\n%s")
-                    self.log.error(str(err))
-                    self.report_error(jid, msg % (str(err), ))
-                except Exception, exc:
-                    msg = _("An internal error has occured, please contact the administrator.")
-                    self.log.critical(str(exc))
-                    self.report_error(jid, msg)
+        cmddata = {'text': _("Credentials check failed, you might be unable to see all information.")}
+        warning = cmd.NotificationCommandI18n([jid], cmddata, async=False)
+        self.commands_out.put_nowait(warning)
 
-            finally:
-                del self.token
-                del self.multicall
+    def _get_multicall_result(self, jid):
+        """Returns multicall results and issues a warning if there's an auth error
 
-        return wrapped_func
+        @param jid: a full JID to use if there's an error
+        @type jid: str
 
-    def warn_no_credentials(self, jid):
-        msg = _("Credentials check failed, you may be unable to see all information.")
-        warning = cmd.NotificationCommand([jid], msg, async=False)
-        self.commands_out.put_nowait(warning)
+        """
+
+        if not self.token:
+            result = self.multicall()[0]
+            token_result = u"FAILURE"
+        else:
+            token_result, result = self.multicall()
+
+        if token_result != u"SUCCESS":
+            self.warn_no_credentials(jid)
+
+        return result
+
 
     def get_page(self, command):
         """Returns a raw page"""
 
         self.multicall.getPage(command.pagename)
-
-        if not self.token:
-            self.warn_no_credentials(command.jid)
-            getpage_result = self.multicall()[0]
-        else:
-            token_result, getpage_result = self.multicall()
-            if token_result != u"SUCCESS":
-                self.warn_no_credentials(command.jid)
-
-        command.data = getpage_result
+        command.data = self._get_multicall_result(command.jid)
 
     get_page = _xmlrpc_decorator(get_page)
 
@@ -161,16 +199,7 @@
         """Returns a html-formatted page"""
 
         self.multicall.getPageHTML(command.pagename)
-
-        if not self.token:
-            self.warn_no_credentials(command.jid)
-            getpagehtml_result = self.multicall()[0]
-        else:
-            token_result, getpagehtml_result = self.multicall()
-            if token_result != u"SUCCESS":
-                self.warn_no_credentials(command.jid)
-
-        command.data = getpagehtml_result
+        command.data = self._get_multicall_result(command.jid)
 
     get_page_html = _xmlrpc_decorator(get_page_html)
 
@@ -178,21 +207,15 @@
     def get_page_list(self, command):
         """Returns a list of all accesible pages"""
 
-        txt = _("This command may take a while to complete, please be patient...")
-        info = cmd.NotificationCommand([command.jid], txt, async=False)
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
+
+        cmd_data = {'text': _("This command may take a while to complete, please be patient...")}
+        info = cmd.NotificationCommandI18n([command.jid], cmd_data, async=False, msg_type=u"chat")
         self.commands_out.put_nowait(info)
 
         self.multicall.getAllPages()
-
-        if not self.token:
-            # FIXME: notify the user that he may not have full rights on the wiki
-            getpagelist_result = self.multicall()[0]
-        else:
-            token_result, getpagelist_result = self.multicall()
-            if token_result != u"SUCCESS":
-                self.warn_no_credentials(command.jid)
-
-        command.data = getpagelist_result
+        command.data = self._get_multicall_result(command.jid)
 
     get_page_list = _xmlrpc_decorator(get_page_list)
 
@@ -201,39 +224,47 @@
         """Returns detailed information about a given page"""
 
         self.multicall.getPageInfo(command.pagename)
-
-        if not self.token:
-            self.warn_no_credentials(command.jid)
-            getpageinfo_result = self.multicall()[0]
-        else:
-            token_result, getpageinfo_result = self.multicall()
-            if token_result != u"SUCCESS":
-                self.warn_no_credentials(command.jid)
-
-        author = getpageinfo_result['author']
-        if author.startswith("Self:"):
-            author = getpageinfo_result['author'][5:]
-
-        datestr = str(getpageinfo_result['lastModified'])
-        date = u"%(year)s-%(month)s-%(day)s at %(time)s" % {
-                    'year': datestr[:4],
-                    'month': datestr[4:6],
-                    'day': datestr[6:8],
-                    'time': datestr[9:17],
-                }
-
-        msg = _("""Last author: %(author)s
-Last modification: %(modification)s
-Current version: %(version)s""") % {
-             'author': author,
-             'modification': date,
-             'version': getpageinfo_result['version'],
-         }
-
-        command.data = msg
+        command.data = self._get_multicall_result(command.jid)
 
     get_page_info = _xmlrpc_decorator(get_page_info)
 
+    def do_search(self, command):
+        """Performs a search"""
+
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
+
+        cmd_data = {'text': _("This command may take a while to complete, please be patient...")}
+        info = cmd.NotificationCommandI18n([command.jid], cmd_data, async=False, msg_type=u"chat")
+        self.commands_out.put_nowait(info)
+
+        c = command
+        self.multicall.searchPagesEx(c.term, c.search_type, 30, c.case, c.mtime, c.regexp)
+        command.data = self._get_multicall_result(command.jid)
+
+    do_search = _xmlrpc_decorator(do_search)
+
+    def do_revert(self, command):
+        """Performs a page revert"""
+
+        # Dummy function, so that the string appears in a .po file
+        _ = lambda x: x
+
+        self.multicall.revertPage(command.pagename, command.revision)
+        data = self._get_multicall_result(command.jid)
+
+        if type(data) == bool and data:
+            cmd_data = {'text': _("Page has been reverted.")}
+        elif isinstance(str, data) or isinstance(unicode, data):
+            cmd_data = {'text': _("Revert failed: %(reason)s" % {'reason': data})}
+        else:
+            cmd_data = {'text': _("Revert failed.")}
+
+        info = cmd.NotificationCommandI18n([command.jid], cmd_data, async=False, msg_type=u"chat")
+        self.commands_out.put_nowait(info)
+
+    do_revert = _xmlrpc_decorator(do_revert)
+
     def get_language_by_jid(self, command):
         """Returns language of the a user identified by the given JID"""
 
@@ -268,19 +299,22 @@
         self.commands = commands
         self.verbose = config.verbose
         self.log = logging.getLogger("log")
+        self.config = config
 
         if config.secret:
             self.secret = config.secret
         else:
-            error = _("You must set a (long) secret string")
+            error = "You must set a (long) secret string"
             self.log.critical(error)
             raise ConfigurationError(error)
 
-        self.server = SimpleXMLRPCServer((config.xmlrpc_host, config.xmlrpc_port))
+        self.server = None
 
     def run(self):
         """Starts the server / thread"""
 
+        self.server = SimpleXMLRPCServer((self.config.xmlrpc_host, self.config.xmlrpc_port))
+
         # Register methods having an "export" attribute as XML RPC functions and
         # decorate them with a check for a shared (wiki-bot) secret.
         items = self.__class__.__dict__.items()
@@ -300,23 +334,28 @@
         """
         def protected_func(secret, *args):
             if secret != self.secret:
-                raise xmlrpclib.Fault(1, _("You are not allowed to use this bot!"))
+                raise xmlrpclib.Fault(1, "You are not allowed to use this bot!")
             else:
                 return function(self, *args)
 
         return protected_func
 
 
-    def send_notification(self, jids, text, subject):
+    def send_notification(self, jids, notification):
         """Instructs the XMPP component to send a notification
 
+        The notification dict has following entries:
+        'text' - notification text (REQUIRED)
+        'subject' - notification subject
+        'url_list' - a list of dicts describing attached URLs
+
         @param jids: a list of JIDs to send a message to (bare JIDs)
         @type jids: a list of str or unicode
-        @param text: a message body
-        @type text: unicode
+        @param notification: dictionary with notification data
+        @type notification: dict
 
         """
-        command = cmd.NotificationCommand(jids, text, subject)
+        command = cmd.NotificationCommand(jids, notification, async=True)
         self.commands.put_nowait(command)
         return True
     send_notification.export = True
--- a/jabberbot/xmppbot.py	Sun Aug 19 19:29:40 2007 +0200
+++ b/jabberbot/xmppbot.py	Sun Aug 19 19:51:56 2007 +0200
@@ -6,7 +6,7 @@
     @license: GNU GPL, see COPYING for details.
 """
 
-import logging, time, libxml2, Queue
+import logging, time, Queue
 from threading import Thread
 
 from pyxmpp.client import Client
@@ -16,9 +16,11 @@
 from pyxmpp.presence import Presence
 from pyxmpp.iq import Iq
 import pyxmpp.jabber.dataforms as forms
+import libxml2
 
 import jabberbot.commands as cmd
 import jabberbot.i18n as i18n
+import jabberbot.oob as oob
 
 
 class Contact:
@@ -33,7 +35,7 @@
 
     def __init__(self, jid, resource, priority, show, language=None):
         self.jid = jid
-        self.resources = {resource: {'show': show, 'priority': priority, 'forms': False}}
+        self.resources = {resource: {'show': show, 'priority': priority, 'supports': []}}
         self.language = language
 
         # The last time when this contact was seen online.
@@ -64,20 +66,47 @@
         @param priority: priority of the given resource
 
         """
-        self.resources[resource] = {'show': show, 'priority': priority}
+        self.resources[resource] = {'show': show, 'priority': priority, supports: []}
         self.last_online = None
 
-    def set_supports_forms(self, resource):
-        """Flag the given resource as supporting Data Forms"""
-        if resource in self.resources:
-            self.resources[resource]["forms"] = True
+    def set_supports(self, resource, extension):
+        """Flag a given resource as supporting a particular extension"""
+        self.resources[resource]['supports'].append(extension)
 
-    def supports_forms(self, resource):
-        """Check if the given resource supports Data Forms"""
-        if resource in self.resources:
-            return self.resources[resource]["forms"]
+    def supports(self, resource, extension):
+        """Check if a given resource supports a particular extension
+
+        If no resource is specified, check the resource with the highest
+        priority among currently connected.
+
+        """
+        if resource:
+            return extension in self.resources[resource]['supports']
         else:
-            return False
+            resource = self.max_prio_resource()
+            return resource and extension in resource['supports']
+
+    def max_prio_resource(self):
+        """Returns the resource (dict) with the highest priority
+
+        @return: highest priority resource or None if contacts is offline
+        @rtype: dict or None
+
+        """
+        if not self.resources:
+            return None
+
+        # Priority can't be lower than -128
+        max_prio = -129
+        selected = None
+
+        for resource in self.resources.itervalues():
+            # TODO: check RFC for behaviour of 2 resources with the same priority
+            if resource['priority'] > max_prio:
+                max_prio = resource['priority']
+                selected = resource
+
+        return selected
 
     def remove_resource(self, resource):
         """Removes information about a connected resource
@@ -99,18 +128,13 @@
         The contact is DND if its resource with the highest priority is DND
 
         """
-        # Priority can't be lower than -128
-        max_prio = -129
-        max_prio_show = u"dnd"
-
-        for resource in self.resources.itervalues():
-            # TODO: check RFC for behaviour of 2 resources with the same priority
-            if resource['priority'] > max_prio:
-                max_prio = resource['priority']
-                max_prio_show = resource['show']
+        max_prio_res = self.max_prio_resource()
 
         # If there are no resources the contact is offline, not dnd
-        return self.resources and max_prio_show == u'dnd'
+        if max_prio_res:
+            return max_prio_res['show'] == u"dnd"
+        else:
+            return False
 
     def set_show(self, resource, show):
         """Sets show property for a given resource
@@ -163,8 +187,9 @@
 
         # How often should the contacts be checked for expiration, in seconds
         self.contact_check = 600
+        self.stopping = False
 
-        self.known_xmlrpc_cmds = [cmd.GetPage, cmd.GetPageHTML, cmd.GetPageList, cmd.GetPageInfo, cmd.Search]
+        self.known_xmlrpc_cmds = [cmd.GetPage, cmd.GetPageHTML, cmd.GetPageList, cmd.GetPageInfo, cmd.Search, cmd.RevertPage]
         self.internal_commands = ["ping", "help", "searchform"]
 
         self.xmlrpc_commands = {}
@@ -180,10 +205,17 @@
         self.connect()
         self.loop()
 
+    def stop(self):
+        """Stop the thread"""
+        self.stopping = True
+
     def loop(self, timeout=1):
         """Main event loop - stream and command handling"""
 
         while True:
+            if self.stopping:
+                break
+
             stream = self.get_stream()
             if not stream:
                 break
@@ -245,6 +277,7 @@
         except Queue.Empty:
             return False
 
+    # XXX: refactor this, if-elif sequence is already too long
     def handle_command(self, command, ignore_dnd=False):
         """Excecutes commands from other components
 
@@ -255,22 +288,40 @@
         """
         # Handle normal notifications
         if isinstance(command, cmd.NotificationCommand):
+            cmd_data = command.notification
+
             for recipient in command.jids:
                 jid = JID(recipient)
                 jid_text = jid.bare().as_utf8()
-                text = command.text
+
+                text = cmd_data['text']
+                subject = cmd_data.get('subject', '')
+                msg_data = command.notification
+
+                if isinstance(command, cmd.NotificationCommandI18n):
+                    # Translate&interpolate the message with data
+                    gettext_func = self.get_text(jid_text)
+                    text, subject = command.translate(gettext_func)
+                    msg_data = {'text': text, 'subject': subject,
+                                'url_list': cmd_data.get('url_list', []),
+                                'action': cmd_data.get('action', '')}
 
                 # Check if contact is DoNotDisturb.
                 # If so, queue the message for delayed delivery.
-                try:
-                    contact = self.contacts[jid_text]
+                contact = self.contacts.get(jid_text, '')
+                if contact:
                     if command.async and contact.is_dnd() and not ignore_dnd:
                         contact.messages.append(command)
                         return
-                except KeyError:
-                    pass
 
-                self.send_message(jid, text)
+                    if contact.supports(jid.resource, u"jabber:x:data"):
+                        action = msg_data.get('action', '')
+                        if action == "page_changed":
+                            self.send_change_form(jid, msg_data)
+                        else:
+                            self.send_message(jid, msg_data, command.msg_type)
+                else:
+                    self.send_message(jid, msg_data, command.msg_type)
 
             return
 
@@ -288,30 +339,93 @@
         elif isinstance(command, cmd.GetPage) or isinstance(command, cmd.GetPageHTML):
             msg = _(u"""Here's the page "%(pagename)s" that you've requested:\n\n%(data)s""")
 
-            self.send_message(command.jid, msg % {
-                      'pagename': command.pagename,
-                      'data': command.data,
-            })
+            cmd_data = {'text': msg % {'pagename': command.pagename, 'data': command.data}}
+            self.send_message(command.jid, cmd_data)
 
         elif isinstance(command, cmd.GetPageList):
             msg = _("That's the list of pages accesible to you:\n\n%s")
             pagelist = u"\n".join(command.data)
 
-            self.send_message(command.jid, msg % (pagelist, ))
+            self.send_message(command.jid, {'text': msg % (pagelist, )})
 
         elif isinstance(command, cmd.GetPageInfo):
-            msg = _("""Following detailed information on page "%(pagename)s" \
-is available::\n\n%(data)s""")
+            intro = _("""Following detailed information on page "%(pagename)s" \
+is available:""")
 
-            self.send_message(command.jid, msg % {
-                      'pagename': command.pagename,
-                      'data': command.data,
-            })
+            if command.data['author'].startswith("Self:"):
+                author = command.data['author'][5:]
+            else:
+                author = command.data['author']
+
+            datestr = str(command.data['lastModified'])
+            date = u"%(year)s-%(month)s-%(day)s at %(time)s" % {
+                        'year': datestr[:4],
+                        'month': datestr[4:6],
+                        'day': datestr[6:8],
+                        'time': datestr[9:17],
+            }
+
+            msg = _("""Last author: %(author)s
+Last modification: %(modification)s
+Current version: %(version)s""") % {
+             'author': author,
+             'modification': date,
+             'version': command.data['version'],
+            }
+
+            self.send_message(command.jid, {'text': intro % {'pagename': command.pagename}})
+            self.send_message(command.jid, {'text': msg})
 
         elif isinstance(command, cmd.GetUserLanguage):
             if command.jid in self.contacts:
                 self.contacts[command.jid].language = command.language
 
+        elif isinstance(command, cmd.Search):
+            warnings = []
+            if not command.data:
+                warnings.append(_("There are no pages matching your search criteria!"))
+
+            # This hardcoded limitation relies on (mostly correct) assumption that Jabber
+            # servers have rather tight traffic limits. Sending more than 25 results is likely
+            # to take a second or two - users should not have to wait longer (+search time!).
+            elif len(command.data) > 25:
+                warnings.append(_("There are too many results (%(number)s). Limiting to first 25 entries.") % {'number': str(len(command.data))})
+                command.data = command.data[:25]
+
+            results = [{'description': result[0], 'url': result[2]} for result in command.data]
+
+            if command.presentation == u"text":
+                for warning in warnings:
+                    self.send_message(command.jid, {'text': warning})
+
+                if not results:
+                    return
+
+                data = {'text': _('Following pages match your search criteria:'), 'url_list': results}
+                self.send_message(command.jid, data, u"chat")
+            else:
+                form_title = _("Search results").encode("utf-8")
+
+                warnings = []
+                for no, warning in enumerate(warnings):
+                    field = forms.Field(name="warning", field_type="fixed", value=warning)
+                    warnings.append(forms.Item([field]))
+
+                reported = [forms.Field(name="url", field_type="text-single"), forms.Field(name="description", field_type="text-single")]
+                if warnings:
+                    reported.append(forms.Field(name="warning", field_type="fixed"))
+
+                form = forms.Form(xmlnode_or_type="result", title=form_title, reported_fields=reported)
+
+                for no, result in enumerate(results):
+                    url = forms.Field(name="url", value=result["url"], field_type="text-single")
+                    description = forms.Field(name="description", value=result["description"], field_type="text-single")
+                    item = forms.Item([url, description])
+                    form.add_item(item)
+
+                self.send_form(command.jid, form, _("Search results"))
+
+
     def ask_for_subscription(self, jid):
         """Sends a <presence/> stanza with type="subscribe"
 
@@ -335,20 +449,59 @@
         stanza = Presence(to_jid=jid, stanza_type="unsubscribed")
         self.get_stream().send(stanza)
 
-    def send_message(self, jid, text, subject="", msg_type=u"chat"):
+    def send_message(self, jid_text, data, msg_type=u"chat"):
         """Sends a message
 
-        @param jid: JID to send the message to
-        @param text: message's body:
-        @param type: message type, as defined in RFC
-        @type jid: pyxmpp.jid.JID
+        @param jid_text: JID to send the message to
+        @param data: dictionary containing notification data
+        @param msg_type: message type, as defined in RFC
+        @type jid_text: unicode
 
         """
-        message = Message(to_jid=jid, body=text, stanza_type=msg_type, subject=subject)
+        use_oob = False
+        subject = data.get('subject', '')
+        jid = JID(jid_text)
+
+        if data.has_key('url_list') and data['url_list']:
+            jid_bare = jid.bare().as_utf8()
+            contact = self.contacts.get(jid_bare, None)
+            if contact and contact.supports(jid.resource, u'jabber:x:oob'):
+                use_oob = True
+            else:
+                url_strings = ['%s - %s' % (entry['url'], entry['description']) for entry in data['url_list']]
+
+                # Insert a newline, so that the list of URLs doesn't start in the same
+                # line as the rest of message text
+                url_strings.insert(0, '\n')
+                data['text'] = data['text'] + '\n'.join(url_strings)
+
+        message = Message(to_jid=jid, body=data['text'], stanza_type=msg_type, subject=subject)
+
+        if use_oob:
+            oob.add_urls(message, data['url_list'])
+
         self.get_stream().send(message)
 
-    def send_form(self, jid, form):
-        pass
+    def send_form(self, jid, form, subject):
+        """Send a data form
+
+        @param jid: jid to send the form to (full)
+        @param form: the form to send
+        @param subject: subject of the message
+        @type jid: unicode
+        @type form: pyxmpp.jabber.dataforms.Form
+        @type subject: unicode
+
+        """
+        if not isinstance(form, forms.Form):
+            raise ValueError("The 'form' argument must be of type pyxmpp.jabber.dataforms.Form!")
+
+        _ = self.get_text(JID(jid).bare().as_unicode())
+
+        warning = _("If you see this, your client probably doesn't support Data Forms.")
+        message = Message(to_jid=jid, body=warning, subject=subject)
+        message.add_content(form)
+        self.get_stream().send(message)
 
     def send_search_form(self, jid):
         _ = self.get_text(jid)
@@ -361,18 +514,46 @@
         search_type2 = _("Full-text search")
         search_label = _("Search type")
         search_label2 = _("Search text")
-
+        case_label = _("Case-sensitive search")
+        regexp_label = _("Treat terms as regular expressions")
+        forms_warn = _("If you see this, your client probably doesn't support Data Forms.")
 
         title_search = forms.Option("t", search_type1)
         full_search = forms.Option("f", search_type2)
 
         form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=help_form)
+        form.add_field(name="action", field_type="hidden", value="search")
+        form.add_field(name="case", field_type="boolean", label=case_label)
+        form.add_field(name="regexp", field_type="boolean", label=regexp_label)
         form.add_field(name="search_type", options=[title_search, full_search], field_type="list-single", label=search_label)
         form.add_field(name="search", field_type="text-single", label=search_label2)
 
-        message = Message(to_jid=jid, body=_("Please specify the search criteria."), subject=_("Wiki search"))
-        message.add_content(form)
-        self.get_stream().send(message)
+        self.send_form(jid, form, _("Wiki search"))
+
+    def send_change_form(self, jid, msg_data):
+        """Sends a page change notification using Data Forms"""
+        _ = self.get_text(jid)
+
+        form_title = _("Page changed notification").encode("utf-8")
+        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
+        url_label = _("URL")
+        pagename_label = _("Page name")
+        action_label = _("What to do next")
+
+        action1 = _("Do nothing")
+        action2 = _("Revert change")
+        action3 = _("View page info")
+
+        do_nothing = forms.Option("n", action1)
+        revert = forms.Option("r", action2)
+        view_info = forms.Option("v", action3)
+
+        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
+        form.add_field(name="action", field_type="hidden", value="change_notify_action")
+        form.add_field(name="notification", field_type="fixed", value=msg_data['text'])
+        form.add_field(name="options", field_type="list-single", options=[do_nothing, revert, view_info], label=action_label)
+
+        self.send_form(jid, form, _("Page change notification"))
 
     def is_internal(self, command):
         """Check if a given command is internal
@@ -398,6 +579,90 @@
 
         return False
 
+    def contains_form(self, message):
+        """Checks if passed message stanza contains a submitted form and parses it
+
+        @param message: message stanza
+        @type message: pyxmpp.message.Message
+        @return: xml node with form data if found, or None
+
+        """
+        if not isinstance(message, Message):
+            raise ValueError("The 'message' parameter must be of type pyxmpp.message.Message!")
+
+        payload = message.get_node()
+        form = message.xpath_eval('/ns:message/data:x', {'data': 'jabber:x:data'})
+
+        if form:
+            return form[0]
+        else:
+            return None
+
+    def handle_form(self, jid, form_node):
+        """Handles a submitted data form
+
+        @param jid: jid that submitted the form (full jid)
+        @type jid: pyxmpp.jid.JID
+        @param form_node: a xml node with data form
+        @type form_node: libxml2.xmlNode
+
+        """
+        if not isinstance(form_node, libxml2.xmlNode):
+            raise ValueError("The 'form' parameter must be of type libxml2.xmlNode!")
+
+        if not isinstance(jid, JID):
+            raise ValueError("The 'jid' parameter must be of type jid!")
+
+        _ = self.get_text(jid.bare().as_unicode())
+
+        form = forms.Form(form_node)
+
+        if form.type != u"submit":
+            return
+
+        try:
+            action = form["action"].value
+        except KeyError:
+            data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
+            self.send_message(jid.as_unicode(), data, u"message")
+            return
+
+        if action == u"search":
+            self.handle_search_form(jid, form)
+        else:
+            data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
+            self.send_message(jid.as_unicode(), data, u"message")
+
+
+    def handle_search_form(self, jid, form):
+        """Handles a search form
+
+        @param jid: jid that submitted the form
+        @type jid: pyxmpp.jid.JID
+        @param form: a form object
+        @type form_node: pyxmpp.jabber.dataforms.Form
+
+        """
+        required_fields = ["case", "regexp", "search_type", "search"]
+        jid_text = jid.bare().as_unicode()
+        _ = self.get_text(jid_text)
+
+        for field in required_fields:
+            if field not in form:
+                data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
+                self.send_message(jid.as_unicode(), data, u"message")
+
+        case_sensitive = form['case'].value
+        regexp_terms = form['regexp'].value
+        if form['search_type'].value == 't':
+            search_type = 'title'
+        else:
+            search_type = 'text'
+
+        command = cmd.Search(jid.as_unicode(), search_type, form["search"].value, case=form['case'].value,
+                             regexp=form['regexp'].value, presentation='dataforms')
+        self.from_commands.put_nowait(command)
+
     def handle_message(self, message):
         """Handles incoming messages
 
@@ -409,6 +674,11 @@
             msg = "Message from %s." % (message.get_from_jid().as_utf8(), )
             self.log.debug(msg)
 
+        form = self.contains_form(message)
+        if form:
+            self.handle_form(message.get_from_jid(), form)
+            return
+
         text = message.get_body()
         sender = message.get_from_jid()
         if text:
@@ -425,7 +695,7 @@
             response = self.reply_help(sender)
 
         if response:
-            self.send_message(sender, response)
+            self.send_message(sender, {'text': response})
 
     def handle_internal_command(self, sender, command):
         """Handles internal commands, that can be completed by the XMPP bot itself
@@ -447,16 +717,19 @@
         elif command[0] == "searchform":
             jid = sender.bare().as_utf8()
             resource = sender.resource
-            if self.contacts[jid].supports_forms(resource):
+
+            # Assume that outsiders know what they are doing. Clients that don't support
+            # data forms should display a warning passed in message <body>.
+            if jid not in self.contacts or self.contacts[jid].supports(resource, u"jabber:x:data"):
                 self.send_search_form(sender)
             else:
-                msg = _("This command requires a client supporting Data Forms")
-                self.send_message(sender, msg, u"Error")
+                msg = {'text': _("This command requires a client supporting Data Forms.")}
+                self.send_message(sender, msg, u"")
         else:
             # For unknown command return a generic help message
             return self.reply_help(sender)
 
-    def do_search(self, jid, term, search_type):
+    def do_search(self, jid, search_type, presentation, *args):
         """Performs a Wiki search of term
 
         @param jid: Jabber ID of user performing a search
@@ -465,9 +738,11 @@
         @type term: unicode
         @param search_type: type of search; either "text" or "title"
         @type search_type: unicode
+        @param presentation: how to present the results; "text" or "dataforms"
+        @type presentation: unicode
 
         """
-        search = cmd.Search(jid, term, search_type)
+        search = cmd.Search(jid, search_type, presentation=presentation, *args)
         self.from_commands.put_nowait(search)
 
     def help_on(self, jid, command):
@@ -608,7 +883,8 @@
             # Unknown resource, add it to the list
             else:
                 contact.add_resource(jid.resource, show, priority)
-                self.supports_dataforms(jid)
+                # Discover capabilities of the newly connected client
+                contact.service_discovery(jid)
 
             if self.config.verbose:
                 self.log.debug(contact)
@@ -619,7 +895,7 @@
 
         else:
             self.contacts[bare_jid] = Contact(jid, jid.resource, priority, show)
-            self.supports_dataforms(jid)
+            self.service_discovery(jid)
             self.get_user_language(bare_jid)
             self.log.debug(self.contacts[bare_jid])
 
@@ -635,8 +911,8 @@
         request = cmd.GetUserLanguage(jid)
         self.from_commands.put_nowait(request)
 
-    def supports_dataforms(self, jid):
-        """Check if a clients supports data forms.
+    def service_discovery(self, jid):
+        """Ask a client about supported features
 
         This is not the recommended way of discovering support
         for data forms, but it's easy to implement, so it'll be
@@ -652,6 +928,7 @@
         self.get_stream().set_response_handlers(query, self.handle_disco_result, None)
         self.get_stream().send(query)
 
+
     def handle_disco_result(self, stanza):
         """Handler for <iq> service discovery results
 
@@ -660,10 +937,16 @@
         @param stanza: a received result stanza
         """
         payload = stanza.get_query()
+
         supports = payload.xpathEval('//*[@var="jabber:x:data"]')
         if supports:
             jid = stanza.get_from_jid()
-            self.contacts[jid.bare().as_utf8()].set_supports_forms(jid.resource)
+            self.contacts[jid.bare().as_utf8()].set_supports(jid.resource, u"jabber:x:data")
+
+        supports = payload.xpathEval('//*[@var="jabber:x:oob"]')
+        if supports:
+            jid = stanza.get_from_jid()
+            self.contacts[jid.bare().as_utf8()].set_supports(jid.resource, u"jabber:x:oob")
 
 
     def send_queued_messages(self, contact, ignore_dnd=False):