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 []