Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_xep_0055.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 + + +# SAT plugin for Jabber Search (xep-0055) +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +from twisted.words.protocols.jabber.xmlstream import IQ +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from wokkel import data_form +from libervia.backend.core.constants import Const as C +from libervia.backend.core.exceptions import DataError +from libervia.backend.tools import xml_tools + +from wokkel import disco, iwokkel + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implementer + + +NS_SEARCH = "jabber:iq:search" + +PLUGIN_INFO = { + C.PI_NAME: "Jabber Search", + C.PI_IMPORT_NAME: "XEP-0055", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0055"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0059"], + C.PI_MAIN: "XEP_0055", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of Jabber Search"""), +} + +# config file parameters +CONFIG_SECTION = "plugin search" +CONFIG_SERVICE_LIST = "service_list" + +DEFAULT_SERVICE_LIST = ["salut.libervia.org"] + +FIELD_SINGLE = "field_single" # single text field for the simple search +FIELD_CURRENT_SERVICE = ( + "current_service_jid" +) # read-only text field for the advanced search + + +class XEP_0055(object): + def __init__(self, host): + log.info(_("Jabber search plugin initialization")) + self.host = host + + # default search services (config file + hard-coded lists) + self.services = [ + jid.JID(entry) + for entry in host.memory.config_get( + CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST + ) + ] + + host.bridge.add_method( + "search_fields_ui_get", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._get_fields_ui, + async_=True, + ) + host.bridge.add_method( + "search_request", + ".plugin", + in_sign="sa{ss}s", + out_sign="s", + method=self._search_request, + async_=True, + ) + + self.__search_menu_id = host.register_callback(self._get_main_ui, with_data=True) + host.import_menu( + (D_("Contacts"), D_("Search directory")), + self._get_main_ui, + security_limit=1, + help_string=D_("Search user directory"), + ) + + def _get_host_services(self, profile): + """Return the jabber search services associated to the user host. + + @param profile (unicode): %(doc_profile)s + @return: list[jid.JID] + """ + client = self.host.get_client(profile) + d = self.host.find_features_set(client, [NS_SEARCH]) + return d.addCallback(lambda set_: list(set_)) + + ## Main search UI (menu item callback) ## + + def _get_main_ui(self, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # check if the user's server offers some search services + d = self._get_host_services(profile) + return d.addCallback(lambda services: self.get_main_ui(services, raw_data, profile)) + + def get_main_ui(self, services, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + + @param services (list[jid.JID]): search services offered by the user server + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # extend services offered by user's server with the default services + services.extend([service for service in self.services if service not in services]) + data = xml_tools.xmlui_result_2_data_form_result(raw_data) + main_ui = xml_tools.XMLUI( + C.XMLUI_WINDOW, + container="tabs", + title=_("Search users"), + submit_id=self.__search_menu_id, + ) + + d = self._add_simple_search_ui(services, main_ui, data, profile) + d.addCallback( + lambda __: self._add_advanced_search_ui(services, main_ui, data, profile) + ) + return d.addCallback(lambda __: {"xmlui": main_ui.toXml()}) + + def _add_simple_search_ui(self, services, main_ui, data, profile): + """Add to the main UI a tab for the simple search. + + Display a single input field and search on the main service (it actually does one search per search field and then compile the results). + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a __ Deferred + """ + service_jid = services[ + 0 + ] # TODO: search on all the given services, not only the first one + + form = data_form.Form("form", formNamespace=NS_SEARCH) + form.addField( + data_form.Field( + "text-single", + FIELD_SINGLE, + label=_("Search for"), + value=data.get(FIELD_SINGLE, ""), + ) + ) + + sub_cont = main_ui.main_container.add_tab( + "simple_search", + label=_("Simple search"), + container=xml_tools.VerticalContainer, + ) + main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui))) + xml_tools.data_form_2_widgets(main_ui, form) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # here we added a blank line before the button + main_ui.addDivider("blank") + main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,)) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # a blank line again after the button + + simple_data = { + key: value for key, value in data.items() if key in (FIELD_SINGLE,) + } + if simple_data: + log.debug("Simple search with %s on %s" % (simple_data, service_jid)) + sub_cont.parent.set_selected(True) + main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider("dash") + d = self.search_request(service_jid, simple_data, profile) + d.addCallbacks( + lambda elt: self._display_search_result(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage()), + ) + return d + + return defer.succeed(None) + + def _add_advanced_search_ui(self, services, main_ui, data, profile): + """Add to the main UI a tab for the advanced search. + + Display a service selector and allow to search on all the fields that are implemented by the selected service. + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a __ Deferred + """ + sub_cont = main_ui.main_container.add_tab( + "advanced_search", + label=_("Advanced search"), + container=xml_tools.VerticalContainer, + ) + service_selection_fields = ["service_jid", "service_jid_extra"] + + if "service_jid_extra" in data: + # refresh button has been pushed, select the tab + sub_cont.parent.set_selected(True) + # get the selected service + service_jid_s = data.get("service_jid_extra", "") + if not service_jid_s: + service_jid_s = data.get("service_jid", str(services[0])) + log.debug("Refreshing search fields for %s" % service_jid_s) + else: + service_jid_s = data.get(FIELD_CURRENT_SERVICE, str(services[0])) + services_s = [str(service) for service in services] + if service_jid_s not in services_s: + services_s.append(service_jid_s) + + main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui))) + main_ui.addLabel(_("Search on")) + main_ui.addList("service_jid", options=services_s, selected=service_jid_s) + main_ui.addLabel(_("Other service")) + main_ui.addString(name="service_jid_extra") + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # here we added a blank line before the button + main_ui.addDivider("blank") + main_ui.addButton( + self.__search_menu_id, _("Refresh fields"), service_selection_fields + ) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # a blank line again after the button + main_ui.addLabel(_("Displaying the search form for")) + main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True) + main_ui.addDivider("dash") + main_ui.addDivider("dash") + + main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + service_jid = jid.JID(service_jid_s) + d = self.get_fields_ui(service_jid, profile) + d.addCallbacks( + self._add_advanced_form, + lambda failure: main_ui.addText(failure.getErrorMessage()), + [service_jid, main_ui, sub_cont, data, profile], + ) + return d + + def _add_advanced_form(self, form_elt, service_jid, main_ui, sub_cont, data, profile): + """Add the search form and the search results (if there is some to display). + + @param form_elt (domish.Element): form element listing the fields + @param service_jid (jid.JID): current search service + @param main_ui (XMLUI): the main XMLUI instance + @param sub_cont (Container): the container of the current tab + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a __ Deferred + """ + field_list = data_form.Form.fromElement(form_elt).fieldList + adv_fields = [field.var for field in field_list if field.var] + adv_data = {key: value for key, value in data.items() if key in adv_fields} + + xml_tools.data_form_2_widgets(main_ui, data_form.Form.fromElement(form_elt)) + + # refill the submitted values + # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now + for widget in main_ui.current_container.elem.childNodes: + name = widget.getAttribute("name") + if adv_data.get(name): + widget.setAttribute("value", adv_data[name]) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # here we added a blank line before the button + main_ui.addDivider("blank") + main_ui.addButton( + self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE] + ) + main_ui.addDivider("blank") + main_ui.addDivider("blank") # a blank line again after the button + + if adv_data: # display the search results + log.debug("Advanced search with %s on %s" % (adv_data, service_jid)) + sub_cont.parent.set_selected(True) + main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider("dash") + d = self.search_request(service_jid, adv_data, profile) + d.addCallbacks( + lambda elt: self._display_search_result(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage()), + ) + return d + + return defer.succeed(None) + + def _display_search_result(self, main_ui, elt): + """Display the search results. + + @param main_ui (XMLUI): the main XMLUI instance + @param elt (domish.Element): form result element + """ + if [child for child in elt.children if child.name == "item"]: + headers, xmlui_data = xml_tools.data_form_elt_result_2_xmlui_data(elt) + if "jid" in headers: # use XMLUI JidsListWidget to display the results + values = {} + for i in range(len(xmlui_data)): + header = list(headers.keys())[i % len(headers)] + widget_type, widget_args, widget_kwargs = xmlui_data[i] + value = widget_args[0] + values.setdefault(header, []).append( + jid.JID(value) if header == "jid" else value + ) + main_ui.addJidsList(jids=values["jid"], name=D_("Search results")) + # TODO: also display the values other than JID + else: + xml_tools.xmlui_data_2_advanced_list(main_ui, headers, xmlui_data) + else: + main_ui.addText(D_("The search gave no result")) + + ## Retrieve the search fields ## + + def _get_fields_ui(self, to_jid_s, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI instance + """ + d = self.get_fields_ui(jid.JID(to_jid_s), profile_key) + d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml()) + return d + + def get_fields_ui(self, to_jid, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + client = self.host.get_client(profile_key) + fields_request = IQ(client.xmlstream, "get") + fields_request["from"] = client.jid.full() + fields_request["to"] = to_jid.full() + fields_request.addElement("query", NS_SEARCH) + d = fields_request.send(to_jid.full()) + d.addCallbacks(self._get_fields_ui_cb, self._get_fields_ui_eb) + return d + + def _get_fields_ui_cb(self, answer): + """Callback for self.get_fields_ui. + + @param answer (domish.Element): search query element + @return: domish.Element + """ + try: + query_elts = next(answer.elements("jabber:iq:search", "query")) + except StopIteration: + log.info(_("No query element found")) + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + try: + form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x")) + except StopIteration: + log.info(_("No data form found")) + raise NotImplementedError( + "Only search through data form is implemented so far" + ) + return form_elt + + def _get_fields_ui_eb(self, failure): + """Errback to self.get_fields_ui. + + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Fields request failure: %s") % str(failure.getErrorMessage())) + raise failure + + ## Do the search ## + + def _search_request(self, to_jid_s, search_data, profile_key): + """Actually do a search, according to filled data. + + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI string representation + """ + d = self.search_request(jid.JID(to_jid_s), search_data, profile_key) + d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml()) + return d + + def search_request(self, to_jid, search_data, profile_key): + """Actually do a search, according to filled data. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + if FIELD_SINGLE in search_data: + value = search_data[FIELD_SINGLE] + d = self.get_fields_ui(to_jid, profile_key) + d.addCallback( + lambda elt: self.search_request_multi(to_jid, value, elt, profile_key) + ) + return d + + client = self.host.get_client(profile_key) + search_request = IQ(client.xmlstream, "set") + search_request["from"] = client.jid.full() + search_request["to"] = to_jid.full() + query_elt = search_request.addElement("query", NS_SEARCH) + x_form = data_form.Form("submit", formNamespace=NS_SEARCH) + x_form.makeFields(search_data) + query_elt.addChild(x_form.toElement()) + # TODO: XEP-0059 could be used here (with the needed new method attributes) + d = search_request.send(to_jid.full()) + d.addCallbacks(self._search_ok, self._search_err) + return d + + def search_request_multi(self, to_jid, value, form_elt, profile_key): + """Search for a value simultaneously in all fields, returns the results compilation. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param value (unicode): value to search + @param form_elt (domish.Element): form element listing the fields + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + form = data_form.Form.fromElement(form_elt) + d_list = [] + + for field in [field.var for field in form.fieldList if field.var]: + d_list.append(self.search_request(to_jid, {field: value}, profile_key)) + + def cb(result): # return the results compiled in one domish element + result_elt = None + for success, form_elt in result: + if not success: + continue + if ( + result_elt is None + ): # the result element is built over the first answer + result_elt = form_elt + continue + for item_elt in form_elt.elements("jabber:x:data", "item"): + result_elt.addChild(item_elt) + if result_elt is None: + raise defer.failure.Failure( + DataError(_("The search could not be performed")) + ) + return result_elt + + return defer.DeferredList(d_list).addCallback(cb) + + def _search_ok(self, answer): + """Callback for self.search_request. + + @param answer (domish.Element): search query element + @return: domish.Element + """ + try: + query_elts = next(answer.elements("jabber:iq:search", "query")) + except StopIteration: + log.info(_("No query element found")) + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + try: + form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x")) + except StopIteration: + log.info(_("No data form found")) + raise NotImplementedError( + "Only search through data form is implemented so far" + ) + return form_elt + + def _search_err(self, failure): + """Errback to self.search_request. + + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Search request failure: %s") % str(failure.getErrorMessage())) + raise failure + + +@implementer(iwokkel.IDisco) +class XEP_0055_handler(XMPPHandler): + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=""): + return [disco.DiscoFeature(NS_SEARCH)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=""): + return []