Mercurial > libervia-backend
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 [] |