diff sat/plugins/plugin_xep_0045.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_xep_0045.py@0046283a285d
children 395a3d1c2888
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0045.py	Mon Apr 02 19:44:50 2018 +0200
@@ -0,0 +1,1062 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# SAT plugin for managing xep-0045
+# Copyright (C) 2009-2018 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 sat.core.i18n import _, D_
+from sat.core.constants import Const as C
+from sat.core.log import getLogger
+log = getLogger(__name__)
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.python import failure
+from dateutil.tz import tzutc
+
+from sat.core import exceptions
+from sat.memory import memory
+
+import calendar
+import time
+import uuid
+import copy
+
+from wokkel import muc, disco, iwokkel
+from sat.tools import xml_tools
+
+from zope.interface import implements
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0045 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0045",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0045"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
+    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')
+ENTITY_TYPE_MUC = "MUC"
+
+CONFIG_SECTION = u'plugin muc'
+
+default_conf = {"default_muc": u'sat@chat.jabberfr.org'}
+
+
+class AlreadyJoined(exceptions.ConflictError):
+
+    def __init__(self, room):
+        super(AlreadyJoined, self).__init__()
+        self.room = room
+
+
+class XEP_0045(object):
+    # 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()
+        host.bridge.addMethod("mucJoin", ".plugin", in_sign='ssa{ss}s', out_sign='(bsa{sa{ss}}sss)', method=self._join, async=True)  # return same arguments as mucRoomJoined + a boolean set to True is the room was already joined (first argument)
+        host.bridge.addMethod("mucNick", ".plugin", in_sign='sss', out_sign='', method=self._nick)
+        host.bridge.addMethod("mucNickGet", ".plugin", in_sign='ss', out_sign='s', method=self._getRoomNick)
+        host.bridge.addMethod("mucLeave", ".plugin", in_sign='ss', out_sign='', method=self._leave, async=True)
+        host.bridge.addMethod("mucSubject", ".plugin", in_sign='sss', out_sign='', method=self._subject)
+        host.bridge.addMethod("mucGetRoomsJoined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ss)', method=self._getRoomsJoined)
+        host.bridge.addMethod("mucGetUniqueRoomName", ".plugin", in_sign='ss', out_sign='s', method=self._getUniqueName)
+        host.bridge.addMethod("mucConfigureRoom", ".plugin", in_sign='ss', out_sign='s', method=self._configureRoom, async=True)
+        host.bridge.addMethod("mucGetDefaultService", ".plugin", in_sign='', out_sign='s', method=self.getDefaultMUC)
+        host.bridge.addMethod("mucGetService", ".plugin", in_sign='ss', out_sign='s', method=self._getMUCService, async=True)
+        host.bridge.addSignal("mucRoomJoined", ".plugin", signature='sa{sa{ss}}sss')  # args: room_jid, occupants, user_nick, subject, profile
+        host.bridge.addSignal("mucRoomLeft", ".plugin", signature='ss')  # args: room_jid, profile
+        host.bridge.addSignal("mucRoomUserChangedNick", ".plugin", signature='ssss')  # args: room_jid, old_nick, new_nick, profile
+        host.bridge.addSignal("mucRoomNewSubject", ".plugin", signature='sss')  # args: room_jid, subject, profile
+        self.__submit_conf_id = host.registerCallback(self._submitConfiguration, with_data=True)
+        self._room_join_id = host.registerCallback(self._UIRoomJoinCb, with_data=True)
+        host.importMenu((D_("MUC"), D_("configure")), self._configureRoomMenu, 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(_(u"Text commands not available"))
+        else:
+            self.text_cmds.registerTextCommands(self)
+            self.text_cmds.addWhoIsCb(self._whois, 100)
+
+        host.trigger.add("presence_available", self.presenceTrigger)
+        host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=1000000)
+
+    def profileConnected(self, client):
+        def assign_service(service):
+            client.muc_service = service
+        return self.getMUCService(client).addCallback(assign_service)
+
+    def MessageReceivedTrigger(self, client, message_elt, post_treat):
+        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
+            if message_elt.subject or message_elt.delay:
+                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 not room._room_ok:
+                    log.warning(u"Received non delayed message in a room before its initialisation: {}".format(message_elt.toXml()))
+                    room._cache.append(message_elt)
+                    return False
+            else:
+                log.warning(u"Received groupchat message for a room which has not been joined, ignoring it: {}".format(message_elt.toXml()))
+                return False
+        return True
+
+    def checkRoomJoined(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(_(u"This room has not been joined"))
+
+    def isJoinedRoom(self, client, room_jid):
+        """Tell if a jid is a known and joined room
+
+        @room_jid(jid.JID): jid of the room
+        """
+        try:
+            self.checkRoomJoined(client, room_jid)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    def _getRoomJoinedArgs(self, room, profile):
+        return [
+            room.roomJID.userhost(),
+            XEP_0045._getOccupants(room),
+            room.nick,
+            room.subject,
+            profile
+            ]
+
+    def _UIRoomJoinCb(self, data, profile):
+        room_jid = jid.JID(data['index'])
+        client = self.host.getClient(profile)
+        self.join(client, room_jid)
+        return {}
+
+    def _passwordUICb(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(u"room join for {} is cancelled".format(room_jid.userhost()))
+            raise failure.Failure(exceptions.CancelError(D_(u"Room joining cancelled by user")))
+        password = data[xml_tools.formEscape('password')]
+        return client._muc_client.join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
+
+    def _showListUI(self, items, client, service):
+        xmlui = xml_tools.XMLUI(title=D_('Rooms in {}'.format(service.full())))
+        adv_list = xmlui.changeContainer('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.setRowIndex(item.entity.full())
+            xmlui.addText(item.name)
+        adv_list.end()
+        self.host.actionNew({'xmlui': xmlui.toXml()}, profile=client.profile)
+
+    def _joinCb(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(_(u"room locked !"))
+            d = client._muc_client.configure(room.roomJID, {})
+            d.addErrback(lambda dummy: log.error(_(u'Error while configuring the room')))
+        return room.fully_joined
+
+    def _joinEb(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 = ''
+        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._joinCb, self._joinEb, (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_(u'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.deferXMLUI(self.host, password_ui, profile=client.profile)
+                d.addCallback(self._passwordUICb, client, room_jid, nick)
+                return d
+
+            msg_suffix = ' with condition "{}"'.format(failure.value.condition)
+
+        mess = D_(u"Error while joining the room {room}{suffix}".format(
+            room = room_jid.userhost(), suffix = msg_suffix))
+        log.error(mess)
+        xmlui = xml_tools.note(mess, D_(u"Group chat error"), level=C.XMLUI_DATA_LVL_ERROR)
+        self.host.actionNew({'xmlui': xmlui.toXml()}, profile=client.profile)
+
+    @staticmethod
+    def _getOccupants(room):
+        """Get occupants of a room in a form suitable for bridge"""
+        return {u.nick: {k:unicode(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in room.roster.values()}
+
+    def _getRoomsJoined(self, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        return self.getRoomsJoined(client)
+
+    def getRoomsJoined(self, client):
+        """Return rooms where user is"""
+        result = []
+        for room in client._muc_client.joined_rooms.values():
+            if room._room_ok:
+                result.append((room.roomJID.userhost(), self._getOccupants(room), room.nick, room.subject))
+        return result
+
+    def _getRoomNick(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        return self.getRoomNick(client, jid.JID(room_jid_s))
+
+    def getRoomNick(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.checkRoomJoined(client, room_jid)
+        return client._muc_client.joined_rooms[room_jid].nick
+
+    # FIXME: broken, to be removed !
+    # def getRoomEntityNick(self, client, room_jid, entity_jid, =True):
+    #     """Returns the nick of the given user in the room.
+
+    #     @param room (wokkel.muc.Room): the room
+    #     @param user (jid.JID): bare JID of the user
+    #     @param secure (bool): set to True for a secure check
+    #     @return: unicode or None if the user didn't join the room.
+    #     """
+    #     for user in room.roster.values():
+    #         if user.entity is not None:
+    #             if user.entity.userhostJID() == user_jid.userhostJID():
+    #                 return user.nick
+    #         elif not secure:
+    #             # FIXME: this is NOT ENOUGH to check an identity!!
+    #             # See in which conditions user.entity could be None.
+    #             if user.nick == user_jid.user:
+    #                 return user.nick
+    #     return None
+
+    # def getRoomNicksOfUsers(self, room, users=[], secure=True):
+    #     """Returns the nicks of the given users in the room.
+
+    #     @param room (wokkel.muc.Room): the room
+    #     @param users (list[jid.JID]): list of users
+    #     @param secure (True): set to True for a secure check
+    #     @return: a couple (x, y) with:
+    #         - x (list[unicode]): nicks of the users who are in the room
+    #         - y (list[jid.JID]): JID of the missing users.
+    #     """
+    #     nicks = []
+    #     missing = []
+    #     for user in users:
+    #         nick = self.getRoomNickOfUser(room, user, secure)
+    #         if nick is None:
+    #             missing.append(user)
+    #         else:
+    #             nicks.append(nick)
+    #     return nicks, missing
+
+    def _configureRoom(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        d = self.configureRoom(client, jid.JID(room_jid_s))
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def _configureRoomMenu(self, menu_data, profile):
+        """Return room configuration form
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.getClient(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 xmluiReceived(xmlui):
+            return {"xmlui": xmlui.toXml()}
+        return self.configureRoom(client, room_jid).addCallback(xmluiReceived)
+
+    def configureRoom(self, client, room_jid):
+        """return the room configuration form
+
+        @param room: jid of the room to configure
+        @return: configuration form as XMLUI
+        """
+        self.checkRoomJoined(client, room_jid)
+
+        def config2XMLUI(result):
+            if not result:
+                return ""
+            session_id, session_data = self._sessions.newSession(profile=client.profile)
+            session_data["room_jid"] = room_jid
+            xmlui = xml_tools.dataForm2XMLUI(result, submit_id=self.__submit_conf_id)
+            xmlui.session_id = session_id
+            return xmlui
+
+        d = client._muc_client.getConfiguration(room_jid)
+        d.addCallback(config2XMLUI)
+        return d
+
+    def _submitConfiguration(self, raw_data, profile):
+        client = self.host.getClient(profile)
+        try:
+            session_data = self._sessions.profileGet(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.XMLUIResult2DataFormResult(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 isNickInRoom(self, client, room_jid, nick):
+        """Tell if a nick is currently present in a room"""
+        self.checkRoomJoined(client, room_jid)
+        return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick))
+
+    def _getMUCService(self, jid_=None, profile=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile)
+        d = self.getMUCService(client, jid_ or None)
+        d.addCallback(lambda service_jid: service_jid.full() if service_jid is not None else u'')
+        return d
+
+    @defer.inlineCallbacks
+    def getMUCService(self, client, jid_=None):
+        """Return first found MUC service of an entity
+
+        @param jid_: entity which may have a MUC service, or None for our own server
+        @return (jid.JID, None): 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
+                defer.returnValue(muc_service)
+        services = yield self.host.findServiceEntities(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
+        defer.returnValue(muc_service)
+
+    def _getUniqueName(self, muc_service="", profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        return self.getUniqueName(client, muc_service or None).full()
+
+    def getUniqueName(self, client, muc_service=None):
+        """Return unique name for a room, avoiding collision
+
+        @param muc_service (jid.JID) : leave empty string to use the default service
+        @return: jid.JID (unique room bare JID)
+        """
+        # TODO: we should use #RFC-0045 10.1.4 when available here
+        room_name = unicode(uuid.uuid4())
+        if muc_service is None:
+            try:
+                muc_service = client.muc_service
+            except AttributeError:
+                raise exceptions.NotReady(u"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(u"{}@{}".format(room_name, muc_service))
+
+    def getDefaultMUC(self):
+        """Return the default MUC.
+
+        @return: unicode
+        """
+        return self.host.memory.getConfig(CONFIG_SECTION, 'default_muc', default_conf['default_muc'])
+
+    def _join_eb(self, failure_, client):
+        failure_.trap(AlreadyJoined)
+        room = failure_.value.room
+        return [True] + self._getRoomJoinedArgs(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 [_getRoomJoinedArgs])
+        """
+        client = self.host.getClient(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(_(u"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=unicode(muc_service))))
+            if not room_jid.user:
+                room_jid.user, room_jid.host = room_jid.host, muc_service
+        else:
+            room_jid = self.getUniqueName(profile_key=client.profile)
+        # TODO: error management + signal in bridge
+        d = self.join(client, room_jid, nick, options or None)
+        d.addCallback(lambda room: [False] + self._getRoomJoinedArgs(room, client.profile))
+        d.addErrback(self._join_eb, client)
+        return d
+
+    def join(self, client, room_jid, nick=None, options=None):
+        if not nick:
+            nick = client.jid.user
+        if options is None:
+            options = {}
+        def _errDeferred(exc_obj=Exception, txt=u'Error while joining room'):
+            d = defer.Deferred()
+            d.errback(exc_obj(txt))
+            return d
+
+        if room_jid in client._muc_client.joined_rooms:
+            room = client._muc_client.joined_rooms[room_jid]
+            log.warning(_(u'{profile} is already in room {room_jid}').format(profile=client.profile, room_jid = room_jid.userhost()))
+            return defer.fail(AlreadyJoined(room))
+        log.info(_(u"[{profile}] is joining room {room} with nick {nick}").format(profile=client.profile, room=room_jid.userhost(), nick=nick))
+
+        password = options["password"] if "password" in options else None
+
+        return client._muc_client.join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
+
+    def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(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.checkRoomJoined(client, room_jid)
+        return client._muc_client.nick(room_jid, nick)
+
+    def _leave(self, room_jid, profile_key):
+        client = self.host.getClient(profile_key)
+        return self.leave(client, jid.JID(room_jid))
+
+    def leave(self, client, room_jid):
+        self.checkRoomJoined(client, room_jid)
+        return client._muc_client.leave(room_jid)
+
+    def _subject(self, room_jid_s, new_subject, profile_key):
+        client = self.host.getClient(profile_key)
+        return self.subject(client, jid.JID(room_jid_s), new_subject)
+
+    def subject(self, client, room_jid, subject):
+        self.checkRoomJoined(client, room_jid)
+        return client._muc_client.subject(room_jid, subject)
+
+    def getHandler(self, client):
+        # create a MUC client and associate it with profile' session
+        muc_client = client._muc_client = SatMUCClient(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.checkRoomJoined(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.checkRoomJoined(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.checkRoomJoined(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)
+        """
+        if mess_data["unparsed"].strip():
+            room_jid = self.text_cmds.getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host)
+            nick = (self.getRoomNick(client, room_jid) or
+                    client.jid.user)
+            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)
+        """
+        if mess_data["unparsed"].strip():
+            room = self.text_cmds.getRoomJID(mess_data["unparsed"].strip(), 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.isNickInRoom(client, mess_data["to"], nick)
+        except (IndexError, AssertionError):
+            feedback = _(u"You must provide a member's nick to kick.")
+            self.text_cmds.feedBack(client, feedback, mess_data)
+            return False
+
+        d = self.kick(client, nick, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]})
+
+        def cb(dummy):
+            feedback_msg = _(u'You have kicked {}').format(nick)
+            if len(options) > 1:
+                feedback_msg += _(u' for the following reason: {}').format(options[1])
+            self.text_cmds.feedBack(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 = _(u"You must provide a valid JID to ban, like in '/ban contact@example.net'")
+            self.text_cmds.feedBack(client, feedback, mess_data)
+            return False
+
+        d = self.ban(client, entity_jid, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]})
+
+        def cb(dummy):
+            feedback_msg = _(u'You have banned {}').format(entity_jid)
+            if len(options) > 1:
+                feedback_msg += _(u' for the following reason: {}').format(options[1])
+            self.text_cmds.feedBack(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 = _(u"You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'")
+            self.text_cmds.feedBack(client, feedback, mess_data)
+            return False
+
+        affiliation = options[1] if len(options) > 1 else 'none'
+        if affiliation not in AFFILIATIONS:
+            feedback = _(u"You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS)
+            self.text_cmds.feedBack(client, feedback, mess_data)
+            return False
+
+        d = self.affiliate(client, entity_jid, mess_data["to"], {'affiliation': affiliation})
+
+        def cb(dummy):
+            feedback_msg = _(u'New affiliation for %(entity)s: %(affiliation)s').format(entity=entity_jid, affiliation=affiliation)
+            self.text_cmds.feedBack(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_(u"No known default MUC service".format(unparsed))
+                self.text_cmds.feedBack(client, msg, mess_data)
+                return False
+        except jid.InvalidFormat:
+            msg = D_(u"{} is not a valid JID!".format(unparsed))
+            self.text_cmds.feedBack(client, msg, mess_data)
+            return False
+        d = self.host.getDiscoItems(client, service)
+        d.addCallback(self._showListUI, 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 presenceTrigger(self, presence_elt, client):
+        # XXX: shouldn't it be done by the server ?!!
+        muc_client = client._muc_client
+        for room_jid, room in muc_client.joined_rooms.iteritems():
+            elt = copy.deepcopy(presence_elt)
+            elt['to'] = room_jid.userhost() + '/' + room.nick
+            client.presence.send(elt)
+        return True
+
+
+class SatMUCClient(muc.MUCClient):
+    implements(iwokkel.IDisco)
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        muc.MUCClient.__init__(self)
+        self.rec_subjects = {}
+        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
+
+    def _addRoom(self, room):
+        super(SatMUCClient, self)._addRoom(room)
+        room._roster_ok = False  # True when occupants list has been fully received
+        room._room_ok = None  # False when roster, history and subject are available
+                              # True when new messages are saved to database
+        room._history_d = defer.Deferred()  # used to send bridge signal once backlog are written in history
+        room._history_d.callback(None)
+        # FIXME: check if history_d is not redundant with fully_joined
+        room.fully_joined = defer.Deferred()  # called when everything is OK
+        room._cache = []
+
+    def _gotLastDbHistory(self, mess_data_list, room_jid, nick, password):
+        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
+        d = super(SatMUCClient, self).join(room_jid, nick, muc.HistoryOptions(seconds=seconds), password)
+        return d
+
+    def join(self, room_jid, nick, password=None):
+        d = self.host.memory.historyGet(self.parent.jid.userhostJID(), room_jid, 1, True, profile=self.parent.profile)
+        d.addCallback(self._gotLastDbHistory, room_jid, nick, password)
+        return d
+
+    ## 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
+            user = muc.User(nick, presence.entity)
+
+        # 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.userChangedNick(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
+            room._roster_ok = True
+            log.debug(u"room {room} joined with nick {nick}".format(room=room.occupantJID.userhost(), nick=user.nick))
+            # We set type so we don't have use a deferred with disco to check entity type
+            self.host.memory.updateEntityData(room.roomJID, C.ENTITY_TYPE, ENTITY_TYPE_MUC, profile_key=self.parent.profile)
+        elif not room._room_ok:
+            log.warning(u"Received user presence data in a room before its initialisation (and after our own presence),"
+                "this is not standard! Ignoring it: {} ({})".format(
+                room.roomJID.userhost(),
+                user.nick))
+            return
+        elif room._roster_ok:
+            try:
+                self._changing_nicks.remove(user.nick)
+            except KeyError:
+                # this is a new user
+                log.debug(_(u"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.parent.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.parent.jid,
+                    "uid": unicode(uuid.uuid4()),
+                    "message": {'': D_(u"=> {} has joined the room").format(user.nick)},
+                    "subject": {},
+                    "type": C.MESS_TYPE_INFO,
+                    "extra": extra,
+                    "timestamp": time.time(),
+                }
+                self.parent.messageAddToHistory(mess_data)
+                self.parent.messageSendToBridge(mess_data)
+
+
+    def userLeftRoom(self, room, user):
+        if not self.host.trigger.point("MUC user left", room, user, self.parent.profile):
+            return
+        if user.nick == room.nick:
+            # we left the room
+            room_jid_s = room.roomJID.userhost()
+            log.info(_(u"Room ({room}) left ({profile})").format(
+                room = room_jid_s, profile = self.parent.profile))
+            self.host.memory.delEntityCache(room.roomJID, profile_key=self.parent.profile)
+            self.host.bridge.mucRoomLeft(room.roomJID.userhost(), self.parent.profile)
+        elif not room._room_ok:
+            log.warning(u"Received user presence data in a room before its initialisation (and after our own presence),"
+                "this is not standard! Ignoring it: {} ({})".format(
+                room.roomJID.userhost(),
+                user.nick))
+            return
+        else:
+            log.debug(_(u"user {nick} left room {room_id}").format(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 = {  # dict is similar to the one used in client.onMessage
+                "from": room.roomJID,
+                "to": self.parent.jid,
+                "uid": unicode(uuid.uuid4()),
+                "message": {'': D_(u"<= {} has left the room").format(user.nick)},
+                "subject": {},
+                "type": C.MESS_TYPE_INFO,
+                "extra": extra,
+                "timestamp": time.time(),
+            }
+            self.parent.messageAddToHistory(mess_data)
+            self.parent.messageSendToBridge(mess_data)
+
+    def userChangedNick(self, room, user, new_nick):
+        self.host.bridge.mucRoomUserChangedNick(room.roomJID.userhost(), user.nick, new_nick, self.parent.profile)
+
+    def userUpdatedStatus(self, room, user, show, status):
+        self.host.bridge.presenceUpdate(room.roomJID.userhost() + '/' + user.nick, show or '', 0, {C.PRESENCE_STATUSES_DEFAULT: status or ''}, self.parent.profile)
+
+    ## messages ##
+
+    def receivedGroupChat(self, room, user, body):
+        log.debug(u'receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body))
+
+    def _addToHistory(self, dummy, user, message):
+        # we check if message is not in history
+        # and raise ConflictError else
+        stamp = message.delay.stamp.astimezone(tzutc()).timetuple()
+        timestamp = float(calendar.timegm(stamp))
+        data = {  # dict is similar to the one used in client.onMessage
+            "from": message.sender,
+            "to": message.recipient,
+            "uid": unicode(uuid.uuid4()),
+            "type": C.MESS_TYPE_GROUPCHAT,
+            "extra": {},
+            "timestamp": timestamp,
+            "received_timestamp": unicode(time.time()),
+        }
+        # FIXME: message and subject don't handle xml:lang
+        data['message'] = {'': message.body} if message.body is not None else {}
+        data['subject'] = {'': message.subject} if message.subject is not None else {}
+
+        if data['message'] or data['subject']:
+            return self.host.memory.addToHistory(self.parent, data)
+        else:
+            return defer.succeed(None)
+
+    def _addToHistoryEb(self, failure):
+        failure.trap(exceptions.CancelError)
+
+    def receivedHistory(self, room, user, message):
+        """Called when history (backlog) message are received
+
+        we check if message is not already in our history
+        and add it if needed
+        @param room(muc.Room): room instance
+        @param user(muc.User, None): the user that sent the message
+            None if the message come from the room
+        @param message(muc.GroupChat): the parsed message
+        """
+        room._history_d.addCallback(self._addToHistory, user, message)
+        room._history_d.addErrback(self._addToHistoryEb)
+
+    ## 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
+        # FIXME: remove this merge fixed upstream
+        room, user = self._getRoomUser(message)
+
+        if room is None:
+            return
+
+        if message.subject is not None:
+            self.receivedSubject(room, user, message.subject)
+        elif message.delay is None:
+            self.receivedGroupChat(room, user, message)
+        else:
+            self.receivedHistory(room, user, message)
+
+    def subject(self, room, subject):
+        return muc.MUCClientProtocol.subject(self, room, subject)
+
+    def _historyCb(self, dummy, room):
+        args = self.plugin_parent._getRoomJoinedArgs(room, self.parent.profile)
+        self.host.bridge.mucRoomJoined(*args)
+        del room._history_d
+        cache = room._cache
+        del room._cache
+        room._room_ok = True
+        for elem in cache:
+            self.parent.xmlstream.dispatch(elem)
+
+    def _historyEb(self, failure_, room):
+        log.error(u"Error while managing history: {}".format(failure_))
+        self._historyCb(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
+        self.rec_subjects[room.roomJID.userhost()] = (room.roomJID.userhost(), subject)
+        if room._room_ok is None:
+            # this is the first subject we receive
+            # that mean that we have received everything we need
+            room._room_ok = False
+            room._history_d.addCallbacks(self._historyCb, self._historyEb, [room], errbackArgs=[room])
+            room.fully_joined.callback(room)
+        else:
+            # the subject has been changed
+            log.debug(_(u"New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject))
+            self.host.bridge.mucRoomNewSubject(room.roomJID.userhost(), subject, self.parent.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 []