#!/usr/bin/env python3
# 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
# 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 .
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING, cast
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"
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"""),
}
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
# 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:
"""
Implementation for XEP 0085
"""
params = """
""" % {
"category_name": PARAM_KEY,
"category_label": _(PARAM_KEY),
"param_name": PARAM_NAME,
"param_label": _("Enable chat state notifications"),
}
def __init__(self, host: "LiberviaBackend") -> None:
log.info(_("Chat State Notifications plugin initialization"))
self.host = host
# 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: SatXMPPEntity) -> "XEP_0085_handler":
return XEP_0085_handler(self, client.profile)
def profile_disconnected(self, client: SatXMPPEntity) -> None:
"""Eventually send a 'gone' state to all one2one contacts."""
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
machine._onEvent("gone")
chat_states_data.machines.clear()
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
@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 contact
self.host.bridge.chat_state_received(entity_jid.full(), "", profile)
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.
"""
if (category, name) == (PARAM_KEY, PARAM_NAME):
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
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"))
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()
# 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,
)
if not is_groupchat:
# one2one or MUC DM: implicit discovery (XEP-0085 §5.1)
# check if this is a content message (has