diff libervia/backend/plugins/plugin_xep_0085.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_0085.py@c23cad65ae99
children 0d7bb4df2343
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0085.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,433 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Chat State Notifications Protocol (xep-0085)
+# Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.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 _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.protocols.jabber.jid import JID
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import reactor
+from twisted.internet import error as internet_error
+
+NS_XMPP_CLIENT = "jabber:client"
+NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
+CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
+MESSAGE_TYPES = ["chat", "groupchat"]
+PARAM_KEY = "Notifications"
+PARAM_NAME = "Enable chat state notifications"
+ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
+DELETE_VALUE = "DELETE"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Chat State Notifications Protocol Plugin",
+    C.PI_IMPORT_NAME: "XEP-0085",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0085"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0085",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol"""),
+}
+
+
+# Describe the internal transitions that are triggered
+# by a timer. Beside that, external transitions can be
+# runned to target the states "active" or "composing".
+# Delay is specified here in seconds.
+TRANSITIONS = {
+    "active": {"next_state": "inactive", "delay": 120},
+    "inactive": {"next_state": "gone", "delay": 480},
+    "gone": {"next_state": "", "delay": 0},
+    "composing": {"next_state": "paused", "delay": 30},
+    "paused": {"next_state": "inactive", "delay": 450},
+}
+
+
+class UnknownChatStateException(Exception):
+    """
+    This error is raised when an unknown chat state is used.
+    """
+
+    pass
+
+
+class XEP_0085(object):
+    """
+    Implementation for XEP 0085
+    """
+
+    params = """
+    <params>
+    <individual>
+    <category name="%(category_name)s" label="%(category_label)s">
+        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
+     </category>
+    </individual>
+    </params>
+    """ % {
+        "category_name": PARAM_KEY,
+        "category_label": _(PARAM_KEY),
+        "param_name": PARAM_NAME,
+        "param_label": _("Enable chat state notifications"),
+    }
+
+    def __init__(self, host):
+        log.info(_("Chat State Notifications plugin initialization"))
+        self.host = host
+        self.map = {}  # FIXME: would be better to use client instead of mapping profile to data
+
+        # parameter value is retrieved before each use
+        host.memory.update_params(self.params)
+
+        # triggers from core
+        host.trigger.add("message_received", self.message_received_trigger)
+        host.trigger.add("sendMessage", self.send_message_trigger)
+        host.trigger.add("param_update_trigger", self.param_update_trigger)
+
+        # args: to_s (jid as string), profile
+        host.bridge.add_method(
+            "chat_state_composing",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self.chat_state_composing,
+        )
+
+        # args: from (jid as string), state in CHAT_STATES, profile
+        host.bridge.add_signal("chat_state_received", ".plugin", signature="sss")
+
+    def get_handler(self, client):
+        return XEP_0085_handler(self, client.profile)
+
+    def profile_disconnected(self, client):
+        """Eventually send a 'gone' state to all one2one contacts."""
+        profile = client.profile
+        if profile not in self.map:
+            return
+        for to_jid in self.map[profile]:
+            # FIXME: the "unavailable" presence stanza is received by to_jid
+            # before the chat state, so it will be ignored... find a way to
+            # actually defer the disconnection
+            self.map[profile][to_jid]._onEvent("gone")
+        del self.map[profile]
+
+    def update_cache(self, entity_jid, value, profile):
+        """Update the entity data of the given profile for one or all contacts.
+        Reset the chat state(s) display if the notification has been disabled.
+
+        @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts.
+        @param value: True, False or DELETE_VALUE to delete the entity data
+        @param profile: current profile
+        """
+        client = self.host.get_client(profile)
+        if value == DELETE_VALUE:
+            self.host.memory.del_entity_datum(client, entity_jid, ENTITY_KEY)
+        else:
+            self.host.memory.update_entity_data(
+                client, entity_jid, ENTITY_KEY, value
+            )
+        if not value or value == DELETE_VALUE:
+            # reinit chat state UI for this or these contact(s)
+            self.host.bridge.chat_state_received(entity_jid.full(), "", profile)
+
+    def param_update_trigger(self, name, value, category, type_, profile):
+        """Reset all the existing chat state entity data associated with this profile after a parameter modification.
+
+        @param name: parameter name
+        @param value: "true" to activate the notifications, or any other value to delete it
+        @param category: parameter category
+        @param type_: parameter type
+        """
+        if (category, name) == (PARAM_KEY, PARAM_NAME):
+            self.update_cache(
+                C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile
+            )
+            return False
+        return True
+
+    def message_received_trigger(self, client, message, post_treat):
+        """
+        Update the entity cache when we receive a message with body.
+        Check for a chat state in the message and signal frontends.
+        """
+        profile = client.profile
+        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
+            return True
+
+        from_jid = JID(message.getAttribute("from"))
+        if self._is_muc(from_jid, profile):
+            from_jid = from_jid.userhostJID()
+        else:  # update entity data for one2one chat
+            # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource
+            try:
+                next(domish.generateElementsNamed(message.elements(), name="body"))
+                try:
+                    next(domish.generateElementsNamed(message.elements(), name="active"))
+                    # contact enabled Chat State Notifications
+                    self.update_cache(from_jid, True, profile=profile)
+                except StopIteration:
+                    if message.getAttribute("type") == "chat":
+                        # contact didn't enable Chat State Notifications
+                        self.update_cache(from_jid, False, profile=profile)
+                        return True
+            except StopIteration:
+                pass
+
+        # send our next "composing" states to any MUC and to the contacts who enabled the feature
+        self._chat_state_init(from_jid, message.getAttribute("type"), profile)
+
+        state_list = [
+            child.name
+            for child in message.elements()
+            if message.getAttribute("type") in MESSAGE_TYPES
+            and child.name in CHAT_STATES
+            and child.defaultUri == NS_CHAT_STATES
+        ]
+        for state in state_list:
+            # there must be only one state according to the XEP
+            if state != "gone" or message.getAttribute("type") != "groupchat":
+                self.host.bridge.chat_state_received(
+                    message.getAttribute("from"), state, profile
+                )
+            break
+        return True
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """
+        Eventually add the chat state to the message and initiate
+        the state machine when sending an "active" state.
+        """
+        profile = client.profile
+
+        def treatment(mess_data):
+            message = mess_data["xml"]
+            to_jid = JID(message.getAttribute("to"))
+            if not self._check_activation(to_jid, forceEntityData=True, profile=profile):
+                return mess_data
+            try:
+                # message with a body always mean active state
+                next(domish.generateElementsNamed(message.elements(), name="body"))
+                message.addElement("active", NS_CHAT_STATES)
+                # launch the chat state machine (init the timer)
+                if self._is_muc(to_jid, profile):
+                    to_jid = to_jid.userhostJID()
+                self._chat_state_active(to_jid, mess_data["type"], profile)
+            except StopIteration:
+                if "chat_state" in mess_data["extra"]:
+                    state = mess_data["extra"].pop("chat_state")
+                    assert state in CHAT_STATES
+                    message.addElement(state, NS_CHAT_STATES)
+            return mess_data
+
+        post_xml_treatments.addCallback(treatment)
+        return True
+
+    def _is_muc(self, to_jid, profile):
+        """Tell if that JID is a MUC or not
+
+        @param to_jid (JID): full or bare JID to check
+        @param profile (str): %(doc_profile)s
+        @return: bool
+        """
+        client = self.host.get_client(profile)
+        try:
+            type_ = self.host.memory.get_entity_datum(
+                client, to_jid.userhostJID(), C.ENTITY_TYPE)
+            if type_ == C.ENTITY_TYPE_MUC:
+                return True
+        except (exceptions.UnknownEntityError, KeyError):
+            pass
+        return False
+
+    def _check_activation(self, to_jid, forceEntityData, profile):
+        """
+        @param to_jid: the contact's full JID (or bare if you know it's a MUC)
+        @param forceEntityData: if set to True, a non-existing
+        entity data will be considered to be True (and initialized)
+        @param: current profile
+        @return: True if the notifications should be sent to this JID.
+        """
+        client = self.host.get_client(profile)
+        # check if the parameter is active
+        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
+            return False
+        # check if notifications should be sent to this contact
+        if self._is_muc(to_jid, profile):
+            return True
+        # FIXME: this assertion crash when we want to send a message to an online bare jid
+        # assert to_jid.resource or not self.host.memory.is_entity_available(to_jid, profile) # must either have a resource, or talk to an offline contact
+        try:
+            return self.host.memory.get_entity_datum(client, to_jid, ENTITY_KEY)
+        except (exceptions.UnknownEntityError, KeyError):
+            if forceEntityData:
+                # enable it for the first time
+                self.update_cache(to_jid, True, profile=profile)
+                return True
+        # wait for the first message before sending states
+        return False
+
+    def _chat_state_init(self, to_jid, mess_type, profile):
+        """
+        Data initialization for the chat state machine.
+
+        @param to_jid (JID): full JID for one2one, bare JID for MUC
+        @param mess_type (str): "one2one" or "groupchat"
+        @param profile (str): %(doc_profile)s
+        """
+        if mess_type is None:
+            return
+        profile_map = self.map.setdefault(profile, {})
+        if to_jid not in profile_map:
+            machine = ChatStateMachine(self.host, to_jid, mess_type, profile)
+            self.map[profile][to_jid] = machine
+
+    def _chat_state_active(self, to_jid, mess_type, profile_key):
+        """
+        Launch the chat state machine on "active" state.
+
+        @param to_jid (JID): full JID for one2one, bare JID for MUC
+        @param mess_type (str): "one2one" or "groupchat"
+        @param profile (str): %(doc_profile)s
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if profile is None:
+            raise exceptions.ProfileUnknownError
+        self._chat_state_init(to_jid, mess_type, profile)
+        self.map[profile][to_jid]._onEvent("active")
+
+    def chat_state_composing(self, to_jid_s, profile_key):
+        """Move to the "composing" state when required.
+
+        Since this method is called from the front-end, it needs to check the
+        values of the parameter "Send chat state notifications" and the entity
+        data associated to the target JID.
+
+        @param to_jid_s (str): contact full JID as a string
+        @param profile_key (str): %(doc_profile_key)s
+        """
+        # TODO: try to optimize this method which is called often
+        client = self.host.get_client(profile_key)
+        to_jid = JID(to_jid_s)
+        if self._is_muc(to_jid, client.profile):
+            to_jid = to_jid.userhostJID()
+        elif not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(client, to_jid)
+        if not self._check_activation(
+            to_jid, forceEntityData=False, profile=client.profile
+        ):
+            return
+        try:
+            self.map[client.profile][to_jid]._onEvent("composing")
+        except (KeyError, AttributeError):
+            # no message has been sent/received since the notifications
+            # have been enabled, it's better to wait for a first one
+            pass
+
+
+class ChatStateMachine(object):
+    """
+    This class represents a chat state, between one profile and
+    one target contact. A timer is used to move from one state
+    to the other. The initialization is done through the "active"
+    state which is internally set when a message is sent. The state
+    "composing" can be set externally (through the bridge by a
+    frontend). Other states are automatically set with the timer.
+    """
+
+    def __init__(self, host, to_jid, mess_type, profile):
+        """
+        Initialization need to store the target, message type
+        and a profile for sending later messages.
+        """
+        self.host = host
+        self.to_jid = to_jid
+        self.mess_type = mess_type
+        self.profile = profile
+        self.state = None
+        self.timer = None
+
+    def _onEvent(self, state):
+        """
+        Move to the specified state, eventually send the
+        notification to the contact (the "active" state is
+        automatically sent with each message) and set the timer.
+        """
+        assert state in TRANSITIONS
+        transition = TRANSITIONS[state]
+        assert "next_state" in transition and "delay" in transition
+
+        if state != self.state and state != "active":
+            if state != "gone" or self.mess_type != "groupchat":
+                # send a new message without body
+                log.debug(
+                    "sending state '{state}' to {jid}".format(
+                        state=state, jid=self.to_jid.full()
+                    )
+                )
+                client = self.host.get_client(self.profile)
+                mess_data = {
+                    "from": client.jid,
+                    "to": self.to_jid,
+                    "uid": "",
+                    "message": {},
+                    "type": self.mess_type,
+                    "subject": {},
+                    "extra": {},
+                }
+                client.generate_message_xml(mess_data)
+                mess_data["xml"].addElement(state, NS_CHAT_STATES)
+                client.send(mess_data["xml"])
+
+        self.state = state
+        try:
+            self.timer.cancel()
+        except (internet_error.AlreadyCalled, AttributeError):
+            pass
+
+        if transition["next_state"] and transition["delay"] > 0:
+            self.timer = reactor.callLater(
+                transition["delay"], self._onEvent, transition["next_state"]
+            )
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0085_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_CHAT_STATES)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []