libervia-backend/libervia/backend/plugins/plugin_xep_0045.py
Goffi dc9e3e98d5 plugin XEP-0045: Use disco as fallback for is_room:
If a room was not joined (e.g., due to failure), `is_room` was wrongly returning `False`.
Disco identity is now used as fallback to detect correctly is an entity is a room or not.
This changes makes `is_room` async, and by extension `get_bare_or_full` too.
2026-08-12 18:10:51 +02:00

1723 lines
61 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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"(?<!\w){re.escape(room.nick)}(?!\w)", body, re.I):
return data
extra = NotificationExtraMention(
message_uid=data["uid"],
from_jid=from_jid,
to_jid=data["to"],
room_jid=room_jid,
)
await self.host.memory.add_notification(
client,
NotificationType.chat,
body[:200],
title=D_("{nick} ({room})").format(room=room_jid, nick=from_jid.resource),
extra=extra.model_dump(mode="json"),
)
return data
async def jid_search_perform_search_trigger(
self,
client: SatXMPPEntity,
search_term: str,
options: plugin_misc_jid_search.Options,
sequence_matcher: difflib.SequenceMatcher,
matches: plugin_misc_jid_search.SearchItems,
) -> 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
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))
)
# 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))
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)
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: SatXMPPEntity, room_jid: jid.JID) -> defer.Deferred:
"""Leave a room.
@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)
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"]
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 <presence/> 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 []