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