view libervia/backend/plugins/plugin_xep_0045.py @ 4270:0d7bb4df2343

Reformatted code base using black.
author Goffi <goffi@goffi.org>
date Wed, 19 Jun 2024 18:44:57 +0200
parents a7d4007a8fa5
children
line wrap: on
line source

#!/usr/bin/env python3


# SAT plugin for managing xep-0045
# Copyright (C) 2009-2021 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/>.

import time
from typing import Optional
import uuid

import shortuuid
from twisted.internet import defer
from twisted.python import failure
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber import error as xmpp_error
from wokkel import disco, iwokkel, muc
from wokkel import rsm
from wokkel import mam
from zope.interface import implementer

from libervia.backend.core import exceptions
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.constants import Const as C
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
from libervia.backend.memory import memory
from libervia.backend.tools import xml_tools, utils


log = getLogger(__name__)


PLUGIN_INFO = {
    C.PI_NAME: "XEP-0045 Plugin",
    C.PI_IMPORT_NAME: "XEP-0045",
    C.PI_TYPE: "XEP",
    C.PI_MODES: C.PLUG_MODE_BOTH,
    C.PI_PROTOCOLS: ["XEP-0045"],
    C.PI_DEPENDENCIES: ["XEP-0359"],
    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"
AFFILIATIONS = ("owner", "admin", "member", "none", "outcast")
ROOM_USER_JOINED = "ROOM_USER_JOINED"
ROOM_USER_LEFT = "ROOM_USER_LEFT"
OCCUPANT_KEYS = ("nick", "entity", "affiliation", "role")
ROOM_STATE_OCCUPANTS = "occupants"
ROOM_STATE_SELF_PRESENCE = "self-presence"
ROOM_STATE_LIVE = "live"
ROOM_STATES = (ROOM_STATE_OCCUPANTS, ROOM_STATE_SELF_PRESENCE, ROOM_STATE_LIVE)
HISTORY_LEGACY = "legacy"
HISTORY_MAM = "mam"


CONFIG_SECTION = "plugin muc"

default_conf = {"default_muc": "sat@chat.jabberfr.org"}


class AlreadyJoined(exceptions.ConflictError):

    def __init__(self, room):
        super(AlreadyJoined, self).__init__()
        self.room = room


class XEP_0045:
    # TODO: handle invitations
    # FIXME: this plugin need a good cleaning, join method is messy

    def __init__(self, host):
        log.info(_("Plugin XEP_0045 initialization"))
        self.host = host
        self._sessions = memory.Sessions()
        # return same arguments as muc_room_joined + a boolean set to True is 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,
        )
        # called when a room will be joined but must be locked until join is received
        # (room is prepared, history is getting retrieved)
        # args: room_jid, profile
        host.bridge.add_signal("muc_room_prepare_join", ".plugin", signature="ss")
        # args: room_jid, occupants, user_nick, subject, profile
        host.bridge.add_signal("muc_room_joined", ".plugin", signature="sa{sa{ss}}ssass")
        # args: room_jid, profile
        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 = self.host.plugins.get("XEP-0313")
        self._si = 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)

    async def profile_connected(self, client):
        client.muc_service = await self.get_muc_service(client)

    def _message_parse_trigger(self, client, message_elt, data):
        """Add stanza-id from the room if present"""
        if message_elt.getAttribute("type") != C.MESS_TYPE_GROUPCHAT:
            return True

        # stanza_id will not be filled by parse_message because the emitter
        # is the room and not our server, so we have to parse it here
        room_jid = data["from"].userhostJID()
        stanza_id = self._si.get_stanza_id(message_elt, room_jid)
        if stanza_id:
            data["extra"]["stanza_id"] = stanza_id

    def message_received_trigger(self, client, message_elt, post_treat):
        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
            if message_elt.subject:
                return False
            from_jid = jid.JID(message_elt["from"])
            room_jid = from_jid.userhostJID()
            if room_jid in client._muc_client.joined_rooms:
                room = client._muc_client.joined_rooms[room_jid]
                if room.state != ROOM_STATE_LIVE:
                    if getattr(room, "_history_type", HISTORY_LEGACY) == HISTORY_LEGACY:
                        # With MAM history, order is different, and we can get live
                        # messages before history is complete, so this is not a warning
                        # but an expected case.
                        # On the other hand, with legacy history, it's not normal.
                        log.warning(
                            _(
                                "Received non delayed message in a room before its "
                                "initialisation: state={state}, msg={msg}"
                            ).format(state=room.state, msg=message_elt.toXml())
                        )
                    room._cache.append(message_elt)
                    return False
            else:
                log.warning(
                    "Received groupchat message for a room which has not been "
                    "joined, ignoring it: {}".format(message_elt.toXml())
                )
                return False
        return True

    def get_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> muc.Room:
        """Retrieve Room instance from its jid

        @param room_jid: jid of the room
        @raise exceptions.NotFound: the room has not been joined
        """
        try:
            return client._muc_client.joined_rooms[room_jid]
        except KeyError:
            raise exceptions.NotFound(_("This room has not been joined"))

    def check_room_joined(self, client, room_jid):
        """Check that given room has been joined in current session

        @param room_jid (JID): room JID
        """
        if room_jid not in client._muc_client.joined_rooms:
            raise exceptions.NotFound(_("This room has not been joined"))

    def is_joined_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> bool:
        """Tell if a jid is a known and joined room

        @room_jid: jid of the room
        """
        try:
            self.check_room_joined(client, room_jid)
        except exceptions.NotFound:
            return False
        else:
            return True

    def is_room(self, client, entity_jid):
        """Tell if a jid is a joined MUC

        similar to is_joined_room but returns a boolean
        @param entity_jid(jid.JID): full or bare jid of the entity check
        @return (bool): True if the bare jid of the entity is a room jid
        """
        try:
            self.check_room_joined(client, entity_jid.userhostJID())
        except exceptions.NotFound:
            return False
        else:
            return True

    def get_bare_or_full(self, client, peer_jid):
        """use full jid if peer_jid is an occupant of a room, bare jid else

        @param peer_jid(jid.JID): entity to test
        @return (jid.JID): bare or full jid
        """
        if peer_jid.resource:
            if not 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 room_jid: jid of the room to use
        @return: JID used in room

        @raise exceptions.NotFound: this room has not been joined
        @raise exceptions.InternalError: invalid room_jid
        """
        # FIXME: doesn't check if room is anonymous or not
        # TODO: check if room is (semi)anonymous
        if room_jid.resource:
            raise exceptions.InternalError(f"{room_jid} should not have a ressource.")
        room = self.get_room(client, room_jid)
        return jid.JID(tuple=(room_jid.user, room_jid.host, room.nick))

    def _get_room_joined_args(self, room, profile):
        return [
            room.roomJID.userhost(),
            XEP_0045._get_occupants(room),
            room.nick,
            # FIXME: getattr below is a Q&D fix as `subject` may not be set in early call
            #    of _get_rooms_joined (during `join` call). The whole workflow of this
            #    plugin should be refactored.
            getattr(room, "subject", ""),
            [s.name for s in room.statuses],
            profile,
        ]

    def _ui_room_join_cb(self, data, profile):
        room_jid = jid.JID(data["index"])
        client = self.host.get_client(profile)
        defer.ensureDeferred(self.join(client, room_jid))
        return {}

    def _password_ui_cb(self, data, client, room_jid, nick):
        """Called when the user has given room password (or cancelled)"""
        if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")):
            log.info("room join for {} is cancelled".format(room_jid.userhost()))
            raise failure.Failure(
                exceptions.CancelError(D_("Room joining cancelled by user"))
            )
        password = data[xml_tools.form_escape("password")]
        return client._muc_client.join(room_jid, nick, password).addCallbacks(
            self._join_cb,
            self._join_eb,
            (client, room_jid, nick),
            errbackArgs=(client, room_jid, nick, password),
        )

    def _show_list_ui(self, items, client, service):
        xmlui = xml_tools.XMLUI(title=D_("Rooms in {}".format(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_cb(self, room, client, room_jid, nick):
        """Called when the user is in the requested room"""
        if room.locked:
            # FIXME: the current behaviour is to create an instant room
            # and send the signal only when the room is unlocked
            # a proper configuration management should be done
            log.debug(_("room locked !"))
            d = client._muc_client.configure(room.roomJID, {})
            d.addErrback(
                self.host.log_errback,
                msg=_("Error while configuring the room: {failure_}"),
            )
        return room.fully_joined

    def _join_eb(self, failure_, client, room_jid, nick, password):
        """Called when something is going wrong when joining the room"""
        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._muc_client.join(room_jid, nick, password).addCallbacks(
                    self._join_cb,
                    self._join_eb,
                    (client, room_jid, nick),
                    errbackArgs=(client, room_jid, nick, password),
                )
            elif condition == "not-allowed":
                # room is restricted, we need a password
                password_ui = xml_tools.XMLUI(
                    "form",
                    title=D_("Room {} is restricted").format(room_jid.userhost()),
                    submit_id="",
                )
                password_ui.addText(
                    D_("This room is restricted, please enter the password")
                )
                password_ui.addPassword("password")
                d = xml_tools.defer_xmlui(self.host, password_ui, profile=client.profile)
                d.addCallback(self._password_ui_cb, client, room_jid, nick)
                return d

            msg_suffix = ' with condition "{}"'.format(failure_.value.condition)

        mess = D_(
            "Error while joining the room {room}{suffix}".format(
                room=room_jid.userhost(), suffix=msg_suffix
            )
        )
        log.warning(mess)
        xmlui = xml_tools.note(mess, D_("Group chat error"), level=C.XMLUI_DATA_LVL_ERROR)
        self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile)

    @staticmethod
    def _get_occupants(room):
        """Get occupants of a room in a form suitable for bridge"""
        return {
            u.nick: {k: str(getattr(u, k) or "") for k in OCCUPANT_KEYS}
            for u in list(room.roster.values())
        }

    def _get_room_occupants(self, room_jid_s, profile_key):
        client = self.host.get_client(profile_key)
        room_jid = jid.JID(room_jid_s)
        return self.get_room_occupants(client, room_jid)

    def get_room_occupants(self, client, room_jid):
        room = self.get_room(client, room_jid)
        return self._get_occupants(room)

    def _get_rooms_joined(self, profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        return self.get_rooms_joined(client)

    def get_rooms_joined(self, client):
        """Return rooms where user is"""
        result = []
        for room in list(client._muc_client.joined_rooms.values()):
            if room.state == ROOM_STATE_LIVE:
                result.append(
                    (
                        room.roomJID.userhost(),
                        self._get_occupants(room),
                        room.nick,
                        room.subject,
                        [s.name for s in room.statuses],
                    )
                )
        return result

    def _get_room_nick(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        return self.get_room_nick(client, jid.JID(room_jid_s))

    def get_room_nick(self, client, room_jid):
        """return nick used in room by user

        @param room_jid (jid.JID): JID of the room
        @profile_key: profile
        @return: nick or empty string in case of error
        @raise exceptions.Notfound: use has not joined the room
        """
        self.check_room_joined(client, room_jid)
        return client._muc_client.joined_rooms[room_jid].nick

    def _configure_room(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        d = self.configure_room(client, jid.JID(room_jid_s))
        d.addCallback(lambda xmlui: xmlui.toXml())
        return d

    def _configure_room_menu(self, menu_data, profile):
        """Return room configuration form

        @param menu_data: %(menu_data)s
        @param profile: %(doc_profile)s
        """
        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):
            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, room_jid):
        """return the room configuration form

        @param room: jid of the room to configure
        @return: configuration form as XMLUI
        """
        self.check_room_joined(client, room_jid)

        def config_2_xmlui(result):
            if not result:
                return ""
            session_id, session_data = self._sessions.new_session(profile=client.profile)
            session_data["room_jid"] = room_jid
            xmlui = xml_tools.data_form_2_xmlui(result, submit_id=self.__submit_conf_id)
            xmlui.session_id = session_id
            return xmlui

        d = client._muc_client.getConfiguration(room_jid)
        d.addCallback(config_2_xmlui)
        return d

    def _submit_configuration(self, raw_data, profile):
        cancelled = C.bool(raw_data.get("cancelled", C.BOOL_FALSE))
        if cancelled:
            return defer.succeed({})
        client = self.host.get_client(profile)
        try:
            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
        except KeyError:
            log.warning(D_("Session ID doesn't exist, session has probably expired."))
            _dialog = xml_tools.XMLUI("popup", title=D_("Room configuration failed"))
            _dialog.addText(D_("Session ID doesn't exist, session has probably expired."))
            return defer.succeed({"xmlui": _dialog.toXml()})

        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
        d = client._muc_client.configure(session_data["room_jid"], data)
        _dialog = xml_tools.XMLUI("popup", title=D_("Room configuration succeed"))
        _dialog.addText(D_("The new settings have been saved."))
        d.addCallback(lambda ignore: {"xmlui": _dialog.toXml()})
        del self._sessions[raw_data["session_id"]]
        return d

    def is_nick_in_room(self, client, room_jid, nick):
        """Tell if a nick is currently present in a room"""
        self.check_room_joined(client, room_jid)
        return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick))

    def _get_muc_service(self, jid_=None, profile=C.PROF_KEY_NONE):
        client = self.host.get_client(profile)
        d = defer.ensureDeferred(self.get_muc_service(client, jid_ or None))
        d.addCallback(
            lambda service_jid: service_jid.full() if service_jid is not None else ""
        )
        return d

    async def get_muc_service(
        self, client: SatXMPPEntity, jid_: Optional[jid.JID] = None
    ) -> Optional[jid.JID]:
        """Return first found MUC service of an entity

        @param jid_: entity which may have a MUC service, or None for our own server
        @return: found service jid or None
        """
        if jid_ is None:
            try:
                muc_service = client.muc_service
            except AttributeError:
                pass
            else:
                # we have a cached value, we return it
                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")
                muc_service = service
                break
        else:
            muc_service = None
        return muc_service

    def _get_unique_name(self, muc_service="", profile_key=C.PROF_KEY_NONE):
        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: Client instance.
        @param muc_service: leave empty string to use the default service
        @param prefix: prefix to use in room name.
        @return: unique room bare JID
        """
        room_name = f"{prefix}{shortuuid.uuid()}"
        if muc_service is None:
            try:
                muc_service = client.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

        muc_service = muc_service.userhost()
        return jid.JID(f"{room_name}@{muc_service}")

    def get_default_muc(self):
        """Return the default MUC.

        @return: unicode
        """
        return self.host.memory.config_get(
            CONFIG_SECTION, "default_muc", default_conf["default_muc"]
        )

    def _bridge_join_eb(self, failure_, client):
        failure_.trap(AlreadyJoined)
        room = failure_.value.room
        return [True] + self._get_room_joined_args(room, client.profile)

    def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE):
        """join method used by bridge

        @return (tuple): already_joined boolean + room joined arguments (see [_get_room_joined_args])
        """
        client = self.host.get_client(profile_key)
        if room_jid_s:
            muc_service = client.muc_service
            try:
                room_jid = jid.JID(room_jid_s)
            except (RuntimeError, jid.InvalidFormat, AttributeError):
                return defer.fail(
                    jid.InvalidFormat(
                        _(
                            "Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'."
                        ).format(room_id=room_jid_s, muc_service=str(muc_service))
                    )
                )
            if not room_jid.user:
                room_jid.user, room_jid.host = room_jid.host, muc_service
        else:
            room_jid = self.get_unique_name(client)
        # TODO: error management + signal in bridge
        d = defer.ensureDeferred(self.join(client, room_jid, nick, options or None))

        d.addCallback(
            lambda room: [False] + self._get_room_joined_args(room, client.profile)
        )
        d.addErrback(self._bridge_join_eb, client)
        return d

    async def join(
        self,
        client: SatXMPPEntity,
        room_jid: jid.JID,
        nick: Optional[str] = None,
        options: Optional[dict] = None,
    ) -> Optional[muc.Room]:
        if not nick:
            nick = client.jid.user
        if options is None:
            options = {}
        if room_jid in client._muc_client.joined_rooms:
            room = client._muc_client.joined_rooms[room_jid]
            log.info(
                _("{profile} is already in room {room_jid}").format(
                    profile=client.profile, room_jid=room_jid.userhost()
                )
            )
            raise AlreadyJoined(room)
        log.info(
            _("[{profile}] is joining room {room} with nick {nick}").format(
                profile=client.profile, room=room_jid.userhost(), nick=nick
            )
        )
        self.host.bridge.muc_room_prepare_join(room_jid.userhost(), client.profile)

        password = options.get("password")

        try:
            room = await client._muc_client.join(room_jid, nick, password)
        except Exception as e:
            room = await utils.as_deferred(
                self._join_eb(failure.Failure(e), client, room_jid, nick, password)
            )
        else:
            room.on_joined_callbacks = []
            room.on_left_callbacks = []
            await defer.ensureDeferred(self._join_cb(room, client, room_jid, nick))
        return room

    def pop_rooms(self, client):
        """Remove rooms and return data needed to re-join them

        This methods is to be called before a hot reconnection
        @return (list[(jid.JID, unicode)]): arguments needed to re-join the rooms
            This list can be used directly (unpacked) with self.join
        """
        args_list = []
        for room in list(client._muc_client.joined_rooms.values()):
            client._muc_client._removeRoom(room.roomJID)
            args_list.append((client, room.roomJID, room.nick))
        return args_list

    def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        return self.nick(client, jid.JID(room_jid_s), nick)

    def nick(self, client, room_jid, nick):
        """Change nickname in a room"""
        self.check_room_joined(client, room_jid)
        return client._muc_client.nick(room_jid, nick)

    def _leave(self, room_jid, profile_key):
        client = self.host.get_client(profile_key)
        return self.leave(client, jid.JID(room_jid))

    def leave(self, client, room_jid):
        self.check_room_joined(client, room_jid)
        return client._muc_client.leave(room_jid)

    def _subject(self, room_jid_s, new_subject, profile_key):
        client = self.host.get_client(profile_key)
        return self.subject(client, jid.JID(room_jid_s), new_subject)

    def subject(self, client, room_jid, subject):
        self.check_room_joined(client, room_jid)
        return client._muc_client.subject(room_jid, subject)

    def get_handler(self, client):
        # create a MUC client and associate it with profile' session
        muc_client = client._muc_client = LiberviaMUCClient(self)
        return muc_client

    def kick(self, client, nick, room_jid, options=None):
        """Kick a participant from the room

        @param nick (str): nick of the user to kick
        @param room_jid_s (JID): jid of the room
        @param options (dict): attribute with extra info (reason, password) as in #XEP-0045
        """
        if options is None:
            options = {}
        self.check_room_joined(client, room_jid)
        return client._muc_client.kick(room_jid, nick, reason=options.get("reason", None))

    def ban(self, client, entity_jid, room_jid, options=None):
        """Ban an entity from the room

        @param entity_jid (JID): bare jid of the entity to be banned
        @param room_jid (JID): jid of the room
        @param options: attribute with extra info (reason, password) as in #XEP-0045
        """
        self.check_room_joined(client, room_jid)
        if options is None:
            options = {}
        assert not entity_jid.resource
        assert not room_jid.resource
        return client._muc_client.ban(
            room_jid, entity_jid, reason=options.get("reason", None)
        )

    def affiliate(self, client, entity_jid, room_jid, options):
        """Change the affiliation of an entity

        @param entity_jid (JID): bare jid of the entity
        @param room_jid_s (JID): jid of the room
        @param options: attribute with extra info (reason, nick) as in #XEP-0045
        """
        self.check_room_joined(client, room_jid)
        assert not entity_jid.resource
        assert not room_jid.resource
        assert "affiliation" in options
        # TODO: handles reason and nick
        return client._muc_client.modifyAffiliationList(
            room_jid, [entity_jid], options["affiliation"]
        )

    # Text commands #

    def cmd_nick(self, client, mess_data):
        """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, mess_data):
        """join a new room

        @command (all): JID
            - JID: room to join (on the same service if full jid is not specified)
        """
        room_raw = mess_data["unparsed"].strip()
        if room_raw:
            if self.is_joined_room(client, mess_data["to"]):
                # we use the same service as the one from the room where the command has
                # been entered if full jid is not entered
                muc_service = mess_data["to"].host
                nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user
            else:
                # the command has been entered in a one2one conversation, so we use
                # our server MUC service as default service
                muc_service = client.muc_service or ""
                nick = client.jid.user
            room_jid = self.text_cmds.get_room_jid(room_raw, muc_service)
            defer.ensureDeferred(self.join(client, room_jid, nick, {}))

        return False

    def cmd_leave(self, client, mess_data):
        """quit a room

        @command (group): [ROOM_JID]
            - ROOM_JID: jid of the room to live (current room if not specified)
        """
        room_raw = mess_data["unparsed"].strip()
        if room_raw:
            room = self.text_cmds.get_room_jid(room_raw, mess_data["to"].host)
        else:
            room = mess_data["to"]

        self.leave(client, room)

        return False

    def cmd_part(self, client, mess_data):
        """just a synonym of /leave

        @command (group): [ROOM_JID]
            - ROOM_JID: jid of the room to live (current room if not specified)
        """
        return self.cmd_leave(client, mess_data)

    def cmd_kick(self, client, mess_data):
        """kick a room member

        @command (group): ROOM_NICK
            - ROOM_NICK: the nick of the person to 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(__):
            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, mess_data):
        """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(__):
            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, mess_data):
        """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 AFFILIATIONS:
            feedback = _("You must provide a valid affiliation: %s") % " ".join(
                AFFILIATIONS
            )
            self.text_cmds.feed_back(client, feedback, mess_data)
            return False

        d = self.affiliate(
            client, entity_jid, mess_data["to"], {"affiliation": affiliation}
        )

        def cb(__):
            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, mess_data):
        """change room's subject

        @command (group): title
            - title: new room subject
        """
        subject = mess_data["unparsed"].strip()

        if subject:
            room = mess_data["to"]
            self.subject(client, room, subject)

        return False

    def cmd_topic(self, client, mess_data):
        """just a synonym of /title

        @command (group): title
            - title: new room subject
        """
        return self.cmd_title(client, mess_data)

    def cmd_list(self, client, mess_data):
        """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:
            if mess_data["type"] == C.MESS_TYPE_GROUPCHAT:
                room_jid = mess_data["to"]
                service = jid.JID(room_jid.host)
            elif client.muc_service is not None:
                service = client.muc_service
            else:
                msg = D_("No known default MUC service {unparsed}").format(
                    unparsed=unparsed
                )
                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 False

    def _whois(self, client, whois_msg, mess_data, target_jid):
        """Add MUC user information to whois"""
        if mess_data["type"] != "groupchat":
            return
        if target_jid.userhostJID() not in client._muc_client.joined_rooms:
            log.warning(_("This room has not been joined"))
            return
        if not target_jid.resource:
            return
        user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser(
            target_jid.resource
        )
        whois_msg.append(_("Nickname: %s") % user.nick)
        if user.entity:
            whois_msg.append(_("Entity: %s") % user.entity)
        if user.affiliation != "none":
            whois_msg.append(_("Affiliation: %s") % user.affiliation)
        if user.role != "none":
            whois_msg.append(_("Role: %s") % user.role)
        if user.status:
            whois_msg.append(_("Status: %s") % user.status)
        if user.show:
            whois_msg.append(_("Show: %s") % user.show)

    def presence_trigger(self, presence_elt, client):
        # FIXME: should we add a privacy parameters in settings to activate before
        #        broadcasting the presence to all MUC rooms ?
        muc_client = client._muc_client
        for room_jid, room in muc_client.joined_rooms.items():
            elt = xml_tools.element_copy(presence_elt)
            elt["to"] = room_jid.userhost() + "/" + room.nick
            client.presence.send(elt)
        return True

    def presence_received_trigger(self, client, entity, show, priority, statuses):
        entity_bare = entity.userhostJID()
        muc_client = client._muc_client
        if entity_bare in muc_client.joined_rooms:
            # presence is already handled in (un)availableReceived
            return False
        return True


@implementer(iwokkel.IDisco)
class LiberviaMUCClient(muc.MUCClient):

    def __init__(self, plugin_parent):
        self.plugin_parent = plugin_parent
        muc.MUCClient.__init__(self)
        self._changing_nicks = set()  # used to keep trace of who is changing nick,
        # and to discard userJoinedRoom signal in this case
        print("init SatMUCClient OK")

    @property
    def joined_rooms(self):
        return self._rooms

    @property
    def host(self):
        return self.plugin_parent.host

    @property
    def client(self):
        return self.parent

    @property
    def _mam(self):
        return self.plugin_parent._mam

    @property
    def _si(self):
        return self.plugin_parent._si

    def change_room_state(self, room, new_state):
        """Check that room is in expected state, and change it

        @param new_state: one of ROOM_STATE_*
        """
        new_state_idx = ROOM_STATES.index(new_state)
        if new_state_idx == -1:
            raise exceptions.InternalError("unknown room state")
        if new_state_idx < 1:
            raise exceptions.InternalError(
                "unexpected new room state ({room}): {state}".format(
                    room=room.userhost(), state=new_state
                )
            )
        expected_state = ROOM_STATES[new_state_idx - 1]
        if room.state != expected_state:
            log.error(
                _(
                    "room {room} is not in expected state: room is in state {current_state} "
                    "while we were expecting {expected_state}"
                ).format(
                    room=room.roomJID.userhost(),
                    current_state=room.state,
                    expected_state=expected_state,
                )
            )
        room.state = new_state

    def _addRoom(self, room):
        super(LiberviaMUCClient, self)._addRoom(room)
        room._roster_ok = False  # True when occupants list has been fully received
        room.state = ROOM_STATE_OCCUPANTS
        # FIXME: check if history_d is not redundant with fully_joined
        room.fully_joined = defer.Deferred()  # called when everything is OK
        # cache data until room is ready
        # list of elements which will be re-injected in stream
        room._cache = []
        # we only need to keep last presence status for each jid, so a dict is suitable
        room._cache_presence = {}

    async def _join_legacy(
        self, client: SatXMPPEntity, room_jid: jid.JID, nick: str, password: Optional[str]
    ) -> muc.Room:
        """Join room an retrieve history with legacy method"""
        mess_data_list = await self.host.memory.history_get(
            room_jid,
            client.jid.userhostJID(),
            limit=1,
            between=True,
            profile=client.profile,
        )
        if mess_data_list:
            timestamp = mess_data_list[0][1]
            # we use seconds since last message to get backlog without duplicates
            # and we remove 1 second to avoid getting the last message again
            seconds = int(time.time() - timestamp) - 1
        else:
            seconds = None

        room = await super(LiberviaMUCClient, self).join(
            room_jid, nick, muc.HistoryOptions(seconds=seconds), password
        )
        # used to send bridge signal once backlog are written in history
        room._history_type = HISTORY_LEGACY
        room._history_d = defer.Deferred()
        room._history_d.callback(None)
        return room

    async def _get_mam_history(
        self, client: SatXMPPEntity, room: muc.Room, room_jid: jid.JID
    ) -> None:
        """Retrieve history for rooms handling MAM"""
        history_d = room._history_d = defer.Deferred()
        # we trigger now the deferred so all callback are processed as soon as possible
        # and in order
        history_d.callback(None)

        last_mess = await self.host.memory.history_get(
            room_jid,
            None,
            limit=1,
            between=False,
            filters={"types": C.MESS_TYPE_GROUPCHAT, "last_stanza_id": True},
            profile=client.profile,
        )
        if last_mess:
            stanza_id = last_mess[0][-1]["stanza_id"]
            rsm_req = rsm.RSMRequest(max_=20, after=stanza_id)
            no_loop = False
        else:
            log.info(
                "We have no MAM archive for room {room_jid}.".format(room_jid=room_jid)
            )
            # we don't want the whole archive if we have no archive yet
            # as it can be huge
            rsm_req = rsm.RSMRequest(max_=50, before="")
            no_loop = True

        mam_req = mam.MAMRequest(rsm_=rsm_req)
        complete = False
        count = 0
        while not complete:
            try:
                mam_data = await self._mam.get_archives(client, mam_req, service=room_jid)
            except xmpp_error.StanzaError as e:
                if last_mess and e.condition == "item-not-found":
                    log.warning(
                        f"requested item (with id {stanza_id!r}) can't be found in "
                        f"history of {room_jid}, history has probably been purged on "
                        f"server."
                    )
                    # we get last items like for a new room
                    rsm_req = rsm.RSMRequest(max_=50, before="")
                    mam_req = mam.MAMRequest(rsm_=rsm_req)
                    no_loop = True
                    continue
                else:
                    raise e
            elt_list, rsm_response, mam_response = mam_data
            complete = True if no_loop else mam_response["complete"]
            # we update MAM request for next iteration
            mam_req.rsm.after = rsm_response.last

            if not elt_list:
                break
            else:
                count += len(elt_list)

                for mess_elt in elt_list:
                    try:
                        fwd_message_elt = self._mam.get_message_from_result(
                            client, mess_elt, mam_req, service=room_jid
                        )
                    except exceptions.DataError:
                        continue
                    if fwd_message_elt.getAttribute("to"):
                        log.warning(
                            'Forwarded message element has a "to" attribute while it is '
                            "forbidden by specifications"
                        )
                    fwd_message_elt["to"] = client.jid.full()
                    client.messageProt.onMessage(fwd_message_elt)
                    client._muc_client._onGroupChat(fwd_message_elt)

        if not count:
            log.info(
                _(
                    "No message received while offline in {room_jid}".format(
                        room_jid=room_jid
                    )
                )
            )
        else:
            log.info(
                _(
                    "We have received {num_mess} message(s) in {room_jid} while "
                    "offline."
                ).format(num_mess=count, room_jid=room_jid)
            )

        # for legacy history, the following steps are done in receivedSubject but for MAM
        # the order is different (we have to join then get MAM archive, so subject
        # is received before archive), so we change state and add the callbacks here.
        self.change_room_state(room, ROOM_STATE_LIVE)
        history_d.addCallbacks(
            self._history_cb, self._history_eb, [room], errbackArgs=[room]
        )

        # we wait for all callbacks to be processed
        await history_d

    async def _join_mam(
        self, client: SatXMPPEntity, room_jid: jid.JID, nick: str, password: Optional[str]
    ) -> muc.Room:
        """Join room and retrieve history using MAM"""
        room = await super(LiberviaMUCClient, self).join(
            # we don't want any history from room as we'll get it with MAM
            room_jid,
            nick,
            muc.HistoryOptions(maxStanzas=0),
            password=password,
        )
        room._history_type = HISTORY_MAM
        # MAM history retrieval can be very long, and doesn't need to be sync, so we don't
        # wait for it
        defer.ensureDeferred(self._get_mam_history(client, room, room_jid))
        room.fully_joined.callback(room)

        return room

    async def join(self, room_jid, nick, password=None):
        room_service = jid.JID(room_jid.host)
        has_mam = await self.host.hasFeature(self.client, mam.NS_MAM, room_service)
        if not self._mam or not has_mam:
            return await self._join_legacy(self.client, room_jid, nick, password)
        else:
            return await self._join_mam(self.client, room_jid, nick, password)

    ## presence/roster ##

    def availableReceived(self, presence):
        """
        Available presence was received.
        """
        # XXX: we override MUCClient.availableReceived to fix bugs
        # (affiliation and role are not set)

        room, user = self._getRoomUser(presence)

        if room is None:
            return

        if user is None:
            nick = presence.sender.resource
            if not nick:
                log.warning(
                    _("missing nick in presence: {xml}").format(
                        xml=presence.toElement().toXml()
                    )
                )
                return
            user = muc.User(nick, presence.entity)

        # we want to keep statuses with room
        # XXX: presence if broadcasted, and we won't have status code
        #      like 110 (REALJID_PUBLIC) after first <presence/> received
        #      so we keep only the initial <presence> (with SELF_PRESENCE),
        #      thus we check if attribute already exists
        if (
            not hasattr(room, "statuses")
            and muc.STATUS_CODE.SELF_PRESENCE in presence.mucStatuses
        ):
            room.statuses = presence.mucStatuses

        # Update user data
        user.role = presence.role
        user.affiliation = presence.affiliation
        user.status = presence.status
        user.show = presence.show

        if room.inRoster(user):
            self.userUpdatedStatus(room, user, presence.show, presence.status)
        else:
            room.addUser(user)
            self.userJoinedRoom(room, user)

    def unavailableReceived(self, presence):
        # XXX: we override this method to manage nickname change
        """
        Unavailable presence was received.

        If this was received from a MUC room occupant JID, that occupant has
        left the room.
        """
        room, user = self._getRoomUser(presence)

        if room is None or user is None:
            return

        room.removeUser(user)

        if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses:
            self._changing_nicks.add(presence.nick)
            self.user_changed_nick(room, user, presence.nick)
        else:
            self._changing_nicks.discard(presence.nick)
            self.userLeftRoom(room, user)

    def userJoinedRoom(self, room, user):
        if user.nick == room.nick:
            # we have received our own nick,
            # this mean that the full room roster was received
            self.change_room_state(room, ROOM_STATE_SELF_PRESENCE)
            log.debug(
                "room {room} joined with nick {nick}".format(
                    room=room.occupantJID.userhost(), nick=user.nick
                )
            )
            # we set type so we don't have to use a deferred
            # with disco to check entity type
            self.host.memory.update_entity_data(
                self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC
            )
        elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE):
            log.warning(
                "Received user presence data in a room before its initialisation "
                "(current state: {state}),"
                "this is not standard! Ignoring it: {room} ({nick})".format(
                    state=room.state, room=room.roomJID.userhost(), nick=user.nick
                )
            )
            return
        else:
            if not room.fully_joined.called:
                return
            for cb in room.on_joined_callbacks:
                defer.ensureDeferred(cb(room, user))
            try:
                self._changing_nicks.remove(user.nick)
            except KeyError:
                # this is a new user
                log.debug(
                    _("user {nick} has joined room {room_id}").format(
                        nick=user.nick, room_id=room.occupantJID.userhost()
                    )
                )
                if not self.host.trigger.point(
                    "MUC user joined", room, user, self.client.profile
                ):
                    return

                extra = {
                    "info_type": ROOM_USER_JOINED,
                    "user_affiliation": user.affiliation,
                    "user_role": user.role,
                    "user_nick": user.nick,
                }
                if user.entity is not None:
                    extra["user_entity"] = user.entity.full()
                mess_data = {  # dict is similar to the one used in client.onMessage
                    "from": room.roomJID,
                    "to": self.client.jid,
                    "uid": str(uuid.uuid4()),
                    "message": {"": D_("=> {} has joined the room").format(user.nick)},
                    "subject": {},
                    "type": C.MESS_TYPE_INFO,
                    "extra": extra,
                    "timestamp": time.time(),
                }
                # FIXME: we disable presence in history as it's taking a lot of space
                #        while not indispensable. In the future an option may allow
                #        to re-enable it
                # self.client.message_add_to_history(mess_data)
                self.client.message_send_to_bridge(mess_data)

    def userLeftRoom(self, room, user):
        if not self.host.trigger.point("MUC user left", room, user, self.client.profile):
            return
        if user.nick == room.nick:
            # we left the room
            room_jid_s = room.roomJID.userhost()
            log.info(
                _("Room ({room}) left ({profile})").format(
                    room=room_jid_s, profile=self.client.profile
                )
            )
            self.host.memory.del_entity_cache(
                room.roomJID, profile_key=self.client.profile
            )
            self.host.bridge.muc_room_left(room.roomJID.userhost(), self.client.profile)
        elif room.state != ROOM_STATE_LIVE:
            log.warning(
                "Received user presence data in a room before its initialisation (current state: {state}),"
                "this is not standard! Ignoring it: {room} ({nick})".format(
                    state=room.state, room=room.roomJID.userhost(), nick=user.nick
                )
            )
            return
        else:
            if not room.fully_joined.called:
                return
            log.debug(
                _("user {nick} left room {room_id}").format(
                    nick=user.nick, room_id=room.occupantJID.userhost()
                )
            )
            for cb in room.on_left_callbacks:
                defer.ensureDeferred(cb(room, user))
            extra = {
                "info_type": ROOM_USER_LEFT,
                "user_affiliation": user.affiliation,
                "user_role": user.role,
                "user_nick": user.nick,
            }
            if user.entity is not None:
                extra["user_entity"] = user.entity.full()
            mess_data = {  # dict is similar to the one used in client.onMessage
                "from": room.roomJID,
                "to": self.client.jid,
                "uid": str(uuid.uuid4()),
                "message": {"": D_("<= {} has left the room").format(user.nick)},
                "subject": {},
                "type": C.MESS_TYPE_INFO,
                "extra": extra,
                "timestamp": time.time(),
            }
            # FIXME: disable history, see userJoinRoom comment
            # self.client.message_add_to_history(mess_data)
            self.client.message_send_to_bridge(mess_data)

    def user_changed_nick(self, room, user, new_nick):
        self.host.bridge.muc_room_user_changed_nick(
            room.roomJID.userhost(), user.nick, new_nick, self.client.profile
        )

    def userUpdatedStatus(self, room, user, show, status):
        entity = jid.JID(tuple=(room.roomJID.user, room.roomJID.host, user.nick))
        if hasattr(room, "_cache_presence"):
            # room has a cache for presence, meaning it has not been fully
            # joined yet. So we put presence in cache, and stop workflow.
            # Or delete current presence and continue workflow if it's an
            # "unavailable" presence
            cache = room._cache_presence
            cache[entity] = {
                "room": room,
                "user": user,
                "show": show,
                "status": status,
            }
            return
        statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ""}
        self.host.bridge.presence_update(
            entity.full(), show or "", 0, statuses, self.client.profile
        )

    ## messages ##

    def receivedGroupChat(self, room, user, body):
        log.debug(
            "receivedGroupChat: room=%s user=%s body=%s"
            % (room.roomJID.full(), user, body)
        )

    ## subject ##

    def groupChatReceived(self, message):
        """
        A group chat message has been received from a MUC room.

        There are a few event methods that may get called here.
        L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}.
        """
        # We override this method to fix subject handling (empty strings were discarded)
        # FIXME: remove this once fixed upstream
        room, user = self._getRoomUser(message)

        if room is None:
            log.warning(
                "No room found for message: {message}".format(
                    message=message.toElement().toXml()
                )
            )
            return

        if message.subject is not None:
            self.receivedSubject(room, user, message.subject)
        elif message.delay is None:
            self.receivedGroupChat(room, user, message)

    def subject(self, room, subject):
        return muc.MUCClientProtocol.subject(self, room, subject)

    def _history_cb(self, __, room):
        """Called when history have been written to database and subject is received

        this method will finish joining by:
            - sending message to bridge
            - calling fully_joined deferred (for legacy history)
            - sending stanza put in cache
            - cleaning variables not needed anymore
        """
        args = self.plugin_parent._get_room_joined_args(room, self.client.profile)
        self.host.bridge.muc_room_joined(*args)
        if room._history_type == HISTORY_LEGACY:
            room.fully_joined.callback(room)
        del room._history_d
        del room._history_type
        cache = room._cache
        del room._cache
        cache_presence = room._cache_presence
        del room._cache_presence
        for elem in cache:
            self.client.xmlstream.dispatch(elem)
        for presence_data in cache_presence.values():
            if not presence_data["show"] and not presence_data["status"]:
                # occupants are already sent in muc_room_joined, so if we don't have
                # extra information like show or statuses, we can discard the signal
                continue
            else:
                self.userUpdatedStatus(**presence_data)

    def _history_eb(self, failure_, room):
        log.error("Error while managing history: {}".format(failure_))
        self._history_cb(None, room)

    def receivedSubject(self, room, user, subject):
        # when subject is received, we know that we have whole roster and history
        # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject
        room.subject = subject  # FIXME: subject doesn't handle xml:lang
        if room.state != ROOM_STATE_LIVE:
            if room._history_type == HISTORY_LEGACY:
                self.change_room_state(room, ROOM_STATE_LIVE)
                room._history_d.addCallbacks(
                    self._history_cb, self._history_eb, [room], errbackArgs=[room]
                )
        else:
            # the subject has been changed
            log.debug(
                _("New subject for room ({room_id}): {subject}").format(
                    room_id=room.roomJID.full(), subject=subject
                )
            )
            self.host.bridge.muc_room_new_subject(
                room.roomJID.userhost(), subject, self.client.profile
            )

    ## disco ##

    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
        return [disco.DiscoFeature(NS_MUC)]

    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
        # TODO: manage room queries ? Bad for privacy, must be disabled by default
        #       see XEP-0045 § 6.7
        return []