view sat/plugins/plugin_xep_0353.py @ 4051:c23cad65ae99

core: renamed `messageReceived` trigger to `message_received`
author Goffi <goffi@goffi.org>
date Mon, 29 May 2023 13:32:08 +0200
parents 3900626bc100
children 38819c69aa39
line wrap: on
line source

#!/usr/bin/env python3

# SàT plugin for Jingle Message Initiation (XEP-0353)
# 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 zope.interface import implementer
from twisted.internet import defer
from twisted.internet import reactor
from twisted.words.protocols.jabber import xmlstream, jid, error
from twisted.words.xish import domish
from wokkel import disco, iwokkel
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core import exceptions
from sat.core.log import getLogger
from sat.tools import utils
from sat.tools import xml_tools

log = getLogger(__name__)


NS_JINGLE_MESSAGE = "urn:xmpp:jingle-message:0"

PLUGIN_INFO = {
    C.PI_NAME: "Jingle Message Initiation",
    C.PI_IMPORT_NAME: "XEP-0353",
    C.PI_TYPE: "XEP",
    C.PI_MODES: [C.PLUG_MODE_CLIENT],
    C.PI_PROTOCOLS: ["XEP-0353"],
    C.PI_DEPENDENCIES: ["XEP-0166"],
    C.PI_MAIN: "XEP_0353",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: _("""Implementation of Jingle Message Initiation"""),
}


class XEP_0353:

    def __init__(self, host):
        log.info(_("plugin {name} initialization").format(name=PLUGIN_INFO[C.PI_NAME]))
        self.host = host
        host.register_namespace("jingle-message", NS_JINGLE_MESSAGE)
        self._j = host.plugins["XEP-0166"]
        host.trigger.add("XEP-0166_initiate", self._on_initiate_trigger)
        host.trigger.add("message_received", self._on_message_received)

    def get_handler(self, client):
        return Handler()

    def profile_connecting(self, client):
        # mapping from session id to deferred used to wait for destinee answer
        client._xep_0353_pending_sessions = {}

    def build_message_data(self, client, peer_jid, verb, session_id):
        mess_data = {
            'from': client.jid,
            'to': peer_jid,
            'uid': '',
            'message': {},
            'type': C.MESS_TYPE_CHAT,
            'subject': {},
            'extra': {}
        }
        client.generate_message_xml(mess_data)
        verb_elt = mess_data["xml"].addElement((NS_JINGLE_MESSAGE, verb))
        verb_elt["id"] = session_id
        return mess_data

    async def _on_initiate_trigger(self, client, session, contents):
        # FIXME: check that at least one resource of the peer_jid can handle the feature
        peer_jid = session['peer_jid']
        if peer_jid.resource:
            return True

        try:
            infos = await self.host.memory.disco.get_infos(client, peer_jid)
        except error.StanzaError as e:
            if e.condition == "service-unavailable":
                categories = {}
            else:
                raise e
        else:
            categories = {c for c, __ in infos.identities}
        if "component" in categories:
            # we don't use message initiation with components
            return True

        if peer_jid.userhostJID() not in client.roster:
            # if the contact is not in our roster, we need to send a directed presence
            # according to XEP-0353 §3.1
            await client.presence.available(peer_jid)

        mess_data = self.build_message_data(client, peer_jid, "propose", session['id'])
        for content in contents:
            content_data = self._j.get_content_data(
                content)
            try:
                jingle_description_elt = (
                    content_data.application.handler.jingle_description_elt
                )
            except AttributeError:
                log.debug(
                    "no jingle_description_elt set for "
                    f"{content_data.application.handler}"
                )
                description_elt = domish.Element((content["app_ns"], "description"))
            else:
                description_elt = await utils.as_deferred(
                    jingle_description_elt,
                    client, session, content_data.content_name, *content_data.app_args,
                    **content_data.app_kwargs
                )
        mess_data["xml"].propose.addChild(description_elt)
        response_d = defer.Deferred()
        # we wait for 2 min before cancelling the session init
        # response_d.addTimeout(2*60, reactor)
        # FIXME: let's application decide timeout?
        response_d.addTimeout(2, reactor)
        client._xep_0353_pending_sessions[session['id']] = response_d
        await client.send_message_data(mess_data)
        try:
            accepting_jid = await response_d
        except defer.TimeoutError:
            log.warning(_(
                "Message initiation with {peer_jid} timed out"
            ).format(peer_jid=peer_jid))
        else:
            session["peer_jid"] = accepting_jid
        del client._xep_0353_pending_sessions[session['id']]
        return True

    async def _on_message_received(self, client, message_elt, post_treat):
        for elt in message_elt.elements():
            if elt.uri == NS_JINGLE_MESSAGE:
                if elt.name == "propose":
                    return await self._handle_propose(client, message_elt, elt)
                elif elt.name == "retract":
                    return self._handle_retract(client, message_elt, elt)
                elif elt.name == "proceed":
                    return self._handle_proceed(client, message_elt, elt)
                elif elt.name == "accept":
                    return self._handle_accept(client, message_elt, elt)
                elif elt.name == "reject":
                    return self._handle_accept(client, message_elt, elt)
                else:
                    log.warning(f"invalid element: {elt.toXml}")
                    return True
        return True

    async def _handle_propose(self, client, message_elt, elt):
        peer_jid = jid.JID(message_elt["from"])
        session_id = elt["id"]
        if peer_jid.userhostJID() not in client.roster:
            app_ns = elt.description.uri
            try:
                application = self._j.get_application(app_ns)
                human_name = getattr(application.handler, "human_name", application.name)
            except (exceptions.NotFound, AttributeError):
                if app_ns.startswith("urn:xmpp:jingle:apps:"):
                    human_name = app_ns[21:].split(":", 1)[0].replace('-', ' ').title()
                else:
                    splitted_ns = app_ns.split(':')
                    if len(splitted_ns) > 1:
                        human_name = splitted_ns[-2].replace('- ', ' ').title()
                    else:
                        human_name = app_ns

            confirm_msg = D_(
                "Somebody not in your contact list ({peer_jid}) wants to do a "
                '"{human_name}" session with you, this would leak your presence and '
                "possibly you IP (internet localisation), do you accept?"
            ).format(peer_jid=peer_jid, human_name=human_name)
            confirm_title = D_("Invitation from an unknown contact")
            accept = await xml_tools.defer_confirm(
                self.host, confirm_msg, confirm_title, profile=client.profile,
                action_extra={
                    "type": C.META_TYPE_NOT_IN_ROSTER_LEAK,
                    "session_id": session_id,
                    "from_jid": peer_jid.full(),
                }
            )
            if not accept:
                mess_data = self.build_message_data(
                    client, client.jid.userhostJID(), "reject", session_id)
                await client.send_message_data(mess_data)
                # we don't sent anything to sender, to avoid leaking presence
                return False
            else:
                await client.presence.available(peer_jid)
        session_id = elt["id"]
        # FIXME: accept is not used anymore in new specification, check it and remove it
        mess_data = self.build_message_data(
            client, client.jid.userhostJID(), "accept", session_id)
        await client.send_message_data(mess_data)
        mess_data = self.build_message_data(
            client, peer_jid, "proceed", session_id)
        await client.send_message_data(mess_data)
        return False

    def _handle_retract(self, client, message_elt, proceed_elt):
        log.warning("retract is not implemented yet")
        return False

    def _handle_proceed(self, client, message_elt, proceed_elt):
        try:
            session_id = proceed_elt["id"]
        except KeyError:
            log.warning(f"invalid proceed element in message_elt: {message_elt}")
            return True
        try:
            response_d = client._xep_0353_pending_sessions[session_id]
        except KeyError:
            log.warning(
                _("no pending session found with id {session_id}, did it timed out?")
                .format(session_id=session_id)
            )
            return True

        response_d.callback(jid.JID(message_elt["from"]))
        return False

    def _handle_accept(self, client, message_elt, accept_elt):
        pass

    def _handle_reject(self, client, message_elt, accept_elt):
        pass


@implementer(iwokkel.IDisco)
class Handler(xmlstream.XMPPHandler):

    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
        return [disco.DiscoFeature(NS_JINGLE_MESSAGE)]

    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
        return []