comparison sat/plugins/plugin_xep_0277.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_xep_0277.py@0046283a285d
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for microblogging over XMPP (xep-0277)
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _
21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger
23 log = getLogger(__name__)
24 from twisted.words.protocols.jabber import jid, error
25 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
26 from twisted.words.xish import domish
27 from twisted.internet import defer
28 from twisted.python import failure
29 from sat.core import exceptions
30 from sat.tools import xml_tools
31 from sat.tools import sat_defer
32 from sat.tools import utils
33 from sat.tools.common import data_format
34 from sat.tools.common import uri as xmpp_uri
35
36 # XXX: sat_tmp.wokkel.pubsub is actually used instead of wokkel version
37 from wokkel import pubsub
38 from wokkel import disco, iwokkel
39 from zope.interface import implements
40 import shortuuid
41 import time
42 import dateutil
43 import calendar
44 import urlparse
45
46 NS_MICROBLOG = 'urn:xmpp:microblog:0'
47 NS_ATOM = 'http://www.w3.org/2005/Atom'
48 NS_PUBSUB_EVENT = "{}{}".format(pubsub.NS_PUBSUB, "#event")
49 NS_COMMENT_PREFIX = '{}:comments/'.format(NS_MICROBLOG)
50
51
52 PLUGIN_INFO = {
53 C.PI_NAME: "Microblogging over XMPP Plugin",
54 C.PI_IMPORT_NAME: "XEP-0277",
55 C.PI_TYPE: "XEP",
56 C.PI_PROTOCOLS: ["XEP-0277"],
57 C.PI_DEPENDENCIES: ["XEP-0163", "XEP-0060", "TEXT-SYNTAXES"],
58 C.PI_RECOMMENDATIONS: ["XEP-0059", "EXTRA-PEP"],
59 C.PI_MAIN: "XEP_0277",
60 C.PI_HANDLER: "yes",
61 C.PI_DESCRIPTION: _("""Implementation of microblogging Protocol""")
62 }
63
64
65 class NodeAccessChangeException(Exception):
66 pass
67
68
69 class XEP_0277(object):
70 namespace = NS_MICROBLOG
71
72 def __init__(self, host):
73 log.info(_(u"Microblogging plugin initialization"))
74 self.host = host
75 host.registerNamespace('microblog', NS_MICROBLOG)
76 self._p = self.host.plugins["XEP-0060"] # this facilitate the access to pubsub plugin
77 self.rt_sessions = sat_defer.RTDeferredSessions()
78 self.host.plugins["XEP-0060"].addManagedNode(NS_MICROBLOG, items_cb=self._itemsReceived)
79
80 host.bridge.addMethod("mbSend", ".plugin",
81 in_sign='ssa{ss}s', out_sign='',
82 method=self._mbSend,
83 async=True)
84 host.bridge.addMethod("mbRetract", ".plugin",
85 in_sign='ssss', out_sign='',
86 method=self._mbRetract,
87 async=True)
88 host.bridge.addMethod("mbGet", ".plugin",
89 in_sign='ssiasa{ss}s', out_sign='(aa{ss}a{ss})',
90 method=self._mbGet,
91 async=True)
92 host.bridge.addMethod("mbSetAccess", ".plugin", in_sign='ss', out_sign='',
93 method=self.mbSetAccess,
94 async=True)
95 host.bridge.addMethod("mbSubscribeToMany", ".plugin", in_sign='sass', out_sign='s',
96 method=self._mbSubscribeToMany)
97 host.bridge.addMethod("mbGetFromManyRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssaa{ss}a{ss}))',
98 method=self._mbGetFromManyRTResult, async=True)
99 host.bridge.addMethod("mbGetFromMany", ".plugin", in_sign='sasia{ss}s', out_sign='s',
100 method=self._mbGetFromMany)
101 host.bridge.addMethod("mbGetFromManyWithCommentsRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssa(a{ss}a(sssaa{ss}a{ss}))a{ss}))',
102 method=self._mbGetFromManyWithCommentsRTResult, async=True)
103 host.bridge.addMethod("mbGetFromManyWithComments", ".plugin", in_sign='sasiia{ss}a{ss}s', out_sign='s',
104 method=self._mbGetFromManyWithComments)
105
106 def getHandler(self, client):
107 return XEP_0277_handler()
108
109 def _checkFeaturesCb(self, available):
110 return {'available': C.BOOL_TRUE}
111
112 def _checkFeaturesEb(self, fail):
113 return {'available': C.BOOL_FALSE}
114
115 def getFeatures(self, profile):
116 client = self.host.getClient(profile)
117 d = self.host.checkFeatures(client, [], identity=('pubsub', 'pep'))
118 d.addCallbacks(self._checkFeaturesCb, self._checkFeaturesEb)
119 return d
120
121 ## plugin management methods ##
122
123 def _itemsReceived(self, client, itemsEvent):
124 """Callback which manage items notifications (publish + retract)"""
125 def manageItem(data, event):
126 self.host.bridge.psEvent(C.PS_MICROBLOG, itemsEvent.sender.full(), itemsEvent.nodeIdentifier, event, data, client.profile)
127
128 for item in itemsEvent.items:
129 if item.name == C.PS_ITEM:
130 self.item2mbdata(item).addCallbacks(manageItem, lambda failure: None, (C.PS_PUBLISH,))
131 elif item.name == C.PS_RETRACT:
132 manageItem({'id': item['id']}, C.PS_RETRACT)
133 else:
134 raise exceptions.InternalError("Invalid event value")
135
136
137 ## data/item transformation ##
138
139 @defer.inlineCallbacks
140 def item2mbdata(self, item_elt):
141 """Convert an XML Item to microblog data used in bridge API
142
143 @param item_elt: domish.Element of microblog item
144 @return: microblog data (dictionary)
145 """
146 microblog_data = {}
147
148 def check_conflict(key, increment=False):
149 """Check if key is already in microblog data
150
151 @param key(unicode): key to check
152 @param increment(bool): if suffix the key with an increment
153 instead of raising an exception
154 @raise exceptions.DataError: the key already exists
155 (not raised if increment is True)
156 """
157 if key in microblog_data:
158 if not increment:
159 raise failure.Failure(exceptions.DataError("key {} is already present for item {}").format(key, item_elt['id']))
160 else:
161 idx=1 # the idx 0 is the key without suffix
162 fmt = "{}#{}"
163 new_key = fmt.format(key, idx)
164 while new_key in microblog_data:
165 idx+=1
166 new_key = fmt.format(key, idx)
167 key = new_key
168 return key
169
170 @defer.inlineCallbacks
171 def parseElement(elem):
172 """Parse title/content elements and fill microblog_data accordingly"""
173 type_ = elem.getAttribute('type')
174 if type_ == 'xhtml':
175 data_elt = elem.firstChildElement()
176 if data_elt is None:
177 raise failure.Failure(exceptions.DataError(u"XHML content not wrapped in a <div/> element, this is not standard !"))
178 if data_elt.uri != C.NS_XHTML:
179 raise failure.Failure(exceptions.DataError(_('Content of type XHTML must declare its namespace!')))
180 key = check_conflict(u'{}_xhtml'.format(elem.name))
181 data = data_elt.toXml()
182 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].cleanXHTML(data)
183 else:
184 key = check_conflict(elem.name)
185 microblog_data[key] = unicode(elem)
186
187
188 id_ = item_elt.getAttribute('id', '') # there can be no id for transient nodes
189 microblog_data[u'id'] = id_
190 if item_elt.uri not in (pubsub.NS_PUBSUB, NS_PUBSUB_EVENT):
191 msg = u"Unsupported namespace {ns} in pubsub item {id_}".format(ns=item_elt.uri, id_=id_)
192 log.warning(msg)
193 raise failure.Failure(exceptions.DataError(msg))
194
195 try:
196 entry_elt = item_elt.elements(NS_ATOM, 'entry').next()
197 except StopIteration:
198 msg = u'No atom entry found in the pubsub item {}'.format(id_)
199 raise failure.Failure(exceptions.DataError(msg))
200
201 # language
202 try:
203 microblog_data[u'language'] = entry_elt[(C.NS_XML, u'lang')].strip()
204 except KeyError:
205 pass
206
207 # atom:id
208 try:
209 id_elt = entry_elt.elements(NS_ATOM, 'id').next()
210 except StopIteration:
211 msg = u'No atom id found in the pubsub item {}, this is not standard !'.format(id_)
212 log.warning(msg)
213 microblog_data[u'atom_id'] = ""
214 else:
215 microblog_data[u'atom_id'] = unicode(id_elt)
216
217 # title/content(s)
218
219 # FIXME: ATOM and XEP-0277 only allow 1 <title/> element
220 # but in the wild we have some blogs with several ones
221 # so we don't respect the standard for now (it doesn't break
222 # anything anyway), and we'll find a better option later
223 # try:
224 # title_elt = entry_elt.elements(NS_ATOM, 'title').next()
225 # except StopIteration:
226 # msg = u'No atom title found in the pubsub item {}'.format(id_)
227 # raise failure.Failure(exceptions.DataError(msg))
228 title_elts = list(entry_elt.elements(NS_ATOM, 'title'))
229 if not title_elts:
230 msg = u'No atom title found in the pubsub item {}'.format(id_)
231 raise failure.Failure(exceptions.DataError(msg))
232 for title_elt in title_elts:
233 yield parseElement(title_elt)
234
235 # FIXME: as for <title/>, Atom only authorise at most 1 content
236 # but XEP-0277 allows several ones. So for no we handle as
237 # if more than one can be present
238 for content_elt in entry_elt.elements(NS_ATOM, 'content'):
239 yield parseElement(content_elt)
240
241 # we check that text content is present
242 for key in ('title', 'content'):
243 if key not in microblog_data and ('{}_xhtml'.format(key)) in microblog_data:
244 log.warning(u"item {id_} provide a {key}_xhtml data but not a text one".format(id_=id_, key=key))
245 # ... and do the conversion if it's not
246 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].\
247 convert(microblog_data[u'{}_xhtml'.format(key)],
248 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML,
249 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT,
250 False)
251
252 if 'content' not in microblog_data:
253 # use the atom title data as the microblog body content
254 microblog_data[u'content'] = microblog_data[u'title']
255 del microblog_data[u'title']
256 if 'title_xhtml' in microblog_data:
257 microblog_data[u'content_xhtml'] = microblog_data[u'title_xhtml']
258 del microblog_data[u'title_xhtml']
259
260 # published/updated dates
261 try:
262 updated_elt = entry_elt.elements(NS_ATOM, 'updated').next()
263 except StopIteration:
264 msg = u'No atom updated element found in the pubsub item {}'.format(id_)
265 raise failure.Failure(exceptions.DataError(msg))
266 microblog_data[u'updated'] = unicode(calendar.timegm(dateutil.parser.parse(unicode(updated_elt)).utctimetuple()))
267 try:
268 published_elt = entry_elt.elements(NS_ATOM, 'published').next()
269 except StopIteration:
270 microblog_data[u'published'] = microblog_data[u'updated']
271 else:
272 microblog_data[u'published'] = unicode(calendar.timegm(dateutil.parser.parse(unicode(published_elt)).utctimetuple()))
273
274 # links
275 for link_elt in entry_elt.elements(NS_ATOM, 'link'):
276 if link_elt.getAttribute('rel') == 'replies' and link_elt.getAttribute('title') == 'comments':
277 key = check_conflict('comments', True)
278 microblog_data[key] = link_elt['href']
279 try:
280 service, node = self.parseCommentUrl(microblog_data[key])
281 except:
282 log.warning(u"Can't parse url {}".format(microblog_data[key]))
283 del microblog_data[key]
284 else:
285 microblog_data[u'{}_service'.format(key)] = service.full()
286 microblog_data[u'{}_node'.format(key)] = node
287 else:
288 rel = link_elt.getAttribute('rel','')
289 title = link_elt.getAttribute('title','')
290 href = link_elt.getAttribute('href','')
291 log.warning(u"Unmanaged link element: rel={rel} title={title} href={href}".format(rel=rel, title=title, href=href))
292
293 # author
294 try:
295 author_elt = entry_elt.elements(NS_ATOM, 'author').next()
296 except StopIteration:
297 log.debug(u"Can't find author element in item {}".format(id_))
298 else:
299 publisher = item_elt.getAttribute("publisher")
300 # name
301 try:
302 name_elt = author_elt.elements(NS_ATOM, 'name').next()
303 except StopIteration:
304 log.warning(u"No name element found in author element of item {}".format(id_))
305 else:
306 microblog_data[u'author'] = unicode(name_elt)
307 # uri
308 try:
309 uri_elt = author_elt.elements(NS_ATOM, 'uri').next()
310 except StopIteration:
311 log.debug(u"No uri element found in author element of item {}".format(id_))
312 if publisher:
313 microblog_data[u'author_jid'] = publisher
314 else:
315 uri = unicode(uri_elt)
316 if uri.startswith("xmpp:"):
317 uri = uri[5:]
318 microblog_data[u'author_jid'] = uri
319 else:
320 microblog_data[u'author_jid'] = item_elt.getAttribute(u"publisher") or ""
321
322 if not publisher:
323 log.debug(u"No publisher attribute, we can't verify author jid")
324 microblog_data[u'author_jid_verified'] = C.BOOL_FALSE
325 elif jid.JID(publisher).userhostJID() == jid.JID(uri).userhostJID():
326 microblog_data[u'author_jid_verified'] = C.BOOL_TRUE
327 else:
328 log.warning(u"item atom:uri differ from publisher attribute, spoofing attempt ? atom:uri = {} publisher = {}".format(uri, item_elt.getAttribute("publisher")))
329 microblog_data[u'author_jid_verified'] = C.BOOL_FALSE
330 # email
331 try:
332 email_elt = author_elt.elements(NS_ATOM, 'email').next()
333 except StopIteration:
334 pass
335 else:
336 microblog_data[u'author_email'] = unicode(email_elt)
337
338 # categories
339 categories = (category_elt.getAttribute('term','') for category_elt in entry_elt.elements(NS_ATOM, 'category'))
340 data_format.iter2dict('tag', categories, microblog_data)
341
342 ## the trigger ##
343 # if other plugins have things to add or change
344 yield self.host.trigger.point("XEP-0277_item2data", item_elt, entry_elt, microblog_data)
345
346 defer.returnValue(microblog_data)
347
348 @defer.inlineCallbacks
349 def data2entry(self, client, data, item_id, service, node):
350 """Convert a data dict to en entry usable to create an item
351
352 @param data: data dict as given by bridge method.
353 @param item_id(unicode): id of the item to use
354 @param service(jid.JID, None): pubsub service where the item is sent
355 Needed to construct Atom id
356 @param node(unicode): pubsub node where the item is sent
357 Needed to construct Atom id
358 @return: deferred which fire domish.Element
359 """
360 entry_elt = domish.Element((NS_ATOM, 'entry'))
361
362 ## language ##
363 if u'language' in data:
364 entry_elt[(C.NS_XML, u'lang')] = data[u'language'].strip()
365
366 ## content and title ##
367 synt = self.host.plugins["TEXT-SYNTAXES"]
368
369 for elem_name in ('title', 'content'):
370 for type_ in ['', '_rich', '_xhtml']:
371 attr = "{}{}".format(elem_name, type_)
372 if attr in data:
373 elem = entry_elt.addElement(elem_name)
374 if type_:
375 if type_ == '_rich': # convert input from current syntax to XHTML
376 xml_content = yield synt.convert(data[attr], synt.getCurrentSyntax(client.profile), "XHTML")
377 if '{}_xhtml'.format(elem_name) in data:
378 raise failure.Failure(exceptions.DataError(_("Can't have xhtml and rich content at the same time")))
379 else:
380 xml_content = data[attr]
381
382 div_elt = xml_tools.ElementParser()(xml_content, namespace=C.NS_XHTML)
383 if div_elt.name != 'div' or div_elt.uri != C.NS_XHTML or div_elt.attributes:
384 # we need a wrapping <div/> at the top with XHTML namespace
385 wrap_div_elt = domish.Element((C.NS_XHTML, 'div'))
386 wrap_div_elt.addChild(div_elt)
387 div_elt = wrap_div_elt
388 elem.addChild(div_elt)
389 elem['type'] = 'xhtml'
390 if elem_name not in data:
391 # there is raw text content, which is mandatory
392 # so we create one from xhtml content
393 elem_txt = entry_elt.addElement(elem_name)
394 text_content = yield self.host.plugins["TEXT-SYNTAXES"].convert(xml_content,
395 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML,
396 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT,
397 False)
398 elem_txt.addContent(text_content)
399 elem_txt['type'] = 'text'
400
401 else: # raw text only needs to be escaped to get HTML-safe sequence
402 elem.addContent(data[attr])
403 elem['type'] = 'text'
404
405 try:
406 entry_elt.elements(NS_ATOM, 'title').next()
407 except StopIteration:
408 # we have no title element which is mandatory
409 # so we transform content element to title
410 elems = list(entry_elt.elements(NS_ATOM, 'content'))
411 if not elems:
412 raise exceptions.DataError("There must be at least one content or title element")
413 for elem in elems:
414 elem.name = 'title'
415
416 ## author ##
417 author_elt = entry_elt.addElement('author')
418 try:
419 author_name = data['author']
420 except KeyError:
421 # FIXME: must use better name
422 author_name = client.jid.user
423 author_elt.addElement('name', content=author_name)
424
425 try:
426 author_jid_s = data['author_jid']
427 except KeyError:
428 author_jid_s = client.jid.userhost()
429 author_elt.addElement('uri', content="xmpp:{}".format(author_jid_s))
430
431 try:
432 author_jid_s = data['author_email']
433 except KeyError:
434 pass
435
436 ## published/updated time ##
437 current_time = time.time()
438 entry_elt.addElement('updated',
439 content = utils.xmpp_date(float(data.get('updated', current_time))))
440 entry_elt.addElement('published',
441 content = utils.xmpp_date(float(data.get('published', current_time))))
442
443 ## categories ##
444 for tag in data_format.dict2iter("tag", data):
445 category_elt = entry_elt.addElement("category")
446 category_elt['term'] = tag
447
448 ## id ##
449 entry_id = data.get('id', xmpp_uri.buildXMPPUri(
450 u'pubsub',
451 path=service.full() if service is not None else client.jid.userhost(),
452 node=node,
453 item=item_id))
454 entry_elt.addElement('id', content=entry_id) #
455
456 ## comments ##
457 if 'comments' in data:
458 link_elt = entry_elt.addElement('link')
459 link_elt['href'] = data['comments']
460 link_elt['rel'] = 'replies'
461 link_elt['title'] = 'comments'
462
463 ## final item building ##
464 item_elt = pubsub.Item(id=item_id, payload=entry_elt)
465
466 ## the trigger ##
467 # if other plugins have things to add or change
468 yield self.host.trigger.point("XEP-0277_data2entry", client, data, entry_elt, item_elt)
469
470 defer.returnValue(item_elt)
471
472 ## publish ##
473
474 def getCommentsNode(self, item_id):
475 """Generate comment node
476
477 @param item_id(unicode): id of the parent item
478 @return (unicode): comment node to use
479 """
480 return u"{}{}".format(NS_COMMENT_PREFIX, item_id)
481
482 def getCommentsService(self, client, parent_service=None):
483 """Get prefered PubSub service to create comment node
484
485 @param pubsub_service(jid.JID, None): PubSub service of the parent item
486 @param return((D)jid.JID, None): PubSub service to use
487 """
488 if parent_service is not None:
489 if parent_service.user:
490 # we are on a PEP
491 if parent_service.host == client.jid.host:
492 # it's our server, we use already found client.pubsub_service below
493 pass
494 else:
495 # other server, let's try to find a non PEP service there
496 d = self.host.findServiceEntity(client, "pubsub", "service", parent_service)
497 d.addCallback(lambda entity: entity or parent_service)
498 else:
499 # parent is already on a normal Pubsub service, we re-use it
500 return defer.succeed(parent_service)
501
502 return defer.succeed(client.pubsub_service if client.pubsub_service is not None else parent_service)
503
504 @defer.inlineCallbacks
505 def _manageComments(self, client, mb_data, service, node, item_id, access=None):
506 """Check comments keys in mb_data and create comments node if necessary
507
508 if mb_data['comments'] exists, it is used (or mb_data['comments_service'] and/or mb_data['comments_node']),
509 else it is generated (if allow_comments is True).
510 @param mb_data(dict): microblog mb_data
511 @param service(jid.JID, None): PubSub service of the parent item
512 @param node(unicode): node of the parent item
513 @param item_id(unicode): id of the parent item
514 @param access(unicode, None): access model
515 None to use same access model as parent item
516 """
517 # FIXME: if 'comments' already exists in mb_data, it is not used to create the Node
518 allow_comments = C.bool(mb_data.pop("allow_comments", "false"))
519 if not allow_comments:
520 if 'comments' in mb_data:
521 log.warning(u"comments are not allowed but there is already a comments node, it may be lost: {uri}".format(uri=mb_data['comments']))
522 del mb_data['comments']
523 return
524
525 if access is None:
526 # TODO: cache access models per service/node
527 parent_node_config = yield self._p.getConfiguration(client, service, node)
528 access = parent_node_config.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN)
529
530 options = {self._p.OPT_ACCESS_MODEL: access,
531 self._p.OPT_PERSIST_ITEMS: 1,
532 self._p.OPT_MAX_ITEMS: -1,
533 self._p.OPT_DELIVER_PAYLOADS: 1,
534 self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
535 # FIXME: would it make sense to restrict publish model to subscribers?
536 self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
537 }
538
539 # if other plugins need to change the options
540 yield self.host.trigger.point("XEP-0277_comments", client, mb_data, options)
541
542 try:
543 comments_node = mb_data['comments_node']
544 except KeyError:
545 comments_node = self.getCommentsNode(item_id)
546 else:
547 if not comments_node:
548 raise exceptions.DataError(u"if comments_node is present, it must not be empty")
549
550 try:
551 comments_service = jid.JID(mb_data['comments_service'])
552 except KeyError:
553 comments_service = yield self.getCommentsService(client, service)
554
555 try:
556 yield self._p.createNode(client, comments_service, comments_node, options)
557 except error.StanzaError as e:
558 if e.condition == 'conflict':
559 log.info(u"node {} already exists on service {}".format(comments_node, comments_service))
560 else:
561 raise e
562 else:
563 if access == self._p.ACCESS_WHITELIST:
564 # for whitelist access we need to copy affiliations from parent item
565 comments_affiliations = yield self._p.getNodeAffiliations(client, service, node)
566 # …except for "member", that we transform to publisher
567 # because we wants members to be able to write to comments
568 for jid_, affiliation in comments_affiliations.items():
569 if affiliation == 'member':
570 comments_affiliations[jid_] == 'publisher'
571
572 yield self._p.setNodeAffiliations(client, comments_service, comments_node, comments_affiliations)
573
574 if comments_service is None:
575 comments_service = client.jid.userhostJID()
576
577 if 'comments' in mb_data:
578 if not mb_data['comments']:
579 raise exceptions.DataError(u"if comments is present, it must not be empty")
580 if 'comments_node' in mb_data or 'comments_service' in mb_data:
581 raise exceptions.DataError(u"You can't use comments_service/comments_node and comments at the same time")
582 else:
583 mb_data['comments'] = self._p.getNodeURI(comments_service, comments_node)
584
585 def _mbSend(self, service, node, data, profile_key):
586 service = jid.JID(service) if service else None
587 node = node if node else NS_MICROBLOG
588 client = self.host.getClient(profile_key)
589 return self.send(client, data, service, node)
590
591 @defer.inlineCallbacks
592 def send(self, client, data, service=None, node=NS_MICROBLOG):
593 """Send XEP-0277's microblog data
594
595 @param data(dict): microblog data (must include at least a "content" or a "title" key).
596 see http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en for details
597 @param service(jid.JID, None): PubSub service where the microblog must be published
598 None to publish on profile's PEP
599 @param node(unicode, None): PubSub node to use (defaut to microblog NS)
600 None is equivalend as using default value
601 """
602 # TODO: check that all data keys are used, this would avoid sending publicly a private message
603 # by accident (e.g. if group pluging is not loaded, and "grou*" key are not used)
604 if node is None:
605 node = NS_MICROBLOG
606
607 item_id = data.get('id') or unicode(shortuuid.uuid())
608
609 try:
610 yield self._manageComments(client, data, service, node, item_id, access=None)
611 except error.StanzaError:
612 log.warning(u"Can't create comments node for item {}".format(item_id))
613 item = yield self.data2entry(client, data, item_id, service, node)
614 ret = yield self._p.publish(client, service, node, [item])
615 defer.returnValue(ret)
616
617 ## retract ##
618
619 def _mbRetract(self, service_jid_s, nodeIdentifier, itemIdentifier, profile_key):
620 """Call self._p._retractItem, but use default node if node is empty"""
621 return self._p._retractItem(service_jid_s, nodeIdentifier or NS_MICROBLOG, itemIdentifier, True, profile_key)
622
623 ## get ##
624
625 def _mbGet(self, service='', node='', max_items=10, item_ids=None, extra_dict=None, profile_key=C.PROF_KEY_NONE):
626 """
627 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
628 @param item_ids (list[unicode]): list of item IDs
629 """
630 client = self.host.getClient(profile_key)
631 service = jid.JID(service) if service else None
632 max_items = None if max_items == C.NO_LIMIT else max_items
633 extra = self._p.parseExtra(extra_dict)
634 return self.mbGet(client, service, node or None, max_items, item_ids, extra.rsm_request, extra.extra)
635
636
637 @defer.inlineCallbacks
638 def mbGet(self, client, service=None, node=None, max_items=10, item_ids=None, rsm_request=None, extra=None):
639 """Get some microblogs
640
641 @param service(jid.JID, None): jid of the publisher
642 None to get profile's PEP
643 @param node(unicode, None): node to get (or microblog node if None)
644 @param max_items(int): maximum number of item to get, None for no limit
645 @param item_ids (list[unicode]): list of item IDs
646 @param rsm_request (rsm.RSMRequest): RSM request data
647 @param extra (dict): extra data
648
649 @return: a deferred couple with the list of items and metadatas.
650 """
651 if node is None:
652 node = NS_MICROBLOG
653 items_data = yield self._p.getItems(client, service, node, max_items=max_items, item_ids=item_ids, rsm_request=rsm_request, extra=extra)
654 serialised = yield self._p.serItemsDataD(items_data, self.item2mbdata)
655 defer.returnValue(serialised)
656
657 def parseCommentUrl(self, node_url):
658 """Parse a XMPP URI
659
660 Determine the fields comments_service and comments_node of a microblog data
661 from the href attribute of an entry's link element. For example this input:
662 xmpp:sat-pubsub.example.net?;node=urn%3Axmpp%3Acomments%3A_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn%3Axmpp%3Agroupblog%3Asomebody%40example.net
663 will return(JID(u'sat-pubsub.example.net'), 'urn:xmpp:comments:_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn:xmpp:groupblog:somebody@example.net')
664 @return (tuple[jid.JID, unicode]): service and node
665 """
666 parsed_url = urlparse.urlparse(node_url, 'xmpp')
667 service = jid.JID(parsed_url.path)
668 parsed_queries = urlparse.parse_qs(parsed_url.query.encode('utf-8'))
669 node = parsed_queries.get('node', [''])[0].decode('utf-8')
670
671 if not node:
672 raise failure.Failure(exceptions.DataError('Invalid comments link'))
673
674 return (service, node)
675
676 ## configure ##
677
678 def mbSetAccess(self, access="presence", profile_key=C.PROF_KEY_NONE):
679 """Create a microblog node on PEP with given access
680
681 If the node already exists, it change options
682 @param access: Node access model, according to xep-0060 #4.5
683 @param profile_key: profile key
684 """
685 # FIXME: check if this mehtod is need, deprecate it if not
686 client = self.host.getClient(profile_key)
687
688 _options = {self._p.OPT_ACCESS_MODEL: access, self._p.OPT_PERSIST_ITEMS: 1, self._p.OPT_MAX_ITEMS: -1, self._p.OPT_DELIVER_PAYLOADS: 1, self._p.OPT_SEND_ITEM_SUBSCRIBE: 1}
689
690 def cb(result):
691 #Node is created with right permission
692 log.debug(_(u"Microblog node has now access %s") % access)
693
694 def fatal_err(s_error):
695 #Something went wrong
696 log.error(_(u"Can't set microblog access"))
697 raise NodeAccessChangeException()
698
699 def err_cb(s_error):
700 #If the node already exists, the condition is "conflict",
701 #else we have an unmanaged error
702 if s_error.value.condition == 'conflict':
703 #d = self.host.plugins["XEP-0060"].deleteNode(client, client.jid.userhostJID(), NS_MICROBLOG)
704 #d.addCallback(lambda x: create_node().addCallback(cb).addErrback(fatal_err))
705 change_node_options().addCallback(cb).addErrback(fatal_err)
706 else:
707 fatal_err(s_error)
708
709 def create_node():
710 return self._p.createNode(client, client.jid.userhostJID(), NS_MICROBLOG, _options)
711
712 def change_node_options():
713 return self._p.setOptions(client.jid.userhostJID(), NS_MICROBLOG, client.jid.userhostJID(), _options, profile_key=profile_key)
714
715 create_node().addCallback(cb).addErrback(err_cb)
716
717 ## methods to manage several stanzas/jids at once ##
718
719 # common
720
721 def _getClientAndNodeData(self, publishers_type, publishers, profile_key):
722 """Helper method to construct node_data from publishers_type/publishers
723
724 @param publishers_type: type of the list of publishers, one of:
725 C.ALL: get all jids from roster, publishers is not used
726 C.GROUP: get jids from groups
727 C.JID: use publishers directly as list of jids
728 @param publishers: list of publishers, according to "publishers_type" (None, list of groups or list of jids)
729 @param profile_key: %(doc_profile_key)s
730 """
731 client = self.host.getClient(profile_key)
732 if publishers_type == C.JID:
733 jids_set = set(publishers)
734 else:
735 jids_set = client.roster.getJidsSet(publishers_type, publishers)
736 if publishers_type == C.ALL:
737 try: # display messages from salut-a-toi@libervia.org or other PEP services
738 services = self.host.plugins["EXTRA-PEP"].getFollowedEntities(profile_key)
739 except KeyError:
740 pass # plugin is not loaded
741 else:
742 if services:
743 log.debug("Extra PEP followed entities: %s" % ", ".join([unicode(service) for service in services]))
744 jids_set.update(services)
745
746 node_data = []
747 for jid_ in jids_set:
748 node_data.append((jid_, NS_MICROBLOG))
749 return client, node_data
750
751 def _checkPublishers(self, publishers_type, publishers):
752 """Helper method to deserialise publishers coming from bridge
753
754 publishers_type(unicode): type of the list of publishers, one of:
755 publishers: list of publishers according to type
756 @return: deserialised (publishers_type, publishers) tuple
757 """
758 if publishers_type == C.ALL:
759 if publishers:
760 raise failure.Failure(ValueError("Can't use publishers with {} type".format(publishers_type)))
761 else:
762 publishers = None
763 elif publishers_type == C.JID:
764 publishers[:] = [jid.JID(publisher) for publisher in publishers]
765 return publishers_type, publishers
766
767 # subscribe #
768
769 def _mbSubscribeToMany(self, publishers_type, publishers, profile_key):
770 """
771
772 @return (str): session id: Use pubsub.getSubscribeRTResult to get the results
773 """
774 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
775 return self.mbSubscribeToMany(publishers_type, publishers, profile_key)
776
777 def mbSubscribeToMany(self, publishers_type, publishers, profile_key):
778 """Subscribe microblogs for a list of groups or jids
779
780 @param publishers_type: type of the list of publishers, one of:
781 C.ALL: get all jids from roster, publishers is not used
782 C.GROUP: get jids from groups
783 C.JID: use publishers directly as list of jids
784 @param publishers: list of publishers, according to "publishers_type" (None, list of groups or list of jids)
785 @param profile: %(doc_profile)s
786 @return (str): session id
787 """
788 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
789 return self._p.subscribeToMany(node_data, client.jid.userhostJID(), profile_key=profile_key)
790
791 # get #
792
793 def _mbGetFromManyRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
794 """Get real-time results for mbGetFromMany session
795
796 @param session_id: id of the real-time deferred session
797 @param return (tuple): (remaining, results) where:
798 - remaining is the number of still expected results
799 - results is a list of tuple with
800 - service (unicode): pubsub service
801 - node (unicode): pubsub node
802 - failure (unicode): empty string in case of success, error message else
803 - items_data(list): data as returned by [mbGet]
804 - items_metadata(dict): metadata as returned by [mbGet]
805 @param profile_key: %(doc_profile_key)s
806 """
807 def onSuccess(items_data):
808 """convert items elements to list of microblog data in items_data"""
809 d = self._p.serItemsDataD(items_data, self.item2mbdata)
810 d.addCallback(lambda serialised:('', serialised))
811 return d
812
813 profile = self.host.getClient(profile_key).profile
814 d = self._p.getRTResults(session_id,
815 on_success = onSuccess,
816 on_error = lambda failure: (unicode(failure.value), ([],{})),
817 profile = profile)
818 d.addCallback(lambda ret: (ret[0],
819 [(service.full(), node, failure, items, metadata)
820 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()]))
821 return d
822
823 def _mbGetFromMany(self, publishers_type, publishers, max_items=10, extra_dict=None, profile_key=C.PROF_KEY_NONE):
824 """
825 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
826 """
827 max_items = None if max_items == C.NO_LIMIT else max_items
828 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
829 extra = self._p.parseExtra(extra_dict)
830 return self.mbGetFromMany(publishers_type, publishers, max_items, extra.rsm_request, extra.extra, profile_key)
831
832 def mbGetFromMany(self, publishers_type, publishers, max_items=None, rsm_request=None, extra=None, profile_key=C.PROF_KEY_NONE):
833 """Get the published microblogs for a list of groups or jids
834
835 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
836 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
837 @param max_items (int): optional limit on the number of retrieved items.
838 @param rsm_request (rsm.RSMRequest): RSM request data, common to all publishers
839 @param extra (dict): Extra data
840 @param profile_key: profile key
841 @return (str): RT Deferred session id
842 """
843 # XXX: extra is unused here so far
844 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
845 return self._p.getFromMany(node_data, max_items, rsm_request, profile_key=profile_key)
846
847 # comments #
848
849 def _mbGetFromManyWithCommentsRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
850 """Get real-time results for [mbGetFromManyWithComments] session
851
852 @param session_id: id of the real-time deferred session
853 @param return (tuple): (remaining, results) where:
854 - remaining is the number of still expected results
855 - results is a list of 5-tuple with
856 - service (unicode): pubsub service
857 - node (unicode): pubsub node
858 - failure (unicode): empty string in case of success, error message else
859 - items(list[tuple(dict, list)]): list of 2-tuple with
860 - item(dict): item microblog data
861 - comments_list(list[tuple]): list of 5-tuple with
862 - service (unicode): pubsub service where the comments node is
863 - node (unicode): comments node
864 - failure (unicode): empty in case of success, else error message
865 - comments(list[dict]): list of microblog data
866 - comments_metadata(dict): metadata of the comment node
867 - metadata(dict): original node metadata
868 @param profile_key: %(doc_profile_key)s
869 """
870 profile = self.host.getClient(profile_key).profile
871 d = self.rt_sessions.getResults(session_id, profile=profile)
872 d.addCallback(lambda ret: (ret[0],
873 [(service.full(), node, failure, items, metadata)
874 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()]))
875 return d
876
877 def _mbGetFromManyWithComments(self, publishers_type, publishers, max_items=10, max_comments=C.NO_LIMIT, extra_dict=None, extra_comments_dict=None, profile_key=C.PROF_KEY_NONE):
878 """
879 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
880 @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no limit
881 """
882 max_items = None if max_items == C.NO_LIMIT else max_items
883 max_comments = None if max_comments == C.NO_LIMIT else max_comments
884 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
885 extra = self._p.parseExtra(extra_dict)
886 extra_comments = self._p.parseExtra(extra_comments_dict)
887 return self.mbGetFromManyWithComments(publishers_type, publishers, max_items, max_comments or None,
888 extra.rsm_request,
889 extra.extra,
890 extra_comments.rsm_request,
891 extra_comments.extra,
892 profile_key)
893
894 def mbGetFromManyWithComments(self, publishers_type, publishers, max_items=None, max_comments=None, rsm_request=None, extra=None, rsm_comments=None, extra_comments=None, profile_key=C.PROF_KEY_NONE):
895 """Helper method to get the microblogs and their comments in one shot
896
897 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
898 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
899 @param max_items (int): optional limit on the number of retrieved items.
900 @param max_comments (int): maximum number of comments to retrieve
901 @param rsm_request (rsm.RSMRequest): RSM request for initial items only
902 @param extra (dict): extra configuration for initial items only
903 @param rsm_comments (rsm.RSMRequest): RSM request for comments only
904 @param extra_comments (dict): extra configuration for comments only
905 @param profile_key: profile key
906 @return (str): RT Deferred session id
907 """
908 # XXX: this method seems complicated because it do a couple of treatments
909 # to serialise and associate the data, but it make life in frontends side
910 # a lot easier
911
912 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
913
914 def getComments(items_data):
915 """Retrieve comments and add them to the items_data
916
917 @param items_data: serialised items data
918 @return (defer.Deferred): list of items where each item is associated
919 with a list of comments data (service, node, list of items, metadata)
920 """
921 items, metadata = items_data
922 items_dlist = [] # deferred list for items
923 for item in items:
924 dlist = [] # deferred list for comments
925 for key, value in item.iteritems():
926 # we look for comments
927 if key.startswith('comments') and key.endswith('_service'):
928 prefix = key[:key.find('_')]
929 service_s = value
930 node = item["{}{}".format(prefix, "_node")]
931 # time to get the comments
932 d = self._p.getItems(client, jid.JID(service_s), node, max_comments, rsm_request=rsm_comments, extra=extra_comments)
933 # then serialise
934 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata))
935 # with failure handling
936 d.addCallback(lambda serialised_items_data: ('',) + serialised_items_data)
937 d.addErrback(lambda failure: (unicode(failure.value), [], {}))
938 # and associate with service/node (needed if there are several comments nodes)
939 d.addCallback(lambda serialised, service_s=service_s, node=node: (service_s, node) + serialised)
940 dlist.append(d)
941 # we get the comments
942 comments_d = defer.gatherResults(dlist)
943 # and add them to the item data
944 comments_d.addCallback(lambda comments_data, item=item: (item, comments_data))
945 items_dlist.append(comments_d)
946 # we gather the items + comments in a list
947 items_d = defer.gatherResults(items_dlist)
948 # and add the metadata
949 items_d.addCallback(lambda items_completed: (items_completed, metadata))
950 return items_d
951
952 deferreds = {}
953 for service, node in node_data:
954 d = deferreds[(service, node)] = self._p.getItems(client, service, node, max_items, rsm_request=rsm_request, extra=extra)
955 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata))
956 d.addCallback(getComments)
957 d.addCallback(lambda items_comments_data: ('', items_comments_data))
958 d.addErrback(lambda failure: (unicode(failure.value), ([],{})))
959
960 return self.rt_sessions.newSession(deferreds, client.profile)
961
962
963 class XEP_0277_handler(XMPPHandler):
964 implements(iwokkel.IDisco)
965
966 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
967 return [disco.DiscoFeature(NS_MICROBLOG)]
968
969 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
970 return []