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