Mercurial > moin > 1.9
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] + # Dummy function, so that the string appears in a .po file + _ = lambda x: x - try: - try: - self.get_auth_token(command.jid) - if self.token: - self.multicall.applyAuthToken(self.token) + 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) + + def _get_multicall_result(self, jid): + """Returns multicall results and issues a warning if there's an auth error + + @param jid: a full JID to use if there's an error + @type jid: str - 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) + """ - finally: - del self.token - del self.multicall + if not self.token: + result = self.multicall()[0] + token_result = u"FAILURE" + else: + token_result, result = self.multicall() - return wrapped_func + if token_result != u"SUCCESS": + self.warn_no_credentials(jid) - 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) + 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(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: + resource = self.max_prio_resource() + return resource and extension in resource['supports'] - def supports_forms(self, resource): - """Check if the given resource supports Data Forms""" - if resource in self.resources: - return self.resources[resource]["forms"] - else: - return False + 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:""") + + if command.data['author'].startswith("Self:"): + author = command.data['author'][5:] + else: + author = command.data['author'] - self.send_message(command.jid, msg % { - 'pagename': command.pagename, - 'data': command.data, - }) + 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):