From a55ef47b0a18c7acc0b11e0f810c85cd788339f0 Mon Sep 17 00:00:00 2001 From: Goffi Date: Tue, 21 Apr 2026 16:18:31 +0200 Subject: [PATCH] plugin XEP-0045: MUC joining refactoring: - Join method is completely refactored so a room is now joined immediately, and MAM synchronisation happens in background. - Legacy history is not used anymore to simplify code. - Global simplification of the plugin, not more complicated states handling or message caching. --- libervia/backend/core/xmpp.py | 3 + libervia/backend/plugins/plugin_xep_0045.py | 1786 ++++++++++--------- libervia/backend/plugins/plugin_xep_0313.py | 2 + 3 files changed, 910 insertions(+), 881 deletions(-) diff --git a/libervia/backend/core/xmpp.py b/libervia/backend/core/xmpp.py index d4629ff4..339be7b8 100644 --- a/libervia/backend/core/xmpp.py +++ b/libervia/backend/core/xmpp.py @@ -1645,6 +1645,9 @@ class SatMessageProtocol(xmppim.MessageProtocol): else: client.mess_id2uid[(data["from"], message_id)] = data["uid"] + if getattr(message_elt, "_is_from_archive", False): + extra["is_from_archive"] = True + # origin if not from_jid_bare in (client.jid.userhostJID(), client.server_jid): disco_infos = await G.host.memory.disco.get_infos(client, from_jid_bare) diff --git a/libervia/backend/plugins/plugin_xep_0045.py b/libervia/backend/plugins/plugin_xep_0045.py index 291f1ebc..465108e9 100644 --- a/libervia/backend/plugins/plugin_xep_0045.py +++ b/libervia/backend/plugins/plugin_xep_0045.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -# SAT plugin for managing xep-0045 +# Libervia implementation of XEP-0045 (Multi-User Chat) # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify @@ -17,39 +17,49 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from dataclasses import dataclass import difflib +from enum import StrEnum import time -from typing import Optional, cast +from typing import TYPE_CHECKING, cast +from collections.abc import Sequence + +from twisted.python.failure import Failure import uuid +from zope.interface import implementer from pydantic import Field import shortuuid from twisted.internet import defer from twisted.python import failure from twisted.words.protocols.jabber import jid -from twisted.words.protocols.jabber import error as xmpp_error -from tx_xmpp import disco, itx_xmpp, muc -from tx_xmpp import rsm -from tx_xmpp import mam -from zope.interface import implementer +from twisted.words.protocols.jabber.error import StanzaError +from tx_xmpp import disco, itx_xmpp, mam, muc, rsm from libervia.backend import G from libervia.backend.core import exceptions -from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.constants import Const as C +from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.memory import memory from libervia.backend.plugins import plugin_misc_jid_search -from libervia.backend.tools import xml_tools, utils +from libervia.backend.plugins.plugin_xep_0313 import XEP_0313 +from libervia.backend.plugins.plugin_xep_0359 import XEP_0359 +from libervia.backend.tools import utils +from libervia.backend.tools import xml_tools from libervia.backend.tools.config import PluginSettings +if TYPE_CHECKING: + from libervia.backend.core.main import LiberviaBackend + log = getLogger(__name__) +IMPORT_NAME = "XEP-0045" PLUGIN_INFO = { C.PI_NAME: "XEP-0045 Plugin", - C.PI_IMPORT_NAME: "XEP-0045", + C.PI_IMPORT_NAME: IMPORT_NAME, C.PI_TYPE: "XEP", C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0045"], @@ -57,45 +67,69 @@ PLUGIN_INFO = { C.PI_RECOMMENDATIONS: [C.TEXT_CMDS, "XEP-0313"], C.PI_MAIN: "XEP_0045", C.PI_HANDLER: "yes", - C.PI_DESCRIPTION: _("""Implementation of Multi-User Chat"""), + C.PI_DESCRIPTION: _("Implementation of Multi-User Chat"), } NS_MUC = "http://jabber.org/protocol/muc" -AFFILIATIONS = ("owner", "admin", "member", "none", "outcast") ROOM_USER_JOINED = "ROOM_USER_JOINED" ROOM_USER_LEFT = "ROOM_USER_LEFT" OCCUPANT_KEYS = ("nick", "entity", "affiliation", "role") -ROOM_STATE_OCCUPANTS = "occupants" -ROOM_STATE_SELF_PRESENCE = "self-presence" -ROOM_STATE_LIVE = "live" -ROOM_STATES = (ROOM_STATE_OCCUPANTS, ROOM_STATE_SELF_PRESENCE, ROOM_STATE_LIVE) -HISTORY_LEGACY = "legacy" -HISTORY_MAM = "mam" +DEFAULT_HISTORY_SYNC_LIMIT = 50 + + +@dataclass +class MUCClientData: + _muc_client: "LiberviaMUCClient | None" = None + muc_service: jid.JID | None = None + + @property + def muc_client(self) -> "LiberviaMUCClient": + """Get the MUC client, asserting it exists.""" + assert self._muc_client is not None + return self._muc_client + + +class Affiliation(StrEnum): + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + NONE = "none" + OUTCAST = "outcast" + + +@dataclass +class OccupantInfo: + nick: str + entity: str + affiliation: str + role: str class PluginsMucSettings(PluginSettings, plugin_info=PLUGIN_INFO): - default_muc: str = Field(default="sat@chat.jabberfr.org") + default_muc: str = Field(default="libervia@chat.jabberfr.org") class AlreadyJoined(exceptions.ConflictError): + """Raised when attempting to join an already joined room.""" - def __init__(self, room): - super(AlreadyJoined, self).__init__() + def __init__(self, room: muc.Room) -> None: + super().__init__() self.room = room class XEP_0045: # TODO: handle invitations - # FIXME: this plugin need a good cleaning, join method is messy - def __init__(self, host): + def __init__(self, host: "LiberviaBackend") -> None: log.info(_("Plugin XEP_0045 initialization")) self.host = host + + self.settings = G.config.get_settings(PluginsMucSettings) self._sessions = memory.Sessions() self._jid_search = cast( plugin_misc_jid_search.JidSearch, host.plugins["JID_SEARCH"] ) - # return same arguments as muc_room_joined + a boolean set to True is the room was + # return same arguments as muc_room_joined + a boolean set to True if the room was # already joined (first argument) host.bridge.add_method( "muc_join", @@ -170,10 +204,6 @@ class XEP_0045: method=self._get_muc_service, async_=True, ) - # called when a room will be joined but must be locked until join is received - # (room is prepared, history is getting retrieved) - # args: room_jid, profile - host.bridge.add_signal("muc_room_prepare_join", ".plugin", signature="ss") # args: room_jid, occupants, user_nick, subject, profile host.bridge.add_signal("muc_room_joined", ".plugin", signature="sa{sa{ss}}ssass") # args: room_jid, profile @@ -201,8 +231,8 @@ class XEP_0045: self.text_cmds.register_text_commands(self) self.text_cmds.add_who_is_cb(self._whois, 100) - self._mam = self.host.plugins.get("XEP-0313") - self._si = self.host.plugins["XEP-0359"] + self._mam = cast(XEP_0313, self.host.plugins.get("XEP-0313")) + self._si = cast(XEP_0359, self.host.plugins["XEP-0359"]) host.trigger.add("presence_available", self.presence_trigger) host.trigger.add("presence_received", self.presence_received_trigger) @@ -214,49 +244,46 @@ class XEP_0045: "JID_SEARCH_perform_search", self.jid_search_perform_search_trigger ) - async def profile_connected(self, client): - client.muc_service = await self.get_muc_service(client) + async def profile_connected(self, client: SatXMPPEntity) -> None: + """Called when a profile connects.""" + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) + muc_data.muc_service = await self.get_muc_service(client) - def _message_parse_trigger(self, client, message_elt, data): - """Add stanza-id from the room if present""" + def _message_parse_trigger( + self, client: SatXMPPEntity, message_elt, data: dict + ) -> bool: + """Add stanza-id from the room if present.""" + # stanza_id will not be filled by parse_message because the emitter + # is the room and not our server, so we have to parse it here if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT: - # stanza_id will not be filled by parse_message because the emitter - # is the room and not our server, so we have to parse it here room_jid = data["from"].userhostJID() stanza_id = self._si.get_stanza_id(message_elt, room_jid) if stanza_id: data["extra"]["stanza_id"] = stanza_id - return True - def message_received_trigger(self, client, message_elt, post_treat): - if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT: - if message_elt.subject: - return False - from_jid = jid.JID(message_elt["from"]) - room_jid = from_jid.userhostJID() - if room_jid in client._muc_client.joined_rooms: - room = client._muc_client.joined_rooms[room_jid] - if room.state != ROOM_STATE_LIVE: - if getattr(room, "_history_type", HISTORY_LEGACY) == HISTORY_LEGACY: - # With MAM history, order is different, and we can get live - # messages before history is complete, so this is not a warning - # but an expected case. - # On the other hand, with legacy history, it's not normal. - log.warning( - _( - "Received non delayed message in a room before its " - "initialisation: state={state}, msg={msg}" - ).format(state=room.state, msg=message_elt.toXml()) - ) - room._cache.append(message_elt) - return False - else: - log.warning( - "Received groupchat message for a room which has not been " - "joined, ignoring it: {}".format(message_elt.toXml()) - ) - return False + def message_received_trigger( + self, client: SatXMPPEntity, message_elt, post_treat: bool + ) -> bool: + """Handle incoming groupchat messages.""" + if message_elt.getAttribute("type") != C.MESS_TYPE_GROUPCHAT: + return True + + from_jid = jid.JID(message_elt["from"]) + room_jid = from_jid.userhostJID() + + if ( + room_jid + not in client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.joined_rooms + ): + log.warning( + "Received groupchat message for unjoined room, ignoring: %s", + message_elt.toXml(), + ) + return False + return True async def jid_search_perform_search_trigger( @@ -267,72 +294,83 @@ class XEP_0045: sequence_matcher: difflib.SequenceMatcher, matches: plugin_misc_jid_search.SearchItems, ) -> bool: - if options.groupchat: - if client.muc_service is not None: - # FIXME: RSM is not handled, we need proper RSM handling as number of - # rooms may be large. - disco_items = await self.host.memory.disco.get_items( - client, client.muc_service - ) - for disco_item in disco_items: - search_item = plugin_misc_jid_search.RoomSearchItem( - entity=disco_item.entity, name=disco_item.name or "", local=True - ) - self._jid_search.process_matching( - search_term, sequence_matcher, matches, search_item - ) + """Search MUC rooms if groupchat option is enabled.""" + # FIXME: RSM is not handled, we need proper RSM handling as number of + # rooms may be large. + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) + if not options.groupchat or muc_data.muc_service is None: + return True + + disco_items = await self.host.memory.disco.get_items(client, muc_data.muc_service) + for disco_item in disco_items: + search_item = plugin_misc_jid_search.RoomSearchItem( + entity=disco_item.entity, name=disco_item.name or "", local=True + ) + self._jid_search.process_matching( + search_term, sequence_matcher, matches, search_item + ) return True def get_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> muc.Room: - """Retrieve Room instance from its jid + """Retrieve Room instance from its jid. - @param room_jid: jid of the room - @raise exceptions.NotFound: the room has not been joined + @param client: The client instance. + @param room_jid: JID of the room. + @return: The joined room. """ try: - return client._muc_client.joined_rooms[room_jid] + return client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.joined_rooms[room_jid] except KeyError: raise exceptions.NotFound(_("This room has not been joined")) - def check_room_joined(self, client, room_jid): - """Check that given room has been joined in current session + def check_room_joined(self, client: SatXMPPEntity, room_jid: jid.JID) -> None: + """Check that given room has been joined in current session. - @param room_jid (JID): room JID + @param client: The client instance. + @param room_jid: Room JID. """ - if room_jid not in client._muc_client.joined_rooms: + if ( + room_jid + not in client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.joined_rooms + ): raise exceptions.NotFound(_("This room has not been joined")) def is_joined_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> bool: - """Tell if a jid is a known and joined room + """Check if a JID is a known and joined room. - @room_jid: jid of the room + @param client: The client instance. + @param room_jid: JID of the room. + @return: True if the room is joined. """ try: self.check_room_joined(client, room_jid) except exceptions.NotFound: return False - else: - return True + return True - def is_room(self, client, entity_jid): - """Tell if a jid is a joined MUC + def is_room(self, client: SatXMPPEntity, entity_jid: jid.JID) -> bool: + """Check if a JID is a joined MUC. - similar to is_joined_room but returns a boolean - @param entity_jid(jid.JID): full or bare jid of the entity check - @return (bool): True if the bare jid of the entity is a room jid + @param client: The client instance. + @param entity_jid: JID of the entity to check. + @return: True if the JID is a joined MUC. """ try: self.check_room_joined(client, entity_jid.userhostJID()) except exceptions.NotFound: return False - else: - return True + return True - def get_bare_or_full(self, client, peer_jid): - """use full jid if peer_jid is an occupant of a room, bare jid else + def get_bare_or_full(self, client: SatXMPPEntity, peer_jid: jid.JID) -> jid.JID: + """Use full JID if peer is an occupant of a room, bare JID else. - @param peer_jid(jid.JID): entity to test - @return (jid.JID): bare or full jid + @param client: The client instance. + @param peer_jid: JID of the entity to test. + @return: Bare or full JID. """ if peer_jid.resource: if not self.is_room(client, peer_jid): @@ -340,57 +378,84 @@ class XEP_0045: return peer_jid def get_room_user_jid(self, client: SatXMPPEntity, room_jid: jid.JID) -> jid.JID: - """Get the nick used in the room + """Get the nick used in the room. - @param room_jid: jid of the room to use - @return: JID used in room - - @raise exceptions.NotFound: this room has not been joined - @raise exceptions.InternalError: invalid room_jid + @param client: The client instance. + @param room_jid: Room JID (bare, no resource). + @return: Occupant JID with nick as resource. """ # FIXME: doesn't check if room is anonymous or not # TODO: check if room is (semi)anonymous if room_jid.resource: - raise exceptions.InternalError(f"{room_jid} should not have a ressource.") + raise exceptions.InternalError(f"{room_jid} should not have a resource") room = self.get_room(client, room_jid) return jid.JID(tuple=(room_jid.user, room_jid.host, room.nick)) - def _get_room_joined_args(self, room, profile): + def _get_room_joined_bridge_args( + self, client: SatXMPPEntity, room: muc.Room + ) -> Sequence[str | dict | list[str]]: + """Build arguments for muc_room_joined bridge signal. + + @param client: The client instance. + @param room: The joined room. + @return: Sequence of arguments for the bridge signal. + """ + occupants = self.get_room_occupants(client, room.roomJID) return [ room.roomJID.userhost(), - XEP_0045._get_occupants(room), + occupants, room.nick, - # FIXME: getattr below is a Q&D fix as `subject` may not be set in early call - # of _get_rooms_joined (during `join` call). The whole workflow of this - # plugin should be refactored. + # getattr because subject arrives after self-presence (XEP-0045 §7.1) getattr(room, "subject", ""), - [s.name for s in room.statuses], - profile, + [s.name for s in getattr(room, "statuses", [])], + client.profile, ] - def _ui_room_join_cb(self, data, profile): + def _ui_room_join_cb(self, data: dict, profile: str) -> dict: + """Handle UI room join callback. + + @param data: UI data dict. + @param profile: Profile key. + @return: Result dict. + """ room_jid = jid.JID(data["index"]) client = self.host.get_client(profile) defer.ensureDeferred(self.join(client, room_jid)) return {} - def _password_ui_cb(self, data, client, room_jid, nick): - """Called when the user has given room password (or cancelled)""" + def _password_ui_cb( + self, + data: dict, + client: SatXMPPEntity, + room_jid: jid.JID, + nick: str, + ) -> defer.Deferred: + """Handle password UI callback. + + @param data: UI data dict. + @param client: The client instance. + @param room_jid: Room JID. + @param nick: Nickname. + @return: Deferred. + """ if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")): - log.info("room join for {} is cancelled".format(room_jid.userhost())) - raise failure.Failure( - exceptions.CancelError(D_("Room joining cancelled by user")) + log.info("Room join for %s cancelled", room_jid.userhost()) + return defer.fail( + failure.Failure(exceptions.CancelError("Room joining cancelled")) ) password = data[xml_tools.form_escape("password")] - return client._muc_client.join(room_jid, nick, password).addCallbacks( - self._join_cb, - self._join_eb, - (client, room_jid, nick), - errbackArgs=(client, room_jid, nick, password), + return ( + client.get_plugin_data(IMPORT_NAME, MUCClientData) + .muc_client.join(room_jid, nick, password) + .addCallbacks( + lambda room: room, + lambda f: self._join_eb(f, client, room_jid, nick, password), + ) ) - def _show_list_ui(self, items, client, service): - xmlui = xml_tools.XMLUI(title=D_("Rooms in {}".format(service.full()))) + def _show_list_ui(self, items: list, client: SatXMPPEntity, service: jid.JID) -> None: + """Show room list UI.""" + xmlui = xml_tools.XMLUI(title=f"Rooms in {service.full()}") adv_list = xmlui.change_container( "advanced_list", columns=1, @@ -404,22 +469,24 @@ class XEP_0045: adv_list.end() self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile) - def _join_cb(self, room, client, room_jid, nick): - """Called when the user is in the requested room""" - if room.locked: - # FIXME: the current behaviour is to create an instant room - # and send the signal only when the room is unlocked - # a proper configuration management should be done - log.debug(_("room locked !")) - d = client._muc_client.configure(room.roomJID, {}) - d.addErrback( - self.host.log_errback, - msg=_("Error while configuring the room: {failure_}"), - ) - return room.fully_joined + def _join_eb( + self, + failure_: failure.Failure, + client: SatXMPPEntity, + room_jid: jid.JID, + nick: str, + password: str | None, + ) -> defer.Deferred: + """Handle join errors. - def _join_eb(self, failure_, client, room_jid, nick, password): - """Called when something is going wrong when joining the room""" + @param failure_: The failure object. + @param client: The client instance. + @param room_jid: Room JID. + @param nick: Nickname. + @param password: Password or None. + @return: Deferred. + """ + # TODO: error management + signal in bridge try: condition = failure_.value.condition except AttributeError: @@ -428,110 +495,143 @@ class XEP_0045: if condition == "conflict": # we have a nickname conflict, we try again with "_" suffixed to current nickname nick += "_" - return client._muc_client.join(room_jid, nick, password).addCallbacks( - self._join_cb, - self._join_eb, - (client, room_jid, nick), - errbackArgs=(client, room_jid, nick, password), + return ( + client.get_plugin_data(IMPORT_NAME, MUCClientData) + .muc_client.join(room_jid, nick, password) + .addCallbacks( + lambda room: room, + lambda f: self._join_eb(f, client, room_jid, nick, password), + ) ) elif condition == "not-allowed": # room is restricted, we need a password password_ui = xml_tools.XMLUI( "form", - title=D_("Room {} is restricted").format(room_jid.userhost()), + title=f"Room {room_jid.userhost()} is restricted", submit_id="", ) - password_ui.addText( - D_("This room is restricted, please enter the password") - ) + password_ui.addText("This room is restricted, please enter the password") password_ui.addPassword("password") d = xml_tools.defer_xmlui(self.host, password_ui, profile=client.profile) - d.addCallback(self._password_ui_cb, client, room_jid, nick) + d.addCallback( + lambda data: self._password_ui_cb(data, client, room_jid, nick) + ) return d - msg_suffix = ' with condition "{}"'.format(failure_.value.condition) + msg_suffix = f' with condition "{condition}"' - mess = D_( - "Error while joining the room {room}{suffix}".format( - room=room_jid.userhost(), suffix=msg_suffix - ) - ) + mess = f"Error while joining room {room_jid.userhost()}{msg_suffix}" log.warning(mess) - xmlui = xml_tools.note(mess, D_("Group chat error"), level=C.XMLUI_DATA_LVL_ERROR) + xmlui = xml_tools.note(mess, "Group chat error", level=C.XMLUI_DATA_LVL_ERROR) self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile) + return defer.fail(failure_) - @staticmethod - def _get_occupants(room): - """Get occupants of a room in a form suitable for bridge""" - return { - u.nick: {k: str(getattr(u, k) or "") for k in OCCUPANT_KEYS} - for u in list(room.roster.values()) - } - - def _get_room_occupants(self, room_jid_s, profile_key): + def _get_room_occupants( + self, room_jid_s: str, profile_key: str + ) -> dict[str, dict[str, str]]: + """Get room occupants.""" client = self.host.get_client(profile_key) room_jid = jid.JID(room_jid_s) return self.get_room_occupants(client, room_jid) - def get_room_occupants(self, client, room_jid): + def get_room_occupants( + self, client: SatXMPPEntity, room_jid: jid.JID + ) -> dict[str, dict[str, str]]: + """Get room occupants as dict suitable for bridge.""" room = self.get_room(client, room_jid) - return self._get_occupants(room) + return { + u.nick: {k: str(getattr(u, k, "")) for k in OCCUPANT_KEYS} + for u in room.roster.values() + } - def _get_rooms_joined(self, profile_key=C.PROF_KEY_NONE): + def _get_rooms_joined(self, profile_key: str = C.PROF_KEY_NONE) -> list[tuple]: + """Get joined rooms. + + @param profile_key: Profile key. + @return: List of joined room tuples. + """ client = self.host.get_client(profile_key) return self.get_rooms_joined(client) - def get_rooms_joined(self, client): - """Return rooms where user is""" + def get_rooms_joined(self, client: SatXMPPEntity) -> list[tuple]: + """Return rooms where user is. + + @param client: The client instance. + @return: List of joined room tuples. + """ result = [] - for room in list(client._muc_client.joined_rooms.values()): - if room.state == ROOM_STATE_LIVE: - result.append( - ( - room.roomJID.userhost(), - self._get_occupants(room), - room.nick, - room.subject, - [s.name for s in room.statuses], - ) + for room in client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.joined_rooms.values(): + occupants = { + u.nick: {k: str(getattr(u, k, "")) for k in OCCUPANT_KEYS} + for u in room.roster.values() + } + result.append( + ( + room.roomJID.userhost(), + occupants, + room.nick, + # getattr because subject arrives after self-presence (XEP-0045 §7.1) + getattr(room, "subject", ""), + [s.name for s in getattr(room, "statuses", [])], ) + ) return result - def _get_room_nick(self, room_jid_s, profile_key=C.PROF_KEY_NONE): + def _get_room_nick(self, room_jid_s: str, profile_key: str = C.PROF_KEY_NONE) -> str: + """Get room nick. + + @param room_jid_s: Room JID string. + @param profile_key: Profile key. + @return: Room nick string. + """ client = self.host.get_client(profile_key) return self.get_room_nick(client, jid.JID(room_jid_s)) - def get_room_nick(self, client, room_jid): - """return nick used in room by user + def get_room_nick(self, client: SatXMPPEntity, room_jid: jid.JID) -> str: + """Return nick used in room by user. - @param room_jid (jid.JID): JID of the room - @profile_key: profile - @return: nick or empty string in case of error - @raise exceptions.Notfound: use has not joined the room + @param client: The client instance. + @param room_jid: Room JID. + @return: Nick or empty string in case of error. """ self.check_room_joined(client, room_jid) - return client._muc_client.joined_rooms[room_jid].nick + return ( + client.get_plugin_data(IMPORT_NAME, MUCClientData) + .muc_client.joined_rooms[room_jid] + .nick + ) - def _configure_room(self, room_jid_s, profile_key=C.PROF_KEY_NONE): + def _configure_room( + self, room_jid_s: str, profile_key: str = C.PROF_KEY_NONE + ) -> defer.Deferred: + """Configure room. + + @param room_jid_s: Room JID string. + @param profile_key: Profile key. + @return: Deferred with XMLUI form string. + """ client = self.host.get_client(profile_key) d = self.configure_room(client, jid.JID(room_jid_s)) - d.addCallback(lambda xmlui: xmlui.toXml()) + d.addCallback(lambda xmlui: xmlui.toXml() if xmlui else "") return d - def _configure_room_menu(self, menu_data, profile): - """Return room configuration form + def _configure_room_menu(self, menu_data: dict, profile: str) -> defer.Deferred: + """Return room configuration form. - @param menu_data: %(menu_data)s - @param profile: %(doc_profile)s + @param menu_data: Menu data dict. + @param profile: Profile key. + @return: Deferred with XMLUI form string. """ client = self.host.get_client(profile) try: room_jid = jid.JID(menu_data["room_jid"]) except KeyError: - log.error(_("room_jid key is not present !")) - return defer.fail(exceptions.DataError) + log.error("room_jid key is not present!") + return defer.fail(exceptions.DataError()) - def xmlui_received(xmlui): + def xmlui_received(xmlui: xml_tools.XMLUI | None) -> dict: if not xmlui: msg = D_("No configuration available for this room") return {"xmlui": xml_tools.note(msg).toXml()} @@ -539,77 +639,110 @@ class XEP_0045: return self.configure_room(client, room_jid).addCallback(xmlui_received) - def configure_room(self, client, room_jid): - """return the room configuration form + def configure_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> defer.Deferred: + """Return the room configuration form. - @param room: jid of the room to configure - @return: configuration form as XMLUI + @param client: The client instance. + @param room_jid: Room JID to configure. + @return: Deferred firing with configuration form as XMLUI. """ self.check_room_joined(client, room_jid) - def config_2_xmlui(result): + def config_2_xmlui(result) -> xml_tools.XMLUI | None: if not result: - return "" + return None session_id, session_data = self._sessions.new_session(profile=client.profile) session_data["room_jid"] = room_jid xmlui = xml_tools.data_form_2_xmlui(result, submit_id=self.__submit_conf_id) xmlui.session_id = session_id return xmlui - d = client._muc_client.getConfiguration(room_jid) + d = client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.getConfiguration(room_jid) d.addCallback(config_2_xmlui) return d - def _submit_configuration(self, raw_data, profile): + def _submit_configuration(self, raw_data: dict, profile: str) -> defer.Deferred: + """Submit room configuration.""" cancelled = C.bool(raw_data.get("cancelled", C.BOOL_FALSE)) if cancelled: return defer.succeed({}) + client = self.host.get_client(profile) try: session_data = self._sessions.profile_get(raw_data["session_id"], profile) except KeyError: - log.warning(D_("Session ID doesn't exist, session has probably expired.")) - _dialog = xml_tools.XMLUI("popup", title=D_("Room configuration failed")) - _dialog.addText(D_("Session ID doesn't exist, session has probably expired.")) - return defer.succeed({"xmlui": _dialog.toXml()}) + log.warning("Session ID doesn't exist, session has probably expired.") + dialog = xml_tools.XMLUI("popup", title=D_("Room configuration failed")) + dialog.addText(D_("Session ID doesn't exist, session has probably expired.")) + return defer.succeed({"xmlui": dialog.toXml()}) data = xml_tools.xmlui_result_2_data_form_result(raw_data) - d = client._muc_client.configure(session_data["room_jid"], data) - _dialog = xml_tools.XMLUI("popup", title=D_("Room configuration succeed")) - _dialog.addText(D_("The new settings have been saved.")) - d.addCallback(lambda ignore: {"xmlui": _dialog.toXml()}) + d = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.configure( + session_data["room_jid"], data + ) + + def on_success(_) -> dict: + dialog = xml_tools.XMLUI("popup", title=D_("Room configuration succeed")) + dialog.addText(D_("The new settings have been saved.")) + return {"xmlui": dialog.toXml()} + + d.addCallback(on_success) del self._sessions[raw_data["session_id"]] return d - def is_nick_in_room(self, client, room_jid, nick): - """Tell if a nick is currently present in a room""" + def is_nick_in_room( + self, client: SatXMPPEntity, room_jid: jid.JID, nick: str + ) -> bool: + """Tell if a nick is currently present in a room.""" self.check_room_joined(client, room_jid) - return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick)) + return ( + client.get_plugin_data(IMPORT_NAME, MUCClientData) + .muc_client.joined_rooms[room_jid] + .inRoster(muc.User(nick)) + ) - def _get_muc_service(self, jid_=None, profile=C.PROF_KEY_NONE): + # Text commands + + def _get_muc_service( + self, jid_: str | None = None, profile: str = C.PROF_KEY_NONE + ) -> defer.Deferred: + """Get MUC service JID. + + @param jid_: JID string or None. + @param profile: Profile key. + @return: Deferred with MUC service JID string. + """ client = self.host.get_client(profile) - d = defer.ensureDeferred(self.get_muc_service(client, jid_ or None)) + d = defer.ensureDeferred( + self.get_muc_service(client, jid_.userhostJID() if jid_ else None) + ) d.addCallback( lambda service_jid: service_jid.full() if service_jid is not None else "" ) return d async def get_muc_service( - self, client: SatXMPPEntity, jid_: Optional[jid.JID] = None - ) -> Optional[jid.JID]: - """Return first found MUC service of an entity + self, client: SatXMPPEntity, jid_: jid.JID | None = None + ) -> jid.JID | None: + """Return first found MUC service of an entity. - @param jid_: entity which may have a MUC service, or None for our own server - @return: found service jid or None + @param client: The client instance. + @param jid_: Entity which may have a MUC service, or None for our own server. + @return: Found service JID or None. """ if jid_ is None: try: - muc_service = client.muc_service + muc_service = client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_service except AttributeError: pass else: - # we have a cached value, we return it - return muc_service + if muc_service is not None: + return muc_service + services = await self.host.find_service_entities( client, "conference", "text", jid_ ) @@ -618,213 +751,429 @@ class XEP_0045: # FIXME: # This ugly hack is here to avoid an issue with openfire: the IRC gateway # use "conference/text" identity (instead of "conference/irc") - muc_service = service - break - else: - muc_service = None - return muc_service + return service + return None - def _get_unique_name(self, muc_service="", profile_key=C.PROF_KEY_NONE): + def _get_unique_name( + self, muc_service: str = "", profile_key: str = C.PROF_KEY_NONE + ) -> str: + """Get unique room name. + + @param muc_service: MUC service JID string. + @param profile_key: Profile key. + @return: Unique room name JID string. + """ client = self.host.get_client(profile_key) return self.get_unique_name(client, muc_service or None).full() def get_unique_name( - self, client: SatXMPPEntity, muc_service: jid.JID | None = None, prefix: str = "" + self, + client: SatXMPPEntity, + muc_service: jid.JID | None = None, + prefix: str = "", ) -> jid.JID: - """Return unique name for a room, avoiding collision + """Return unique name for a room, avoiding collision. - @param client: Client instance. - @param muc_service: leave empty string to use the default service - @param prefix: prefix to use in room name. - @return: unique room bare JID + @param client: The client instance. + @param muc_service: Leave empty to use the default service. + @param prefix: Optional prefix for room name. + @return: Unique room bare JID. """ room_name = f"{prefix}{shortuuid.uuid()}" if muc_service is None: try: - muc_service = client.muc_service + muc_service = client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_service except AttributeError: raise exceptions.NotReady( "Main server MUC service has not been checked yet" ) if muc_service is None: - log.warning(_("No MUC service found on main server")) + log.warning("No MUC service found on main server") raise exceptions.FeatureNotFound - muc_service = muc_service.userhost() - return jid.JID(f"{room_name}@{muc_service}") + return jid.JID(f"{room_name}@{muc_service.userhost()}") - def get_default_muc(self): - """Return the default MUC.""" - plugin_settings = G.config.get_settings(PluginsMucSettings) - return plugin_settings.default_muc + def get_default_muc(self) -> str: + """Return the default MUC. - def _bridge_join_eb(self, failure_, client): - failure_.trap(AlreadyJoined) - room = failure_.value.room - return [True] + self._get_room_joined_args(room, client.profile) + @return: Default MUC JID string. + """ + return self.settings.default_muc - def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE): - """join method used by bridge + @utils.ensure_deferred + async def _join( + self, + room_jid_s: str, + nick: str, + options: dict, + profile_key: str = C.PROF_KEY_NONE, + ) -> list[str | dict | list[str] | bool]: + """Join method used by bridge. - @return (tuple): already_joined boolean + room joined arguments (see [_get_room_joined_args]) + @param room_jid_s: Room JID string (bare or full). + @param nick: Nickname to use. + @param options: Join options dict. + @param profile_key: Profile identifier. + @return: Tuple of (already_joined, room_joined_args). """ client = self.host.get_client(profile_key) + if room_jid_s: - muc_service = client.muc_service + muc_service = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_service try: room_jid = jid.JID(room_jid_s) except (RuntimeError, jid.InvalidFormat, AttributeError): - return defer.fail( - jid.InvalidFormat( - _( - "Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'." - ).format(room_id=room_jid_s, muc_service=str(muc_service)) - ) + raise jid.InvalidFormat( + _( + "Invalid room identifier: {room_id}. " + "Use 'room' or 'room@{muc_service}'" + ).format(room_id=room_jid_s, muc_service=str(muc_service)) ) if not room_jid.user: - room_jid.user, room_jid.host = room_jid.host, muc_service + assert muc_service is not None + room_jid.user, room_jid.host = room_jid.host, str(muc_service) else: room_jid = self.get_unique_name(client) - # TODO: error management + signal in bridge - d = defer.ensureDeferred(self.join(client, room_jid, nick, options or None)) - d.addCallback( - lambda room: [False] + self._get_room_joined_args(room, client.profile) - ) - d.addErrback(self._bridge_join_eb, client) - return d + # TODO: error management + signal in bridge + try: + room = await self.join(client, room_jid, nick, options or None) + except AlreadyJoined as e: + return [True] + list(self._get_room_joined_bridge_args(client, e.room)) + else: + return [False] + list(self._get_room_joined_bridge_args(client, room)) async def join( self, client: SatXMPPEntity, room_jid: jid.JID, - nick: Optional[str] = None, - options: Optional[dict] = None, - ) -> Optional[muc.Room]: - if not nick: + nick: str | None = None, + options: dict | None = None, + ) -> muc.Room: + """Join a MUC room. + + History sync will run in background to join as quick as possible. + + @param client: The client instance. + @param room_jid: Room JID to join. + @param nick: Nickname to use (defaults to username). + @param options: Optional dict with 'password' key. + @return: The joined room. + @raise AlreadyJoined: The room is already joined by this profile. + """ + if nick is None: nick = client.jid.user if options is None: options = {} - if room_jid in client._muc_client.joined_rooms: - room = client._muc_client.joined_rooms[room_jid] + + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) + muc_client = muc_data.muc_client + + if room_jid in muc_client.joined_rooms: + room = muc_client.joined_rooms[room_jid] log.info( - _("{profile} is already in room {room_jid}").format( - profile=client.profile, room_jid=room_jid.userhost() - ) + "{profile} is already in room {room_jid}", + profile=client.profile, + room_jid=room_jid.userhost(), ) raise AlreadyJoined(room) + log.info( - _("[{profile}] is joining room {room} with nick {nick}").format( - profile=client.profile, room=room_jid.userhost(), nick=nick - ) + "[{profile}] joining room {room} as {nick}", + profile=client.profile, + room=room_jid.userhost(), + nick=nick, ) - self.host.bridge.muc_room_prepare_join(room_jid.userhost(), client.profile) password = options.get("password") try: - room = await client._muc_client.join(room_jid, nick, password) + room = await muc_client.join( + room_jid, nick, muc.HistoryOptions(maxStanzas=0), password + ) except Exception as e: - room = await utils.as_deferred( - self._join_eb(failure.Failure(e), client, room_jid, nick, password) + room = await self._join_eb( + failure.Failure(e), client, room_jid, nick, password ) else: - room.on_joined_callbacks = [] - room.on_left_callbacks = [] - await defer.ensureDeferred(self._join_cb(room, client, room_jid, nick)) + if room.locked: + log.debug("Room locked, configuring it") + try: + await muc_client.configure(room.roomJID, {}) + except Exception as e: + log.exception(f"Can't configure MUC room {room_jid}") + + if self._mam is not None: + # We use `ensureDeferred` to run the archive sync in background + defer.ensureDeferred(self._sync_mam_history(client, room)) + return room - def pop_rooms(self, client): - """Remove rooms and return data needed to re-join them + async def _sync_mam_history(self, client: SatXMPPEntity, room: muc.Room) -> None: + """Retrieve MAM history and send messages to bridge progressively. - This methods is to be called before a hot reconnection - @return (list[(jid.JID, unicode)]): arguments needed to re-join the rooms - This list can be used directly (unpacked) with self.join + @param client: The client instance. + @param room: The joined room. + """ + if self._mam is None: + return + + room_jid = room.roomJID + total_messages = 0 + + try: + last_mess = await self.host.memory.history_get( + room_jid, + None, + limit=1, + between=False, + filters={"types": C.MESS_TYPE_GROUPCHAT, "last_stanza_id": True}, + profile=client.profile, + ) + + if last_mess: + stanza_id = last_mess[0][-1].get("stanza_id") + rsm_req = rsm.RSMRequest(max_=100, after=stanza_id) + else: + log.info( + "We have no MAM archive for room {room_jid}.".format( + room_jid=room_jid + ) + ) + stanza_id = None + rsm_req = rsm.RSMRequest(max_=DEFAULT_HISTORY_SYNC_LIMIT, before="") + + mam_req = mam.MAMRequest(rsm_=rsm_req) + + while True: + try: + mam_data = await self._mam.get_archives( + client, mam_req, service=room_jid + ) + except StanzaError as e: + if last_mess and e.condition == "item-not-found": + log.warning( + f"Requested item (with stanza_id {stanza_id!r}) can't be " + f"found in history of {room_jid}, history has probably been " + "purged on server. Falling back to last N items." + ) + # Fall back to retrieving last N items like for a new room + rsm_req = rsm.RSMRequest( + max_=DEFAULT_HISTORY_SYNC_LIMIT, before="" + ) + mam_req = mam.MAMRequest(rsm_=rsm_req) + # Clear last_mess to prevent infinite loop if server keeps failing + last_mess = None + continue + else: + raise + + elt_list, rsm_response, mam_response = mam_data + + if not elt_list: + break + + for mess_elt in elt_list: + try: + fwd_message_elt = self._mam.get_message_from_result( + client, mess_elt, mam_req, service=room_jid + ) + except exceptions.DataError: + log.exception("Invalid element received") + continue + + if to_ := fwd_message_elt.getAttribute("to"): + log.warning( + f'Forwarded message element has a "to" attribute ({to_!r})' + " while it is forbidden by specifications" + ) + fwd_message_elt["to"] = client.jid.full() + + client.messageProt.onMessage(fwd_message_elt) + total_messages += 1 + + if mam_response.get("complete", False): + break + + if rsm_response and rsm_response.last: + # Create new MAMRequest with updated rsm (rsm is a property) + rsm_req = rsm.RSMRequest(max_=100, after=rsm_response.last) + mam_req = mam.MAMRequest(rsm_=rsm_req) + else: + break + + if total_messages == 0: + log.info( + _("No message received while offline in {room_jid}").format( + room_jid=room_jid + ) + ) + else: + log.info( + f"Retrieved {total_messages} message(s) from {room_jid} while" + " offline.", + ) + + except Exception as e: + log.exception(f"MAM history sync failed for {room_jid}") + + def pop_rooms(self, client: SatXMPPEntity) -> list[tuple]: + """Remove rooms and return data needed to re-join them. + + This method is to be called before a hot reconnection. + + @param client: The client instance. + @return: List of tuples (client, room_jid, nick) for re-joining. """ args_list = [] - for room in list(client._muc_client.joined_rooms.values()): - client._muc_client._removeRoom(room.roomJID) + muc_client = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client + for room in list(muc_client.joined_rooms.values()): + muc_client._removeRoom(room.roomJID) args_list.append((client, room.roomJID, room.nick)) return args_list - def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE): + def _nick( + self, room_jid_s: str, nick: str, profile_key: str = C.PROF_KEY_NONE + ) -> None: + """Change nickname.""" client = self.host.get_client(profile_key) - return self.nick(client, jid.JID(room_jid_s), nick) + self.nick(client, jid.JID(room_jid_s), nick) - def nick(self, client, room_jid, nick): - """Change nickname in a room""" + def nick(self, client: SatXMPPEntity, room_jid: jid.JID, nick: str) -> None: + """Change nickname in a room. + + @param client: The client instance. + @param room_jid: Room JID. + @param nick: New nickname. + """ self.check_room_joined(client, room_jid) - return client._muc_client.nick(room_jid, nick) + client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.nick(room_jid, nick) - def _leave(self, room_jid, profile_key): + def _leave(self, room_jid: str, profile_key: str) -> defer.Deferred: + """Leave room. + + @param room_jid: Room JID string. + @param profile_key: Profile key. + @return: Deferred. + """ client = self.host.get_client(profile_key) return self.leave(client, jid.JID(room_jid)) - def leave(self, client, room_jid): - self.check_room_joined(client, room_jid) - return client._muc_client.leave(room_jid) + def leave(self, client: SatXMPPEntity, room_jid: jid.JID) -> defer.Deferred: + """Leave a room. - def _subject(self, room_jid_s, new_subject, profile_key): + @param client: The client instance. + @param room_jid: Room JID. + @return: Deferred firing when leave is complete. + """ + self.check_room_joined(client, room_jid) + return client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.leave( + room_jid + ) + + def _subject(self, room_jid_s: str, new_subject: str, profile_key: str) -> None: + """Set room subject.""" client = self.host.get_client(profile_key) - return self.subject(client, jid.JID(room_jid_s), new_subject) + self.subject(client, jid.JID(room_jid_s), new_subject) - def subject(self, client, room_jid, subject): + def subject(self, client: SatXMPPEntity, room_jid: jid.JID, subject: str) -> None: + """Set room subject. + + @param client: The client instance. + @param room_jid: Room JID. + @param subject: New subject. + """ self.check_room_joined(client, room_jid) - return client._muc_client.subject(room_jid, subject) + client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.subject( + room_jid, subject + ) - def get_handler(self, client): + def get_handler(self, client: SatXMPPEntity) -> "LiberviaMUCClient": + """Create a MUC client and associate it with profile's session. + + @param client: The client instance. + @return: The MUC client. + """ # create a MUC client and associate it with profile' session - muc_client = client._muc_client = LiberviaMUCClient(self) + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) + muc_client = muc_data._muc_client = LiberviaMUCClient(self) return muc_client - def kick(self, client, nick, room_jid, options=None): - """Kick a participant from the room + def kick( + self, + client: SatXMPPEntity, + nick: str, + room_jid: jid.JID, + options: dict | None = None, + ) -> defer.Deferred: + """Kick a participant from the room. - @param nick (str): nick of the user to kick - @param room_jid_s (JID): jid of the room - @param options (dict): attribute with extra info (reason, password) as in #XEP-0045 + @param client: The client instance. + @param nick: Nick of the user to kick. + @param room_jid: Room JID. + @param options: Dict with 'reason' key. + @return: Deferred firing when kick is complete. """ if options is None: options = {} self.check_room_joined(client, room_jid) - return client._muc_client.kick(room_jid, nick, reason=options.get("reason", None)) + return client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.kick( + room_jid, nick, reason=options.get("reason", None) + ) - def ban(self, client, entity_jid, room_jid, options=None): - """Ban an entity from the room + def ban( + self, + client: SatXMPPEntity, + entity_jid: jid.JID, + room_jid: jid.JID, + options: dict | None = None, + ) -> defer.Deferred: + """Ban an entity from the room. - @param entity_jid (JID): bare jid of the entity to be banned - @param room_jid (JID): jid of the room - @param options: attribute with extra info (reason, password) as in #XEP-0045 + @param client: The client instance. + @param entity_jid: Bare JID of the entity to be banned. + @param room_jid: Room JID. + @param options: Dict with 'reason' key. + @return: Deferred firing when ban is complete. """ self.check_room_joined(client, room_jid) if options is None: options = {} assert not entity_jid.resource assert not room_jid.resource - return client._muc_client.ban( + return client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.ban( room_jid, entity_jid, reason=options.get("reason", None) ) - def affiliate(self, client, entity_jid, room_jid, options): - """Change the affiliation of an entity + def affiliate( + self, + client: SatXMPPEntity, + entity_jid: jid.JID, + room_jid: jid.JID, + options: dict, + ) -> defer.Deferred: + """Change the affiliation of an entity. - @param entity_jid (JID): bare jid of the entity - @param room_jid_s (JID): jid of the room - @param options: attribute with extra info (reason, nick) as in #XEP-0045 + @param client: The client instance. + @param entity_jid: Bare JID of the entity. + @param room_jid: Room JID. + @param options: Dict with 'affiliation' key. + @return: + Deferred firing when affiliation change is complete. """ self.check_room_joined(client, room_jid) assert not entity_jid.resource assert not room_jid.resource assert "affiliation" in options # TODO: handles reason and nick - return client._muc_client.modifyAffiliationList( - room_jid, [entity_jid], options["affiliation"] - ) + return client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.modifyAffiliationList(room_jid, [entity_jid], options["affiliation"]) - # Text commands # - - def cmd_nick(self, client, mess_data): - """change nickname + def cmd_nick(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Change nickname. @command (group): new_nick - new_nick: new nick to use @@ -833,61 +1182,59 @@ class XEP_0045: if nick: room = mess_data["to"] self.nick(client, room, nick) - return False - def cmd_join(self, client, mess_data): - """join a new room + def cmd_join(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Join a new room. @command (all): JID - JID: room to join (on the same service if full jid is not specified) """ room_raw = mess_data["unparsed"].strip() - if room_raw: - if self.is_joined_room(client, mess_data["to"]): - # we use the same service as the one from the room where the command has - # been entered if full jid is not entered - muc_service = mess_data["to"].host - nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user - else: - # the command has been entered in a one2one conversation, so we use - # our server MUC service as default service - muc_service = client.muc_service or "" - nick = client.jid.user - room_jid = self.text_cmds.get_room_jid(room_raw, muc_service) - defer.ensureDeferred(self.join(client, room_jid, nick, {})) + if not room_raw: + return False + if self.is_joined_room(client, mess_data["to"]): + # we use the same service as the one from the room where the command has + # been entered if full jid is not entered + muc_service = mess_data["to"].host + nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user + else: + # the command has been entered in a one2one conversation, so we use + # our server MUC service as default service + muc_service = ( + client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_service or "" + ) + nick = client.jid.user + + room_jid = self.text_cmds.get_room_jid(room_raw, muc_service) + defer.ensureDeferred(self.join(client, room_jid, nick, {})) return False - def cmd_leave(self, client, mess_data): - """quit a room + def cmd_leave(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Quit a room. @command (group): [ROOM_JID] - - ROOM_JID: jid of the room to live (current room if not specified) + - ROOM_JID: jid of the room to leave (current room if not specified) """ room_raw = mess_data["unparsed"].strip() if room_raw: room = self.text_cmds.get_room_jid(room_raw, mess_data["to"].host) else: room = mess_data["to"] - self.leave(client, room) - return False - def cmd_part(self, client, mess_data): - """just a synonym of /leave - - @command (group): [ROOM_JID] - - ROOM_JID: jid of the room to live (current room if not specified) - """ + def cmd_part(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Synonym of /leave.""" return self.cmd_leave(client, mess_data) - def cmd_kick(self, client, mess_data): - """kick a room member + def cmd_kick(self, client: SatXMPPEntity, mess_data: dict) -> bool | defer.Deferred: + """Kick a room member. - @command (group): ROOM_NICK + @command (group): ROOM_NICK [reason] - ROOM_NICK: the nick of the person to kick + - reason: reason for the kick """ options = mess_data["unparsed"].strip().split() try: @@ -899,10 +1246,9 @@ class XEP_0045: return False reason = " ".join(options[1:]) if len(options) > 1 else None - d = self.kick(client, nick, mess_data["to"], {"reason": reason}) - def cb(__): + def cb(_) -> bool: feedback_msg = _("You have kicked {}").format(nick) if reason is not None: feedback_msg += _(" for the following reason: {reason}").format( @@ -914,8 +1260,8 @@ class XEP_0045: d.addCallback(cb) return d - def cmd_ban(self, client, mess_data): - """ban an entity from the room + def cmd_ban(self, client: SatXMPPEntity, mess_data: dict) -> bool | defer.Deferred: + """Ban an entity from the room. @command (group): (JID) [reason] - JID: the JID of the entity to ban @@ -941,10 +1287,9 @@ class XEP_0045: return False reason = " ".join(options[1:]) if len(options) > 1 else None - d = self.ban(client, entity_jid, mess_data["to"], {"reason": reason}) - def cb(__): + def cb(_) -> bool: feedback_msg = _("You have banned {}").format(entity_jid) if reason is not None: feedback_msg += _(" for the following reason: {reason}").format( @@ -956,8 +1301,10 @@ class XEP_0045: d.addCallback(cb) return d - def cmd_affiliate(self, client, mess_data): - """affiliate an entity to the room + def cmd_affiliate( + self, client: SatXMPPEntity, mess_data: dict + ) -> bool | defer.Deferred: + """Affiliate an entity to the room. @command (group): (JID) [owner|admin|member|none|outcast] - JID: the JID of the entity to affiliate @@ -981,15 +1328,16 @@ class XEP_0045: AssertionError, ): feedback = _( - "You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'" + "You must provide a valid JID to affiliate, like in '/affiliate" + " contact@example.net member'" ) self.text_cmds.feed_back(client, feedback, mess_data) return False affiliation = options[1] if len(options) > 1 else "none" - if affiliation not in AFFILIATIONS: - feedback = _("You must provide a valid affiliation: %s") % " ".join( - AFFILIATIONS + if affiliation not in Affiliation: + feedback = _("You must provide a valid affiliation: {}").format( + " ".join(a.value for a in Affiliation) ) self.text_cmds.feed_back(client, feedback, mess_data) return False @@ -998,7 +1346,7 @@ class XEP_0045: client, entity_jid, mess_data["to"], {"affiliation": affiliation} ) - def cb(__): + def cb(_) -> bool: feedback_msg = _("New affiliation for {entity}: {affiliation}").format( entity=entity_jid, affiliation=affiliation ) @@ -1008,30 +1356,24 @@ class XEP_0045: d.addCallback(cb) return d - def cmd_title(self, client, mess_data): - """change room's subject + def cmd_title(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Change room's subject. @command (group): title - title: new room subject """ subject = mess_data["unparsed"].strip() - if subject: room = mess_data["to"] self.subject(client, room, subject) - return False - def cmd_topic(self, client, mess_data): - """just a synonym of /title - - @command (group): title - - title: new room subject - """ + def cmd_topic(self, client: SatXMPPEntity, mess_data: dict) -> bool: + """Synonym of /title.""" return self.cmd_title(client, mess_data) - def cmd_list(self, client, mess_data): - """list available rooms in a muc server + def cmd_list(self, client: SatXMPPEntity, mess_data: dict) -> bool | defer.Deferred: + """List available rooms in a MUC server. @command (all): [MUC_SERVICE] - MUC_SERVICE: service to request @@ -1042,65 +1384,86 @@ class XEP_0045: try: service = jid.JID(unparsed) except RuntimeError: + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) if mess_data["type"] == C.MESS_TYPE_GROUPCHAT: room_jid = mess_data["to"] service = jid.JID(room_jid.host) - elif client.muc_service is not None: - service = client.muc_service + elif muc_data.muc_service is not None: + service = muc_data.muc_service else: - msg = D_("No known default MUC service {unparsed}").format( - unparsed=unparsed - ) + msg = D_("No known default MUC service") self.text_cmds.feed_back(client, msg, mess_data) return False except jid.InvalidFormat: - msg = D_("{} is not a valid JID!".format(unparsed)) + msg = D_("{} is not a valid JID!").format(unparsed) self.text_cmds.feed_back(client, msg, mess_data) return False + d = self.host.getDiscoItems(client, service) d.addCallback(self._show_list_ui, client, service) + return d - return False - - def _whois(self, client, whois_msg, mess_data, target_jid): - """Add MUC user information to whois""" + def _whois( + self, + client: SatXMPPEntity, + whois_msg: list, + mess_data: dict, + target_jid: jid.JID, + ) -> None: + """Add MUC user information to whois.""" if mess_data["type"] != "groupchat": return - if target_jid.userhostJID() not in client._muc_client.joined_rooms: - log.warning(_("This room has not been joined")) + if ( + target_jid.userhostJID() + not in client.get_plugin_data( + IMPORT_NAME, MUCClientData + ).muc_client.joined_rooms + ): + log.warning("This room has not been joined") return if not target_jid.resource: return - user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser( + + muc_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) + user = muc_data.muc_client.joined_rooms[target_jid.userhostJID()].getUser( target_jid.resource ) - whois_msg.append(_("Nickname: %s") % user.nick) + whois_msg.append(_("Nickname: {}").format(user.nick)) if user.entity: - whois_msg.append(_("Entity: %s") % user.entity) + whois_msg.append(_("Entity: {}").format(user.entity)) if user.affiliation != "none": - whois_msg.append(_("Affiliation: %s") % user.affiliation) + whois_msg.append(_("Affiliation: {}").format(user.affiliation)) if user.role != "none": - whois_msg.append(_("Role: %s") % user.role) + whois_msg.append(_("Role: {}").format(user.role)) if user.status: - whois_msg.append(_("Status: %s") % user.status) + whois_msg.append(_("Status: {}").format(user.status)) if user.show: - whois_msg.append(_("Show: %s") % user.show) + whois_msg.append(_("Show: {}").format(user.show)) - def presence_trigger(self, presence_elt, client): + def presence_trigger(self, presence_elt, client: SatXMPPEntity) -> bool: + """Broadcast presence to all joined rooms.""" # FIXME: should we add a privacy parameters in settings to activate before # broadcasting the presence to all MUC rooms ? - muc_client = client._muc_client + muc_client = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client for room_jid, room in muc_client.joined_rooms.items(): elt = xml_tools.element_copy(presence_elt) elt["to"] = room_jid.userhost() + "/" + room.nick client.presence.send(elt) return True - def presence_received_trigger(self, client, entity, show, priority, statuses): + def presence_received_trigger( + self, + client: SatXMPPEntity, + entity: jid.JID, + show: str, + priority: int, + statuses: dict, + ) -> bool: + """Handle presence received trigger.""" + # presence is already handled in (un)availableReceived entity_bare = entity.userhostJID() - muc_client = client._muc_client + muc_client = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client if entity_bare in muc_client.joined_rooms: - # presence is already handled in (un)availableReceived return False return True @@ -1108,287 +1471,52 @@ class XEP_0045: @implementer(itx_xmpp.IDisco) class LiberviaMUCClient(muc.MUCClient): - def __init__(self, plugin_parent): + ## presence/roster ## + + def __init__(self, plugin_parent: XEP_0045) -> None: self.plugin_parent = plugin_parent - muc.MUCClient.__init__(self) - self._changing_nicks = set() # used to keep trace of who is changing nick, - # and to discard userJoinedRoom signal in this case - print("init SatMUCClient OK") + super().__init__() @property - def joined_rooms(self): + def joined_rooms(self) -> dict: + """Get joined rooms.""" return self._rooms @property - def host(self): + def host(self) -> "LiberviaBackend": + """Get host.""" return self.plugin_parent.host @property - def client(self): + def client(self) -> SatXMPPEntity: + """Get client.""" + assert self.parent is not None return self.parent - @property - def _mam(self): - return self.plugin_parent._mam - - @property - def _si(self): - return self.plugin_parent._si - - def change_room_state(self, room, new_state): - """Check that room is in expected state, and change it - - @param new_state: one of ROOM_STATE_* - """ - new_state_idx = ROOM_STATES.index(new_state) - if new_state_idx == -1: - raise exceptions.InternalError("unknown room state") - if new_state_idx < 1: - raise exceptions.InternalError( - "unexpected new room state ({room}): {state}".format( - room=room.userhost(), state=new_state - ) - ) - expected_state = ROOM_STATES[new_state_idx - 1] - if room.state != expected_state: - log.error( - _( - "room {room} is not in expected state: room is in state {current_state} " - "while we were expecting {expected_state}" - ).format( - room=room.roomJID.userhost(), - current_state=room.state, - expected_state=expected_state, - ) - ) - room.state = new_state - - def _addRoom(self, room): - super(LiberviaMUCClient, self)._addRoom(room) - room._roster_ok = False # True when occupants list has been fully received - room.state = ROOM_STATE_OCCUPANTS - # FIXME: check if history_d is not redundant with fully_joined - room.fully_joined = defer.Deferred() # called when everything is OK - # cache data until room is ready - # list of elements which will be re-injected in stream - room._cache = [] - # we only need to keep last presence status for each jid, so a dict is suitable - room._cache_presence = {} - - async def _join_legacy( - self, client: SatXMPPEntity, room_jid: jid.JID, nick: str, password: Optional[str] - ) -> muc.Room: - """Join room an retrieve history with legacy method""" - mess_data_list = await self.host.memory.history_get( - room_jid, - client.jid.userhostJID(), - limit=1, - between=True, - profile=client.profile, - ) - if mess_data_list: - timestamp = mess_data_list[0][1] - # we use seconds since last message to get backlog without duplicates - # and we remove 1 second to avoid getting the last message again - seconds = int(time.time() - timestamp) - 1 - else: - seconds = None - - room = await super(LiberviaMUCClient, self).join( - room_jid, nick, muc.HistoryOptions(seconds=seconds), password - ) - # used to send bridge signal once backlog are written in history - room._history_type = HISTORY_LEGACY - room._history_d = defer.Deferred() - room._history_d.callback(None) - return room - - async def _get_mam_history( - self, client: SatXMPPEntity, room: muc.Room, room_jid: jid.JID - ) -> None: - """Retrieve history for rooms handling MAM""" - history_d = room._history_d = defer.Deferred() - # we trigger now the deferred so all callback are processed as soon as possible - # and in order - history_d.callback(None) - - last_mess = await self.host.memory.history_get( - room_jid, - None, - limit=1, - between=False, - filters={"types": C.MESS_TYPE_GROUPCHAT, "last_stanza_id": True}, - profile=client.profile, - ) - if last_mess: - stanza_id = last_mess[0][-1]["stanza_id"] - rsm_req = rsm.RSMRequest(max_=20, after=stanza_id) - no_loop = False - else: - log.info( - "We have no MAM archive for room {room_jid}.".format(room_jid=room_jid) - ) - # we don't want the whole archive if we have no archive yet - # as it can be huge - rsm_req = rsm.RSMRequest(max_=50, before="") - no_loop = True - - mam_req = mam.MAMRequest(rsm_=rsm_req) - complete = False - count = 0 - while not complete: - try: - mam_data = await self._mam.get_archives(client, mam_req, service=room_jid) - except xmpp_error.StanzaError as e: - if last_mess and e.condition == "item-not-found": - log.warning( - f"requested item (with id {stanza_id!r}) can't be found in " - f"history of {room_jid}, history has probably been purged on " - f"server." - ) - # we get last items like for a new room - rsm_req = rsm.RSMRequest(max_=50, before="") - mam_req = mam.MAMRequest(rsm_=rsm_req) - no_loop = True - continue - else: - raise e - elt_list, rsm_response, mam_response = mam_data - complete = True if no_loop else mam_response["complete"] - # we update MAM request for next iteration - mam_req.rsm.after = rsm_response.last - - if not elt_list: - break - else: - count += len(elt_list) - - for mess_elt in elt_list: - try: - fwd_message_elt = self._mam.get_message_from_result( - client, mess_elt, mam_req, service=room_jid - ) - except exceptions.DataError: - continue - if fwd_message_elt.getAttribute("to"): - log.warning( - 'Forwarded message element has a "to" attribute while it is ' - "forbidden by specifications" - ) - fwd_message_elt["to"] = client.jid.full() - client.messageProt.onMessage(fwd_message_elt) - client._muc_client._onGroupChat(fwd_message_elt) - - if not count: - log.info( - _( - "No message received while offline in {room_jid}".format( - room_jid=room_jid - ) - ) - ) - else: - log.info( - _( - "We have received {num_mess} message(s) in {room_jid} while " - "offline." - ).format(num_mess=count, room_jid=room_jid) - ) - - # for legacy history, the following steps are done in receivedSubject but for MAM - # the order is different (we have to join then get MAM archive, so subject - # is received before archive), so we change state and add the callbacks here. - self.change_room_state(room, ROOM_STATE_LIVE) - history_d.addCallbacks( - self._history_cb, self._history_eb, [room], errbackArgs=[room] - ) - - # we wait for all callbacks to be processed - await history_d - - async def _join_mam( - self, client: SatXMPPEntity, room_jid: jid.JID, nick: str, password: Optional[str] - ) -> muc.Room: - """Join room and retrieve history using MAM""" - room = await super(LiberviaMUCClient, self).join( - # we don't want any history from room as we'll get it with MAM - room_jid, - nick, - muc.HistoryOptions(maxStanzas=0), - password=password, - ) - room._history_type = HISTORY_MAM - # MAM history retrieval can be very long, and doesn't need to be sync, so we don't - # wait for it - defer.ensureDeferred(self._get_mam_history(client, room, room_jid)) - room.fully_joined.callback(room) - - return room - - async def join(self, room_jid, nick, password=None): - room_service = jid.JID(room_jid.host) - has_mam = await self.host.has_feature(self.client, mam.NS_MAM, room_service) - if not self._mam or not has_mam: - return await self._join_legacy(self.client, room_jid, nick, password) - else: - return await self._join_mam(self.client, room_jid, nick, password) - - ## presence/roster ## - - def availableReceived(self, presence): - """ - Available presence was received. - """ - # XXX: we override MUCClient.availableReceived to fix bugs - # (affiliation and role are not set) - + def availableReceived(self, presence) -> None: + """Handle available presence.""" room, user = self._getRoomUser(presence) - if room is None: + if room is None or user is None: return - if user is None: - nick = presence.sender.resource - if not nick: - log.warning( - _("missing nick in presence: {xml}").format( - xml=presence.toElement().toXml() - ) - ) - return - user = muc.User(nick, presence.entity) - - # we want to keep statuses with room - # XXX: presence if broadcasted, and we won't have status code - # like 110 (REALJID_PUBLIC) after first received - # so we keep only the initial (with SELF_PRESENCE), - # thus we check if attribute already exists + # we want to keep statuses with room so they can be sent to the frontend via the + # muc_room_joined bridge signal + # XXX: after the first received, subsequent broadcasts won't include + # status codes like 110 (SELF_PRESENCE) or 100 (REALJID_PUBLIC), so we only + # keep the statuses from the initial presence (identified by SELF_PRESENCE); + # thus we check if the attribute already exists. if ( not hasattr(room, "statuses") and muc.STATUS_CODE.SELF_PRESENCE in presence.mucStatuses ): room.statuses = presence.mucStatuses - # Update user data - user.role = presence.role - user.affiliation = presence.affiliation - user.status = presence.status - user.show = presence.show + super().availableReceived(presence) - if room.inRoster(user): - self.userUpdatedStatus(room, user, presence.show, presence.status) - else: - room.addUser(user) - self.userJoinedRoom(room, user) - - def unavailableReceived(self, presence): + def unavailableReceived(self, presence) -> None: + """Handle unavailable presence.""" # XXX: we override this method to manage nickname change - """ - Unavailable presence was received. - - If this was received from a MUC room occupant JID, that occupant has - left the room. - """ room, user = self._getRoomUser(presence) if room is None or user is None: @@ -1397,256 +1525,152 @@ class LiberviaMUCClient(muc.MUCClient): room.removeUser(user) if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses: - self._changing_nicks.add(presence.nick) self.user_changed_nick(room, user, presence.nick) else: - self._changing_nicks.discard(presence.nick) + # we left the room self.userLeftRoom(room, user) - def userJoinedRoom(self, room, user): + def userJoinedRoom(self, room: muc.Room, user: muc.User) -> None: + """Handle user joined room event.""" if user.nick == room.nick: - # we have received our own nick, - # this mean that the full room roster was received - self.change_room_state(room, ROOM_STATE_SELF_PRESENCE) + # we have received our own nick, this mean that the full room roster was + # received log.debug( - "room {room} joined with nick {nick}".format( - room=room.occupantJID.userhost(), nick=user.nick - ) + "room {room} joined with nick {nick}", + room=room.occupantJID.userhost(), + nick=user.nick, ) - # we set type so we don't have to use a deferred - # with disco to check entity type + args = self.plugin_parent._get_room_joined_bridge_args(self.client, room) + self.plugin_parent.host.bridge.muc_room_joined(*args) + + # we set type so we don't have to use a deferred with disco to check entity + # type self.host.memory.update_entity_data( self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC ) - elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE): - log.warning( - "Received user presence data in a room before its initialisation " - "(current state: {state})," - "this is not standard! Ignoring it: {room} ({nick})".format( - state=room.state, room=room.roomJID.userhost(), nick=user.nick - ) - ) return - else: - if not room.fully_joined.called: - return - for cb in room.on_joined_callbacks: - defer.ensureDeferred(cb(room, user)) - try: - self._changing_nicks.remove(user.nick) - except KeyError: - # this is a new user - log.debug( - _("user {nick} has joined room {room_id}").format( - nick=user.nick, room_id=room.occupantJID.userhost() - ) - ) - if not self.host.trigger.point( - "MUC user joined", room, user, self.client.profile - ): - return - extra = { - "info_type": ROOM_USER_JOINED, - "user_affiliation": user.affiliation, - "user_role": user.role, - "user_nick": user.nick, - } - if user.entity is not None: - extra["user_entity"] = user.entity.full() - mess_data = { # dict is similar to the one used in client.onMessage - "from": room.roomJID, - "to": self.client.jid, - "uid": str(uuid.uuid4()), - "message": {"": D_("=> {} has joined the room").format(user.nick)}, - "subject": {}, - "type": C.MESS_TYPE_INFO, - "extra": extra, - "timestamp": time.time(), - } - # FIXME: we disable presence in history as it's taking a lot of space - # while not indispensable. In the future an option may allow - # to re-enable it - # self.client.message_add_to_history(mess_data) - self.client.message_send_to_bridge(mess_data) + log.debug( + "user {nick} has joined room {room_id}", + nick=user.nick, + room_id=room.occupantJID.userhost(), + ) + if not self.host.trigger.point( + "MUC user joined", room, user, self.client.profile + ): + return - def userLeftRoom(self, room, user): + extra = { + "info_type": ROOM_USER_JOINED, + "user_affiliation": user.affiliation, + "user_role": user.role, + "user_nick": user.nick, + } + if user.entity is not None: + extra["user_entity"] = user.entity.full() + + mess_data = { + "from": room.roomJID, + "to": self.client.jid, + "uid": str(uuid.uuid4()), + "message": {"": _("=> {} has joined the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.client.message_send_to_bridge(mess_data) + + def userLeftRoom(self, room: muc.Room, user: muc.User) -> None: + """Handle user left room event.""" if not self.host.trigger.point("MUC user left", room, user, self.client.profile): return + if user.nick == room.nick: - # we left the room room_jid_s = room.roomJID.userhost() log.info( - _("Room ({room}) left ({profile})").format( - room=room_jid_s, profile=self.client.profile - ) + "Room ({room}) left ({profile})", + room=room_jid_s, + profile=self.client.profile, ) self.host.memory.del_entity_cache( room.roomJID, profile_key=self.client.profile ) - self.host.bridge.muc_room_left(room.roomJID.userhost(), self.client.profile) - elif room.state != ROOM_STATE_LIVE: - log.warning( - "Received user presence data in a room before its initialisation (current state: {state})," - "this is not standard! Ignoring it: {room} ({nick})".format( - state=room.state, room=room.roomJID.userhost(), nick=user.nick - ) - ) + self.host.bridge.muc_room_left(room_jid_s, self.client.profile) return - else: - if not room.fully_joined.called: - return - log.debug( - _("user {nick} left room {room_id}").format( - nick=user.nick, room_id=room.occupantJID.userhost() - ) - ) - for cb in room.on_left_callbacks: - defer.ensureDeferred(cb(room, user)) - extra = { - "info_type": ROOM_USER_LEFT, - "user_affiliation": user.affiliation, - "user_role": user.role, - "user_nick": user.nick, - } - if user.entity is not None: - extra["user_entity"] = user.entity.full() - mess_data = { # dict is similar to the one used in client.onMessage - "from": room.roomJID, - "to": self.client.jid, - "uid": str(uuid.uuid4()), - "message": {"": D_("<= {} has left the room").format(user.nick)}, - "subject": {}, - "type": C.MESS_TYPE_INFO, - "extra": extra, - "timestamp": time.time(), - } - # FIXME: disable history, see userJoinRoom comment - # self.client.message_add_to_history(mess_data) - self.client.message_send_to_bridge(mess_data) - def user_changed_nick(self, room, user, new_nick): + log.debug( + "user {nick} left room {room_id}", + nick=user.nick, + room_id=room.occupantJID.userhost(), + ) + + extra = { + "info_type": ROOM_USER_LEFT, + "user_affiliation": user.affiliation, + "user_role": user.role, + "user_nick": user.nick, + } + if user.entity is not None: + extra["user_entity"] = user.entity.full() + + mess_data = { + "from": room.roomJID, + "to": self.client.jid, + "uid": str(uuid.uuid4()), + "message": {"": _("<= {} has left the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.client.message_send_to_bridge(mess_data) + + def user_changed_nick(self, room: muc.Room, user: muc.User, new_nick: str) -> None: + """Handle user nick change.""" self.host.bridge.muc_room_user_changed_nick( room.roomJID.userhost(), user.nick, new_nick, self.client.profile ) - def userUpdatedStatus(self, room, user, show, status): + def userUpdatedStatus( + self, room: muc.Room, user: muc.User, show: str, status: str + ) -> None: + """Handle user status update.""" entity = jid.JID(tuple=(room.roomJID.user, room.roomJID.host, user.nick)) - if hasattr(room, "_cache_presence"): - # room has a cache for presence, meaning it has not been fully - # joined yet. So we put presence in cache, and stop workflow. - # Or delete current presence and continue workflow if it's an - # "unavailable" presence - cache = room._cache_presence - cache[entity] = { - "room": room, - "user": user, - "show": show, - "status": status, - } - return statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ""} self.host.bridge.presence_update( entity.full(), show or "", 0, statuses, self.client.profile ) - ## messages ## - - def receivedGroupChat(self, room, user, body): - log.debug( - "receivedGroupChat: room=%s user=%s body=%s" - % (room.roomJID.full(), user, body) - ) - - ## subject ## - - def groupChatReceived(self, message): - """ - A group chat message has been received from a MUC room. - - There are a few event methods that may get called here. - L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. - """ + def receivedSubject( + self, room: muc.Room, user: muc.User | None, subject: str + ) -> None: + """Handle room subject.""" # We override this method to fix subject handling (empty strings were discarded) # FIXME: remove this once fixed upstream - room, user = self._getRoomUser(message) + room.subject = subject - if room is None: - log.warning( - "No room found for message: {message}".format( - message=message.toElement().toXml() - ) - ) + if user is not None and user.nick == room.nick: return - if message.subject is not None: - self.receivedSubject(room, user, message.subject) - elif message.delay is None: - self.receivedGroupChat(room, user, message) - - def subject(self, room, subject): - return muc.MUCClientProtocol.subject(self, room, subject) - - def _history_cb(self, __, room): - """Called when history have been written to database and subject is received - - this method will finish joining by: - - sending message to bridge - - calling fully_joined deferred (for legacy history) - - sending stanza put in cache - - cleaning variables not needed anymore - """ - args = self.plugin_parent._get_room_joined_args(room, self.client.profile) - self.host.bridge.muc_room_joined(*args) - if room._history_type == HISTORY_LEGACY: - room.fully_joined.callback(room) - del room._history_d - del room._history_type - cache = room._cache - del room._cache - cache_presence = room._cache_presence - del room._cache_presence - for elem in cache: - self.client.xmlstream.dispatch(elem) - for presence_data in cache_presence.values(): - if not presence_data["show"] and not presence_data["status"]: - # occupants are already sent in muc_room_joined, so if we don't have - # extra information like show or statuses, we can discard the signal - continue - else: - self.userUpdatedStatus(**presence_data) - - def _history_eb(self, failure_, room): - log.error("Error while managing history: {}".format(failure_)) - self._history_cb(None, room) - - def receivedSubject(self, room, user, subject): - # when subject is received, we know that we have whole roster and history - # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject - room.subject = subject # FIXME: subject doesn't handle xml:lang - if room.state != ROOM_STATE_LIVE: - if room._history_type == HISTORY_LEGACY: - self.change_room_state(room, ROOM_STATE_LIVE) - room._history_d.addCallbacks( - self._history_cb, self._history_eb, [room], errbackArgs=[room] - ) - else: - # the subject has been changed - log.debug( - _("New subject for room ({room_id}): {subject}").format( - room_id=room.roomJID.full(), subject=subject - ) - ) - self.host.bridge.muc_room_new_subject( - room.roomJID.userhost(), subject, self.client.profile - ) + log.debug( + "New subject for room ({room_id}): {subject}", + room_id=room.occupantJID.full(), + subject=subject, + ) + # the subject has been changed + self.host.bridge.muc_room_new_subject( + room.roomJID.userhost(), subject, self.client.profile + ) ## disco ## - def getDiscoInfo(self, requestor, target, nodeIdentifier=""): + def getDiscoInfo(self, requestor, target: jid.JID, nodeIdentifier: str = "") -> list: + """Get disco info.""" return [disco.DiscoFeature(NS_MUC)] - def getDiscoItems(self, requestor, target, nodeIdentifier=""): + def getDiscoItems(self, requestor, target: jid.JID, nodeIdentifier: str = "") -> list: + """Get disco items.""" # TODO: manage room queries ? Bad for privacy, must be disabled by default # see XEP-0045 § 6.7 return [] diff --git a/libervia/backend/plugins/plugin_xep_0313.py b/libervia/backend/plugins/plugin_xep_0313.py index fff12918..ee8c2ed7 100644 --- a/libervia/backend/plugins/plugin_xep_0313.py +++ b/libervia/backend/plugins/plugin_xep_0313.py @@ -341,6 +341,8 @@ class XEP_0313(object): if delay_elt is not None: fwd_message_elt.addChild(delay_elt) + fwd_message_elt._is_from_archive = True + return fwd_message_elt def queryFields(self, client, service=None):