view src/plugins/plugin_xep_0045.py @ 1963:a2bc5089c2eb

backend, frontends: message refactoring (huge commit): /!\ several features are temporarily disabled, like notifications in frontends next step in refactoring, with the following changes: - jp: updated jp message to follow changes in backend/bridge - jp: added --lang, --subject, --subject_lang, and --type options to jp message + fixed unicode handling for jid - quick_frontend (QuickApp, QuickChat): - follow backend changes - refactored chat, message are now handled in OrderedDict and uid are kept so they can be updated - Message and Occupant classes handle metadata, so frontend just have to display them - Primitivus (Chat): - follow backend/QuickFrontend changes - info & standard messages are handled in the same MessageWidget class - improved/simplified handling of messages, removed update() method - user joined/left messages are merged when next to each other - a separator is shown when message is received while widget is out of focus, so user can quickly see the new messages - affiliation/role are shown (in a basic way for now) in occupants panel - removed "/me" messages handling, as it will be done by a backend plugin - message language is displayed when available (only one language per message for now) - fixed :history and :search commands - core (constants): new constants for messages type, XML namespace, entity type - core: *Message methods renamed to follow new code sytle (e.g. sendMessageToBridge => messageSendToBridge) - core (messages handling): fixed handling of language - core (messages handling): mes_data['from'] and ['to'] are now jid.JID - core (core.xmpp): reorganised message methods, added getNick() method to client.roster - plugin text commands: fixed plugin and adapted to new messages behaviour. client is now used in arguments instead of profile - plugins: added information for cancellation reason in CancelError calls - plugin XEP-0045: various improvments, but this plugin still need work: - trigger is used to avoid message already handled by the plugin to be handled a second time - changed the way to handle history, the last message from DB is checked and we request only messages since this one, in seconds (thanks Poezio folks :)) - subject reception is waited before sending the roomJoined signal, this way we are sure that everything including history is ready - cmd_* method now follow the new convention with client instead of profile - roomUserJoined and roomUserLeft messages are removed, the events are now handled with info message with a "ROOM_USER_JOINED" info subtype - probably other forgotten stuffs :p
author Goffi <goffi@goffi.org>
date Mon, 20 Jun 2016 18:41:53 +0200
parents 2daf7b4c6756
children 200cd707a46d
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# SAT plugin for managing xep-0045
# Copyright (C) 2009-2016 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 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 = {
    "name": "XEP 0045 Plugin",
    "import_name": "XEP-0045",
    "type": "XEP",
    "protocols": ["XEP-0045"],
    "dependencies": [],
    "recommendations": [C.TEXT_CMDS],
    "main": "XEP_0045",
    "handler": "yes",
    "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 UnknownRoom(Exception):
    pass


class AlreadyJoinedRoom(Exception):
    pass

class NotReadyYet(Exception):
    pass


class XEP_0045(object):
    # TODO: this plugin is messy, need a big cleanup/refactoring

    def __init__(self, host):
        log.info(_("Plugin XEP_0045 initialization"))
        self.host = host
        self.clients = {}  # FIXME: should be moved to profile's client
        self._sessions = memory.Sessions()
        host.bridge.addMethod("mucJoin", ".plugin", in_sign='ssa{ss}s', out_sign='s', method=self._join, async=True)
        host.bridge.addMethod("mucNick", ".plugin", in_sign='sss', out_sign='', method=self.mucNick)
        host.bridge.addMethod("mucLeave", ".plugin", in_sign='ss', out_sign='', method=self.mucLeave, async=True)
        host.bridge.addMethod("getRoomsJoined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ss)', method=self.getRoomsJoined)
        host.bridge.addMethod("getRoomsSubjects", ".plugin", in_sign='s', out_sign='a(ss)', method=self.getRoomsSubjects)
        host.bridge.addMethod("getUniqueRoomName", ".plugin", in_sign='ss', out_sign='s', method=self._getUniqueName)
        host.bridge.addMethod("configureRoom", ".plugin", in_sign='ss', out_sign='s', method=self._configureRoom, async=True)
        host.bridge.addMethod("getDefaultMUC", ".plugin", in_sign='', out_sign='s', method=self.getDefaultMUC)
        host.bridge.addSignal("roomJoined", ".plugin", signature='sa{sa{ss}}sss')  # args: room_jid, occupants, user_nick, subject, profile
        host.bridge.addSignal("roomLeft", ".plugin", signature='ss')  # args: room_jid, profile
        host.bridge.addSignal("roomUserChangedNick", ".plugin", signature='ssss')  # args: room_jid, old_nick, new_nick, profile
        host.bridge.addSignal("roomNewSubject", ".plugin", signature='sss')  # args: room_jid, subject, profile
        self.__submit_conf_id = host.registerCallback(self._submitConfiguration, 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.host.plugins[C.TEXT_CMDS].registerTextCommands(self)
            self.host.plugins[C.TEXT_CMDS].addWhoIsCb(self._whois, 100)
        except KeyError:
            log.info(_("Text commands not available"))

        host.trigger.add("presence_available", self.presenceTrigger)
        host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=1000000)

    def profileConnected(self, profile):
        def assign_service(service):
            client = self.host.getClient(profile)
            client.muc_service = service
        return self.getMUCService(profile=profile).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 self.clients[client.profile].joined_rooms:
                room = self.clients[client.profile].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 checkClient(self, profile):
        """Check if the profile is connected and has used the MUC feature.

        If profile was using MUC feature but is now disconnected, remove it from the client list.
        @param profile: profile to check
        @return: True if the profile is connected and has used the MUC feature, else False"""
        if not profile or profile not in self.clients or not self.host.isConnected(profile):
            log.error(_(u'Unknown or disconnected profile (%s)') % profile)
            if profile in self.clients:
                del self.clients[profile]
            return False
        return True

    def getProfileAssertInRoom(self, room_jid, profile_key):
        """Retrieve the profile name, assert that it's connected and participating in the given room.

        @param room_jid (JID): room JID
        @param profile_key (str): %(doc_profile_key)
        @return: the profile name
        """
        profile = self.host.memory.getProfileName(profile_key)
        if not self.checkClient(profile):
            raise exceptions.ProfileUnknownError("Unknown or disconnected profile")
        if room_jid not in self.clients[profile].joined_rooms:
            raise UnknownRoom("This room has not been joined")
        return profile

    def _joinCb(self, room, profile):
        """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
            print "room locked !"
            d = self.clients[profile].configure(room.roomJID, {})
            d.addErrback(lambda dummy: log.error(_(u'Error while configuring the room')))
        return room

    def _joinEb(self, failure, room_jid, nick, password, profile):
        """Called when something is going wrong when joining the room"""
        if hasattr(failure.value, "condition") and failure.value.condition == 'conflict':
            # we have a nickname conflict, we try again with "_" suffixed to current nickname
            nick += '_'
            return self.clients[profile].join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, callbackKeywords={'profile': profile}, errbackArgs=[room_jid, nick, password, profile])
        mess = D_("Error while joining the room %s" % room_jid.userhost())
        try:
            mess += " with condition '%s'" % failure.value.condition
        except AttributeError:
            pass
        log.error(mess)
        self.host.bridge.newAlert(mess, D_("Group chat error"), "ERROR", profile)
        raise failure

    @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 isRoom(self, entity_bare, profile_key):
        """Tell if a bare entity is a MUC room.

        @param entity_bare (jid.JID): bare entity
        @param profile_key (unicode): %(doc_profile_key)s
        @return bool
        """
        profile = self.host.memory.getProfileName(profile_key)
        return entity_bare in self.clients[profile].joined_rooms

    def getRoomsJoined(self, profile_key=C.PROF_KEY_NONE):
        """Return room where user is"""
        profile = self.host.memory.getProfileName(profile_key)
        result = []
        if not self.checkClient(profile):
            return result
        for room in self.clients[profile].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, profile_key=C.PROF_KEY_NONE):
        """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"""
        profile = self.host.memory.getProfileName(profile_key)
        if not self.checkClient(profile) or room_jid not in self.clients[profile].joined_rooms:
            return ''
        return self.clients[profile].joined_rooms[room_jid].nick

    def getRoomNickOfUser(self, room, user_jid, secure=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):
        d = self.configureRoom(jid.JID(room_jid_s), profile_key)
        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
        """
        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(room_jid, profile).addCallback(xmluiReceived)

    def configureRoom(self, room_jid, profile_key=C.PROF_KEY_NONE):
        """return the room configuration form

        @param room: jid of the room to configure
        @param profile_key: %(doc_profile_key)s
        @return: configuration form as XMLUI
        """
        profile = self.getProfileAssertInRoom(room_jid, profile_key)

        def config2XMLUI(result):
            if not result:
                return ""
            session_id, session_data = self._sessions.newSession(profile=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 = self.clients[profile].getConfiguration(room_jid)
        d.addCallback(config2XMLUI)
        return d

    def _submitConfiguration(self, raw_data, 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 = self.clients[profile].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, room_jid, nick, profile):
        """Tell if a nick is currently present in a room"""
        profile = self.getProfileAssertInRoom(room_jid, profile)
        return self.clients[profile].joined_rooms[room_jid].inRoster(muc.User(nick))

    def getRoomsSubjects(self, profile_key=C.PROF_KEY_NONE):
        """Return received subjects of rooms"""
        # FIXME: to be removed
        profile = self.host.memory.getProfileName(profile_key)
        if not self.checkClient(profile):
            return []
        return self.clients[profile].rec_subjects.values()

    @defer.inlineCallbacks
    def getMUCService(self, jid_=None, profile=C.PROF_KEY_NONE):
        """Return first found MUC service of an entity

        @param jid_: entity which may have a MUC service, or None for our own server
        @param profile: %(doc_profile)s
        """
        muc_service = None
        services = yield self.host.findServiceEntities("conference", "text", jid_, profile=profile)
        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):
        return self.getUniqueName(muc_service or None, profile_key).full()

    def getUniqueName(self, muc_service=None, profile_key=C.PROF_KEY_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
        client = self.host.getClient(profile_key)
        room_name = uuid.uuid1()
        if muc_service is None:
            try:
                muc_service = client.muc_service
            except AttributeError:
                raise NotReadyYet("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("%s@%s" % (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(self, client, room_jid, nick, options):
        def _errDeferred(exc_obj=Exception, txt='Error while joining room'):
            d = defer.Deferred()
            d.errback(exc_obj(txt))
            return d

        if room_jid in self.clients[client.profile].joined_rooms:
            log.warning(_(u'%(profile)s is already in room %(room_jid)s') % {'profile': client.profile, 'room_jid': room_jid.userhost()})
            return _errDeferred(AlreadyJoinedRoom, D_(u"The room has already been joined"))
        log.info(_(u"[%(profile)s] is joining room %(room)s with nick %(nick)s") % {'profile': client.profile, 'room': room_jid.userhost(), 'nick': nick})

        password = options["password"] if "password" in options else None

        return self.clients[client.profile].join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, callbackKeywords={'profile': client.profile}, errbackArgs=[room_jid, nick, password, client.profile])

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

        @return: unicode (the room bare)
        """
        client = self.host.getClient(profile_key)
        if options is None:
            options = {}
        if room_jid_s:
            muc_service = self.host.getClient(client.profile).muc_service
            try:
                room_jid = jid.JID(room_jid_s)
            except (RuntimeError, jid.InvalidFormat, AttributeError):
                return defer.fail(jid.InvalidFormat(_(u"Invalid room identifier: '%s'. Please give a room short or full identifier like 'room' or 'room@%s'.") % (room_jid_s, 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)
        return d.addCallback(lambda room: room.roomJID.userhost())

    def nick(self, room_jid, nick, profile_key):
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        return self.clients[profile].nick(room_jid, nick)

    def leave(self, room_jid, profile_key):
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        return self.clients[profile].leave(room_jid)

    def subject(self, room_jid, subject, profile_key):
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        return self.clients[profile].subject(room_jid, subject)

    def mucNick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE):
        """Change nickname in a room"""
        return self.nick(jid.JID(room_jid_s), nick, profile_key)

    def mucLeave(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
        """Leave a room"""
        return self.leave(jid.JID(room_jid_s), profile_key)

    def getHandler(self, profile):
        self.clients[profile] = SatMUCClient(self)
        return self.clients[profile]

    def profileDisconnected(self, profile):
        try:
            del self.clients[profile]
        except KeyError:
            pass

    def kick(self, nick, room_jid, options={}, profile_key=C.PROF_KEY_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
        @param profile_key (str): %(doc_profile_key)s
        """
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        return self.clients[profile].kick(room_jid, nick, reason=options.get('reason', None))

    def ban(self, entity_jid, room_jid, options={}, profile_key=C.PROF_KEY_NONE):
        """
        Ban an entity from the room
        @param entity_jid (JID): bare jid of the entity to be banned
        @param room_jid_s (JID): jid of the room
        @param options: attribute with extra info (reason, password) as in #XEP-0045
        @param profile_key (str): %(doc_profile_key)s
        """
        assert(not entity_jid.resource)
        assert(not room_jid.resource)
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        return self.clients[profile].ban(room_jid, entity_jid, reason=options.get('reason', None))

    def affiliate(self, entity_jid, room_jid, options=None, profile_key=C.PROF_KEY_NONE):
        """
        Change the affiliation of an entity
        @param entity_jid (JID): bare jid of the entity
        @param room_jid_s (JID): jid of the room
        @param options: attribute with extra info (reason, nick) as in #XEP-0045
        @param profile_key (str): %(doc_profile_key)s
        """
        assert(not entity_jid.resource)
        assert(not room_jid.resource)
        assert('affiliation' in options)
        profile = self.getProfileAssertInRoom(room_jid, profile_key)
        # TODO: handles reason and nick
        return self.clients[profile].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(room, nick, client.profile)

        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.host.plugins[C.TEXT_CMDS].getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host)
            nick = (self.getRoomNick(room_jid, client.profile) or
                    self.host.getClient(client.profile).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.host.plugins[C.TEXT_CMDS].getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host)
        else:
            room = mess_data["to"]

        self.leave(room, client.profile)

        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(mess_data["to"], nick, client.profile))
        except (IndexError, AssertionError):
            feedback = _(u"You must provide a member's nick to kick.")
            self.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data)
            return False

        d = self.kick(nick, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, client.profile)

        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.host.plugins[C.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.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data)
            return False

        d = self.ban(entity_jid, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}, client.profile)

        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.host.plugins[C.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.host.plugins[C.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.host.plugins[C.TEXT_CMDS].feedBack(client, feedback, mess_data)
            return False

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

        def cb(dummy):
            feedback_msg = _(u'New affiliation for %(entity)s: %(affiliation)s').format(entity=entity_jid, affiliation=affiliation)
            self.host.plugins[C.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(room, subject, client.profile)

        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 _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 self.clients[client.profile].joined_rooms:
            log.warning(_("This room has not been joined"))
            return
        if not target_jid.resource:
            return
        user = self.clients[client.profile].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 = self.clients[client.profile]
        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
        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()  # use to send bridge signal once backlog are written in history
        room._history_d.callback(None)
        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 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.host.messageAddToHistory(mess_data, self.parent)
                self.host.messageSendToBridge(mess_data, self.parent)


    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.roomLeft(room.roomJID.userhost(), self.parent.profile)
        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.host.messageAddToHistory(mess_data, self.parent)
            self.host.messageSendToBridge(mess_data, self.parent)

    def userChangedNick(self, room, user, new_nick):
        self.host.bridge.roomUserChangedNick(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):
        self.host.bridge.roomJoined(
            room.roomJID.userhost(),
            XEP_0045._getOccupants(room),
            room.nick,
            room.subject,
            self.parent.profile)
        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])
        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.roomNewSubject(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 []