#!/usr/bin/env python3 # 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 # 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 collections.abc import Sequence from dataclasses import dataclass import difflib from enum import StrEnum import re import time from typing import TYPE_CHECKING, cast import uuid from pydantic import BaseModel, 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.error import StanzaError from tx_xmpp import disco, itx_xmpp, mam, muc, rsm from zope.interface import implementer from libervia.backend import G 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 D_, _ from libervia.backend.core.log import getLogger from libervia.backend.memory import memory from libervia.backend.memory.sqla_mapping import NotificationType from libervia.backend.models.core import MessageData from libervia.backend.models.types import JIDType from libervia.backend.plugins import plugin_misc_jid_search 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: IMPORT_NAME, C.PI_TYPE: "XEP", C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0045"], C.PI_DEPENDENCIES: ["XEP-0359", "JID_SEARCH"], 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"), } NS_MUC = "http://jabber.org/protocol/muc" ROOM_USER_JOINED = "ROOM_USER_JOINED" ROOM_USER_LEFT = "ROOM_USER_LEFT" OCCUPANT_KEYS = ("nick", "entity", "affiliation", "role") 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 NotificationExtraMention(BaseModel): """Extra data for a MUC mention notification.""" message_uid: str from_jid: JIDType to_jid: JIDType room_jid: JIDType class PluginsMucSettings(PluginSettings, plugin_info=PLUGIN_INFO): 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: muc.Room) -> None: super().__init__() self.room = room class XEP_0045: # TODO: handle invitations 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 if the room was # already joined (first argument) host.bridge.add_method( "muc_join", ".plugin", in_sign="ssa{ss}s", out_sign="(bsa{sa{ss}}ssass)", method=self._join, async_=True, ) host.bridge.add_method( "muc_nick", ".plugin", in_sign="sss", out_sign="", method=self._nick ) host.bridge.add_method( "muc_nick_get", ".plugin", in_sign="ss", out_sign="s", method=self._get_room_nick, ) host.bridge.add_method( "muc_leave", ".plugin", in_sign="ss", out_sign="", method=self._leave, async_=True, ) host.bridge.add_method( "muc_occupants_get", ".plugin", in_sign="ss", out_sign="a{sa{ss}}", method=self._get_room_occupants, ) host.bridge.add_method( "muc_subject", ".plugin", in_sign="sss", out_sign="", method=self._subject ) host.bridge.add_method( "muc_get_rooms_joined", ".plugin", in_sign="s", out_sign="a(sa{sa{ss}}ssas)", method=self._get_rooms_joined, ) host.bridge.add_method( "muc_get_unique_room_name", ".plugin", in_sign="ss", out_sign="s", method=self._get_unique_name, ) host.bridge.add_method( "muc_configure_room", ".plugin", in_sign="ss", out_sign="s", method=self._configure_room, async_=True, ) host.bridge.add_method( "muc_get_default_service", ".plugin", in_sign="", out_sign="s", method=self.get_default_muc, ) host.bridge.add_method( "muc_get_service", ".plugin", in_sign="ss", out_sign="s", method=self._get_muc_service, async_=True, ) # 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 host.bridge.add_signal("muc_room_left", ".plugin", signature="ss") # args: room_jid, old_nick, new_nick, profile host.bridge.add_signal("muc_room_user_changed_nick", ".plugin", signature="ssss") # args: room_jid, subject, profile host.bridge.add_signal("muc_room_new_subject", ".plugin", signature="sss") self.__submit_conf_id = host.register_callback( self._submit_configuration, with_data=True ) self._room_join_id = host.register_callback(self._ui_room_join_cb, with_data=True) host.import_menu( (D_("MUC"), D_("configure")), self._configure_room_menu, security_limit=0, help_string=D_("Configure Multi-User Chat room"), type_=C.MENU_ROOM, ) try: self.text_cmds = self.host.plugins[C.TEXT_CMDS] except KeyError: log.info(_("Text commands not available")) else: self.text_cmds.register_text_commands(self) self.text_cmds.add_who_is_cb(self._whois, 100) 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) host.trigger.add( "message_received", self.message_received_trigger, priority=1000000 ) host.trigger.add("message_parse", self._message_parse_trigger) host.trigger.add( "JID_SEARCH_perform_search", self.jid_search_perform_search_trigger ) 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: 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: 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: 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: " f"{message_elt.toXml()}" ) return False post_treat.addCallback( lambda data: defer.ensureDeferred(self._check_mention(data, client, room_jid)) ) return True async def _check_mention( self, data: MessageData, client: SatXMPPEntity, room_jid: jid.JID ) -> MessageData: from_jid = data["from"] room = self.get_room(client, room_jid) if from_jid == room.occupantJID: return data body = data["message"].get("") or next(iter(data["message"].values()), "") if not body: return data if not re.search(rf"(? bool: """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. @param client: The client instance. @param room_jid: JID of the room. @return: The joined room. """ try: 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: SatXMPPEntity, room_jid: jid.JID) -> None: """Check that given room has been joined in current session. @param client: The client instance. @param room_jid: Room JID. """ 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: """Check if a JID is a known and joined 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 return True async def is_room(self, client: SatXMPPEntity, entity_jid: jid.JID) -> bool: """Check if a JID is a joined MUC or a known MUC service. @param client: The client instance. @param entity_jid: JID of the entity to check. @return: True if the JID is a joined MUC or a known MUC service. """ try: self.check_room_joined(client, entity_jid.userhostJID()) except exceptions.NotFound: # It's not a joined room (yet?), we check disco as fallback. try: return await self.host.memory.disco.has_identity( client, "conference", "text", entity_jid.userhostJID() ) except Exception: log.exception(f"Can't get identity of {entity_jid.full()}") return False return True async 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 client: The client instance. @param peer_jid: JID of the entity to test. @return: Bare or full JID. """ if peer_jid.resource: if not await self.is_room(client, peer_jid): return peer_jid.userhostJID() return peer_jid def get_room_user_jid(self, client: SatXMPPEntity, room_jid: jid.JID) -> jid.JID: """Get the nick used in the room. @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 resource") room = self.get_room(client, room_jid) return jid.JID(tuple=(room_jid.user, room_jid.host, room.nick)) 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(), 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", [])], client.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: 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 %s cancelled", room_jid.userhost()) return defer.fail( failure.Failure(exceptions.CancelError("Room joining cancelled")) ) password = data[xml_tools.form_escape("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: 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, selectable="single", callback_id=self._room_join_id, ) items = sorted(items, key=lambda i: i.name.lower()) for item in items: adv_list.set_row_index(item.entity.full()) xmlui.addText(item.name) adv_list.end() self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile) def _join_eb( self, failure_: failure.Failure, client: SatXMPPEntity, room_jid: jid.JID, nick: str, password: str | None, ) -> defer.Deferred: """Handle join errors. @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: msg_suffix = f": {failure_}" else: if condition == "conflict": # we have a nickname conflict, we try again with "_" suffixed to current nickname nick += "_" 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=f"Room {room_jid.userhost()} is restricted", submit_id="", ) 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( lambda data: self._password_ui_cb(data, client, room_jid, nick) ) return d msg_suffix = f' with condition "{condition}"' mess = f"Error while joining room {room_jid.userhost()}{msg_suffix}" log.warning(mess) 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_) 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: 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 { 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: 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: SatXMPPEntity) -> list[tuple]: """Return rooms where user is. @param client: The client instance. @return: List of joined room tuples. """ result = [] 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: 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: SatXMPPEntity, room_jid: jid.JID) -> str: """Return nick used in room by user. @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.get_plugin_data(IMPORT_NAME, MUCClientData) .muc_client.joined_rooms[room_jid] .nick ) 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() if xmlui else "") return d def _configure_room_menu(self, menu_data: dict, profile: str) -> defer.Deferred: """Return room configuration form. @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()) 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()} return {"xmlui": xmlui.toXml()} return self.configure_room(client, room_jid).addCallback(xmlui_received) def configure_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> defer.Deferred: """Return the room configuration form. @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) -> xml_tools.XMLUI | None: if not result: 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.get_plugin_data( IMPORT_NAME, MUCClientData ).muc_client.getConfiguration(room_jid) d.addCallback(config_2_xmlui) return d 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("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.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 async def set_room_configuration( self, client: SatXMPPEntity, room_jid: jid.JID, options: dict ) -> None: """Set MUC configuration. Values from ``options`` are set if the corresponding field exists in the room's configuration form, otherwise they are ignored; a ``None`` value removes the field's value. Other fields are not modified. @param client: The client instance. @param options: option name -> value. """ muc_client = client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client form = await muc_client.getConfiguration(room_jid) if form is None: log.warning( f"[{client.profile}] can't get the configuration form of " f"{room_jid}, not applying {options}" ) return config: dict = {} for field in form.fields.values(): if field.values: # DataForm values are always lists (even single-valued fields # have one element); `configure()` accepts a list for any field config[field.var] = field.values for var, value in options.items(): if var in form.fields: config[var] = value else: log.warning( f"[{client.profile}] config field {var} not present in the form of " f"{room_jid}, ignoring it" ) await muc_client.configure(room_jid, config) log.info( f"[{client.profile}] room {room_jid.userhost()} configuration updated with " f"{options}" ) 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.get_plugin_data(IMPORT_NAME, MUCClientData) .muc_client.joined_rooms[room_jid] .inRoster(muc.User(nick)) ) def is_jid_in_room( self, client: SatXMPPEntity, room_jid: jid.JID, user_jid: jid.JID ) -> bool: """Tell if a read JIDcurrently present in a room. Only the bare JID will be checked. This can work only if profile can access real JIDs in the room (owner of the room, or the room is not anonymous). @param room_jid: JID of the room to check. @param user_jid: JID of the entity to check. """ try: room = self.get_room(client, room_jid) except exceptions.NotFound: return False bare_jid = user_jid.userhost() return any( user.entity is not None and user.entity.userhost() == bare_jid for user in room.roster.values() ) # 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_.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_: jid.JID | None = None ) -> jid.JID | None: """Return first found MUC service of an entity. @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.get_plugin_data( IMPORT_NAME, MUCClientData ).muc_service except AttributeError: pass else: if muc_service is not None: return muc_service services = await self.host.find_service_entities( client, "conference", "text", jid_ ) for service in services: if ".irc." not in service.userhost(): # FIXME: # This ugly hack is here to avoid an issue with openfire: the IRC gateway # use "conference/text" identity (instead of "conference/irc") return service return 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 = "", ) -> jid.JID: """Return unique name for a room, avoiding collision. @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.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") raise exceptions.FeatureNotFound return jid.JID(f"{room_name}@{muc_service.userhost()}") def get_default_muc(self) -> str: """Return the default MUC. @return: Default MUC JID string. """ return self.settings.default_muc @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. @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.get_plugin_data(IMPORT_NAME, MUCClientData).muc_service try: room_jid = jid.JID(room_jid_s) except (RuntimeError, jid.InvalidFormat, AttributeError): 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: 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 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: 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 not nick: nick = client.jid.user if options is None: options = {} 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(f"{client.profile} is already in room {room_jid.userhost()}") raise AlreadyJoined(room) log.info(f"[{client.profile}] joining room {room_jid.userhost()} as {nick}") password = options.get("password") try: room = await muc_client.join( room_jid, nick, muc.HistoryOptions(maxStanzas=0), password ) except Exception as e: room = await self._join_eb( failure.Failure(e), client, room_jid, nick, password ) else: 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)) await self.host.trigger.async_point("muc_room_joined", client, room_jid) return room async def _sync_mam_history(self, client: SatXMPPEntity, room: muc.Room) -> None: """Retrieve MAM history and send messages to bridge progressively. @param client: The client instance. @param room: The joined room. """ if self._mam is None: return room_jid = room.roomJID total_messages = 0 self._mam.sync_started(room_jid, client.profile) 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.", ) self._mam.sync_completed(room_jid, client.profile, total_messages) except Exception as e: log.exception(f"MAM history sync failed for {room_jid}") self._mam.sync_error(room_jid, client.profile, str(e)) finally: client.messageProt.flush_archive_batch() 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 = [] 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: str, nick: str, profile_key: str = C.PROF_KEY_NONE ) -> None: """Change nickname.""" client = self.host.get_client(profile_key) self.nick(client, jid.JID(room_jid_s), nick) 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) client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.nick(room_jid, nick) @utils.ensure_deferred async def _leave(self, room_jid: str, profile_key: str) -> None: client = self.host.get_client(profile_key) await self.leave(client, jid.JID(room_jid)) async def leave(self, client: SatXMPPEntity, room_jid: jid.JID) -> None: """Leave a room. @param client: The client instance. @param room_jid: Room JID. """ self.check_room_joined(client, room_jid) await client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.leave( room_jid ) await self.host.trigger.async_point("muc_room_left", client, 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) self.subject(client, jid.JID(room_jid_s), new_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) client.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.subject( room_jid, subject ) 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_data = client.get_plugin_data(IMPORT_NAME, MUCClientData) muc_client = muc_data._muc_client = LiberviaMUCClient(self) return muc_client def kick( self, client: SatXMPPEntity, nick: str, room_jid: jid.JID, options: dict | None = None, ) -> defer.Deferred: """Kick a participant from the room. @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.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.kick( room_jid, nick, reason=options.get("reason", None) ) 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 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.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client.ban( room_jid, entity_jid, reason=options.get("reason", None) ) def affiliate( self, client: SatXMPPEntity, entity_jid: jid.JID, room_jid: jid.JID, options: dict, ) -> defer.Deferred: """Change the affiliation of an entity. @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.get_plugin_data( IMPORT_NAME, MUCClientData ).muc_client.modifyAffiliationList(room_jid, [entity_jid], options["affiliation"]) def cmd_nick(self, client: SatXMPPEntity, mess_data: dict) -> bool: """Change nickname. @command (group): new_nick - new_nick: new nick to use """ nick = mess_data["unparsed"].strip() if nick: room = mess_data["to"] self.nick(client, room, nick) return False 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 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: SatXMPPEntity, mess_data: dict) -> bool: """Quit a room. @command (group): [ROOM_JID] - 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"] defer.ensureDeferred(self.leave(client, room)) return False 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: SatXMPPEntity, mess_data: dict) -> bool | defer.Deferred: """Kick a room member. @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: nick = options[0] assert self.is_nick_in_room(client, mess_data["to"], nick) except (IndexError, AssertionError): feedback = _("You must provide a member's nick to kick.") self.text_cmds.feed_back(client, feedback, mess_data) return False reason = " ".join(options[1:]) if len(options) > 1 else None d = self.kick(client, nick, mess_data["to"], {"reason": reason}) def cb(_) -> bool: feedback_msg = _("You have kicked {}").format(nick) if reason is not None: feedback_msg += _(" for the following reason: {reason}").format( reason=reason ) self.text_cmds.feed_back(client, feedback_msg, mess_data) return True d.addCallback(cb) return d 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 - reason: the reason why this entity is being banned """ options = mess_data["unparsed"].strip().split() try: jid_s = options[0] entity_jid = jid.JID(jid_s).userhostJID() assert entity_jid.user assert entity_jid.host except ( RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError, ): feedback = _( "You must provide a valid JID to ban, like in '/ban contact@example.net'" ) self.text_cmds.feed_back(client, feedback, mess_data) 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(_) -> bool: feedback_msg = _("You have banned {}").format(entity_jid) if reason is not None: feedback_msg += _(" for the following reason: {reason}").format( reason=reason ) self.text_cmds.feed_back(client, feedback_msg, mess_data) return True d.addCallback(cb) return d 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 - owner: grant owner privileges - admin: grant admin privileges - member: grant member privileges - none: reset entity privileges - outcast: ban entity """ options = mess_data["unparsed"].strip().split() try: jid_s = options[0] entity_jid = jid.JID(jid_s).userhostJID() assert entity_jid.user assert entity_jid.host except ( RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError, ): feedback = _( "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 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 d = self.affiliate( client, entity_jid, mess_data["to"], {"affiliation": affiliation} ) def cb(_) -> bool: feedback_msg = _("New affiliation for {entity}: {affiliation}").format( entity=entity_jid, affiliation=affiliation ) self.text_cmds.feed_back(client, feedback_msg, mess_data) return True d.addCallback(cb) return d 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: SatXMPPEntity, mess_data: dict) -> bool: """Synonym of /title.""" return self.cmd_title(client, mess_data) 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 empty value will request room's service for a room, or user's server default MUC service in a one2one chat """ unparsed = mess_data["unparsed"].strip() 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 muc_data.muc_service is not None: service = muc_data.muc_service else: 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) 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 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.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 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: {}").format(user.nick)) if user.entity: whois_msg.append(_("Entity: {}").format(user.entity)) if user.affiliation != "none": whois_msg.append(_("Affiliation: {}").format(user.affiliation)) if user.role != "none": whois_msg.append(_("Role: {}").format(user.role)) if user.status: whois_msg.append(_("Status: {}").format(user.status)) if user.show: whois_msg.append(_("Show: {}").format(user.show)) 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.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: 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.get_plugin_data(IMPORT_NAME, MUCClientData).muc_client if entity_bare in muc_client.joined_rooms: return False return True @implementer(itx_xmpp.IDisco) class LiberviaMUCClient(muc.MUCClient): ## presence/roster ## def __init__(self, plugin_parent: XEP_0045) -> None: self.plugin_parent = plugin_parent super().__init__() @property def joined_rooms(self) -> dict: """Get joined rooms.""" return self._rooms @property def host(self) -> "LiberviaBackend": """Get host.""" return self.plugin_parent.host @property def client(self) -> SatXMPPEntity: """Get client.""" assert self.parent is not None return self.parent def availableReceived(self, presence) -> None: """Handle available presence.""" room, __ = self._getRoomUser(presence) if room is None: return # 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 super().availableReceived(presence) def unavailableReceived(self, presence) -> None: """Handle unavailable presence.""" # XXX: we override this method to manage nickname change room, user = self._getRoomUser(presence) if room is None or user is None: return room.removeUser(user) if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses: self.user_changed_nick(room, user, presence.nick) else: # we left the room self.userLeftRoom(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 log.debug( "room {room} joined with nick {nick}", room=room.occupantJID.userhost(), nick=user.nick, ) 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 ) return 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 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: room_jid_s = room.roomJID.userhost() log.info( "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_jid_s, self.client.profile) return 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: 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)) statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ""} self.host.bridge.presence_update( entity.full(), show or "", 0, statuses, self.client.profile ) 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.subject = subject if user is not None and user.nick == room.nick: return 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: jid.JID, nodeIdentifier: str = "") -> list: """Get disco info.""" return [disco.DiscoFeature(NS_MUC)] 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 []