comparison sat/plugins/plugin_xep_0055.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_0055.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 Jabber Search (xep-0055)
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 _, D_
21 from sat.core.log import getLogger
22 log = getLogger(__name__)
23
24 from twisted.words.protocols.jabber.xmlstream import IQ
25 from twisted.words.protocols.jabber import jid
26 from twisted.internet import defer
27 from wokkel import data_form
28 from sat.core.constants import Const as C
29 from sat.core.exceptions import DataError
30 from sat.tools import xml_tools
31
32 from wokkel import disco, iwokkel
33 try:
34 from twisted.words.protocols.xmlstream import XMPPHandler
35 except ImportError:
36 from wokkel.subprotocols import XMPPHandler
37 from zope.interface import implements
38
39
40 NS_SEARCH = 'jabber:iq:search'
41
42 PLUGIN_INFO = {
43 C.PI_NAME: "Jabber Search",
44 C.PI_IMPORT_NAME: "XEP-0055",
45 C.PI_TYPE: "XEP",
46 C.PI_PROTOCOLS: ["XEP-0055"],
47 C.PI_DEPENDENCIES: [],
48 C.PI_RECOMMENDATIONS: ["XEP-0059"],
49 C.PI_MAIN: "XEP_0055",
50 C.PI_HANDLER: "no",
51 C.PI_DESCRIPTION: _("""Implementation of Jabber Search""")
52 }
53
54 # config file parameters
55 CONFIG_SECTION = "plugin search"
56 CONFIG_SERVICE_LIST = "service_list"
57
58 DEFAULT_SERVICE_LIST = ["salut.libervia.org"]
59
60 FIELD_SINGLE = "field_single" # single text field for the simple search
61 FIELD_CURRENT_SERVICE = "current_service_jid" # read-only text field for the advanced search
62
63 class XEP_0055(object):
64
65 def __init__(self, host):
66 log.info(_("Jabber search plugin initialization"))
67 self.host = host
68
69 # default search services (config file + hard-coded lists)
70 self.services = [jid.JID(entry) for entry in host.memory.getConfig(CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST)]
71
72 host.bridge.addMethod("searchGetFieldsUI", ".plugin", in_sign='ss', out_sign='s',
73 method=self._getFieldsUI,
74 async=True)
75 host.bridge.addMethod("searchRequest", ".plugin", in_sign='sa{ss}s', out_sign='s',
76 method=self._searchRequest,
77 async=True)
78
79 self.__search_menu_id = host.registerCallback(self._getMainUI, with_data=True)
80 host.importMenu((D_("Contacts"), D_("Search directory")), self._getMainUI, security_limit=1, help_string=D_("Search user directory"))
81
82 def _getHostServices(self, profile):
83 """Return the jabber search services associated to the user host.
84
85 @param profile (unicode): %(doc_profile)s
86 @return: list[jid.JID]
87 """
88 client = self.host.getClient(profile)
89 d = self.host.findFeaturesSet(client, [NS_SEARCH])
90 return d.addCallback(lambda set_: list(set_))
91
92
93 ## Main search UI (menu item callback) ##
94
95
96 def _getMainUI(self, raw_data, profile):
97 """Get the XMLUI for selecting a service and searching the directory.
98
99 @param raw_data (dict): data received from the frontend
100 @param profile (unicode): %(doc_profile)s
101 @return: a deferred XMLUI string representation
102 """
103 # check if the user's server offers some search services
104 d = self._getHostServices(profile)
105 return d.addCallback(lambda services: self.getMainUI(services, raw_data, profile))
106
107 def getMainUI(self, services, raw_data, profile):
108 """Get the XMLUI for selecting a service and searching the directory.
109
110 @param services (list[jid.JID]): search services offered by the user server
111 @param raw_data (dict): data received from the frontend
112 @param profile (unicode): %(doc_profile)s
113 @return: a deferred XMLUI string representation
114 """
115 # extend services offered by user's server with the default services
116 services.extend([service for service in self.services if service not in services])
117 data = xml_tools.XMLUIResult2DataFormResult(raw_data)
118 main_ui = xml_tools.XMLUI(C.XMLUI_WINDOW, container="tabs", title=_("Search users"), submit_id=self.__search_menu_id)
119
120 d = self._addSimpleSearchUI(services, main_ui, data, profile)
121 d.addCallback(lambda dummy: self._addAdvancedSearchUI(services, main_ui, data, profile))
122 return d.addCallback(lambda dummy: {'xmlui': main_ui.toXml()})
123
124 def _addSimpleSearchUI(self, services, main_ui, data, profile):
125 """Add to the main UI a tab for the simple search.
126
127 Display a single input field and search on the main service (it actually does one search per search field and then compile the results).
128
129 @param services (list[jid.JID]): search services offered by the user server
130 @param main_ui (XMLUI): the main XMLUI instance
131 @param data (dict): form data without SAT_FORM_PREFIX
132 @param profile (unicode): %(doc_profile)s
133
134 @return: a dummy Deferred
135 """
136 service_jid = services[0] # TODO: search on all the given services, not only the first one
137
138 form = data_form.Form('form', formNamespace=NS_SEARCH)
139 form.addField(data_form.Field('text-single', FIELD_SINGLE, label=_('Search for'), value=data.get(FIELD_SINGLE, '')))
140
141 sub_cont = main_ui.main_container.addTab("simple_search", label=_("Simple search"), container=xml_tools.VerticalContainer)
142 main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui)))
143 xml_tools.dataForm2Widgets(main_ui, form)
144
145 # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
146 main_ui.addDivider('blank')
147 main_ui.addDivider('blank') # here we added a blank line before the button
148 main_ui.addDivider('blank')
149 main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,))
150 main_ui.addDivider('blank')
151 main_ui.addDivider('blank') # a blank line again after the button
152
153 simple_data = {key: value for key, value in data.iteritems() if key in (FIELD_SINGLE,)}
154 if simple_data:
155 log.debug("Simple search with %s on %s" % (simple_data, service_jid))
156 sub_cont.parent.setSelected(True)
157 main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
158 main_ui.addDivider('dash')
159 d = self.searchRequest(service_jid, simple_data, profile)
160 d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt),
161 lambda failure: main_ui.addText(failure.getErrorMessage()))
162 return d
163
164 return defer.succeed(None)
165
166 def _addAdvancedSearchUI(self, services, main_ui, data, profile):
167 """Add to the main UI a tab for the advanced search.
168
169 Display a service selector and allow to search on all the fields that are implemented by the selected service.
170
171 @param services (list[jid.JID]): search services offered by the user server
172 @param main_ui (XMLUI): the main XMLUI instance
173 @param data (dict): form data without SAT_FORM_PREFIX
174 @param profile (unicode): %(doc_profile)s
175
176 @return: a dummy Deferred
177 """
178 sub_cont = main_ui.main_container.addTab("advanced_search", label=_("Advanced search"), container=xml_tools.VerticalContainer)
179 service_selection_fields = ['service_jid', 'service_jid_extra']
180
181 if 'service_jid_extra' in data:
182 # refresh button has been pushed, select the tab
183 sub_cont.parent.setSelected(True)
184 # get the selected service
185 service_jid_s = data.get('service_jid_extra', '')
186 if not service_jid_s:
187 service_jid_s = data.get('service_jid', unicode(services[0]))
188 log.debug("Refreshing search fields for %s" % service_jid_s)
189 else:
190 service_jid_s = data.get(FIELD_CURRENT_SERVICE, unicode(services[0]))
191 services_s = [unicode(service) for service in services]
192 if service_jid_s not in services_s:
193 services_s.append(service_jid_s)
194
195 main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui)))
196 main_ui.addLabel(_("Search on"))
197 main_ui.addList('service_jid', options=services_s, selected=service_jid_s)
198 main_ui.addLabel(_("Other service"))
199 main_ui.addString(name='service_jid_extra')
200
201 # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
202 main_ui.addDivider('blank')
203 main_ui.addDivider('blank') # here we added a blank line before the button
204 main_ui.addDivider('blank')
205 main_ui.addButton(self.__search_menu_id, _("Refresh fields"), service_selection_fields)
206 main_ui.addDivider('blank')
207 main_ui.addDivider('blank') # a blank line again after the button
208 main_ui.addLabel(_("Displaying the search form for"))
209 main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True)
210 main_ui.addDivider('dash')
211 main_ui.addDivider('dash')
212
213 main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
214 service_jid = jid.JID(service_jid_s)
215 d = self.getFieldsUI(service_jid, profile)
216 d.addCallbacks(self._addAdvancedForm, lambda failure: main_ui.addText(failure.getErrorMessage()),
217 [service_jid, main_ui, sub_cont, data, profile])
218 return d
219
220 def _addAdvancedForm(self, form_elt, service_jid, main_ui, sub_cont, data, profile):
221 """Add the search form and the search results (if there is some to display).
222
223 @param form_elt (domish.Element): form element listing the fields
224 @param service_jid (jid.JID): current search service
225 @param main_ui (XMLUI): the main XMLUI instance
226 @param sub_cont (Container): the container of the current tab
227 @param data (dict): form data without SAT_FORM_PREFIX
228 @param profile (unicode): %(doc_profile)s
229
230 @return: a dummy Deferred
231 """
232 field_list = data_form.Form.fromElement(form_elt).fieldList
233 adv_fields = [field.var for field in field_list if field.var]
234 adv_data = {key: value for key, value in data.iteritems() if key in adv_fields}
235
236 xml_tools.dataForm2Widgets(main_ui, data_form.Form.fromElement(form_elt))
237
238 # refill the submitted values
239 # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now
240 for widget in main_ui.current_container.elem.childNodes:
241 name = widget.getAttribute("name")
242 if adv_data.get(name):
243 widget.setAttribute("value", adv_data[name])
244
245 # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
246 main_ui.addDivider('blank')
247 main_ui.addDivider('blank') # here we added a blank line before the button
248 main_ui.addDivider('blank')
249 main_ui.addButton(self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE])
250 main_ui.addDivider('blank')
251 main_ui.addDivider('blank') # a blank line again after the button
252
253 if adv_data: # display the search results
254 log.debug("Advanced search with %s on %s" % (adv_data, service_jid))
255 sub_cont.parent.setSelected(True)
256 main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
257 main_ui.addDivider('dash')
258 d = self.searchRequest(service_jid, adv_data, profile)
259 d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt),
260 lambda failure: main_ui.addText(failure.getErrorMessage()))
261 return d
262
263 return defer.succeed(None)
264
265
266 def _displaySearchResult(self, main_ui, elt):
267 """Display the search results.
268
269 @param main_ui (XMLUI): the main XMLUI instance
270 @param elt (domish.Element): form result element
271 """
272 if [child for child in elt.children if child.name == "item"]:
273 headers, xmlui_data = xml_tools.dataFormEltResult2XMLUIData(elt)
274 if "jid" in headers: # use XMLUI JidsListWidget to display the results
275 values = {}
276 for i in range(len(xmlui_data)):
277 header = headers.keys()[i % len(headers)]
278 widget_type, widget_args, widget_kwargs = xmlui_data[i]
279 value = widget_args[0]
280 values.setdefault(header, []).append(jid.JID(value) if header == "jid" else value)
281 main_ui.addJidsList(jids=values["jid"], name=D_(u"Search results"))
282 # TODO: also display the values other than JID
283 else:
284 xml_tools.XMLUIData2AdvancedList(main_ui, headers, xmlui_data)
285 else:
286 main_ui.addText(D_("The search gave no result"))
287
288
289 ## Retrieve the search fields ##
290
291
292 def _getFieldsUI(self, to_jid_s, profile_key):
293 """Ask a service to send us the list of the form fields it manages.
294
295 @param to_jid_s (unicode): XEP-0055 compliant search entity
296 @param profile_key (unicode): %(doc_profile_key)s
297 @return: a deferred XMLUI instance
298 """
299 d = self.getFieldsUI(jid.JID(to_jid_s), profile_key)
300 d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml())
301 return d
302
303 def getFieldsUI(self, to_jid, profile_key):
304 """Ask a service to send us the list of the form fields it manages.
305
306 @param to_jid (jid.JID): XEP-0055 compliant search entity
307 @param profile_key (unicode): %(doc_profile_key)s
308 @return: a deferred domish.Element
309 """
310 client = self.host.getClient(profile_key)
311 fields_request = IQ(client.xmlstream, 'get')
312 fields_request["from"] = client.jid.full()
313 fields_request["to"] = to_jid.full()
314 fields_request.addElement('query', NS_SEARCH)
315 d = fields_request.send(to_jid.full())
316 d.addCallbacks(self._getFieldsUICb, self._getFieldsUIEb)
317 return d
318
319 def _getFieldsUICb(self, answer):
320 """Callback for self.getFieldsUI.
321
322 @param answer (domish.Element): search query element
323 @return: domish.Element
324 """
325 try:
326 query_elts = answer.elements('jabber:iq:search', 'query').next()
327 except StopIteration:
328 log.info(_("No query element found"))
329 raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC
330 try:
331 form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next()
332 except StopIteration:
333 log.info(_("No data form found"))
334 raise NotImplementedError("Only search through data form is implemented so far")
335 return form_elt
336
337 def _getFieldsUIEb(self, failure):
338 """Errback to self.getFieldsUI.
339
340 @param failure (defer.failure.Failure): twisted failure
341 @raise: the unchanged defer.failure.Failure
342 """
343 log.info(_("Fields request failure: %s") % unicode(failure.getErrorMessage()))
344 raise failure
345
346
347 ## Do the search ##
348
349
350 def _searchRequest(self, to_jid_s, search_data, profile_key):
351 """Actually do a search, according to filled data.
352
353 @param to_jid_s (unicode): XEP-0055 compliant search entity
354 @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI
355 @param profile_key (unicode): %(doc_profile_key)s
356 @return: a deferred XMLUI string representation
357 """
358 d = self.searchRequest(jid.JID(to_jid_s), search_data, profile_key)
359 d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml())
360 return d
361
362 def searchRequest(self, to_jid, search_data, profile_key):
363 """Actually do a search, according to filled data.
364
365 @param to_jid (jid.JID): XEP-0055 compliant search entity
366 @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI
367 @param profile_key (unicode): %(doc_profile_key)s
368 @return: a deferred domish.Element
369 """
370 if FIELD_SINGLE in search_data:
371 value = search_data[FIELD_SINGLE]
372 d = self.getFieldsUI(to_jid, profile_key)
373 d.addCallback(lambda elt: self.searchRequestMulti(to_jid, value, elt, profile_key))
374 return d
375
376 client = self.host.getClient(profile_key)
377 search_request = IQ(client.xmlstream, 'set')
378 search_request["from"] = client.jid.full()
379 search_request["to"] = to_jid.full()
380 query_elt = search_request.addElement('query', NS_SEARCH)
381 x_form = data_form.Form('submit', formNamespace=NS_SEARCH)
382 x_form.makeFields(search_data)
383 query_elt.addChild(x_form.toElement())
384 # TODO: XEP-0059 could be used here (with the needed new method attributes)
385 d = search_request.send(to_jid.full())
386 d.addCallbacks(self._searchOk, self._searchErr)
387 return d
388
389 def searchRequestMulti(self, to_jid, value, form_elt, profile_key):
390 """Search for a value simultaneously in all fields, returns the results compilation.
391
392 @param to_jid (jid.JID): XEP-0055 compliant search entity
393 @param value (unicode): value to search
394 @param form_elt (domish.Element): form element listing the fields
395 @param profile_key (unicode): %(doc_profile_key)s
396 @return: a deferred domish.Element
397 """
398 form = data_form.Form.fromElement(form_elt)
399 d_list = []
400
401 for field in [field.var for field in form.fieldList if field.var]:
402 d_list.append(self.searchRequest(to_jid, {field: value}, profile_key))
403
404 def cb(result): # return the results compiled in one domish element
405 result_elt = None
406 for success, form_elt in result:
407 if not success:
408 continue
409 if result_elt is None: # the result element is built over the first answer
410 result_elt = form_elt
411 continue
412 for item_elt in form_elt.elements('jabber:x:data', 'item'):
413 result_elt.addChild(item_elt)
414 if result_elt is None:
415 raise defer.failure.Failure(DataError(_("The search could not be performed")))
416 return result_elt
417
418 return defer.DeferredList(d_list).addCallback(cb)
419
420 def _searchOk(self, answer):
421 """Callback for self.searchRequest.
422
423 @param answer (domish.Element): search query element
424 @return: domish.Element
425 """
426 try:
427 query_elts = answer.elements('jabber:iq:search', 'query').next()
428 except StopIteration:
429 log.info(_("No query element found"))
430 raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC
431 try:
432 form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next()
433 except StopIteration:
434 log.info(_("No data form found"))
435 raise NotImplementedError("Only search through data form is implemented so far")
436 return form_elt
437
438 def _searchErr(self, failure):
439 """Errback to self.searchRequest.
440
441 @param failure (defer.failure.Failure): twisted failure
442 @raise: the unchanged defer.failure.Failure
443 """
444 log.info(_("Search request failure: %s") % unicode(failure.getErrorMessage()))
445 raise failure
446
447
448 class XEP_0055_handler(XMPPHandler):
449 implements(iwokkel.IDisco)
450
451 def __init__(self, plugin_parent, profile):
452 self.plugin_parent = plugin_parent
453 self.host = plugin_parent.host
454 self.profile = profile
455
456 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
457 return [disco.DiscoFeature(NS_SEARCH)]
458
459 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
460 return []
461