view sat/plugins/plugin_xep_0055.py @ 3254:6cf4bd6972c2

core, frontends: avatar refactoring: /!\ huge commit Avatar logic has been reworked around the IDENTITY plugin: plugins able to handle avatar or other identity related metadata (like nicknames) register to IDENTITY plugin in the same way as for other features like download/upload. Once registered, IDENTITY plugin will call them when suitable in order of priority, and handle caching. Methods to manage those metadata from frontend now use serialised data. For now `avatar` and `nicknames` are handled: - `avatar` is now a dict with `path` + metadata like `media_type`, instead of just a string path - `nicknames` is now a list of nicknames in order of priority. This list is never empty, and `nicknames[0]` should be the preferred nickname to use by frontends in most cases. In addition to contact specified nicknames, user set nickname (the one set in roster) is used in priority when available. Among the side changes done with this commit, there are: - a new `contactGet` bridge method to get roster metadata for a single contact - SatPresenceProtocol.send returns a Deferred to check when it has actually been sent - memory's methods to handle entities data now use `client` as first argument - metadata filter can be specified with `getIdentity` - `getAvatar` and `setAvatar` are now part of the IDENTITY plugin instead of XEP-0054 (and there signature has changed) - `isRoom` and `getBareOrFull` are now part of XEP-0045 plugin - jp avatar/get command uses `xdg-open` first when available for `--show` flag - `--no-cache` has been added to jp avatar/get and identity/get - jp identity/set has been simplified, explicit options (`--nickname` only for now) are used instead of `--field`. `--field` may come back in the future if necessary for extra data. - QuickContactList `SetContact` now handle None as a value, and doesn't use it to delete the metadata anymore - improved cache handling for `metadata` and `nicknames` in quick frontend - new `default` argument in QuickContactList `getCache`
author Goffi <goffi@goffi.org>
date Tue, 14 Apr 2020 21:00:33 +0200
parents 559a625a236b
children be6d91572633
line wrap: on
line source

#!/usr/bin/env python3


# SAT plugin for Jabber Search (xep-0055)
# Copyright (C) 2009-2020 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 sat.core.i18n import _, D_
from sat.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 sat.core.constants import Const as C
from sat.core.exceptions import DataError
from sat.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.getConfig(
                CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST
            )
        ]

        host.bridge.addMethod(
            "searchGetFieldsUI",
            ".plugin",
            in_sign="ss",
            out_sign="s",
            method=self._getFieldsUI,
            async_=True,
        )
        host.bridge.addMethod(
            "searchRequest",
            ".plugin",
            in_sign="sa{ss}s",
            out_sign="s",
            method=self._searchRequest,
            async_=True,
        )

        self.__search_menu_id = host.registerCallback(self._getMainUI, with_data=True)
        host.importMenu(
            (D_("Contacts"), D_("Search directory")),
            self._getMainUI,
            security_limit=1,
            help_string=D_("Search user directory"),
        )

    def _getHostServices(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.getClient(profile)
        d = self.host.findFeaturesSet(client, [NS_SEARCH])
        return d.addCallback(lambda set_: list(set_))

    ## Main search UI (menu item callback) ##

    def _getMainUI(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._getHostServices(profile)
        return d.addCallback(lambda services: self.getMainUI(services, raw_data, profile))

    def getMainUI(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.XMLUIResult2DataFormResult(raw_data)
        main_ui = xml_tools.XMLUI(
            C.XMLUI_WINDOW,
            container="tabs",
            title=_("Search users"),
            submit_id=self.__search_menu_id,
        )

        d = self._addSimpleSearchUI(services, main_ui, data, profile)
        d.addCallback(
            lambda __: self._addAdvancedSearchUI(services, main_ui, data, profile)
        )
        return d.addCallback(lambda __: {"xmlui": main_ui.toXml()})

    def _addSimpleSearchUI(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.addTab(
            "simple_search",
            label=_("Simple search"),
            container=xml_tools.VerticalContainer,
        )
        main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui)))
        xml_tools.dataForm2Widgets(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.setSelected(True)
            main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
            main_ui.addDivider("dash")
            d = self.searchRequest(service_jid, simple_data, profile)
            d.addCallbacks(
                lambda elt: self._displaySearchResult(main_ui, elt),
                lambda failure: main_ui.addText(failure.getErrorMessage()),
            )
            return d

        return defer.succeed(None)

    def _addAdvancedSearchUI(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.addTab(
            "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.setSelected(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.changeContainer(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.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
        service_jid = jid.JID(service_jid_s)
        d = self.getFieldsUI(service_jid, profile)
        d.addCallbacks(
            self._addAdvancedForm,
            lambda failure: main_ui.addText(failure.getErrorMessage()),
            [service_jid, main_ui, sub_cont, data, profile],
        )
        return d

    def _addAdvancedForm(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.dataForm2Widgets(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.setSelected(True)
            main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
            main_ui.addDivider("dash")
            d = self.searchRequest(service_jid, adv_data, profile)
            d.addCallbacks(
                lambda elt: self._displaySearchResult(main_ui, elt),
                lambda failure: main_ui.addText(failure.getErrorMessage()),
            )
            return d

        return defer.succeed(None)

    def _displaySearchResult(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.dataFormEltResult2XMLUIData(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.XMLUIData2AdvancedList(main_ui, headers, xmlui_data)
        else:
            main_ui.addText(D_("The search gave no result"))

    ## Retrieve the  search fields ##

    def _getFieldsUI(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.getFieldsUI(jid.JID(to_jid_s), profile_key)
        d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml())
        return d

    def getFieldsUI(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.getClient(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._getFieldsUICb, self._getFieldsUIEb)
        return d

    def _getFieldsUICb(self, answer):
        """Callback for self.getFieldsUI.

        @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 _getFieldsUIEb(self, failure):
        """Errback to self.getFieldsUI.

        @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 _searchRequest(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 getFieldsUI
        @param profile_key (unicode): %(doc_profile_key)s
        @return: a deferred XMLUI string representation
        """
        d = self.searchRequest(jid.JID(to_jid_s), search_data, profile_key)
        d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml())
        return d

    def searchRequest(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 getFieldsUI
        @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.getFieldsUI(to_jid, profile_key)
            d.addCallback(
                lambda elt: self.searchRequestMulti(to_jid, value, elt, profile_key)
            )
            return d

        client = self.host.getClient(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._searchOk, self._searchErr)
        return d

    def searchRequestMulti(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.searchRequest(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 _searchOk(self, answer):
        """Callback for self.searchRequest.

        @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 _searchErr(self, failure):
        """Errback to self.searchRequest.

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