~ubuntu-branches/ubuntu/natty/moin/natty-updates

« back to all changes in this revision

Viewing changes to jabberbot/xmppbot.py

  • Committer: Bazaar Package Importer
  • Author(s): Jonas Smedegaard
  • Date: 2008-06-22 21:17:13 UTC
  • mfrom: (0.9.1 upstream)
  • Revision ID: james.westby@ubuntu.com-20080622211713-fpo2zrq3s5dfecxg
Tags: 1.7.0-3
Simplify /etc/moin/wikilist format: "USER URL" (drop unneeded middle
CONFIG_DIR that was wrongly advertised as DATA_DIR).  Make
moin-mass-migrate handle both formats and warn about deprecation of
the old one.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# -*- coding: iso-8859-1 -*-
 
2
"""
 
3
    MoinMoin - jabber bot
 
4
 
 
5
    @copyright: 2007 by Karol Nowak <grywacz@gmail.com>
 
6
    @license: GNU GPL, see COPYING for details.
 
7
"""
 
8
 
 
9
import logging, time, Queue
 
10
from threading import Thread
 
11
from datetime import timedelta
 
12
 
 
13
from pyxmpp.cache import Cache
 
14
from pyxmpp.cache import CacheItem
 
15
from pyxmpp.client import Client
 
16
from pyxmpp.jid import JID
 
17
from pyxmpp.streamtls import TLSSettings
 
18
from pyxmpp.message import Message
 
19
from pyxmpp.presence import Presence
 
20
from pyxmpp.iq import Iq
 
21
import pyxmpp.jabber.dataforms as forms
 
22
import libxml2
 
23
 
 
24
import jabberbot.commands as cmd
 
25
import jabberbot.i18n as i18n
 
26
import jabberbot.oob as oob
 
27
import jabberbot.capat as capat
 
28
 
 
29
 
 
30
class Contact:
 
31
    """Abstraction of a roster item / contact
 
32
 
 
33
    This class handles some logic related to keeping track of
 
34
    contact availability, status, etc."""
 
35
 
 
36
    # Default Time To Live of a contact. If there are no registered
 
37
    # resources for that period of time, the contact should be removed
 
38
    default_ttl = 3600 * 24 # default of one day
 
39
 
 
40
    def __init__(self, jid, resource, priority, show, language=None):
 
41
        self.jid = jid
 
42
        self.resources = {resource: {'show': show, 'priority': priority, 'supports': []}}
 
43
        self.language = language
 
44
 
 
45
        # The last time when this contact was seen online.
 
46
        # This value has meaning for offline contacts only.
 
47
        self.last_online = None
 
48
 
 
49
        # Queued messages, waiting for contact to change its "show"
 
50
        # status to something different than "dnd". The messages should
 
51
        # also be sent when contact becomes "unavailable" directly from
 
52
        # "dnd", as we can't guarantee, that the bot will be up and running
 
53
        # the next time she becomes "available".
 
54
        self.messages = []
 
55
 
 
56
    def is_valid(self, current_time):
 
57
        """Check if this contact entry is still valid and should be kept
 
58
 
 
59
        @param time: current time in seconds
 
60
 
 
61
        """
 
62
        # No resources == offline
 
63
        return self.resources or current_time < self.last_online + self.default_ttl
 
64
 
 
65
    def add_resource(self, resource, show, priority):
 
66
        """Adds information about a connected resource
 
67
 
 
68
        @param resource: resource name
 
69
        @param show: a show presence property, as defined in XMPP
 
70
        @param priority: priority of the given resource
 
71
 
 
72
        """
 
73
        self.resources[resource] = {'show': show, 'priority': priority, 'supports': []}
 
74
        self.last_online = None
 
75
 
 
76
    def set_supports(self, resource, extension):
 
77
        """Flag a given resource as supporting a particular extension"""
 
78
        self.resources[resource]['supports'].append(extension)
 
79
 
 
80
    def supports(self, resource, extension):
 
81
        """Check if a given resource supports a particular extension
 
82
 
 
83
        If no resource is specified, check the resource with the highest
 
84
        priority among currently connected.
 
85
 
 
86
        """
 
87
        if resource and resource in self.resources:
 
88
            return extension in self.resources[resource]['supports']
 
89
        else:
 
90
            resource = self.max_prio_resource()
 
91
            return resource and extension in resource['supports']
 
92
 
 
93
    def max_prio_resource(self):
 
94
        """Returns the resource (dict) with the highest priority
 
95
 
 
96
        @return: highest priority resource or None if contacts is offline
 
97
        @rtype: dict or None
 
98
 
 
99
        """
 
100
        if not self.resources:
 
101
            return None
 
102
 
 
103
        # Priority can't be lower than -128
 
104
        max_prio = -129
 
105
        selected = None
 
106
 
 
107
        for resource in self.resources.itervalues():
 
108
            # TODO: check RFC for behaviour of 2 resources with the same priority
 
109
            if resource['priority'] > max_prio:
 
110
                max_prio = resource['priority']
 
111
                selected = resource
 
112
 
 
113
        return selected
 
114
 
 
115
    def remove_resource(self, resource):
 
116
        """Removes information about a connected resource
 
117
 
 
118
        @param resource: resource name
 
119
 
 
120
        """
 
121
        if self.resources.has_key(resource):
 
122
            del self.resources[resource]
 
123
        else:
 
124
            raise ValueError("No such resource!")
 
125
 
 
126
        if not self.resources:
 
127
            self.last_online = time.time()
 
128
 
 
129
    def is_dnd(self):
 
130
        """Checks if contact is DoNotDisturb
 
131
 
 
132
        The contact is DND if its resource with the highest priority is DND
 
133
 
 
134
        """
 
135
        max_prio_res = self.max_prio_resource()
 
136
 
 
137
        # If there are no resources the contact is offline, not dnd
 
138
        if max_prio_res:
 
139
            return max_prio_res['show'] == u"dnd"
 
140
        else:
 
141
            return False
 
142
 
 
143
    def set_show(self, resource, show):
 
144
        """Sets show property for a given resource
 
145
 
 
146
        @param resource: resource to alter
 
147
        @param show: new value of the show property
 
148
        @raise ValueError: no resource with given name has been found
 
149
 
 
150
        """
 
151
        if self.resources.has_key(resource):
 
152
            self.resources[resource]['show'] = show
 
153
        else:
 
154
            raise ValueError("There's no such resource")
 
155
 
 
156
    def uses_resource(self, resource):
 
157
        """Checks if contact uses a given resource"""
 
158
        return self.resources.has_key(resource)
 
159
 
 
160
    def __str__(self):
 
161
        retval = "%s (%s) has %d queued messages"
 
162
        res = ", ".join([name + " is " + res['show'] for name, res in self.resources.items()])
 
163
        return retval % (self.jid.as_unicode(), res, len(self.messages))
 
164
 
 
165
 
 
166
class XMPPBot(Client, Thread):
 
167
    """A simple XMPP bot"""
 
168
 
 
169
    def __init__(self, config, from_commands, to_commands):
 
170
        """A constructor
 
171
 
 
172
        @param from_commands: a Queue object used to send commands to other (xmlrpc) threads
 
173
        @param to_commands: a Queue object used to receive commands from other threads
 
174
 
 
175
        """
 
176
        Thread.__init__(self)
 
177
 
 
178
        self.from_commands = from_commands
 
179
        self.to_commands = to_commands
 
180
        jid = u"%s@%s/%s" % (config.xmpp_node, config.xmpp_server, config.xmpp_resource)
 
181
 
 
182
        self.config = config
 
183
        self.log = logging.getLogger(__name__)
 
184
        self.jid = JID(node_or_jid=jid, domain=config.xmpp_server, resource=config.xmpp_resource)
 
185
        self.tlsconfig = TLSSettings(require = True, verify_peer=False)
 
186
 
 
187
        # A dictionary of contact objects, ordered by bare JID
 
188
        self.contacts = {}
 
189
 
 
190
        # The last time when contacts were checked for expiration, in seconds
 
191
        self.last_expiration = time.time()
 
192
 
 
193
        # How often should the contacts be checked for expiration, in seconds
 
194
        self.contact_check = 600
 
195
        self.stopping = False
 
196
 
 
197
        self.known_xmlrpc_cmds = [cmd.GetPage, cmd.GetPageHTML, cmd.GetPageList, cmd.GetPageInfo, cmd.Search, cmd.RevertPage]
 
198
        self.internal_commands = ["ping", "help", "searchform"]
 
199
 
 
200
        self.xmlrpc_commands = {}
 
201
        for command, name in [(command, command.__name__) for command in self.known_xmlrpc_cmds]:
 
202
            self.xmlrpc_commands[name.lower()] = command
 
203
 
 
204
        Client.__init__(self, self.jid, config.xmpp_password, config.xmpp_server, tls_settings=self.tlsconfig)
 
205
 
 
206
        # Setup message handlers
 
207
 
 
208
        self._msg_handlers = {cmd.NotificationCommand: self._handle_notification,
 
209
                              cmd.NotificationCommandI18n: self._handle_notification,
 
210
                              cmd.AddJIDToRosterCommand: self._handle_add_contact,
 
211
                              cmd.RemoveJIDFromRosterCommand: self._handle_remove_contact,
 
212
                              cmd.GetPage: self._handle_get_page,
 
213
                              cmd.GetPageHTML: self._handle_get_page,
 
214
                              cmd.GetPageList: self._handle_get_page_list,
 
215
                              cmd.GetPageInfo: self._handle_get_page_info,
 
216
                              cmd.GetUserLanguage: self._handle_get_language,
 
217
                              cmd.Search: self._handle_search}
 
218
 
 
219
        # cache for service discovery results ( (ver, algo) : Capabilities = libxml2.xmlNode)
 
220
        self.disco_cache = Cache(max_items=config.disco_cache_size, default_purge_period=0)
 
221
 
 
222
        # dictionary of jids waiting for service discovery results
 
223
        # ( (ver, algo) : (timeout=datetime.timedelta, [list_of_jids=pyxmpp.jid]) )
 
224
        self.disco_wait = {}
 
225
 
 
226
        # temporary dictionary ( pyxmpp.jid:  (ver, algo) )
 
227
        self.disco_temp = {}
 
228
 
 
229
    def run(self):
 
230
        """Start the bot - enter the event loop"""
 
231
 
 
232
        self.log.info("Starting the jabber bot.")
 
233
        self.connect()
 
234
        self.loop()
 
235
 
 
236
    def stop(self):
 
237
        """Stop the thread"""
 
238
        self.stopping = True
 
239
 
 
240
    def loop(self, timeout=1):
 
241
        """Main event loop - stream and command handling"""
 
242
 
 
243
        while True:
 
244
            if self.stopping:
 
245
                break
 
246
 
 
247
            stream = self.get_stream()
 
248
            if not stream:
 
249
                break
 
250
 
 
251
            act = stream.loop_iter(timeout)
 
252
            if not act:
 
253
                # Process all available commands
 
254
                while self.poll_commands(): pass
 
255
                self.idle()
 
256
 
 
257
    def idle(self):
 
258
        """Do some maintenance"""
 
259
 
 
260
        Client.idle(self)
 
261
 
 
262
        current_time = time.time()
 
263
        if self.last_expiration + self.contact_check < current_time:
 
264
            self.expire_contacts(current_time)
 
265
            self.last_expiration = current_time
 
266
 
 
267
        self.disco_cache.tick()
 
268
        self.check_disco_delays()
 
269
 
 
270
    def session_started(self):
 
271
        """Handle session started event.
 
272
        Requests the user's roster and sends the initial presence with
 
273
        a <c> child as described in XEP-0115 (Entity Capabilities)
 
274
 
 
275
        """
 
276
        self.request_roster()
 
277
        pres = capat.create_presence(self.jid)
 
278
        self.stream.set_iq_get_handler("query", "http://jabber.org/protocol/disco#info", self.handle_disco_query)
 
279
        self.stream.send(pres)
 
280
 
 
281
    def expire_contacts(self, current_time):
 
282
        """Check which contats have been offline for too long and should be removed
 
283
 
 
284
        @param current_time: current time in seconds
 
285
 
 
286
        """
 
287
        for jid, contact in self.contacts.items():
 
288
            if not contact.is_valid(current_time):
 
289
                del self.contacts[jid]
 
290
 
 
291
    def get_text(self, jid):
 
292
        """Returns a gettext function (_) for the given JID
 
293
 
 
294
        @param jid: bare Jabber ID of the user we're going to communicate with
 
295
        @type jid: str or pyxmpp.jid.JID
 
296
 
 
297
        """
 
298
        language = "en"
 
299
        if isinstance(jid, str) or isinstance(jid, unicode):
 
300
            jid = JID(jid).bare().as_unicode()
 
301
        else:
 
302
            jid = jid.bare().as_unicode()
 
303
 
 
304
        if jid in self.contacts:
 
305
            language = self.contacts[jid].language
 
306
 
 
307
        return lambda text: i18n.get_text(text, lang=language)
 
308
 
 
309
    def poll_commands(self):
 
310
        """Checks for new commands in the input queue and executes them
 
311
 
 
312
        @return: True if any command has been executed, False otherwise.
 
313
 
 
314
        """
 
315
        try:
 
316
            command = self.to_commands.get_nowait()
 
317
            self.handle_command(command)
 
318
            return True
 
319
        except Queue.Empty:
 
320
            return False
 
321
 
 
322
    def handle_command(self, command, ignore_dnd=False):
 
323
        """Excecutes commands from other components
 
324
 
 
325
        @param command: a command to execute
 
326
        @type command: any class defined in commands.py (FIXME?)
 
327
        @param ignore_dnd: if command results in user interaction, should DnD be ignored?
 
328
 
 
329
        """
 
330
 
 
331
        cmd_cls = command.__class__
 
332
 
 
333
        try:
 
334
            handler = self._msg_handlers[cmd_cls]
 
335
        except KeyError:
 
336
            self.log.debug("No such command: " + cmd_cls.__name__)
 
337
            return
 
338
 
 
339
        # NOTE: handler is a method, so it takes self as a hidden arg
 
340
        handler(command, ignore_dnd)
 
341
 
 
342
    def handle_changed_action(self, cmd_data, jid, contact):
 
343
        """Handles a notification command with 'page_changed' action
 
344
 
 
345
        @param cmd_data: notification command data
 
346
        @param jid: jid to send the notification to
 
347
        @param contact: a roster contact
 
348
        @type cmd_data: dict
 
349
        @type jid: pyxmpp.jid.JID
 
350
        @type contact: Contact
 
351
 
 
352
        """
 
353
        if contact and contact.supports(jid.resource, u"jabber:x:data"):
 
354
            self.send_change_form(jid.as_unicode(), cmd_data)
 
355
            return
 
356
        else:
 
357
            self.send_change_text(jid.as_unicode(), cmd_data)
 
358
 
 
359
    def handle_deleted_action(self, cmd_data, jid, contact):
 
360
        """Handles a notification cmd_data with 'page_deleted' action
 
361
 
 
362
        @param cmd_data: notification cmd_data
 
363
        @param jid: jid to send the notification to
 
364
        @param contact: a roster contact
 
365
        @type cmd_data: dict
 
366
        @type jid: pyxmpp.jid.JID
 
367
        @type contact: Contact
 
368
 
 
369
        """
 
370
        if contact and contact.supports(jid.resource, u"jabber:x:data"):
 
371
            self.send_deleted_form(jid.as_unicode(), cmd_data)
 
372
            return
 
373
        else:
 
374
            self.send_deleted_text(jid.as_unicode(), cmd_data)
 
375
 
 
376
    def handle_attached_action(self, cmd_data, jid, contact):
 
377
        """Handles a notification cmd_data with 'file_attached' action
 
378
 
 
379
        @param cmd_data: notification cmd_data
 
380
        @param jid: jid to send the notification to
 
381
        @param contact: a roster contact
 
382
        @type cmd_data: dict
 
383
        @type jid: pyxmpp.jid.JID
 
384
        @type contact: Contact
 
385
 
 
386
        """
 
387
        if contact and contact.supports(jid.resource, u"jabber:x:data"):
 
388
            self.send_attached_form(jid.as_unicode(), cmd_data)
 
389
            return
 
390
        else:
 
391
            self.send_attached_text(jid.as_unicode(), cmd_data)
 
392
 
 
393
    def handle_renamed_action(self, cmd_data, jid, contact):
 
394
        """Handles a notification cmd_data with 'page_renamed' action
 
395
 
 
396
        @param cmd_data: notification cmd_data
 
397
        @param jid: jid to send the notification to
 
398
        @param contact: a roster contact
 
399
        @type cmd_data: dict
 
400
        @type jid: pyxmpp.jid.JID
 
401
        @type contact: Contact
 
402
 
 
403
        """
 
404
        if contact and contact.supports(jid.resource, u"jabber:x:data"):
 
405
            self.send_renamed_form(jid.as_unicode(), cmd_data)
 
406
            return
 
407
        else:
 
408
            self.send_renamed_text(jid.as_unicode(), cmd_data)
 
409
 
 
410
    def handle_user_created_action(self, cmd_data, jid, contact):
 
411
        """Handles a notification cmd_data with 'user_created' action
 
412
 
 
413
        @param cmd_data: notification cmd_data
 
414
        @param jid: jid to send the notification to
 
415
        @param contact: a roster contact
 
416
        @type cmd_data: dict
 
417
        @type jid: pyxmpp.jid.JID
 
418
        @type contact: Contact
 
419
 
 
420
        """
 
421
        pass
 
422
 
 
423
    def ask_for_subscription(self, jid):
 
424
        """Sends a <presence/> stanza with type="subscribe"
 
425
 
 
426
        Bot tries to subscribe to every contact's presence, so that
 
427
        it can honor special cases, like DoNotDisturb setting.
 
428
 
 
429
        @param jid: Jabber ID of entity we're subscribing to
 
430
        @type jid: pyxmpp.jid.JID
 
431
 
 
432
        """
 
433
        stanza = Presence(to_jid=jid, stanza_type="subscribe")
 
434
        self.get_stream().send(stanza)
 
435
 
 
436
    def remove_subscription(self, jid):
 
437
        """Sends a <presence/> stanza with type="unsubscribed
 
438
 
 
439
        @param jid: Jabber ID of entity whose subscription we cancel
 
440
        @type jid: JID
 
441
 
 
442
        """
 
443
        stanza = Presence(to_jid=jid, stanza_type="unsubscribed")
 
444
        self.get_stream().send(stanza)
 
445
 
 
446
    def send_message(self, jid_text, data, msg_type=u"chat"):
 
447
        """Sends a message
 
448
 
 
449
        @param jid_text: JID to send the message to
 
450
        @param data: dictionary containing notification data
 
451
        @param msg_type: message type, as defined in RFC
 
452
        @type jid_text: unicode
 
453
 
 
454
        """
 
455
        use_oob = False
 
456
        subject = data.get('subject', '')
 
457
        jid = JID(jid_text)
 
458
 
 
459
        if data.has_key('url_list') and data['url_list']:
 
460
            jid_bare = jid.bare().as_unicode()
 
461
            contact = self.contacts.get(jid_bare, None)
 
462
            if contact and contact.supports(jid.resource, u'jabber:x:oob'):
 
463
                use_oob = True
 
464
            else:
 
465
                url_strings = ['%s - %s' % (entry['url'], entry['description']) for entry in data['url_list']]
 
466
 
 
467
                # Insert a newline, so that the list of URLs doesn't start in the same
 
468
                # line as the rest of message text
 
469
                url_strings.insert(0, '\n')
 
470
                data['text'] = data['text'] + '\n'.join(url_strings)
 
471
 
 
472
        message = Message(to_jid=jid, body=data['text'], stanza_type=msg_type, subject=subject)
 
473
 
 
474
        if use_oob:
 
475
            oob.add_urls(message, data['url_list'])
 
476
 
 
477
        self.get_stream().send(message)
 
478
 
 
479
    def send_form(self, jid, form, subject, url_list=[]):
 
480
        """Send a data form
 
481
 
 
482
        @param jid: jid to send the form to (full)
 
483
        @param form: the form to send
 
484
        @param subject: subject of the message
 
485
        @param url_list: list of urls to use with OOB
 
486
        @type jid: unicode
 
487
        @type form: pyxmpp.jabber.dataforms.Form
 
488
        @type subject: unicode
 
489
        @type url_list: list
 
490
 
 
491
        """
 
492
        if not isinstance(form, forms.Form):
 
493
            raise ValueError("The 'form' argument must be of type pyxmpp.jabber.dataforms.Form!")
 
494
 
 
495
        _ = self.get_text(JID(jid).bare().as_unicode())
 
496
 
 
497
        message = Message(to_jid=jid, subject=subject)
 
498
        message.add_content(form)
 
499
 
 
500
        if url_list:
 
501
            oob.add_urls(message, url_list)
 
502
 
 
503
        self.get_stream().send(message)
 
504
 
 
505
    def send_search_form(self, jid):
 
506
        _ = self.get_text(jid)
 
507
 
 
508
        # These encode()s may look weird, but due to some pyxmpp oddness we have
 
509
        # to provide an utf-8 string instead of unicode. Bug reported, patches submitted...
 
510
        form_title = _("Wiki search").encode("utf-8")
 
511
        help_form = _("Submit this form to perform a wiki search").encode("utf-8")
 
512
        search_type1 = _("Title search")
 
513
        search_type2 = _("Full-text search")
 
514
        search_label = _("Search type")
 
515
        search_label2 = _("Search text")
 
516
        case_label = _("Case-sensitive search")
 
517
        regexp_label = _("Treat terms as regular expressions")
 
518
        forms_warn = _("If you see this, your client probably doesn't support Data Forms.")
 
519
 
 
520
        title_search = forms.Option("t", search_type1)
 
521
        full_search = forms.Option("f", search_type2)
 
522
 
 
523
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=help_form)
 
524
        form.add_field(name="action", field_type="hidden", value="search")
 
525
        form.add_field(name="case", field_type="boolean", label=case_label)
 
526
        form.add_field(name="regexp", field_type="boolean", label=regexp_label)
 
527
        form.add_field(name="search_type", options=[title_search, full_search], field_type="list-single", label=search_label)
 
528
        form.add_field(name="search", field_type="text-single", label=search_label2)
 
529
 
 
530
        self.send_form(jid, form, _("Wiki search"))
 
531
 
 
532
    def send_change_form(self, jid, msg_data):
 
533
        """Sends a page change notification using Data Forms
 
534
 
 
535
        @param jid: a Jabber ID to send the notification to
 
536
        @type jid: unicode
 
537
        @param msg_data: dictionary with notification data
 
538
        @type msg_data: dict
 
539
 
 
540
        """
 
541
        _ = self.get_text(jid)
 
542
 
 
543
        form_title = _("Page changed notification").encode("utf-8")
 
544
        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
 
545
        action_label = _("What to do next")
 
546
 
 
547
        action1 = _("Do nothing")
 
548
        action2 = _("Revert change")
 
549
        action3 = _("View page info")
 
550
        action4 = _("Perform a search")
 
551
 
 
552
        do_nothing = forms.Option("n", action1)
 
553
        revert = forms.Option("r", action2)
 
554
        view_info = forms.Option("v", action3)
 
555
        search = forms.Option("s", action4)
 
556
 
 
557
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
 
558
        form.add_field(name='revision', field_type='hidden', value=msg_data['revision'])
 
559
        form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
 
560
        form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
 
561
        form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
 
562
 
 
563
        # Add lines of text as separate values, as recommended in XEP
 
564
        diff_lines = msg_data['diff'].split('\n')
 
565
        form.add_field(name="diff", field_type="text-multi", values=diff_lines, label=("Diff"))
 
566
 
 
567
        full_jid = JID(jid)
 
568
        bare_jid = full_jid.bare().as_unicode()
 
569
        resource = full_jid.resource
 
570
 
 
571
        # Add URLs as OOB data if it's supported and as separate fields otherwise
 
572
        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
 
573
            url_list = msg_data['url_list']
 
574
        else:
 
575
            url_list = []
 
576
 
 
577
            for number, url in enumerate(msg_data['url_list']):
 
578
                field_name = "url%d" % (number, )
 
579
                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
 
580
 
 
581
        # Selection of a following action
 
582
        form.add_field(name="options", field_type="list-single", options=[do_nothing, revert, view_info, search], label=action_label)
 
583
 
 
584
        self.send_form(jid, form, _("Page change notification"), url_list)
 
585
 
 
586
    def send_change_text(self, jid, msg_data):
 
587
        """Sends a simple, text page change notification
 
588
 
 
589
        @param jid: a Jabber ID to send the notification to
 
590
        @type jid: unicode
 
591
        @param msg_data: dictionary with notification data
 
592
        @type msg_data: dict
 
593
 
 
594
        """
 
595
        _ = self.get_text(jid)
 
596
        separator = '-' * 78
 
597
        urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
 
598
        message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(diff)s\n%(separator)s\n%(links)s") % {
 
599
                    'preamble': msg_data['text'],
 
600
                    'separator': separator,
 
601
                    'diff': msg_data['diff'],
 
602
                    'comment': msg_data.get('comment', _('no comment')),
 
603
                    'links': urls_text,
 
604
                  }
 
605
 
 
606
        data = {'text': message, 'subject': msg_data.get('subject', '')}
 
607
        self.send_message(jid, data, u"message")
 
608
 
 
609
    def send_deleted_form(self, jid, msg_data):
 
610
        """Sends a page deleted notification using Data Forms
 
611
 
 
612
        @param jid: a Jabber ID to send the notification to
 
613
        @type jid: unicode
 
614
        @param msg_data: dictionary with notification data
 
615
        @type msg_data: dict
 
616
 
 
617
        """
 
618
        _ = self.get_text(jid)
 
619
 
 
620
        form_title = _("Page deletion notification").encode("utf-8")
 
621
        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
 
622
        action_label = _("What to do next")
 
623
 
 
624
        action1 = _("Do nothing")
 
625
        action2 = _("Perform a search")
 
626
 
 
627
        do_nothing = forms.Option("n", action1)
 
628
        search = forms.Option("s", action2)
 
629
 
 
630
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
 
631
        form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
 
632
        form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
 
633
 
 
634
        full_jid = JID(jid)
 
635
        bare_jid = full_jid.bare().as_unicode()
 
636
        resource = full_jid.resource
 
637
 
 
638
        # Add URLs as OOB data if it's supported and as separate fields otherwise
 
639
        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
 
640
            url_list = msg_data['url_list']
 
641
        else:
 
642
            url_list = []
 
643
 
 
644
            for number, url in enumerate(msg_data['url_list']):
 
645
                field_name = "url%d" % (number, )
 
646
                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
 
647
 
 
648
        # Selection of a following action
 
649
        form.add_field(name="options", field_type="list-single", options=[do_nothing, search], label=action_label)
 
650
 
 
651
        self.send_form(jid, form, _("Page deletion notification"), url_list)
 
652
 
 
653
    def send_deleted_text(self, jid, msg_data):
 
654
        """Sends a simple, text page deletion notification
 
655
 
 
656
        @param jid: a Jabber ID to send the notification to
 
657
        @type jid: unicode
 
658
        @param msg_data: dictionary with notification data
 
659
        @type msg_data: dict
 
660
 
 
661
        """
 
662
        _ = self.get_text(jid)
 
663
        separator = '-' * 78
 
664
        urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
 
665
        message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(links)s") % {
 
666
                    'preamble': msg_data['text'],
 
667
                    'separator': separator,
 
668
                    'comment': msg_data.get('comment', _('no comment')),
 
669
                    'links': urls_text,
 
670
                  }
 
671
 
 
672
        data = {'text': message, 'subject': msg_data.get('subject', '')}
 
673
        self.send_message(jid, data, u"message")
 
674
 
 
675
    def send_attached_form(self, jid, msg_data):
 
676
        """Sends a new attachment notification using Data Forms
 
677
 
 
678
        @param jid: a Jabber ID to send the notification to
 
679
        @type jid: unicode
 
680
        @param msg_data: dictionary with notification data
 
681
        @type msg_data: dict
 
682
 
 
683
        """
 
684
        _ = self.get_text(jid)
 
685
 
 
686
        form_title = _("File attached notification").encode("utf-8")
 
687
        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
 
688
        action_label = _("What to do next")
 
689
 
 
690
        action1 = _("Do nothing")
 
691
        action2 = _("View page info")
 
692
        action3 = _("Perform a search")
 
693
 
 
694
        do_nothing = forms.Option("n", action1)
 
695
        view_info = forms.Option("v", action2)
 
696
        search = forms.Option("s", action3)
 
697
 
 
698
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
 
699
        form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
 
700
        form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
 
701
        form.add_field(name='page', field_type='text-single', value=msg_data['page_name'], label=_("Page name"))
 
702
        form.add_field(name='name', field_type='text-single', value=msg_data['attach_name'], label=_("File name"))
 
703
        form.add_field(name='size', field_type='text-single', value=msg_data['attach_size'], label=_("File size"))
 
704
 
 
705
        full_jid = JID(jid)
 
706
        bare_jid = full_jid.bare().as_unicode()
 
707
        resource = full_jid.resource
 
708
 
 
709
        # Add URLs as OOB data if it's supported and as separate fields otherwise
 
710
        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
 
711
            url_list = msg_data['url_list']
 
712
        else:
 
713
            url_list = []
 
714
 
 
715
            for number, url in enumerate(msg_data['url_list']):
 
716
                field_name = "url%d" % (number, )
 
717
                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
 
718
 
 
719
        # Selection of a following action
 
720
        form.add_field(name="options", field_type="list-single", options=[do_nothing, view_info, search], label=action_label)
 
721
 
 
722
        self.send_form(jid, form, _("File attached notification"), url_list)
 
723
 
 
724
    def send_attached_text(self, jid, msg_data):
 
725
        """Sends a simple, text page deletion notification
 
726
 
 
727
        @param jid: a Jabber ID to send the notification to
 
728
        @type jid: unicode
 
729
        @param msg_data: dictionary with notification data
 
730
        @type msg_data: dict
 
731
 
 
732
        """
 
733
        _ = self.get_text(jid)
 
734
        separator = '-' * 78
 
735
        urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
 
736
        message = _("%(preamble)s\n%(separator)s\n%(links)s") % {
 
737
                    'preamble': msg_data['text'],
 
738
                    'separator': separator,
 
739
                    'links': urls_text,
 
740
                  }
 
741
 
 
742
        data = {'text': message, 'subject': msg_data['subject']}
 
743
        self.send_message(jid, data, u"message")
 
744
 
 
745
    def send_renamed_form(self, jid, msg_data):
 
746
        """Sends a page rename notification using Data Forms
 
747
 
 
748
        @param jid: a Jabber ID to send the notification to
 
749
        @type jid: unicode
 
750
        @param msg_data: dictionary with notification data
 
751
        @type msg_data: dict
 
752
 
 
753
        """
 
754
        _ = self.get_text(jid)
 
755
 
 
756
        form_title = _("Page rename notification").encode("utf-8")
 
757
        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
 
758
        action_label = _("What to do next")
 
759
 
 
760
        action1 = _("Do nothing")
 
761
        action2 = _("Revert change")
 
762
        action3 = _("View page info")
 
763
        action4 = _("Perform a search")
 
764
 
 
765
        do_nothing = forms.Option("n", action1)
 
766
        revert = forms.Option("r", action2)
 
767
        view_info = forms.Option("v", action3)
 
768
        search = forms.Option("s", action4)
 
769
 
 
770
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
 
771
        form.add_field(name='revision', field_type='hidden', value=msg_data['revision'])
 
772
        form.add_field(name='page_name', field_type='hidden', value=msg_data['page_name'])
 
773
        form.add_field(name='editor', field_type='text-single', value=msg_data['editor'], label=_("Editor"))
 
774
        form.add_field(name='comment', field_type='text-single', value=msg_data.get('comment', ''), label=_("Comment"))
 
775
        form.add_field(name='old', field_type='text-single', value=msg_data['old_name'], label=_("Old name"))
 
776
        form.add_field(name='new', field_type='text-single', value=msg_data['page_name'], label=_("New name"))
 
777
 
 
778
        full_jid = JID(jid)
 
779
        bare_jid = full_jid.bare().as_unicode()
 
780
        resource = full_jid.resource
 
781
 
 
782
        # Add URLs as OOB data if it's supported and as separate fields otherwise
 
783
        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
 
784
            url_list = msg_data['url_list']
 
785
        else:
 
786
            url_list = []
 
787
 
 
788
            for number, url in enumerate(msg_data['url_list']):
 
789
                field_name = "url%d" % (number, )
 
790
                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
 
791
 
 
792
        # Selection of a following action
 
793
        form.add_field(name="options", field_type="list-single", options=[do_nothing, revert, view_info, search], label=action_label)
 
794
 
 
795
        self.send_form(jid, form, _("Page rename notification"), url_list)
 
796
 
 
797
    def send_renamed_text(self, jid, msg_data):
 
798
        """Sends a simple, text page rename notification
 
799
 
 
800
        @param jid: a Jabber ID to send the notification to
 
801
        @type jid: unicode
 
802
        @param msg_data: dictionary with notification data
 
803
        @type msg_data: dict
 
804
 
 
805
        """
 
806
        _ = self.get_text(jid)
 
807
        separator = '-' * 78
 
808
        urls_text = '\n'.join(["%s - %s" % (url["description"], url["url"]) for url in msg_data['url_list']])
 
809
        message = _("%(preamble)s\nComment: %(comment)s\n%(separator)s\n%(links)s") % {
 
810
                    'preamble': msg_data['text'],
 
811
                    'separator': separator,
 
812
                    'comment': msg_data.get('comment', _('no comment')),
 
813
                    'links': urls_text,
 
814
                  }
 
815
 
 
816
        data = {'text': message, 'subject': msg_data['subject']}
 
817
        self.send_message(jid, data, u"message")
 
818
 
 
819
    def handle_page_info(self, command):
 
820
        """Handles GetPageInfo commands
 
821
 
 
822
        @param command: a command instance
 
823
        @type command: jabberbot.commands.GetPageInfo
 
824
 
 
825
        """
 
826
        # Process command data first so it can be directly usable
 
827
        if command.data['author'].startswith("Self:"):
 
828
            command.data['author'] = command.data['author'][5:]
 
829
 
 
830
        datestr = str(command.data['lastModified'])
 
831
        command.data['lastModified'] = u"%(year)s-%(month)s-%(day)s at %(time)s" % {
 
832
                    'year': datestr[:4],
 
833
                    'month': datestr[4:6],
 
834
                    'day': datestr[6:8],
 
835
                    'time': datestr[9:17],
 
836
        }
 
837
 
 
838
        if command.presentation == u"text":
 
839
            self.send_pageinfo_text(command)
 
840
        elif command.presentation == u"dataforms":
 
841
            self.send_pageinfo_form(command)
 
842
 
 
843
        else:
 
844
            raise ValueError("presentation value '%s' is not supported!" % (command.presentation, ))
 
845
 
 
846
    def send_pageinfo_text(self, command):
 
847
        """Sends detailed page info with plain text
 
848
 
 
849
        @param command: command with detailed data
 
850
        @type command: jabberbot.command.GetPageInfo
 
851
 
 
852
        """
 
853
        _ = self.get_text(command.jid)
 
854
 
 
855
        intro = _("""Following detailed information on page "%(pagename)s" \
 
856
is available:""")
 
857
 
 
858
        msg = _("""Last author: %(author)s
 
859
Last modification: %(modification)s
 
860
Current version: %(version)s""") % {
 
861
         'author': command.data['author'],
 
862
         'modification': command.data['lastModified'],
 
863
         'version': command.data['version'],
 
864
        }
 
865
 
 
866
        self.send_message(command.jid, {'text': intro % {'pagename': command.pagename}})
 
867
        self.send_message(command.jid, {'text': msg})
 
868
 
 
869
    def send_pageinfo_form(self, command):
 
870
        """Sends page info using Data Forms
 
871
 
 
872
 
 
873
        """
 
874
        _ = self.get_text(command.jid)
 
875
        data = command.data
 
876
 
 
877
        form_title = _("Detailed page information").encode("utf-8")
 
878
        instructions = _("Submit this form with a specified action to continue.").encode("utf-8")
 
879
        action_label = _("What to do next")
 
880
 
 
881
        action1 = _("Do nothing")
 
882
        action2 = _("Get page contents")
 
883
        action3 = _("Get page contents (HTML)")
 
884
        action4 = _("Perform a search")
 
885
 
 
886
        do_nothing = forms.Option("n", action1)
 
887
        get_content = forms.Option("c", action2)
 
888
        get_content_html = forms.Option("h", action3)
 
889
        search = forms.Option("s", action4)
 
890
 
 
891
        form = forms.Form(xmlnode_or_type="form", title=form_title, instructions=instructions)
 
892
        form.add_field(name='pagename', field_type='text-single', value=command.pagename, label=_("Page name"))
 
893
        form.add_field(name="changed", field_type='text-single', value=data['lastModified'], label=_("Last changed"))
 
894
        form.add_field(name='editor', field_type='text-single', value=data['author'], label=_("Last editor"))
 
895
        form.add_field(name='version', field_type='text-single', value=data['version'], label=_("Current version"))
 
896
 
 
897
#        full_jid = JID(jid)
 
898
#        bare_jid = full_jid.bare().as_unicode()
 
899
#        resource = full_jid.resource
 
900
 
 
901
        # Add URLs as OOB data if it's supported and as separate fields otherwise
 
902
#        if bare_jid in self.contacts and self.contacts[bare_jid].supports(resource, u'jabber:x:oob'):
 
903
#            url_list = msg_data['url_list']
 
904
#        else:
 
905
#            url_list = []
 
906
#
 
907
#            for number, url in enumerate(msg_data['url_list']):
 
908
#                field_name = "url%d" % (number, )
 
909
#                form.add_field(name=field_name, field_type="text-single", value=url["url"], label=url["description"])
 
910
 
 
911
        # Selection of a following action
 
912
        form.add_field(name="options", field_type="list-single", options=[do_nothing, get_content, get_content_html, search], label=action_label)
 
913
 
 
914
        self.send_form(command.jid, form, _("Detailed page information"))
 
915
 
 
916
    def is_internal(self, command):
 
917
        """Check if a given command is internal
 
918
 
 
919
        @type command: unicode
 
920
 
 
921
        """
 
922
        for internal_cmd in self.internal_commands:
 
923
            if internal_cmd.lower() == command:
 
924
                return True
 
925
 
 
926
        return False
 
927
 
 
928
    def is_xmlrpc(self, command):
 
929
        """Checks if a given commands requires interaction via XMLRPC
 
930
 
 
931
        @type command: unicode
 
932
 
 
933
        """
 
934
        for xmlrpc_cmd in self.xmlrpc_commands:
 
935
            if xmlrpc_cmd.lower() == command:
 
936
                return True
 
937
 
 
938
        return False
 
939
 
 
940
    def contains_form(self, message):
 
941
        """Checks if passed message stanza contains a submitted form and parses it
 
942
 
 
943
        @param message: message stanza
 
944
        @type message: pyxmpp.message.Message
 
945
        @return: xml node with form data if found, or None
 
946
 
 
947
        """
 
948
        if not isinstance(message, Message):
 
949
            raise ValueError("The 'message' parameter must be of type pyxmpp.message.Message!")
 
950
 
 
951
        payload = message.get_node()
 
952
        form = message.xpath_eval('/ns:message/data:x', {'data': 'jabber:x:data'})
 
953
 
 
954
        if form:
 
955
            return form[0]
 
956
        else:
 
957
            return None
 
958
 
 
959
    def handle_form(self, jid, form_node):
 
960
        """Handles a submitted data form
 
961
 
 
962
        @param jid: jid that submitted the form (full jid)
 
963
        @type jid: pyxmpp.jid.JID
 
964
        @param form_node: a xml node with data form
 
965
        @type form_node: libxml2.xmlNode
 
966
 
 
967
        """
 
968
        if not isinstance(form_node, libxml2.xmlNode):
 
969
            raise ValueError("The 'form' parameter must be of type libxml2.xmlNode!")
 
970
 
 
971
        if not isinstance(jid, JID):
 
972
            raise ValueError("The 'jid' parameter must be of type jid!")
 
973
 
 
974
        _ = self.get_text(jid.bare().as_unicode())
 
975
 
 
976
        form = forms.Form(form_node)
 
977
 
 
978
        if form.type != u"submit":
 
979
            return
 
980
 
 
981
        if "action" in form:
 
982
            action = form["action"].value
 
983
            if action == u"search":
 
984
                self.handle_search_form(jid, form)
 
985
            else:
 
986
                data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
 
987
                self.send_message(jid.as_unicode(), data, u"message")
 
988
        elif "options" in form:
 
989
            option = form["options"].value
 
990
 
 
991
            # View page info
 
992
            if option == "v":
 
993
                command = cmd.GetPageInfo(jid.as_unicode(), form["page_name"].value, presentation="dataforms")
 
994
                self.from_commands.put_nowait(command)
 
995
 
 
996
            # Perform an another search
 
997
            elif option == "s":
 
998
                self.handle_internal_command(jid, ["searchform"])
 
999
 
 
1000
            # Revert a change
 
1001
            elif option == "r":
 
1002
                revision = int(form["revision"].value)
 
1003
 
 
1004
                # We can't really revert creation of a page, right?
 
1005
                if revision == 1:
 
1006
                    return
 
1007
 
 
1008
                self.handle_xmlrpc_command(jid, ["revertpage", form["page_name"].value, "%d" % (revision - 1, )])
 
1009
 
 
1010
    def handle_search_form(self, jid, form):
 
1011
        """Handles a search form
 
1012
 
 
1013
        @param jid: jid that submitted the form
 
1014
        @type jid: pyxmpp.jid.JID
 
1015
        @param form: a form object
 
1016
        @type form_node: pyxmpp.jabber.dataforms.Form
 
1017
 
 
1018
        """
 
1019
        required_fields = ["case", "regexp", "search_type", "search"]
 
1020
        jid_text = jid.bare().as_unicode()
 
1021
        _ = self.get_text(jid_text)
 
1022
 
 
1023
        for field in required_fields:
 
1024
            if field not in form:
 
1025
                data = {'text': _('The form you submitted was invalid!'), 'subject': _('Invalid data')}
 
1026
                self.send_message(jid.as_unicode(), data, u"message")
 
1027
 
 
1028
        case_sensitive = form['case'].value
 
1029
        regexp_terms = form['regexp'].value
 
1030
        if form['search_type'].value == 't':
 
1031
            search_type = 'title'
 
1032
        else:
 
1033
            search_type = 'text'
 
1034
 
 
1035
        command = cmd.Search(jid.as_unicode(), search_type, form["search"].value, case=form['case'].value,
 
1036
                             regexp=form['regexp'].value, presentation='dataforms')
 
1037
        self.from_commands.put_nowait(command)
 
1038
 
 
1039
    def handle_message(self, message):
 
1040
        """Handles incoming messages
 
1041
 
 
1042
        @param message: a message stanza to parse
 
1043
        @type message: pyxmpp.message.Message
 
1044
 
 
1045
        """
 
1046
        if self.config.verbose:
 
1047
            msg = "Message from %s." % (message.get_from_jid().as_unicode(), )
 
1048
            self.log.debug(msg)
 
1049
 
 
1050
        form = self.contains_form(message)
 
1051
        if form:
 
1052
            self.handle_form(message.get_from_jid(), form)
 
1053
            return
 
1054
 
 
1055
        text = message.get_body()
 
1056
        sender = message.get_from_jid()
 
1057
        if text:
 
1058
            command = text.split()
 
1059
            command[0] = command[0].lower()
 
1060
        else:
 
1061
            return
 
1062
 
 
1063
        if self.is_internal(command[0]):
 
1064
            response = self.handle_internal_command(sender, command)
 
1065
        elif self.is_xmlrpc(command[0]):
 
1066
            response = self.handle_xmlrpc_command(sender, command)
 
1067
        else:
 
1068
            response = self.reply_help(sender)
 
1069
 
 
1070
        if response:
 
1071
            self.send_message(sender, {'text': response})
 
1072
 
 
1073
    def handle_internal_command(self, sender, command):
 
1074
        """Handles internal commands, that can be completed by the XMPP bot itself
 
1075
 
 
1076
        @param command: list representing a command
 
1077
        @param sender: JID of sender
 
1078
        @type sender: pyxmpp.jid.JID
 
1079
 
 
1080
        """
 
1081
        _ = self.get_text(sender)
 
1082
 
 
1083
        if command[0] == "ping":
 
1084
            return "pong"
 
1085
        elif command[0] == "help":
 
1086
            if len(command) == 1:
 
1087
                return self.reply_help(sender)
 
1088
            else:
 
1089
                return self.help_on(sender, command[1])
 
1090
        elif command[0] == "searchform":
 
1091
            jid = sender.bare().as_unicode()
 
1092
            resource = sender.resource
 
1093
 
 
1094
            # Assume that outsiders know what they are doing. Clients that don't support
 
1095
            # data forms should display a warning passed in message <body>.
 
1096
            if jid not in self.contacts or self.contacts[jid].supports(resource, u"jabber:x:data"):
 
1097
                self.send_search_form(sender)
 
1098
            else:
 
1099
                msg = {'text': _("This command requires a client supporting Data Forms.")}
 
1100
                self.send_message(sender, msg, u"")
 
1101
        else:
 
1102
            # For unknown command return a generic help message
 
1103
            return self.reply_help(sender)
 
1104
 
 
1105
    def do_search(self, jid, search_type, presentation, *args):
 
1106
        """Performs a Wiki search of term
 
1107
 
 
1108
        @param jid: Jabber ID of user performing a search
 
1109
        @type jid: pyxmpp.jid.JID
 
1110
        @param term: term to search for
 
1111
        @type term: unicode
 
1112
        @param search_type: type of search; either "text" or "title"
 
1113
        @type search_type: unicode
 
1114
        @param presentation: how to present the results; "text" or "dataforms"
 
1115
        @type presentation: unicode
 
1116
 
 
1117
        """
 
1118
        search = cmd.Search(jid, search_type, presentation=presentation, *args)
 
1119
        self.from_commands.put_nowait(search)
 
1120
 
 
1121
    def help_on(self, jid, command):
 
1122
        """Returns a help message on a given topic
 
1123
 
 
1124
        @param command: a command to describe in a help message
 
1125
        @type command: str or unicode
 
1126
        @return: a help message
 
1127
 
 
1128
        """
 
1129
        _ = self.get_text(jid)
 
1130
 
 
1131
        if command == "help":
 
1132
            return _("""The "help" command prints a short, helpful message \
 
1133
about a given topic or function.\n\nUsage: help [topic_or_function]""")
 
1134
 
 
1135
        elif command == "ping":
 
1136
            return _("""The "ping" command returns a "pong" message as soon \
 
1137
as it's received.""")
 
1138
 
 
1139
        elif command == "searchform":
 
1140
            return _("""searchform - perform a wiki search using a form""")
 
1141
 
 
1142
        # Here we have to deal with help messages of external (xmlrpc) commands
 
1143
        else:
 
1144
            if command in self.xmlrpc_commands:
 
1145
                classobj = self.xmlrpc_commands[command]
 
1146
                help_str = _(u"%(command)s - %(description)s\n\nUsage: %(command)s %(params)s")
 
1147
                return help_str % {'command': command,
 
1148
                                   'description': classobj.description,
 
1149
                                   'params': classobj.parameter_list,
 
1150
                                  }
 
1151
            else:
 
1152
                return _("""Unknown command "%s" """) % (command, )
 
1153
 
 
1154
    def handle_xmlrpc_command(self, sender, command):
 
1155
        """Creates a command object, and puts it the command queue
 
1156
 
 
1157
        @param command: a valid name of available xmlrpc command
 
1158
        @type command: list representing a command, name and parameters
 
1159
 
 
1160
        """
 
1161
        _ = self.get_text(sender)
 
1162
        command_class = self.xmlrpc_commands[command[0]]
 
1163
 
 
1164
        # Add sender's JID to the argument list
 
1165
        command.insert(1, sender.as_unicode())
 
1166
 
 
1167
        try:
 
1168
            instance = command_class.__new__(command_class)
 
1169
            instance.__init__(*command[1:])
 
1170
            self.from_commands.put_nowait(instance)
 
1171
 
 
1172
        # This happens when user specifies wrong parameters
 
1173
        except TypeError:
 
1174
            msg = _("You've specified a wrong parameter list. \
 
1175
The call should look like:\n\n%(command)s %(params)s")
 
1176
 
 
1177
            return msg % {'command': command[0], 'params': command_class.parameter_list}
 
1178
 
 
1179
    def handle_unsubscribed_presence(self, stanza):
 
1180
        """Handles unsubscribed presence stanzas"""
 
1181
 
 
1182
        # FiXME: what policy should we adopt in this case?
 
1183
        pass
 
1184
 
 
1185
    def handle_subscribe_presence(self, stanza):
 
1186
        """Handles subscribe presence stanzas (requests)"""
 
1187
 
 
1188
        # FIXME: Let's just accept all subscribtion requests for now
 
1189
        response = stanza.make_accept_response()
 
1190
        self.get_stream().send(response)
 
1191
 
 
1192
    def handle_unavailable_presence(self, stanza):
 
1193
        """Handles unavailable presence stanzas
 
1194
 
 
1195
        @type stanza: pyxmpp.presence.Presence
 
1196
 
 
1197
        """
 
1198
        self.log.debug("Handling unavailable presence.")
 
1199
 
 
1200
        jid = stanza.get_from_jid()
 
1201
        bare_jid = jid.bare().as_unicode()
 
1202
 
 
1203
        # If we get presence, this contact should already be known
 
1204
        if bare_jid in self.contacts:
 
1205
            contact = self.contacts[bare_jid]
 
1206
 
 
1207
            if self.config.verbose:
 
1208
                self.log.debug("%s, going OFFLINE." % contact)
 
1209
 
 
1210
            # check if we are waiting for disco#info from this jid
 
1211
            self.check_if_waiting(jid)
 
1212
            del self.disco_temp[jid]
 
1213
 
 
1214
            try:
 
1215
                # Send queued messages now, as we can't guarantee to be
 
1216
                # alive the next time this contact becomes available.
 
1217
                if len(contact.resources) == 1:
 
1218
                    self.send_queued_messages(contact, ignore_dnd=True)
 
1219
                    contact.remove_resource(jid.resource)
 
1220
                else:
 
1221
                    contact.remove_resource(jid.resource)
 
1222
 
 
1223
                    # The highest-priority resource, which used to be DnD might
 
1224
                    # have gone offline. If so, try to deliver messages now.
 
1225
                    if not contact.is_dnd():
 
1226
                        self.send_queued_messages(contact)
 
1227
 
 
1228
            except ValueError:
 
1229
                self.log.error("Unknown contact (resource) going offline...")
 
1230
 
 
1231
        else:
 
1232
            self.log.error("Unavailable presence from unknown contact.")
 
1233
 
 
1234
        # Confirm that we've handled this stanza
 
1235
        return True
 
1236
 
 
1237
    def handle_available_presence(self, presence):
 
1238
        """Handles available presence stanzas
 
1239
 
 
1240
        @type presence: pyxmpp.presence.Presence
 
1241
 
 
1242
        """
 
1243
        self.log.debug("Handling available presence.")
 
1244
 
 
1245
        show = presence.get_show()
 
1246
        if show is None:
 
1247
            show = u'available'
 
1248
 
 
1249
        priority = presence.get_priority()
 
1250
        jid = presence.get_from_jid()
 
1251
        bare_jid = jid.bare().as_unicode()
 
1252
 
 
1253
        if bare_jid in self.contacts:
 
1254
            contact = self.contacts[bare_jid]
 
1255
 
 
1256
            # The resource is already known, so update it
 
1257
            if contact.uses_resource(jid.resource):
 
1258
                contact.set_show(jid.resource, show)
 
1259
 
 
1260
            # Unknown resource, add it to the list
 
1261
            else:
 
1262
                contact.add_resource(jid.resource, show, priority)
 
1263
 
 
1264
                # Discover capabilities of the newly connected client
 
1265
                self.service_discovery(jid, presence)
 
1266
 
 
1267
            if self.config.verbose:
 
1268
                self.log.debug(contact)
 
1269
 
 
1270
            # Either way check, if we can deliver queued messages now
 
1271
            if not contact.is_dnd():
 
1272
                self.send_queued_messages(contact)
 
1273
 
 
1274
        else:
 
1275
            self.contacts[bare_jid] = Contact(jid, jid.resource, priority, show)
 
1276
            self.service_discovery(jid, presence)
 
1277
            self.get_user_language(bare_jid)
 
1278
            self.log.debug(self.contacts[bare_jid])
 
1279
 
 
1280
        # Confirm that we've handled this stanza
 
1281
        return True
 
1282
 
 
1283
    def get_user_language(self, jid):
 
1284
        """Request user's language setting from the wiki
 
1285
 
 
1286
        @param jid: bare Jabber ID of the user to query for
 
1287
        @type jid: unicode
 
1288
        """
 
1289
        request = cmd.GetUserLanguage(jid)
 
1290
        self.from_commands.put_nowait(request)
 
1291
 
 
1292
    def handle_disco_query(self, stanza):
 
1293
        """Handler for <Iq /> service discovery query
 
1294
 
 
1295
        @param stanza: received query stanza (pyxmpp.iq.Iq)
 
1296
        """
 
1297
        response = capat.create_response(stanza)
 
1298
        self.get_stream().send(response)
 
1299
 
 
1300
    def service_discovery(self, jid, presence):
 
1301
        """General handler for XEP-0115 (Entity Capabilities)
 
1302
 
 
1303
        @param jid: whose capabilities to discover (pyxmpp.jid.JID)
 
1304
        @param presence: received presence stanza (pyxmpp.presence.Presence)
 
1305
        """
 
1306
        ver_algo = self.check_presence(presence)
 
1307
        self.disco_temp[jid] = ver_algo
 
1308
 
 
1309
        if ver_algo is None:
 
1310
            # legacy client - send disco#info query
 
1311
            self.send_disco_query(jid)
 
1312
        else:
 
1313
            # check if we have this (ver,algo) already cached
 
1314
            cache_item = self.disco_cache.get_item(ver_algo, state='stale')
 
1315
 
 
1316
            if cache_item is None:
 
1317
                # add to disco_wait
 
1318
                self.add_to_disco_wait(ver_algo, jid)
 
1319
            else:
 
1320
                # use cached capabilities
 
1321
                self.log.debug(u"%s: using cached capabilities." % jid.as_unicode())
 
1322
                payload = cache_item.value
 
1323
                self.set_support(jid, payload)
 
1324
 
 
1325
    def check_presence(self, presence):
 
1326
        """Search received presence for a <c> child with 'ver' and 'algo' attributes
 
1327
        return (ver, algo) or None if no 'ver' found.
 
1328
        (no 'algo' attribute defaults to 'sha-1', as described in XEP-0115)
 
1329
 
 
1330
        @param presence: received presence stanza (pyxmpp.presence.Presence)
 
1331
        @return type: tuple of (str, str) or None
 
1332
        """
 
1333
        # TODO: <c> could be found directly using more appropriate xpath
 
1334
        tags = presence.xpath_eval('child::*')
 
1335
        for tag in tags:
 
1336
            if tag.name == 'c':
 
1337
                ver = tag.xpathEval('@ver')
 
1338
                algo = tag.xpathEval('@algo')
 
1339
                if ver:
 
1340
                    if algo:
 
1341
                        ver_algo = (ver[0].children.content, algo[0].children.content)
 
1342
                    else:
 
1343
                        # no algo attribute defaults to 'sha-1'
 
1344
                        ver_algo = (ver[0].children.content, 'sha-1')
 
1345
 
 
1346
                    return ver_algo
 
1347
                else:
 
1348
                    #self.log.debug(u"%s: presence with <c> but without 'ver' attribute." % jid.as_unicode())
 
1349
                    return None
 
1350
                break
 
1351
        else:
 
1352
            #self.log.debug(u"%s: presence without a <c> tag." % jid.as_unicode())
 
1353
            return None
 
1354
 
 
1355
    def send_disco_query(self, jid):
 
1356
        """Sends disco#info query to a given jid
 
1357
 
 
1358
        @type jid: pyxmpp.jid.JID
 
1359
        """
 
1360
        query = Iq(to_jid=jid, stanza_type="get")
 
1361
        query.new_query("http://jabber.org/protocol/disco#info")
 
1362
        self.get_stream().set_response_handlers(query, self.handle_disco_result, None)
 
1363
        self.get_stream().send(query)
 
1364
 
 
1365
    def add_to_disco_wait(self, ver_algo, jid):
 
1366
        """Adds given jid to the list of contacts waiting for service
 
1367
        discovery results.
 
1368
 
 
1369
        @param ver_algo: 'ver' and 'algo' attributes of the given jid
 
1370
        @type ver_algo: tuple of (str, str)
 
1371
        @type jid: pyxmpp.jid.JID
 
1372
        """
 
1373
        if ver_algo in self.disco_wait:
 
1374
            # query already sent, add to the end of waiting list
 
1375
            self.disco_wait[ver_algo][1].append(jid)
 
1376
        else:
 
1377
            # send a query and create a new entry
 
1378
            self.send_disco_query(jid)
 
1379
            timeout = time.time() + self.config.disco_answering_timeout
 
1380
            self.disco_wait[ver_algo] = (timeout, [jid])
 
1381
 
 
1382
    def handle_disco_result(self, stanza):
 
1383
        """Handler for <iq> service discovery results
 
1384
        check if contact is still available and if 'ver' matches the capabilities' hash
 
1385
 
 
1386
        @param stanza: a received result stanza (pyxmpp.iq.Iq)
 
1387
        """
 
1388
        jid = stanza.get_from_jid()
 
1389
        bare_jid = jid.bare().as_unicode()
 
1390
        payload = stanza.get_query()
 
1391
 
 
1392
        if bare_jid in self.contacts:
 
1393
            ver_algo = self.disco_temp[jid]
 
1394
 
 
1395
            if ver_algo is not None:
 
1396
                ver, algo = ver_algo
 
1397
                payload_hash = capat.hash_iq(stanza, algo)
 
1398
 
 
1399
                if payload_hash == ver:
 
1400
                    # we can trust this 'ver' string
 
1401
                    self.disco_result_right(ver_algo, payload)
 
1402
                else:
 
1403
                    self.log.debug(u"%s: 'ver' and hash do not match! (legacy client?)" % jid.as_unicode())
 
1404
                    self.disco_result_wrong(ver_algo)
 
1405
 
 
1406
            self.set_support(jid, payload)
 
1407
 
 
1408
        else:
 
1409
            self.log.debug(u"%s is unavailable but sends service discovery response." % jid.as_unicode())
 
1410
            # such situation is handled by check_if_waiting
 
1411
 
 
1412
    def disco_result_right(self, ver_algo, payload):
 
1413
        """We received a correct service discovery response so we can safely cache it
 
1414
        for future use and apply to every waiting contact from the list (first one is already done)
 
1415
 
 
1416
        @param ver_algo: 'ver' and 'algo' attributes matching received capabilities
 
1417
        @param payload: received capabilities
 
1418
        @type ver_algo: tuple of (str, str)
 
1419
        @type payload: libxml2.xmlNode
 
1420
        """
 
1421
        delta = timedelta(0)
 
1422
        cache_item = CacheItem(ver_algo, payload, delta, delta, delta)
 
1423
        self.disco_cache.add_item(cache_item)
 
1424
 
 
1425
        timeout, jid_list = self.disco_wait[ver_algo]
 
1426
        for jid in jid_list[1:]:
 
1427
            if jid.bare().as_unicode() in self.contacts:
 
1428
                self.set_support(jid, payload)
 
1429
        del self.disco_wait[ver_algo]
 
1430
 
 
1431
    def disco_result_wrong(self, ver_algo):
 
1432
        """First jid from the list returned wrong response
 
1433
        if it is possible try to ask the second one
 
1434
 
 
1435
        @param ver_algo: 'ver' and 'algo' attributes for which we received an inappropriate response
 
1436
        @type ver_algo: tuple of (str, str)
 
1437
        """
 
1438
        timeout, jid_list = self.disco_wait[ver_algo]
 
1439
        jid_list = jid_list[1:]
 
1440
        if jid_list:
 
1441
            self.send_disco_query(jid_list[0])
 
1442
            timeout = time.time() + self.config.disco_answering_timeout
 
1443
            self.disco_wait[ver_algo] = (timeout, jid_list)
 
1444
        else:
 
1445
            del self.disco_wait[ver_algo]
 
1446
 
 
1447
    def check_disco_delays(self):
 
1448
        """Called when idle to check if some contacts haven't answered in allowed time"""
 
1449
        for item in self.disco_wait:
 
1450
            timeout, jid_list = self.disco_wait[item]
 
1451
            if timeout < time.time():
 
1452
                self.disco_result_wrong(item)
 
1453
 
 
1454
    def check_if_waiting(self, jid):
 
1455
        """Check if we were waiting for disco#info reply from client that
 
1456
        has just become unavailable. If so, ask next candidate.
 
1457
 
 
1458
        @param jid: jid that has just gone unavailable
 
1459
        @type jid: pyxmpp.jid.JID
 
1460
        """
 
1461
        ver_algo = self.disco_temp[jid]
 
1462
        if ver_algo in self.disco_wait:
 
1463
            timeout, jid_list = self.disco_wait[ver_algo]
 
1464
            if jid_list:
 
1465
                if jid == jid_list[0]:
 
1466
                    self.disco_result_wrong(ver_algo)
 
1467
            else:
 
1468
                # this should never happen
 
1469
                self.log.debug(u"disco_wait: keeping empty entry at (%s, %s) !" % ver_algo)
 
1470
 
 
1471
    def set_support(self, jid, payload):
 
1472
        """Searches service discovery results for support for
 
1473
        Out Of Band Data (XEP-066) and Data Forms (XEP-004)
 
1474
        and applies it to newly created Contact.
 
1475
 
 
1476
        @param jid: client's jabber ID (pyxmpp.jid.JID)
 
1477
        @param payload: client's capabilities (libxml2.xmlNode)
 
1478
        """
 
1479
        supports = payload.xpathEval('//*[@var="jabber:x:oob"]')
 
1480
        if supports:
 
1481
            self.contacts[jid.bare().as_unicode()].set_supports(jid.resource, u"jabber:x:oob")
 
1482
 
 
1483
        supports = payload.xpathEval('//*[@var="jabber:x:data"]')
 
1484
        if supports:
 
1485
            self.contacts[jid.bare().as_unicode()].set_supports(jid.resource, u"jabber:x:data")
 
1486
 
 
1487
    def send_queued_messages(self, contact, ignore_dnd=False):
 
1488
        """Sends messages queued for the contact
 
1489
 
 
1490
        @param contact: a contact whose queued messages are to be sent
 
1491
        @type contact: jabberbot.xmppbot.Contact
 
1492
        @param ignore_dnd: should contact's DnD status be ignored?
 
1493
 
 
1494
        """
 
1495
        for command in contact.messages:
 
1496
            self.handle_command(command, ignore_dnd)
 
1497
 
 
1498
    def reply_help(self, jid):
 
1499
        """Constructs a generic help message
 
1500
 
 
1501
        It's sent in response to an uknown message or the "help" command.
 
1502
 
 
1503
        """
 
1504
        _ = self.get_text(jid)
 
1505
 
 
1506
        msg = _("Hello there! I'm a MoinMoin Notification Bot. Available commands:\
 
1507
\n\n%(internal)s\n%(xmlrpc)s")
 
1508
        internal = ", ".join(self.internal_commands)
 
1509
        xmlrpc = ", ".join(self.xmlrpc_commands.keys())
 
1510
 
 
1511
        return msg % {'internal': internal, 'xmlrpc': xmlrpc}
 
1512
 
 
1513
    def authenticated(self):
 
1514
        """Called when authentication succeedes"""
 
1515
        self.log.info("Authenticated.")
 
1516
 
 
1517
    def authorized(self):
 
1518
        """Called when authorization succeedes"""
 
1519
 
 
1520
        self.log.info("Authorized.")
 
1521
 
 
1522
        stream = self.get_stream()
 
1523
        stream.set_message_handler("normal", self.handle_message)
 
1524
        stream.set_presence_handler("available", self.handle_available_presence)
 
1525
        stream.set_presence_handler("unavailable", self.handle_unavailable_presence)
 
1526
        stream.set_presence_handler("unsubscribed", self.handle_unsubscribed_presence)
 
1527
        stream.set_presence_handler("subscribe", self.handle_subscribe_presence)
 
1528
 
 
1529
        self.request_session()
 
1530
 
 
1531
    def connected(self):
 
1532
        """Called when connections has been established"""
 
1533
        self.log.info("Connected.")
 
1534
 
 
1535
    def disconnected(self):
 
1536
        """Called when disconnection occurs"""
 
1537
        self.log.info("Disconnected.")
 
1538
 
 
1539
    def roster_updated(self, item=None):
 
1540
        """Called when roster gets updated"""
 
1541
        self.log.debug("Updating roster.")
 
1542
 
 
1543
    def stream_closed(self, stream):
 
1544
        """Called when stream closes"""
 
1545
        self.log.debug("Stream closed.")
 
1546
 
 
1547
    def stream_created(self, stream):
 
1548
        """Called when stream gets created"""
 
1549
        self.log.debug("Stream created.")
 
1550
 
 
1551
    def stream_error(self, error):
 
1552
        """Called when stream error gets received"""
 
1553
        self.log.error("Received a stream error.")
 
1554
 
 
1555
    # Message handlers
 
1556
 
 
1557
    def _handle_notification(self, command, ignore_dnd):
 
1558
        cmd_data = command.notification
 
1559
        original_text = cmd_data.get('text', '')
 
1560
        original_subject = cmd_data.get('subject', '')
 
1561
 
 
1562
        for recipient in command.jids:
 
1563
            jid = JID(recipient)
 
1564
            jid_text = jid.bare().as_unicode()
 
1565
 
 
1566
            if isinstance(command, cmd.NotificationCommandI18n):
 
1567
                # Translate&interpolate the message with data
 
1568
                gettext_func = self.get_text(jid_text)
 
1569
                text, subject = command.translate(gettext_func)
 
1570
                cmd_data['text'] = text
 
1571
                cmd_data['subject'] = subject
 
1572
            else:
 
1573
                cmd_data['text'] = original_text
 
1574
                cmd_data['subject'] = original_subject
 
1575
 
 
1576
            # Check if contact is DoNotDisturb.
 
1577
            # If so, queue the message for delayed delivery.
 
1578
            contact = self.contacts.get(jid_text, '')
 
1579
            if contact:
 
1580
                if command.async and contact.is_dnd() and not ignore_dnd:
 
1581
                    contact.messages.append(command)
 
1582
                    return
 
1583
 
 
1584
            action = cmd_data.get('action', '')
 
1585
            if action == u'page_changed':
 
1586
                self.handle_changed_action(cmd_data, jid, contact)
 
1587
            elif action == u'page_deleted':
 
1588
                self.handle_deleted_action(cmd_data, jid, contact)
 
1589
            elif action == u'file_attached':
 
1590
                self.handle_attached_action(cmd_data, jid, contact)
 
1591
            elif action == u'page_renamed':
 
1592
                self.handle_renamed_action(cmd_data, jid, contact)
 
1593
            elif action == u'user_created':
 
1594
                self.handle_user_created_action(cmd_data, jid, contact)
 
1595
            else:
 
1596
                self.send_message(jid, cmd_data, command.msg_type)
 
1597
 
 
1598
    def _handle_search(self, command, ignore_dnd):
 
1599
        warnings = []
 
1600
        _ = self.get_text(command.jid)
 
1601
 
 
1602
        if not command.data:
 
1603
            warnings.append(_("There are no pages matching your search criteria!"))
 
1604
 
 
1605
        # This hardcoded limitation relies on (mostly correct) assumption that Jabber
 
1606
        # servers have rather tight traffic limits. Sending more than 25 results is likely
 
1607
        # to take a second or two - users should not have to wait longer (+search time!).
 
1608
        elif len(command.data) > 25:
 
1609
            warnings.append(_("There are too many results (%(number)s). Limiting to first 25 entries.") % {'number': str(len(command.data))})
 
1610
            command.data = command.data[:25]
 
1611
 
 
1612
        results = [{'description': result[0], 'url': result[2]} for result in command.data]
 
1613
 
 
1614
        if command.presentation == u"text":
 
1615
            for warning in warnings:
 
1616
                self.send_message(command.jid, {'text': warning})
 
1617
 
 
1618
            if not results:
 
1619
                return
 
1620
 
 
1621
            data = {'text': _('Following pages match your search criteria:'), 'url_list': results}
 
1622
            self.send_message(command.jid, data, u"chat")
 
1623
        else:
 
1624
            form_title = _("Search results").encode("utf-8")
 
1625
            help_form = _("Submit this form to perform a wiki search").encode("utf-8")
 
1626
            form = forms.Form(xmlnode_or_type="result", title=form_title, instructions=help_form)
 
1627
 
 
1628
            action_label = _("What to do next")
 
1629
            do_nothing = forms.Option("n", _("Do nothing"))
 
1630
            search_again = forms.Option("s", _("Search again"))
 
1631
 
 
1632
            for no, warning in enumerate(warnings):
 
1633
                form.add_field(name="warning", field_type="fixed", value=warning)
 
1634
 
 
1635
            for no, result in enumerate(results):
 
1636
                field_name = "url%d" % (no, )
 
1637
                form.add_field(name=field_name, value=unicode(result["url"]), label=result["description"].encode("utf-8"), field_type="text-single")
 
1638
 
 
1639
            # Selection of a following action
 
1640
            form.add_field(name="options", field_type="list-single", options=[do_nothing, search_again], label=action_label)
 
1641
 
 
1642
            self.send_form(command.jid, form, _("Search results"))
 
1643
 
 
1644
    def _handle_add_contact(self, command, ignore_dnd):
 
1645
        jid = JID(node_or_jid = command.jid)
 
1646
        self.ask_for_subscription(jid)
 
1647
 
 
1648
    def _handle_remove_contact(self, command, ignore_dnd):
 
1649
        jid = JID(node_or_jid = command.jid)
 
1650
        self.remove_subscription(jid)
 
1651
 
 
1652
    def _handle_get_page(self, command, ignore_dnd):
 
1653
        _ = self.get_text(command.jid)
 
1654
        msg = _(u"""Here's the page "%(pagename)s" that you've requested:\n\n%(data)s""")
 
1655
 
 
1656
        cmd_data = {'text': msg % {'pagename': command.pagename, 'data': command.data}}
 
1657
        self.send_message(command.jid, cmd_data)
 
1658
 
 
1659
    def _handle_get_page_list(self, command, ignore_dnd):
 
1660
        _ = self.get_text(command.jid)
 
1661
        msg = _("That's the list of pages accesible to you:\n\n%s")
 
1662
        pagelist = u"\n".join(command.data)
 
1663
 
 
1664
        self.send_message(command.jid, {'text': msg % (pagelist, )})
 
1665
 
 
1666
    def _handle_get_page_info(self, command, ignore_dnd):
 
1667
        self.handle_page_info(command)
 
1668
 
 
1669
    def _handle_get_language(self, command, ignore_dnd):
 
1670
        if command.jid in self.contacts:
 
1671
            self.contacts[command.jid].language = command.language