comparison src/plugins/plugin_xep_0277.py @ 1459:4c4f88d7b156

plugins xep-0060, xep-0163, xep-0277, groupblog: bloging improvments (huge patch, sorry): /!\ not everything is working yet, and specially groupblogs are broken /!\ - renamed bridge api to use prefixed methods (e.g. psSubscribeToMany instead of subscribeToMany in PubSub) - (xep-0060): try to find a default PubSub service, and put it in client.pubsub_service - (xep-0060): extra dictionary can be used in bridge method for RSM and other options - (xep-0060): XEP_0060.addManagedNode and XEP_0060.removeManagedNode allow to easily catch notifications for a specific node - (xep-0060): retractItem manage "notify" attribute - (xep-0060): new signal psEvent will be used to transmit notifications to frontends - (xep-0060, constants): added a bunch of useful constants - (xep-0163): removed personalEvent in favor of psEvent - (xep-0163): addPEPEvent now filter non PEP events for in_callback - (xep-0277): use of new XEP-0060 plugin's addManagedNode - (xep-0277): fixed author handling for incoming blogs: author is the human readable name, author_jid it jid, and author_jid_verified is set to True is the jid is checked - (xep-0277): reworked data2entry with Twisted instead of feed, item_id can now be specified, <content/> is changed to <title/> if there is only content - (xep-0277): comments are now managed here (core removed from groupblog) - (xep-0277): (comments) node is created if needed, default pubsub service is used if available, else PEP - (xep-0277): retract is managed
author Goffi <goffi@goffi.org>
date Sun, 16 Aug 2015 00:39:44 +0200
parents 4e2fab4de195
children 83f71763e1a7
comparison
equal deleted inserted replaced
1458:832846fefe85 1459:4c4f88d7b156
19 19
20 from sat.core.i18n import _ 20 from sat.core.i18n import _
21 from sat.core.constants import Const as C 21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger 22 from sat.core.log import getLogger
23 log = getLogger(__name__) 23 log = getLogger(__name__)
24 from twisted.words.protocols.jabber import jid 24 from twisted.words.protocols.jabber import jid, error
25 from twisted.words.xish import domish
25 from twisted.internet import defer 26 from twisted.internet import defer
26 from twisted.python import failure 27 from twisted.python import failure
27 from sat.core import exceptions 28 from sat.core import exceptions
28 from sat.tools.xml_tools import ElementParser 29 from sat.tools import xml_tools
29 from sat.tools import sat_defer 30 from sat.tools import sat_defer
30 31
32 # XXX: tmp.pubsub is actually use instead of wokkel version
31 from wokkel import pubsub 33 from wokkel import pubsub
32 from wokkel import rsm 34 from feed.date import rfc3339
33 from feed import atom, date
34 import uuid 35 import uuid
35 from time import time 36 import time
36 import urlparse 37 import urlparse
37 from cgi import escape 38 import urllib
38 39
39 NS_MICROBLOG = 'urn:xmpp:microblog:0' 40 NS_MICROBLOG = 'urn:xmpp:microblog:0'
40 NS_ATOM = 'http://www.w3.org/2005/Atom' 41 NS_ATOM = 'http://www.w3.org/2005/Atom'
41 NS_XHTML = 'http://www.w3.org/1999/xhtml' 42 NS_XHTML = 'http://www.w3.org/1999/xhtml'
42 NS_PUBSUB_EVENT = "{}{}".format(pubsub.NS_PUBSUB, "#event") 43 NS_PUBSUB_EVENT = "{}{}".format(pubsub.NS_PUBSUB, "#event")
44 NS_COMMENT_PREFIX = '{}:comments/'.format(NS_MICROBLOG)
45
43 46
44 PLUGIN_INFO = { 47 PLUGIN_INFO = {
45 "name": "Microblogging over XMPP Plugin", 48 "name": "Microblogging over XMPP Plugin",
46 "import_name": "XEP-0277", 49 "import_name": "XEP-0277",
47 "type": "XEP", 50 "type": "XEP",
63 def __init__(self, host): 66 def __init__(self, host):
64 log.info(_("Microblogging plugin initialization")) 67 log.info(_("Microblogging plugin initialization"))
65 self.host = host 68 self.host = host
66 self._p = self.host.plugins["XEP-0060"] # this facilitate the access to pubsub plugin 69 self._p = self.host.plugins["XEP-0060"] # this facilitate the access to pubsub plugin
67 self.rt_sessions = sat_defer.RTDeferredSessions() 70 self.rt_sessions = sat_defer.RTDeferredSessions()
68 self.host.plugins["XEP-0163"].addPEPEvent("MICROBLOG", NS_MICROBLOG, self.microblogCB, self.sendMicroblog, notify=False) 71 self.host.plugins["XEP-0060"].addManagedNode(None, items_cb=self._itemsReceived)
69 host.bridge.addMethod("getLastMicroblogs", ".plugin", 72
70 in_sign='sis', out_sign='(aa{ss}a{ss})', 73 host.bridge.addMethod("mbSend", ".plugin",
71 method=self._getLastMicroblogs, 74 in_sign='ssa{ss}s', out_sign='',
72 async=True, 75 method=self._mbSend,
73 doc={'summary': 'retrieve items',
74 'param_0': 'jid: publisher of wanted microblog',
75 'param_1': 'max_items: see XEP-0060 #6.5.7',
76 'param_2': '%(doc_profile)s',
77 'return': 'list of microblog data (dict)'})
78 host.bridge.addMethod("setMicroblogAccess", ".plugin", in_sign='ss', out_sign='',
79 method=self.setMicroblogAccess,
80 async=True) 76 async=True)
81 host.bridge.addMethod("mBSubscribeToMany", ".plugin", in_sign='sass', out_sign='s', 77 host.bridge.addMethod("mbRetract", ".plugin",
82 method=self._mBSubscribeToMany) 78 in_sign='ssss', out_sign='',
83 host.bridge.addMethod("mBGetFromManyRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssaa{ss}a{ss}))', 79 method=self._mbRetract,
84 method=self._mBGetFromManyRTResult, async=True) 80 async=True)
85 host.bridge.addMethod("mBGetFromMany", ".plugin", in_sign='sasia{ss}s', out_sign='s', 81 host.bridge.addMethod("mbGetLast", ".plugin",
86 method=self._mBGetFromMany) 82 in_sign='ssia{ss}s', out_sign='(aa{ss}a{ss})',
87 host.bridge.addMethod("mBGetFromManyWithCommentsRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssa(a{ss}a(sssaa{ss}a{ss}))a{ss}))', 83 method=self._mbGetLast,
88 method=self._mBGetFromManyWithCommentsRTResult, async=True) 84 async=True)
89 host.bridge.addMethod("mBGetFromManyWithComments", ".plugin", in_sign='sasiia{ss}a{ss}s', out_sign='s', method=self._mBGetFromManyWithComments) 85 host.bridge.addMethod("mbSetAccess", ".plugin", in_sign='ss', out_sign='',
86 method=self.mbSetAccess,
87 async=True)
88 host.bridge.addMethod("mbSetAccess", ".plugin", in_sign='ss', out_sign='',
89 method=self.mbSetAccess,
90 async=True)
91 host.bridge.addMethod("mbSubscribeToMany", ".plugin", in_sign='sass', out_sign='s',
92 method=self._mbSubscribeToMany)
93 host.bridge.addMethod("mbGetFromManyRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssaa{ss}a{ss}))',
94 method=self._mbGetFromManyRTResult, async=True)
95 host.bridge.addMethod("mbGetFromMany", ".plugin", in_sign='sasia{ss}s', out_sign='s',
96 method=self._mbGetFromMany)
97 host.bridge.addMethod("mbGetFromManyWithCommentsRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssa(a{ss}a(sssaa{ss}a{ss}))a{ss}))',
98 method=self._mbGetFromManyWithCommentsRTResult, async=True)
99 host.bridge.addMethod("mbGetFromManyWithComments", ".plugin", in_sign='sasiia{ss}a{ss}s', out_sign='s', method=self._mbGetFromManyWithComments)
90 100
91 ## plugin management methods ## 101 ## plugin management methods ##
92 102
93 def microblogCB(self, itemsEvent, profile): 103 def _itemsReceived(self, itemsEvent, profile):
94 """Callback to "MICROBLOG" PEP event.""" 104 """Callback which manage items notifications (publish + retract)"""
95 def manageItem(microblog_data): 105 if not itemsEvent.nodeIdentifier.startswith(NS_MICROBLOG):
96 self.host.bridge.personalEvent(itemsEvent.sender.full(), "MICROBLOG", microblog_data, profile) 106 return
107 def manageItem(data, event):
108 self.host.bridge.psEvent(C.PS_MICROBLOG, itemsEvent.sender.full(), itemsEvent.nodeIdentifier, event, data, profile)
97 109
98 for item in itemsEvent.items: 110 for item in itemsEvent.items:
99 self.item2mbdata(item).addCallbacks(manageItem, lambda failure: None) 111 if item.name == C.PS_ITEM:
112 self.item2mbdata(item).addCallbacks(manageItem, lambda failure: None, (C.PS_PUBLISH,))
113 elif item.name == C.PS_RETRACT:
114 manageItem({'id': item['id']}, C.PS_RETRACT)
115 else:
116 raise exceptions.InternalError("Invalid event value")
117
100 118
101 ## data/item transformation ## 119 ## data/item transformation ##
102
103 def _removeXHTMLMarkups(self, xhtml):
104 """Remove XHTML markups from the given string.
105
106 @param xhtml: the XHTML string to be cleaned
107 @return: a Deferred instance for the cleaned string
108 """
109 return self.host.plugins["TEXT-SYNTAXES"].convert(xhtml,
110 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML,
111 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT,
112 False)
113 120
114 @defer.inlineCallbacks 121 @defer.inlineCallbacks
115 def item2mbdata(self, item_elt): 122 def item2mbdata(self, item_elt):
116 """Convert an XML Item to microblog data used in bridge API 123 """Convert an XML Item to microblog data used in bridge API
117 124
149 if type_ == 'xhtml': 156 if type_ == 'xhtml':
150 data_elt = elem.firstChildElement() 157 data_elt = elem.firstChildElement()
151 if data_elt.uri != NS_XHTML: 158 if data_elt.uri != NS_XHTML:
152 raise failure.Failure(exceptions.DataError(_('Content of type XHTML must declare its namespace!'))) 159 raise failure.Failure(exceptions.DataError(_('Content of type XHTML must declare its namespace!')))
153 key = check_conflict(u'{}_xhtml'.format(elem.name)) 160 key = check_conflict(u'{}_xhtml'.format(elem.name))
154 data = unicode(data_elt) 161 data = data_elt.toXml()
155 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].clean_xhtml(data) 162 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].clean_xhtml(data)
156 else: 163 else:
157 key = check_conflict(elem.name) 164 key = check_conflict(elem.name)
158 microblog_data[key] = unicode(elem) 165 microblog_data[key] = unicode(elem)
159 166
194 yield parseElement(content_elt) 201 yield parseElement(content_elt)
195 202
196 # we check that text content is present 203 # we check that text content is present
197 for key in ('title', 'content'): 204 for key in ('title', 'content'):
198 if key not in microblog_data and ('{}_xhtml'.format(key)) in microblog_data: 205 if key not in microblog_data and ('{}_xhtml'.format(key)) in microblog_data:
199 log.warning(u"item {id_} provide a {key}_xhtml data but not a text one".format(id_, key)) 206 log.warning(u"item {id_} provide a {key}_xhtml data but not a text one".format(id_=id_, key=key))
200 # ... and do the conversion if it's not 207 # ... and do the conversion if it's not
201 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].\ 208 microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].\
202 convert(microblog_data['{}_xhtml'.format(key)], 209 convert(microblog_data['{}_xhtml'.format(key)],
203 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML, 210 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML,
204 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT, 211 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT,
216 try: 223 try:
217 updated_elt = entry_elt.elements(NS_ATOM, 'updated').next() 224 updated_elt = entry_elt.elements(NS_ATOM, 'updated').next()
218 except StopIteration: 225 except StopIteration:
219 msg = u'No atom updated element found in the pubsub item {}'.format(id_) 226 msg = u'No atom updated element found in the pubsub item {}'.format(id_)
220 raise failure.Failure(exceptions.DataError(msg)) 227 raise failure.Failure(exceptions.DataError(msg))
221 microblog_data['updated'] = unicode(date.rfc3339.tf_from_timestamp(unicode(updated_elt))) 228 microblog_data['updated'] = unicode(rfc3339.tf_from_timestamp(unicode(updated_elt)))
222 try: 229 try:
223 published_elt = entry_elt.elements(NS_ATOM, 'published').next() 230 published_elt = entry_elt.elements(NS_ATOM, 'published').next()
224 except StopIteration: 231 except StopIteration:
225 microblog_data['published'] = microblog_data['updated'] 232 microblog_data['published'] = microblog_data['updated']
226 else: 233 else:
227 microblog_data['published'] = unicode(date.rfc3339.tf_from_timestamp(unicode(published_elt))) 234 microblog_data['published'] = unicode(rfc3339.tf_from_timestamp(unicode(published_elt)))
228 235
229 # links 236 # links
230 for link_elt in entry_elt.elements(NS_ATOM, 'link'): 237 for link_elt in entry_elt.elements(NS_ATOM, 'link'):
231 if link_elt.getAttribute('rel') == 'replies' and link_elt.getAttribute('title') == 'comments': 238 if link_elt.getAttribute('rel') == 'replies' and link_elt.getAttribute('title') == 'comments':
232 key = check_conflict('comments', True) 239 key = check_conflict('comments', True)
244 title = link_elt.getAttribute('title','') 251 title = link_elt.getAttribute('title','')
245 href = link_elt.getAttribute('href','') 252 href = link_elt.getAttribute('href','')
246 log.warning(u"Unmanaged link element: rel={rel} title={title} href={href}".format(rel=rel, title=title, href=href)) 253 log.warning(u"Unmanaged link element: rel={rel} title={title} href={href}".format(rel=rel, title=title, href=href))
247 254
248 # author 255 # author
256 publisher = item_elt.getAttribute("publisher")
249 try: 257 try:
250 author_elt = entry_elt.elements(NS_ATOM, 'author').next() 258 author_elt = entry_elt.elements(NS_ATOM, 'author').next()
251 except StopIteration: 259 except StopIteration:
252 log.debug("Can't find author element in item {}".format(id_)) 260 log.debug("Can't find author element in item {}".format(id_))
253 else: 261 else:
261 # uri 269 # uri
262 try: 270 try:
263 uri_elt = author_elt.elements(NS_ATOM, 'uri').next() 271 uri_elt = author_elt.elements(NS_ATOM, 'uri').next()
264 except StopIteration: 272 except StopIteration:
265 log.debug("No uri element found in author element of item {}".format(id_)) 273 log.debug("No uri element found in author element of item {}".format(id_))
274 if publisher:
275 microblog_data['author_jid'] = publisher
266 else: 276 else:
267 uri = unicode(uri_elt) 277 uri = unicode(uri_elt)
268 if uri.startswith("xmpp:"): 278 if uri.startswith("xmpp:"):
269 uri = uri[5:] 279 uri = uri[5:]
270 microblog_data['author_uri'] = uri 280 microblog_data['author_jid'] = uri
271 if item_elt.getAttribute("publisher") == uri: 281 else:
272 microblog_data['author_uri_verified'] = C.BOOL_TRUE 282 microblog_data['author_jid'] = item_elt.getAttribute("publisher") or ""
283
284 if not publisher:
285 log.debug("No publisher attribute, we can't verify author jid")
286 microblog_data['author_jid_verified'] = C.BOOL_FALSE
287 elif publisher == uri:
288 microblog_data['author_jid_verified'] = C.BOOL_TRUE
273 else: 289 else:
274 log.warning("item atom:uri differ from publisher attribute, spoofing attempt ? atom:uri = {} publisher = {}".format(uri, item_elt.getAttribute("publisher"))) 290 log.warning("item atom:uri differ from publisher attribute, spoofing attempt ? atom:uri = {} publisher = {}".format(uri, item_elt.getAttribute("publisher")))
275 microblog_data['author_uri_verified'] = C.BOOL_FALSE 291 microblog_data['author_jid_verified'] = C.BOOL_FALSE
276 # email 292 # email
277 try: 293 try:
278 email_elt = author_elt.elements(NS_ATOM, 'email').next() 294 email_elt = author_elt.elements(NS_ATOM, 'email').next()
279 except StopIteration: 295 except StopIteration:
280 pass 296 pass
282 microblog_data['author_email'] = unicode(email_elt) 298 microblog_data['author_email'] = unicode(email_elt)
283 299
284 defer.returnValue(microblog_data) 300 defer.returnValue(microblog_data)
285 301
286 @defer.inlineCallbacks 302 @defer.inlineCallbacks
287 def data2entry(self, data, profile): 303 def data2entry(self, data, item_id=None, profile_key=C.PROF_KEY_NONE):
288 """Convert a data dict to en entry usable to create an item 304 """Convert a data dict to en entry usable to create an item
289 305
290 @param data: data dict as given by bridge method. 306 @param data: data dict as given by bridge method.
307 @param item_id(unicode, None): id of the item to use
308 if None the id will be generated randomly
291 @return: deferred which fire domish.Element 309 @return: deferred which fire domish.Element
292 """ 310 """
293 #TODO: rewrite this directly with twisted (i.e. without atom / reparsing) 311 if item_id is None:
294 _uuid = unicode(uuid.uuid1()) 312 item_id = unicode(uuid.uuid4())
295 _entry = atom.Entry() 313 client = self.host.getClient(profile_key)
296 _entry.title = '' # reset the default value which is not empty 314 entry_elt = domish.Element((NS_ATOM, 'entry'))
297 315
298 elems = {'title': atom.Title, 'content': atom.Content} 316 ## content and title ##
299 synt = self.host.plugins["TEXT-SYNTAXES"] 317 synt = self.host.plugins["TEXT-SYNTAXES"]
300 318
301 # loop on ('title', 'title_rich', 'title_xhtml', 'content', 'content_rich', 'content_xhtml') 319 for elem_name in ('title', 'content'):
302 for key in elems.keys(): 320 for type_ in ['', '_rich', '_xhtml']:
303 for type_ in ['', 'rich', 'xhtml']: 321 attr = "{}{}".format(elem_name, type_)
304 attr = "%s_%s" % (key, type_) if type_ else key
305 if attr in data: 322 if attr in data:
323 elem = entry_elt.addElement(elem_name)
306 if type_: 324 if type_:
307 if type_ == 'rich': # convert input from current syntax to XHTML 325 if type_ == '_rich': # convert input from current syntax to XHTML
308 converted = yield synt.convert(data[attr], synt.getCurrentSyntax(profile), "XHTML") 326 converted = yield synt.convert(data[attr], synt.getCurrentSyntax(profile_key), "XHTML")
327 if '{}_xhtml'.format(elem_name) in data:
328 raise failure.Failure(exceptions.DataError(_("Can't have xhtml and rich content at the same time")))
309 else: # clean the XHTML input 329 else: # clean the XHTML input
310 converted = yield synt.clean_xhtml(data[attr]) 330 converted = yield synt.clean_xhtml(data[attr])
311 elem = elems[key]((u'<div xmlns="%s">%s</div>' % (NS_XHTML, converted)).encode('utf-8')) 331
312 elem.attrs['type'] = 'xhtml' 332 xml_content = u'<div xmlns="{ns}">{converted}</div>'.format(
313 if hasattr(_entry, '%s_xhtml' % key): 333 ns=NS_XHTML,
314 raise failure.Failure(exceptions.DataError(_("Can't have xhtml and rich content at the same time"))) 334 converted=converted)
315 setattr(_entry, '%s_xhtml' % key, elem) 335 elem.addChild(xml_tools.ElementParser()(xml_content))
336 elem['type'] = 'xhtml'
337 if elem_name not in data:
338 # there is raw text content, which is mandatory
339 # so we create one from xhtml content
340 elem_txt = entry_elt.addElement(elem_name)
341 text_content = yield self.host.plugins["TEXT-SYNTAXES"].convert(xml_content,
342 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML,
343 self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT,
344 False)
345 elem_txt.addContent(text_content)
346 elem_txt['type'] = 'text'
347
316 else: # raw text only needs to be escaped to get HTML-safe sequence 348 else: # raw text only needs to be escaped to get HTML-safe sequence
317 elem = elems[key](escape(data[attr]).encode('utf-8')) 349 elem.addContent(data[attr])
318 elem.attrs['type'] = 'text' 350 elem['type'] = 'text'
319 setattr(_entry, key, elem) 351
320 if not getattr(_entry, key).text: 352 try:
321 if hasattr(_entry, '%s_xhtml' % key): 353 entry_elt.elements(NS_ATOM, 'title').next()
322 text = yield self._removeXHTMLMarkups(getattr(_entry, '%s_xhtml' % key).text) 354 except StopIteration:
323 setattr(_entry, key, text) 355 # we have no title element which is mandatory
324 if not _entry.title.text: # eventually move the data from content to title 356 # so we transform content element to title
325 _entry.title = _entry.content.text 357 elems = list(entry_elt.elements(NS_ATOM, 'content'))
326 _entry.title.attrs['type'] = _entry.content.attrs['type'] 358 if not elems:
327 _entry.content.text = '' 359 raise exceptions.DataError("There must be at least one content or title element")
328 _entry.content.attrs['type'] = '' 360 for elem in elems:
329 if hasattr(_entry, 'content_xhtml'): 361 elem.name = 'title'
330 _entry.title_xhtml = atom.Title(_entry.content_xhtml.text) 362
331 _entry.title_xhtml.attrs['type'] = _entry.content_xhtml.attrs['type'] 363 ## author ##
332 _entry.content_xhtml.text = '' 364 author_elt = entry_elt.addElement('author')
333 _entry.content_xhtml.attrs['type'] = '' 365 try:
334 366 author_name = data['author']
335 _entry.author = atom.Author() 367 except KeyError:
336 _entry.author.name = data.get('author', self.host.getJidNStream(profile)[0].userhost()).encode('utf-8') 368 # FIXME: must use better name
337 _entry.updated = float(data.get('updated', time())) 369 author_name = client.jid.user
338 _entry.published = float(data.get('published', time())) 370 author_elt.addElement('name', content=author_name)
339 entry_id = data.get('id', unicode(_uuid)) 371
340 _entry.id = entry_id.encode('utf-8') 372 try:
373 author_jid_s = data['author_jid']
374 except KeyError:
375 author_jid_s = client.jid.userhost()
376 author_elt.addElement('uri', content="xmpp:{}".format(author_jid_s))
377
378 ## published/updated time ##
379 current_time = time.time()
380 entry_elt.addElement('updated',
381 content=rfc3339.timestamp_from_tf(float(data.get('updated', current_time))))
382 entry_elt.addElement('published',
383 content=rfc3339.timestamp_from_tf(float(data.get('published', current_time))))
384
385 ## id ##
386 entry_id = data.get('id', item_id) # FIXME: use a proper id (see XEP-0277 §7.1)
387 entry_elt.addElement('id', content=entry_id) #
388
389 ## comments ##
341 if 'comments' in data: 390 if 'comments' in data:
342 link = atom.Link() 391 link_elt = entry_elt.addElement('link')
343 link.attrs['href'] = data['comments'] 392 link_elt['href'] = data['comments']
344 link.attrs['rel'] = 'replies' 393 link_elt['rel'] = 'replies'
345 link.attrs['title'] = 'comments' 394 link_elt['title'] = 'comments'
346 _entry.links.append(link) 395
347 _entry_elt = ElementParser()(str(_entry).decode('utf-8')) 396 ## final item building ##
348 item = pubsub.Item(id=entry_id, payload=_entry_elt) 397 item_elt = pubsub.Item(id=item_id, payload=entry_elt)
349 defer.returnValue(item) 398 defer.returnValue(item_elt)
350 399
351 ## publish ## 400 ## publish ##
352 401
353 @defer.inlineCallbacks 402 @defer.inlineCallbacks
354 def sendMicroblog(self, data, profile): 403 def _manageComments(self, access, mb_data, service, node, item_id, profile):
404 """Check comments keys in mb_data and create comments node if necessary
405
406 @param access(unicode): access model
407 @param mb_data(dict): microblog mb_data
408 @param service(jid.JID): Pubsub service of the parent item
409 @param node(unicode): node of the parent item
410 @param item_id(unicoe): id of the parent item
411 """
412 allow_comments = C.bool(mb_data.pop("allow_comments", "false"))
413 if not allow_comments:
414 return
415
416 client = self.host.getClient(profile)
417
418 options = {self._p.OPT_ACCESS_MODEL: access,
419 self._p.OPT_PERSIST_ITEMS: 1,
420 self._p.OPT_MAX_ITEMS: -1,
421 self._p.OPT_DELIVER_PAYLOADS: 1,
422 self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
423 self._p.OPT_PUBLISH_MODEL: "subscribers", # TODO: should be open if *both* node and item access_model are open (public node and item)
424 }
425
426 comments_node_base = u"{}{}".format(NS_COMMENT_PREFIX, item_id)
427 comments_node = comments_node_base
428
429 suffix = None
430 comments_service = client.pubsub_service if client.pubsub_service is not None else service
431 max_tries = 3
432
433 for i in xrange(max_tries+1):
434 try:
435 yield self._p.createNode(comments_service, comments_node, options, profile_key=profile)
436 break
437 except error.StanzaError as e:
438 if e.condition == 'conflict' and i<max_tries:
439 log.warning(u"node {} already exists on service {}".format(comments_node, comments_service))
440 suffix = 0 if suffix is None else suffix + 1
441 comments_node = u"{}_{}".format(comments_node_base, suffix)
442 else:
443 raise e
444
445 if comments_service is None:
446 comments_service = client.jid.userhostJID()
447
448 mb_data['comments'] = "xmpp:%(service)s?%(query)s" % {
449 'service': comments_service.userhost(),
450 'query': urllib.urlencode([('node', comments_node.encode('utf-8'))])
451 }
452
453 def _mbSend(self, service, node, data, profile_key):
454 service = jid.JID(service) if service else None
455 node = node if node else NS_MICROBLOG
456 profile = self.host.memory.getProfileName(profile_key)
457 return self.send(service, node, data, profile)
458
459 @defer.inlineCallbacks
460 def send(self, service=None, node=NS_MICROBLOG, data=None, profile=None):
355 """Send XEP-0277's microblog data 461 """Send XEP-0277's microblog data
356 462
357 @param data: must include content 463 @param service(jid.JID, None): PubSub service where the microblog must be published
358 @param profile: profile which send the mood""" 464 None to publish on profile's PEP
359 if 'content' not in data: 465 @param node(unicode): PubSub node to use (defaut to microblog NS)
360 log.error("Microblog data must contain at least 'content' key") 466 @param data(dict): microblog data (must include at least a "content" or a "title" key).
361 raise failure.Failure(exceptions.DataError('no "content" key found')) 467 see http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en for details
362 content = data['content'] 468 @param profile: %(doc_profile)s
363 if not content: 469 """
364 log.error("Microblog data's content value must not be empty") 470 assert profile is not None
365 raise failure.Failure(exceptions.DataError('empty content')) 471
366 item = yield self.data2entry(data, profile) 472 item_id = data.get('id') or unicode(uuid.uuid4())
367 ret = yield self._p.publish(None, NS_MICROBLOG, [item], profile_key=profile) 473 try:
474 yield self._manageComments(self._p.ACCESS_OPEN, data, service, node, item_id, profile)
475 except error.StanzaError:
476 log.warning("Can't create comments node for item {}".format(item_id))
477 item = yield self.data2entry(data, item_id, profile)
478 ret = yield self._p.publish(service, node, [item], profile_key=profile)
368 defer.returnValue(ret) 479 defer.returnValue(ret)
369 480
481
482 ## retract ##
483
484 def _mbRetract(self, service_jid_s, nodeIdentifier, itemIdentifier, profile_key):
485 """Call self._p._retractItem, but use default node if node is empty"""
486 return self._p._retractItem(service_jid_s, nodeIdentifier or NS_MICROBLOG, itemIdentifier, True, profile_key)
487
370 ## get ## 488 ## get ##
371 489
372 def _getLastMicroblogs(self, pub_jid_s, max_items=10, profile_key=C.PROF_KEY_NONE): 490 def _mbGetLast(self, service_jid_s, node="", max_items=10, extra_dict=None, profile_key=C.PROF_KEY_NONE):
373 return self.getLastMicroblogs(jid.JID(pub_jid_s), max_items, profile_key) 491 """
492 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
493 """
494 max_items = None if max_items == C.NO_LIMIT else max_items
495 extra = self._p.parseExtra(extra_dict)
496 return self.mbGetLast(jid.JID(service_jid_s), node or None, max_items, extra.rsm_request, extra.extra, profile_key)
374 497
375 @defer.inlineCallbacks 498 @defer.inlineCallbacks
376 def getLastMicroblogs(self, pub_jid, max_items=10, profile_key=C.PROF_KEY_NONE): 499 def mbGetLast(self, service_jid, node=None, max_items=None, rsm_request=None, extra=None, profile_key=C.PROF_KEY_NONE):
377 """Get the last published microblogs 500 """Get the last published microblogs
378 501
379 @param pub_jid(jid.JID): jid of the publisher 502 @param service_jid(jid.JID): jid of the publisher
380 @param max_items: how many microblogs we want to get 503 @param node(unicode, None): node to get (or microblog node if None)
504 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
505 @param rsm_request (rsm.RSMRequest): RSM request data
381 @param profile_key: profile key 506 @param profile_key: profile key
382 507
383 @return: a deferred couple with the list of items and metadatas. 508 @return: a deferred couple with the list of items and metadatas.
384 """ 509 """
385 items_data = yield self._p.getItems(pub_jid, NS_MICROBLOG, max_items=max_items, profile_key=profile_key) 510 if node is None:
511 node = NS_MICROBLOG
512 items_data = yield self._p.getItems(service_jid, node, max_items=max_items, rsm_request=rsm_request, extra=extra, profile_key=profile_key)
386 serialised = yield self._p.serItemsDataD(items_data, self.item2mbdata) 513 serialised = yield self._p.serItemsDataD(items_data, self.item2mbdata)
387 defer.returnValue(serialised) 514 defer.returnValue(serialised)
388 515
389 def parseCommentUrl(self, node_url): 516 def parseCommentUrl(self, node_url):
390 """Parse a XMPP URI 517 """Parse a XMPP URI
408 535
409 return (service, node) 536 return (service, node)
410 537
411 ## configure ## 538 ## configure ##
412 539
413 def setMicroblogAccess(self, access="presence", profile_key=C.PROF_KEY_NONE): 540 def mbSetAccess(self, access="presence", profile_key=C.PROF_KEY_NONE):
414 """Create a microblog node on PEP with given access 541 """Create a microblog node on PEP with given access
415 542
416 If the node already exists, it change options 543 If the node already exists, it change options
417 @param access: Node access model, according to xep-0060 #4.5 544 @param access: Node access model, according to xep-0060 #4.5
418 @param profile_key: profile key""" 545 @param profile_key: profile key"""
489 publishers = None 616 publishers = None
490 elif publishers_type == C.JID: 617 elif publishers_type == C.JID:
491 publishers[:] = [jid.JID(publisher) for publisher in publishers] 618 publishers[:] = [jid.JID(publisher) for publisher in publishers]
492 return publishers_type, publishers 619 return publishers_type, publishers
493 620
621
622
494 # subscribe # 623 # subscribe #
495 624
496 def _mBSubscribeToMany(self, publishers_type, publishers, profile_key): 625 def _mbSubscribeToMany(self, publishers_type, publishers, profile_key):
497 """ 626 """
498 627
499 @return (str): session id: Use pubsub.getSubscribeRTResult to get the results 628 @return (str): session id: Use pubsub.getSubscribeRTResult to get the results
500 """ 629 """
501 publishers_type, publishers = self._checkPublishers(publishers_type, publishers) 630 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
502 return self.mBSubscribeToMany(publishers_type, publishers, profile_key) 631 return self.mbSubscribeToMany(publishers_type, publishers, profile_key)
503 632
504 def mBSubscribeToMany(self, publishers_type, publishers, profile_key): 633 def mbSubscribeToMany(self, publishers_type, publishers, profile_key):
505 """Subscribe microblogs for a list of groups or jids 634 """Subscribe microblogs for a list of groups or jids
506 635
507 @param publishers_type: type of the list of publishers, one of: 636 @param publishers_type: type of the list of publishers, one of:
508 C.ALL: get all jids from roster, publishers is not used 637 C.ALL: get all jids from roster, publishers is not used
509 C.GROUP: get jids from groups 638 C.GROUP: get jids from groups
515 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) 644 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
516 return self._p.subscribeToMany(node_data, client.jid.userhostJID(), profile_key=profile_key) 645 return self._p.subscribeToMany(node_data, client.jid.userhostJID(), profile_key=profile_key)
517 646
518 # get # 647 # get #
519 648
520 def _mBGetFromManyRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): 649 def _mbGetFromManyRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
521 """Get real-time results for mBGetFromMany session 650 """Get real-time results for mbGetFromMany session
522 651
523 @param session_id: id of the real-time deferred session 652 @param session_id: id of the real-time deferred session
524 @param return (tuple): (remaining, results) where: 653 @param return (tuple): (remaining, results) where:
525 - remaining is the number of still expected results 654 - remaining is the number of still expected results
526 - results is a list of tuple with 655 - results is a list of tuple with
527 - service (unicode): pubsub service 656 - service (unicode): pubsub service
528 - node (unicode): pubsub node 657 - node (unicode): pubsub node
529 - failure (unicode): empty string in case of success, error message else 658 - failure (unicode): empty string in case of success, error message else
530 - items_data(tuple): data tuple as returned by [getLastMicroblogs] 659 - items_data(list): data as returned by [mbGetLast]
660 - items_metadata(dict): metadata as returned by [mbGetLast]
531 @param profile_key: %(doc_profile_key)s 661 @param profile_key: %(doc_profile_key)s
532 """ 662 """
533 def onSuccess(items_data): 663 def onSuccess(items_data):
534 """convert items elements to list of microblog data in items_data""" 664 """convert items elements to list of microblog data in items_data"""
535 d = self._p.serItemsDataD(items_data, self.item2mbdata) 665 d = self._p.serItemsDataD(items_data, self.item2mbdata)
544 d.addCallback(lambda ret: (ret[0], 674 d.addCallback(lambda ret: (ret[0],
545 [(service.full(), node, failure, items, metadata) 675 [(service.full(), node, failure, items, metadata)
546 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()])) 676 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()]))
547 return d 677 return d
548 678
549 def _mBGetFromMany(self, publishers_type, publishers, max_item=10, rsm_dict=None, profile_key=C.PROF_KEY_NONE): 679 def _mbGetFromMany(self, publishers_type, publishers, max_items=10, extra_dict=None, profile_key=C.PROF_KEY_NONE):
550 """ 680 """
551 @param max_item(int): maximum number of item to get, C.NO_LIMIT for no limit 681 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
552 """ 682 """
553 max_item = None if max_item == C.NO_LIMIT else max_item 683 max_items = None if max_items == C.NO_LIMIT else max_items
554 publishers_type, publishers = self._checkPublishers(publishers_type, publishers) 684 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
555 return self.mBGetFromMany(publishers_type, publishers, max_item, rsm.RSMRequest(**rsm_dict) if rsm_dict else None, profile_key) 685 extra = self._p.parseExtra(extra_dict)
556 686 return self.mbGetFromMany(publishers_type, publishers, max_items, extra.rsm_request, extra.extra, profile_key)
557 def mBGetFromMany(self, publishers_type, publishers, max_item=None, rsm_data=None, profile_key=C.PROF_KEY_NONE): 687
688 def mbGetFromMany(self, publishers_type, publishers, max_items=None, rsm_request=None, extra=None, profile_key=C.PROF_KEY_NONE):
558 """Get the published microblogs for a list of groups or jids 689 """Get the published microblogs for a list of groups or jids
559 690
560 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL") 691 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
561 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids) 692 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
562 @param max_items (int): optional limit on the number of retrieved items. 693 @param max_items (int): optional limit on the number of retrieved items.
563 @param rsm_data (rsm.RSMRequest): RSM request data, common to all publishers 694 @param rsm_request (rsm.RSMRequest): RSM request data, common to all publishers
695 @param extra (dict): Extra data
564 @param profile_key: profile key 696 @param profile_key: profile key
565 @return: a deferred dict with: 697 @return (str): RT Deferred session id
566 - key: publisher (unicode) 698 """
567 - value: couple (list[dict], dict) with: 699 # XXX: extra is unused here so far
568 - the microblogs data
569 - RSM response data
570 """
571 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) 700 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
572 return self._p.getFromMany(node_data, max_item, rsm_data, profile_key=profile_key) 701 return self._p.getFromMany(node_data, max_items, rsm_request, profile_key=profile_key)
573 702
574 # comments # 703 # comments #
575 704
576 def _mBGetFromManyWithCommentsRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): 705 def _mbGetFromManyWithCommentsRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
577 """Get real-time results for [mBGetFromManyWithComments] session 706 """Get real-time results for [mbGetFromManyWithComments] session
578 707
579 @param session_id: id of the real-time deferred session 708 @param session_id: id of the real-time deferred session
580 @param return (tuple): (remaining, results) where: 709 @param return (tuple): (remaining, results) where:
581 - remaining is the number of still expected results 710 - remaining is the number of still expected results
582 - results is a list of tuple with 711 - results is a list of tuple with
583 - service (unicode): pubsub service 712 - service (unicode): pubsub service
584 - node (unicode): pubsub node 713 - node (unicode): pubsub node
585 - success (bool): True if the getItems was successful
586 - failure (unicode): empty string in case of success, error message else 714 - failure (unicode): empty string in case of success, error message else
587 - items(list): list of items with: 715 - items(list): list of items with:
588 - item(dict): item microblog data 716 - item(dict): item microblog data
589 - comments_list(list): list of comments with 717 - comments_list(list): list of comments with
590 - service (unicode): pubsub service where the comments node is 718 - service (unicode): pubsub service where the comments node is
600 d.addCallback(lambda ret: (ret[0], 728 d.addCallback(lambda ret: (ret[0],
601 [(service.full(), node, failure, items, metadata) 729 [(service.full(), node, failure, items, metadata)
602 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()])) 730 for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()]))
603 return d 731 return d
604 732
605 def _mBGetFromManyWithComments(self, publishers_type, publishers, max_item=10, max_comments=C.NO_LIMIT, rsm_dict=None, rsm_comments_dict=None, profile_key=C.PROF_KEY_NONE): 733 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):
606 """ 734 """
607 @param max_item(int): maximum number of item to get, C.NO_LIMIT for no limit 735 @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
608 @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no limit 736 @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no limit
609 """ 737 """
610 max_item = None if max_item == C.NO_LIMIT else max_item 738 max_items = None if max_items == C.NO_LIMIT else max_items
611 max_comments = None if max_comments == C.NO_LIMIT else max_comments 739 max_comments = None if max_comments == C.NO_LIMIT else max_comments
612 publishers_type, publishers = self._checkPublishers(publishers_type, publishers) 740 publishers_type, publishers = self._checkPublishers(publishers_type, publishers)
613 return self.mBGetFromManyWithComments(publishers_type, publishers, max_item, max_comments, 741 extra = self._p.parseExtra(extra_dict)
614 rsm.RSMRequest(**rsm_dict) if rsm_dict else None, 742 extra_comments = self._p.parseExtra(extra_comments_dict)
615 rsm.RSMRequest(**rsm_comments_dict) if rsm_comments_dict else None, 743 return self.mbGetFromManyWithComments(publishers_type, publishers, max_items, max_comments,
744 extra.rsm_request,
745 extra.extra,
746 extra_comments.rsm_request,
747 extra_comments.extra,
616 profile_key) 748 profile_key)
617 749
618 def mBGetFromManyWithComments(self, publishers_type, publishers, max_item=None, max_comments=None, rsm_request=None, rsm_comments=None, profile_key=C.PROF_KEY_NONE): 750 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):
619 """Helper method to get the microblogs and their comments in one shot 751 """Helper method to get the microblogs and their comments in one shot
620 752
621 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL") 753 @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
622 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids) 754 @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
623 @param max_items (int): optional limit on the number of retrieved items. 755 @param max_items (int): optional limit on the number of retrieved items.
624 @param max_comments (int): maximum number of comments to retrieve 756 @param max_comments (int): maximum number of comments to retrieve
625 @param rsm_request (rsm.RSMRequest): RSM request for initial items only 757 @param rsm_request (rsm.RSMRequest): RSM request for initial items only
758 @param extra (dict): extra configuration for initial items only
626 @param rsm_comments (rsm.RSMRequest): RSM request for comments only 759 @param rsm_comments (rsm.RSMRequest): RSM request for comments only
760 @param extra_comments (dict): extra configuration for comments only
627 @param profile_key: profile key 761 @param profile_key: profile key
628 @return: a deferred dict with: 762 @return (str): RT Deferred session id
629 - key: publisher (unicode)
630 - value: couple (list[dict], dict) with:
631 - the microblogs data
632 - RSM response data
633 """ 763 """
634 # XXX: this method seems complicated because it do a couple of treatments 764 # XXX: this method seems complicated because it do a couple of treatments
635 # to serialise and associate the data, but it make life in frontends side 765 # to serialise and associate the data, but it make life in frontends side
636 # a lot easier 766 # a lot easier
637 767
651 if key.startswith('comments') and key.endswith('_service'): 781 if key.startswith('comments') and key.endswith('_service'):
652 prefix = key[:key.find('_')] 782 prefix = key[:key.find('_')]
653 service_s = value 783 service_s = value
654 node = item["{}{}".format(prefix, "_node")] 784 node = item["{}{}".format(prefix, "_node")]
655 # time to get the comments 785 # time to get the comments
656 d = self._p.getItems(jid.JID(service_s), node, max_comments, rsm_request=rsm_comments, profile_key=profile_key) 786 d = self._p.getItems(jid.JID(service_s), node, max_comments, rsm_request=rsm_comments, extra=extra_comments, profile_key=profile_key)
657 # then serialise 787 # then serialise
658 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata)) 788 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata))
659 # with failure handling 789 # with failure handling
660 d.addCallback(lambda serialised_items_data: ('',) + serialised_items_data) 790 d.addCallback(lambda serialised_items_data: ('',) + serialised_items_data)
661 d.addErrback(lambda failure: (unicode(failure.value), [], {})) 791 d.addErrback(lambda failure: (unicode(failure.value), [], {}))
662 # and associate with service/node (needed if there are several comments nodes) 792 # and associate with service/node (needed if there are several comments nodes)
663 d.addCallback(lambda serialised: (service_s, node) + serialised) 793 d.addCallback(lambda serialised, service_s=service_s, node=node: (service_s, node) + serialised)
664 dlist.append(d) 794 dlist.append(d)
665 # we get the comments 795 # we get the comments
666 comments_d = defer.gatherResults(dlist) 796 comments_d = defer.gatherResults(dlist)
667 # and add them to the item data 797 # and add them to the item data
668 comments_d.addCallback(lambda comments_data: (item, comments_data)) 798 comments_d.addCallback(lambda comments_data, item=item: (item, comments_data))
669 items_dlist.append(comments_d) 799 items_dlist.append(comments_d)
670 # we gather the items + comments in a list 800 # we gather the items + comments in a list
671 items_d = defer.gatherResults(items_dlist) 801 items_d = defer.gatherResults(items_dlist)
672 # and add the metadata 802 # and add the metadata
673 items_d.addCallback(lambda items: (items, metadata)) 803 items_d.addCallback(lambda items_completed: (items_completed, metadata))
674 return items_d 804 return items_d
675 805
676 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) 806 client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key)
677 deferreds = {} 807 deferreds = {}
678 for service, node in node_data: 808 for service, node in node_data:
679 d = deferreds[(service, node)] = self._p.getItems(service, node, max_item, rsm_request=rsm_request, profile_key=profile_key) 809 d = deferreds[(service, node)] = self._p.getItems(service, node, max_items, rsm_request=rsm_request, extra=extra, profile_key=profile_key)
680 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata)) 810 d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata))
681 d.addCallback(getComments) 811 d.addCallback(getComments)
682 d.addCallback(lambda items_comments_data: ('', items_comments_data)) 812 d.addCallback(lambda items_comments_data: ('', items_comments_data))
683 d.addErrback(lambda failure: (unicode(failure.value), ([],{}))) 813 d.addErrback(lambda failure: (unicode(failure.value), ([],{})))
684 814