plugin XEP-0085: Modernisation:

- Async coroutines, type hints
- Use client plugin data instead of per-profile dict
- Detect chat state availability with disco queries
- Better spec compliance
This commit is contained in:
Goffi 2026-07-02 15:35:23 +02:00
commit 5ed83ae9e3

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
# SAT plugin for Chat State Notifications Protocol (xep-0085)
# Libervia plugin for Chat State Notifications Protocol (XEP-0085)
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.org)
# This program is free software: you can redistribute it and/or modify
@ -17,20 +17,32 @@
# 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
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING, cast
log = getLogger(__name__)
from tx_xmpp import disco, itx_xmpp
from zope.interface import implementer
from twisted.words.protocols.jabber.jid import JID
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from twisted.internet import defer
from twisted.internet import reactor
from twisted.internet import error as internet_error
from twisted.internet.base import DelayedCall
from twisted.internet.interfaces import IReactorTime
from twisted.words.protocols.jabber.jid import JID
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from tx_xmpp import disco, itx_xmpp
from zope.interface import implementer
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
if TYPE_CHECKING:
from libervia.backend.core.main import LiberviaBackend
log = getLogger(__name__)
NS_XMPP_CLIENT = "jabber:client"
NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
@ -52,6 +64,18 @@ PLUGIN_INFO = {
C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol"""),
}
IMPORT_NAME = PLUGIN_INFO[C.PI_IMPORT_NAME]
@dataclass
class ChatStatesData:
"""Per-client data for XEP-0085 chat state notifications."""
# peer_jid -> Allowed (True)/Denied (False)
disco_cache: dict[JID, bool] = field(default_factory=dict)
# Chat state machines, keyed by peer_jid
machines: dict[JID, "ChatStateMachine"] = field(default_factory=dict)
# Describe the internal transitions that are triggered
# by a timer. Beside that, external transitions can be
@ -74,7 +98,7 @@ class UnknownChatStateException(Exception):
pass
class XEP_0085(object):
class XEP_0085:
"""
Implementation for XEP 0085
"""
@ -94,13 +118,9 @@ class XEP_0085(object):
"param_label": _("Enable chat state notifications"),
}
def __init__(self, host):
def __init__(self, host: "LiberviaBackend") -> None:
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)
@ -121,26 +141,104 @@ class XEP_0085(object):
# 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):
def get_handler(self, client: SatXMPPEntity) -> "XEP_0085_handler":
return XEP_0085_handler(self, client.profile)
def profile_disconnected(self, client):
def profile_disconnected(self, client: SatXMPPEntity) -> None:
"""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]:
chat_states_data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
for machine in list(chat_states_data.machines.values()):
# 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]
machine._onEvent("gone")
chat_states_data.machines.clear()
def update_cache(self, entity_jid, value, profile):
async def _check_disco_support(
self, client: SatXMPPEntity, peer_jid: JID
) -> bool | None:
"""Check if a contact supports XEP-0085 via service discovery.
For 1:1 (bare JID): checks all known resources.
For MUC DM (full JID): checks the occupant directly.
@param client: the client instance
@param peer_jid: bare JID for 1:1, full JID for MUC DM
@return: True if confirmed, False if denied, None if no resources known
"""
data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
if peer_jid in data.disco_cache:
return data.disco_cache[peer_jid]
if not peer_jid.resource:
# 1:1 (we check all known resources)
try:
resources = self.host.memory.get_all_resources(client, peer_jid)
except exceptions.UnknownEntityError:
# entity not in cache
return None
if not resources:
# no resources known yet
return None
for resource in resources:
full_jid = JID(None, (peer_jid.user, peer_jid.host, resource))
try:
has_feature = await self.host.memory.disco.has_feature(
client, NS_CHAT_STATES, full_jid
)
if has_feature:
data.disco_cache[peer_jid] = True
return True
except (
exceptions.ExternalRequestError,
exceptions.TimeOutError,
) as e:
log.warning(
f"Can't check chat state availability for {full_jid}: {e}"
)
continue
data.disco_cache[peer_jid] = False
return False
# We have a full JID -> This is a MUC DM
try:
has_feature = await self.host.memory.disco.has_feature(
client, NS_CHAT_STATES, peer_jid
)
except (
exceptions.ExternalRequestError,
exceptions.TimeOutError,
):
log.debug(
f"Disco query for chat states support failed for {peer_jid.full()}, "
"falling back to implicit discovery"
)
has_feature = False
data.disco_cache[peer_jid] = has_feature
return has_feature
async def _ensure_disco_check(self, client: SatXMPPEntity, to_jid: JID) -> None:
"""Check disco support for chat states if not already done.
Skipped for MUC groupchat.
For 1:1: passes bare JID, checks all known resources.
For MUC DM: passes full JID, checks occupant.
@param client: the client instance
@param to_jid: JID of the contact
"""
if self._is_muc(to_jid, client.profile) and not to_jid.resource:
# This is a MUC groupchat, we skip
return
data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
if to_jid not in data.disco_cache:
await self._check_disco_support(client, to_jid)
def update_cache(self, entity_jid: JID, value: bool | str, profile: str) -> None:
"""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 entity_jid: contact's JID
@param value: True, False or DELETE_VALUE to delete the entity data
@param profile: current profile
"""
@ -150,108 +248,164 @@ class XEP_0085(object):
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)
# reinit chat state UI for this contact
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.
def param_update_trigger(
self, name: str, value: str, category: str, type_: str, profile: str
) -> bool:
"""Update Chat State data according to parameter.
@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
@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
)
client = self.host.get_client(profile)
if C.bool(value):
self.host.memory.update_entity_data(
client, C.ENTITY_ALL, ENTITY_KEY, True
)
else:
self.host.memory.del_entity_datum(client, C.ENTITY_ALL, ENTITY_KEY)
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.
async def message_received_trigger(
self, client: SatXMPPEntity, message: domish.Element, post_treat: bool
) -> bool:
"""Handle an incoming message.
Update entity cache, init state machine, and signal frontends about
received chat states.
"""
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):
msg_type = message.getAttribute("type")
# MUC: for groupchat we remove resource to get room bare JID.
# for DM (type="chat") we keep full JID to track occupant.
is_groupchat = self._is_muc(from_jid, profile) and msg_type == "groupchat"
if is_groupchat:
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)
# Extract any chat state element from the message (for all message types)
chat_state = next(
(
child.name
for child in message.elements()
if child.name in CHAT_STATES and child.defaultUri == NS_CHAT_STATES
),
None,
)
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
if not is_groupchat:
# one2one or MUC DM: implicit discovery (XEP-0085 §5.1)
# check if this is a content message (has <body/>)
has_body = (
next(
domish.generateElementsNamed(message.elements(), name="body"),
None,
)
is not None
)
if chat_state is not None:
# XEP-0085 §5.1 Rule 3: any chat state proves support
self.update_cache(from_jid, True, profile=profile)
elif has_body and message.getAttribute("type") == "chat":
# XEP-0085 §5.1 Rule 2: content message without chat state
# only deactivate if not already activated
try:
current = self.host.memory.get_entity_datum(
client, from_jid, ENTITY_KEY
)
except (exceptions.UnknownEntityError, KeyError):
current = None
if not current:
self.update_cache(from_jid, False, profile=profile)
return True
# fire explicit disco check for this contact
await self._ensure_disco_check(client, from_jid)
# init state machine so composing states can be sent to this contact
# XEP-0085 §5.4: only use with chat/groupchat message types
if msg_type in MESSAGE_TYPES:
self._chat_state_init(client, from_jid, msg_type)
# Signal frontends about received chat states
if chat_state is not None:
# XEP-0085 §5.5: ignore <gone/> in groupchat
if chat_state != "gone" or message.getAttribute("type") != "groupchat":
self.host.bridge.chat_state_received(
message.getAttribute("from"), chat_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.
"""
self,
client: SatXMPPEntity,
mess_data: dict,
pre_xml_treatments: "defer.Deferred",
post_xml_treatments: "defer.Deferred",
) -> bool:
"""Add chat state to outgoing messages and initiate the state machine."""
profile = client.profile
def treatment(mess_data):
async 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):
# XEP-0085 §4: fire disco check for this contact
await self._ensure_disco_check(client, to_jid)
if not await self._check_activation(client, to_jid, forceEntityData=True):
return mess_data
try:
# message with a body always mean active state
next(domish.generateElementsNamed(message.elements(), name="body"))
# XEP-0085 §2: check if this is a content message (has <body/>)
has_body = (
next(
domish.generateElementsNamed(message.elements(), name="body"),
None,
)
is not None
)
if has_body and mess_data["type"] in MESSAGE_TYPES:
# XEP-0085 §5.4: only use with chat/groupchat message types
# XEP-0085 §5.6: content message -> attach <active/>
message.addElement((NS_CHAT_STATES, "active"))
# 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((NS_CHAT_STATES, state))
# Launch the chat state machine (init the timer)
# MUC groupchat: strip resource to get room bare JID.
# MUC DM (type="chat"): keep full JID to track occupant.
target_jid = (
to_jid.userhostJID()
if self._is_muc(to_jid, profile) and mess_data["type"] == "groupchat"
else to_jid
)
self._chat_state_active(client, target_jid, mess_data["type"])
elif "chat_state" in mess_data["extra"]:
# Standalone notification
state = mess_data["extra"].pop("chat_state")
assert state in CHAT_STATES
message.addElement((NS_CHAT_STATES, state))
return mess_data
post_xml_treatments.addCallback(treatment)
post_xml_treatments.addCallback(
lambda _: defer.ensureDeferred(treatment(mess_data))
)
return True
def _is_muc(self, to_jid, profile):
def _is_muc(self, to_jid: JID, profile: str) -> bool:
"""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
@param to_jid: full or bare JID to check
@param profile: %(doc_profile)s
@return: bool
"""
client = self.host.get_client(profile)
@ -265,92 +419,114 @@ class XEP_0085(object):
pass
return False
def _check_activation(self, to_jid, forceEntityData, profile):
"""
async def _check_activation(
self, client: SatXMPPEntity, to_jid: JID, forceEntityData: bool
) -> bool:
"""Check whether chat state notifications should be sent to a contact.
Activation follows a priority order:
1. User config
2. MUC
3. Disco cache (preferred)
4. Implicit discovery cache (fallback)
@param client: the client instance
@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.
@param forceEntityData: if True, a missing implicit cache entry is
treated as True and initialized (used for initial discovery request)
@return: True if notifications should be sent to this JID.
"""
client = self.host.get_client(profile)
# check if the parameter is active
profile = client.profile
# XEP-0085 §5.2: user must be able to disable globally
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):
# XEP-0085 §5.5: MUC groupchat, no discovery required.
# MUC DMs (to_jid has resource) go through normal checks.
if self._is_muc(to_jid, profile) and not to_jid.resource:
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
# XEP-0085 §4: explicit disco discovery (preferred over implicit)
disco_result = await self._check_disco_support(client, to_jid)
if disco_result is True:
# disco confirmed support, activate unconditionally
self.update_cache(to_jid, True, profile=profile)
return True
if disco_result is False:
# disco confirmed no support, deactivate
return False
# disco_result is None: no resources known, fall through to implicit discovery
# XEP-0085 §5.1: implicit discovery (fallback)
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
# XEP-0085 §5.1 Rule 1: enable for initial discovery request
self.update_cache(to_jid, True, profile=profile)
return True
# wait for the first message before sending states
return False
# no discovery completed yet, wait for first message
return False
def _chat_state_init(self, to_jid, mess_type, profile):
def _chat_state_init(
self, client: SatXMPPEntity, to_jid: JID, mess_type: str
) -> None:
"""Data initialization for the chat state machine.
@param client: the client instance
@param to_jid: full JID for one2one, bare JID for MUC
@param mess_type: "chat" or "groupchat"
"""
Data initialization for the chat state machine.
chat_states_data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
machines = chat_states_data.machines
if to_jid not in machines:
machines[to_jid] = ChatStateMachine(
self.host, to_jid, mess_type, client.profile
)
@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
def _chat_state_active(
self, client: SatXMPPEntity, to_jid: JID, mess_type: str
) -> None:
"""Launch the chat state machine on "active" state.
@param client: the client instance
@param to_jid: full JID for one2one, bare JID for MUC
@param mess_type: "chat" or "groupchat"
"""
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
self._chat_state_init(client, to_jid, mess_type)
chat_states_data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
chat_states_data.machines[to_jid]._onEvent("active")
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):
async def chat_state_composing(self, to_jid_s: str, profile_key: str) -> None:
"""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.
Called from the frontend. Checks user config, disco support,
and implicit discovery state before sending.
@param to_jid_s (str): contact full JID as a string
@param profile_key (str): %(doc_profile_key)s
@param to_jid_s: contact full JID as a string
@param profile_key: %(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
):
profile = client.profile
# XEP-0085 §4: fire disco check if not already done
await self._ensure_disco_check(client, to_jid)
if not await self._check_activation(client, to_jid, forceEntityData=False):
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
# Ensure state machine exists (may not be initialised if no message
# has been exchanged yet but disco confirmed support)
# groupchat (bare JID) vs MUC DM (full JID with resource)
is_groupchat = self._is_muc(to_jid, profile) and not to_jid.resource
self._chat_state_init(client, to_jid, "groupchat" if is_groupchat else "chat")
chat_states_data = client.get_plugin_data(IMPORT_NAME, ChatStatesData)
chat_states_data.machines[to_jid]._onEvent("composing")
class ChatStateMachine(object):
class ChatStateMachine:
"""
This class represents a chat state, between one profile and
one target contact. A timer is used to move from one state
@ -360,7 +536,9 @@ class ChatStateMachine(object):
frontend). Other states are automatically set with the timer.
"""
def __init__(self, host, to_jid, mess_type, profile):
def __init__(
self, host: "LiberviaBackend", to_jid: JID, mess_type: str, profile: str
) -> None:
"""
Initialization need to store the target, message type
and a profile for sending later messages.
@ -370,9 +548,9 @@ class ChatStateMachine(object):
self.mess_type = mess_type
self.profile = profile
self.state = None
self.timer = None
self.timer: DelayedCall | None = None
def _onEvent(self, state):
def _onEvent(self, state: str) -> None:
"""
Move to the specified state, eventually send the
notification to the contact (the "active" state is
@ -400,18 +578,21 @@ class ChatStateMachine(object):
"subject": {},
"extra": {},
}
client.generate_message_xml(mess_data)
mess_data["xml"].addElement((NS_CHAT_STATES, state))
client.send(mess_data["xml"])
client.generate_message_xml(cast(Any, mess_data))
message_elt = cast(domish.Element, mess_data["xml"])
message_elt.addElement((NS_CHAT_STATES, state))
client.send(message_elt)
self.state = state
try:
self.timer.cancel()
except (internet_error.AlreadyCalled, AttributeError):
pass
if self.timer is not None:
try:
self.timer.cancel()
except internet_error.AlreadyCalled:
pass
if transition["next_state"] and transition["delay"] > 0:
self.timer = reactor.callLater(
reactor_time = cast(IReactorTime, reactor)
self.timer = reactor_time.callLater(
transition["delay"], self._onEvent, transition["next_state"]
)
@ -419,13 +600,15 @@ class ChatStateMachine(object):
@implementer(itx_xmpp.IDisco)
class XEP_0085_handler(XMPPHandler):
def __init__(self, plugin_parent, profile):
def __init__(self, plugin_parent: "XEP_0085", profile: str) -> None:
self.plugin_parent = plugin_parent
self.host = plugin_parent.host
self.profile = profile
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
def getDiscoInfo(self, requestor: JID, target: JID, nodeIdentifier: str = "") -> list:
return [disco.DiscoFeature(NS_CHAT_STATES)]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
def getDiscoItems(
self, requestor: JID, target: JID, nodeIdentifier: str = ""
) -> list:
return []