view src/core/sat_main.py @ 2086:4633cfcbcccb

bridge (D-Bus): bad design fixes: - renamed outputed module to dbus_bridge (to avoid uppercase and conflict with dbus module) - class name is now Bridge for both frontend and core (make discovery/import more easy) - register renamed to register_method in core, and register_signal in frontend
author Goffi <goffi@goffi.org>
date Mon, 03 Oct 2016 21:15:39 +0200
parents 046449cc2bff
children c02f96756d5c
line wrap: on
line source

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

# SAT: a jabber client
# 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/>.

import sat
from sat.core.i18n import _, languageSwitch
from twisted.application import service
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish
from twisted.internet import reactor
from wokkel.xmppim import RosterItem
from sat.bridge.dbus_bridge import Bridge
from sat.core import xmpp
from sat.core import exceptions
from sat.core.log import getLogger
log = getLogger(__name__)
from sat.core.constants import Const as C
from sat.memory.memory import Memory
from sat.tools import trigger
from sat.tools import utils
from sat.stdui import ui_contact_list, ui_profile_manager
from glob import glob
from uuid import uuid4
import sys
import os.path
import uuid
import time

try:
    from collections import OrderedDict # only available from python 2.7
except ImportError:
    from ordereddict import OrderedDict


class SAT(service.Service):

    def __init__(self):
        self._cb_map = {}  # map from callback_id to callbacks
        self._menus = OrderedDict()  # dynamic menus. key: callback_id, value: menu data (dictionnary)
        self.initialised = defer.Deferred()
        self.profiles = {}
        self.plugins = {}

        self.memory = Memory(self)
        self.trigger = trigger.TriggerManager()  # trigger are used to change SàT behaviour

        try:
            self.bridge = Bridge()
        except exceptions.BridgeInitError:
            log.error(u"Bridge can't be initialised, can't start SàT core")
            sys.exit(1)
        self.bridge.register_method("getReady", lambda: self.initialised)
        self.bridge.register_method("getVersion", lambda: self.full_version)
        self.bridge.register_method("getFeatures", self.getFeatures)
        self.bridge.register_method("getProfileName", self.memory.getProfileName)
        self.bridge.register_method("getProfilesList", self.memory.getProfilesList)
        self.bridge.register_method("getEntityData", lambda jid_, keys, profile: self.memory.getEntityData(jid.JID(jid_), keys, profile))
        self.bridge.register_method("getEntitiesData", self.memory._getEntitiesData)
        self.bridge.register_method("asyncCreateProfile", self.memory.asyncCreateProfile)
        self.bridge.register_method("asyncDeleteProfile", self.memory.asyncDeleteProfile)
        self.bridge.register_method("profileStartSession", self.memory.startSession)
        self.bridge.register_method("profileIsSessionStarted", self.memory._isSessionStarted)
        self.bridge.register_method("profileSetDefault", self.memory.profileSetDefault)
        self.bridge.register_method("asyncConnect", self._asyncConnect)
        self.bridge.register_method("disconnect", self.disconnect)
        self.bridge.register_method("getContacts", self.getContacts)
        self.bridge.register_method("getContactsFromGroup", self.getContactsFromGroup)
        self.bridge.register_method("getMainResource", self.memory._getMainResource)
        self.bridge.register_method("getPresenceStatuses", self.memory._getPresenceStatuses)
        self.bridge.register_method("getWaitingSub", self.memory.getWaitingSub)
        self.bridge.register_method("messageSend", self._messageSend)
        self.bridge.register_method("getConfig", self._getConfig)
        self.bridge.register_method("setParam", self.setParam)
        self.bridge.register_method("getParamA", self.memory.getStringParamA)
        self.bridge.register_method("asyncGetParamA", self.memory.asyncGetStringParamA)
        self.bridge.register_method("asyncGetParamsValuesFromCategory", self.memory.asyncGetParamsValuesFromCategory)
        self.bridge.register_method("getParamsUI", self.memory.getParamsUI)
        self.bridge.register_method("getParamsCategories", self.memory.getParamsCategories)
        self.bridge.register_method("paramsRegisterApp", self.memory.paramsRegisterApp)
        self.bridge.register_method("historyGet", self.memory._historyGet)
        self.bridge.register_method("setPresence", self._setPresence)
        self.bridge.register_method("subscription", self.subscription)
        self.bridge.register_method("addContact", self._addContact)
        self.bridge.register_method("updateContact", self._updateContact)
        self.bridge.register_method("delContact", self._delContact)
        self.bridge.register_method("isConnected", self.isConnected)
        self.bridge.register_method("launchAction", self.launchCallback)
        self.bridge.register_method("actionsGet", self.actionsGet)
        self.bridge.register_method("progressGet", self._progressGet)
        self.bridge.register_method("progressGetAll", self._progressGetAll)
        self.bridge.register_method("getMenus", self.getMenus)
        self.bridge.register_method("getMenuHelp", self.getMenuHelp)
        self.bridge.register_method("discoInfos", self.memory.disco._discoInfos)
        self.bridge.register_method("discoItems", self.memory.disco._discoItems)
        self.bridge.register_method("saveParamsTemplate", self.memory.save_xml)
        self.bridge.register_method("loadParamsTemplate", self.memory.load_xml)

        self.memory.initialized.addCallback(self._postMemoryInit)

    @property
    def version(self):
        """Return the short version of SàT"""
        return C.APP_VERSION

    @property
    def full_version(self):
        """Return the full version of SàT (with extra data when in development mode)"""
        version = self.version
        if version[-1] == 'D':
            # we are in debug version, we add extra data
            try:
                return self._version_cache
            except AttributeError:
                self._version_cache = u"{} ({})".format(version, utils.getRepositoryData(sat))
                return self._version_cache
        else:
            return version

    def _postMemoryInit(self, ignore):
        """Method called after memory initialization is done"""
        log.info(_("Memory initialised"))
        self._import_plugins()
        ui_contact_list.ContactList(self)
        ui_profile_manager.ProfileManager(self)
        self.initialised.callback(None)
        log.info(_("Backend is ready"))

    def _import_plugins(self):
        """Import all plugins found in plugins directory"""
        import sat.plugins
        plugins_path = os.path.dirname(sat.plugins.__file__)
        plug_lst = [os.path.splitext(plugin)[0] for plugin in map(os.path.basename, glob(os.path.join(plugins_path, "plugin*.py")))]
        plugins_to_import = {}  # plugins we still have to import
        for plug in plug_lst:
            plugin_path = 'sat.plugins.' + plug
            try:
                __import__(plugin_path)
            except exceptions.MissingModule as e:
                try:
                    del sys.modules[plugin_path]
                except KeyError:
                    pass
                log.warning(u"Can't import plugin [{path}] because of an unavailale third party module:\n{msg}".format(
                    path=plugin_path, msg=e))
                continue
            except Exception as e:
                import traceback
                log.error(_(u"Can't import plugin [{path}]:\n{error}").format(path=plugin_path, error=traceback.format_exc()))
                continue
            mod = sys.modules[plugin_path]
            plugin_info = mod.PLUGIN_INFO
            import_name = plugin_info['import_name']
            if import_name in plugins_to_import:
                log.error(_(u"Name conflict for import name [{import_name}], can't import plugin [{name}]").format(**plugin_info))
                continue
            plugins_to_import[import_name] = (plugin_path, mod, plugin_info)
        while True:
            try:
                self._import_plugins_from_dict(plugins_to_import)
            except ImportError:
                pass
            if not plugins_to_import:
                break

    def _import_plugins_from_dict(self, plugins_to_import, import_name=None, optional=False):
        """Recursively import and their dependencies in the right order

        @param plugins_to_import(dict): key=import_name and values=(plugin_path, module, plugin_info)
        @param import_name(unicode, None): name of the plugin to import as found in PLUGIN_INFO['import_name']
        @param optional(bool): if False and plugin is not found, an ImportError exception is raised
        """
        if import_name in self.plugins:
            log.debug(u'Plugin {} already imported, passing'.format(import_name))
            return
        if not import_name:
            import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem()
        else:
            if not import_name in plugins_to_import:
                if optional:
                    log.warning(_(u"Recommended plugin not found: {}").format(import_name))
                    return
                msg = u"Dependency not found: {}".format(import_name)
                log.error(msg)
                raise ImportError(msg)
            plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
        dependencies = plugin_info.setdefault("dependencies", [])
        recommendations = plugin_info.setdefault("recommendations", [])
        for to_import in dependencies + recommendations:
            if to_import not in self.plugins:
                log.debug(u'Recursively import dependency of [%s]: [%s]' % (import_name, to_import))
                try:
                    self._import_plugins_from_dict(plugins_to_import, to_import, to_import not in dependencies)
                except ImportError as e:
                    log.warning(_(u"Can't import plugin {name}: {error}").format(name=plugin_info['name'], error=e))
                    if optional:
                        return
                    raise e
        log.info("importing plugin: {}".format(plugin_info['name']))
        # we instanciate the plugin here
        try:
            self.plugins[import_name] = getattr(mod, plugin_info['main'])(self)
        except Exception as e:
            log.warning(u'Error while loading plugin "{name}", ignoring it: {error}'
                .format(name=plugin_info['name'], error=e))
            if optional:
                return
            raise ImportError(u"Error during initiation")
        if 'handler' in plugin_info and plugin_info['handler'] == 'yes':
            self.plugins[import_name].is_handler = True
        else:
            self.plugins[import_name].is_handler = False
        #TODO: test xmppclient presence and register handler parent

    def pluginsUnload(self):
        """Call unload method on every loaded plugin, if exists

        @return (D): A deferred which return None when all method have been called
        """
        # TODO: in the futur, it should be possible to hot unload a plugin
        #       pluging depending on the unloaded one should be unloaded too
        #       for now, just a basic call on plugin.unload is done
        defers_list = []
        for plugin in self.plugins.itervalues():
            try:
                unload = plugin.unload
            except AttributeError:
                continue
            else:
                defers_list.append(defer.maybeDeferred(unload))
        return defers_list

    def _asyncConnect(self, profile_key, password=''):
        profile = self.memory.getProfileName(profile_key)
        return self.asyncConnect(profile, password)

    def asyncConnect(self, profile, password='', max_retries=C.XMPP_MAX_RETRIES):
        """Retrieve the individual parameters, authenticate the profile
        and initiate the connection to the associated XMPP server.

        @param profile: %(doc_profile)s
        @param password (string): the SàT profile password
        @param max_retries (int): max number of connection retries
        @return (D(bool)):
            - True if the XMPP connection was already established
            - False if the XMPP connection has been initiated (it may still fail)
        @raise exceptions.PasswordError: Profile password is wrong
        """
        def connectXMPPClient(dummy=None):
            if self.isConnected(profile):
                log.info(_("already connected !"))
                return True
            d = self._connectXMPPClient(profile, max_retries)
            return d.addCallback(lambda dummy: False)

        d = self.memory.startSession(password, profile)
        d.addCallback(connectXMPPClient)
        return d

    @defer.inlineCallbacks
    def _connectXMPPClient(self, profile, max_retries):
        """This part is called from asyncConnect when we have loaded individual parameters from memory"""
        try:
            port = int(self.memory.getParamA(C.FORCE_PORT_PARAM, "Connection", profile_key=profile))
        except ValueError:
            log.debug(_("Can't parse port value, using default value"))
            port = None  # will use default value 5222 or be retrieved from a DNS SRV record

        password = yield self.memory.asyncGetParamA("Password", "Connection", profile_key=profile)
        current = self.profiles[profile] = xmpp.SatXMPPClient(self, profile,
            jid.JID(self.memory.getParamA("JabberID", "Connection", profile_key=profile)),
            password, self.memory.getParamA(C.FORCE_SERVER_PARAM, "Connection", profile_key=profile),
            port, max_retries)

        current.messageProt = xmpp.SatMessageProtocol(self)
        current.messageProt.setHandlerParent(current)

        current.roster = xmpp.SatRosterProtocol(self)
        current.roster.setHandlerParent(current)

        current.presence = xmpp.SatPresenceProtocol(self)
        current.presence.setHandlerParent(current)

        current.fallBack = xmpp.SatFallbackHandler(self)
        current.fallBack.setHandlerParent(current)

        current.versionHandler = xmpp.SatVersionHandler(C.APP_NAME_FULL,
                                                        self.full_version)
        current.versionHandler.setHandlerParent(current)

        current.identityHandler = xmpp.SatIdentityHandler()
        current.identityHandler.setHandlerParent(current)

        log.debug(_("setting plugins parents"))

        plugin_conn_cb = []
        for plugin in self.plugins.iteritems():
            if plugin[1].is_handler:
                plugin[1].getHandler(profile).setHandlerParent(current)
            connected_cb = getattr(plugin[1], "profileConnected", None) # profile connected is called after client is ready and roster is got
            if connected_cb:
                plugin_conn_cb.append((plugin[0], connected_cb))
            try:
                yield plugin[1].profileConnecting(profile) # profile connecting is called before actually starting client
            except AttributeError:
                pass

        current.startService()

        yield current.getConnectionDeferred()
        yield current.roster.got_roster  # we want to be sure that we got the roster

        # Call profileConnected callback for all plugins, and print error message if any of them fails
        conn_cb_list = []
        for dummy, callback in plugin_conn_cb:
            conn_cb_list.append(defer.maybeDeferred(callback, profile))
        list_d = defer.DeferredList(conn_cb_list)

        def logPluginResults(results):
            all_succeed = all([success for success, result in results])
            if not all_succeed:
                log.error(_(u"Plugins initialisation error"))
                for idx, (success, result) in enumerate(results):
                    if not success:
                        log.error(u"error (plugin %(name)s): %(failure)s" %
                                  {'name': plugin_conn_cb[idx][0], 'failure': result})

        yield list_d.addCallback(logPluginResults) # FIXME: we should have a timeout here, and a way to know if a plugin freeze
        # TODO: mesure launch time of each plugin

    def disconnect(self, profile_key):
        """disconnect from jabber server"""
        if not self.isConnected(profile_key):
            log.info(_("not connected !"))
            return
        profile = self.memory.getProfileName(profile_key)
        log.info(_("Disconnecting..."))
        self.profiles[profile].stopService()
        for plugin in self.plugins.iteritems():
            disconnected_cb = getattr(plugin[1], "profileDisconnected", None)
            if disconnected_cb:
                disconnected_cb(profile)

    def getFeatures(self, profile_key=C.PROF_KEY_NONE):
        """Get available features

        Return list of activated plugins and plugin specific data
        @param profile_key: %(doc_profile_key)s
            C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile dependent)
        @return (dict)[Deferred]: features data where:
            - key is plugin import name, present only for activated plugins
            - value is a an other dict, when meaning is specific to each plugin.
                this dict is return by plugin's getFeature method.
                If this method doesn't exists, an empty dict is returned.
        """
        try:
            # FIXME: there is no method yet to check profile session
            #        as soon as one is implemented, it should be used here
            self.getClient(profile_key)
        except KeyError:
            log.warning("Requesting features for a profile outside a session")
            profile_key = C.PROF_KEY_NONE
        except exceptions.ProfileNotSetError:
            pass

        features = []
        for import_name, plugin in self.plugins.iteritems():
            try:
                features_d = defer.maybeDeferred(plugin.getFeatures, profile_key)
            except AttributeError:
                features_d = defer.succeed({})
            features.append(features_d)

        d_list = defer.DeferredList(features)
        def buildFeatures(result, import_names):
            assert len(result) == len(import_names)
            ret = {}
            for name, (success, data) in zip (import_names, result):
                if success:
                    ret[name] = data
                else:
                    log.warning(u"Error while getting features for {name}: {failure}".format(
                        name=name, failure=data))
                    ret[name] = {}
            return ret

        d_list.addCallback(buildFeatures, self.plugins.keys())
        return d_list

    def getContacts(self, profile_key):
        client = self.getClient(profile_key)
        def got_roster(dummy):
            ret = []
            for item in client.roster.getItems():  # we get all items for client's roster
                # and convert them to expected format
                attr = client.roster.getAttributes(item)
                ret.append([item.jid.userhost(), attr, item.groups])
            return ret

        return client.roster.got_roster.addCallback(got_roster)

    def getContactsFromGroup(self, group, profile_key):
        client = self.getClient(profile_key)
        return [jid_.full() for jid_ in client.roster.getJidsFromGroup(group)]

    def purgeClient(self, profile):
        """Remove reference to a profile client and purge cache
        the garbage collector can then free the memory"""
        try:
            del self.profiles[profile]
        except KeyError:
            log.error(_("Trying to remove reference to a client not referenced"))
        self.memory.purgeProfileSession(profile)

    def startService(self):
        log.info(u"Salut à toi ô mon frère !")

    def stopService(self):
        log.info(u"Salut aussi à Rantanplan")
        return self.pluginsUnload()

    def run(self):
        log.debug(_("running app"))
        reactor.run()

    def stop(self):
        log.debug(_("stopping app"))
        reactor.stop()

    ## Misc methods ##

    def getJidNStream(self, profile_key):
        """Convenient method to get jid and stream from profile key
        @return: tuple (jid, xmlstream) from profile, can be None"""
        # TODO: deprecate this method (getClient is enough)
        profile = self.memory.getProfileName(profile_key)
        if not profile or not self.profiles[profile].isConnected():
            return (None, None)
        return (self.profiles[profile].jid, self.profiles[profile].xmlstream)

    def getClient(self, profile_key):
        """Convenient method to get client from profile key

        @return: client or None if it doesn't exist
        @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist
        @raise exceptions.NotFound: client is not available
            This happen if profile has not been use yet
        """
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            raise exceptions.ProfileKeyUnknown
        try:
            return self.profiles[profile]
        except KeyError:
            raise exceptions.NotFound

    def getClients(self, profile_key):
        """Convenient method to get list of clients from profile key (manage list through profile_key like C.PROF_KEY_ALL)

        @param profile_key: %(doc_profile_key)s
        @return: list of clients
        """
        try:
            profile = self.memory.getProfileName(profile_key, True)
        except exceptions.ProfileUnknownError:
            return []
        if profile == C.PROF_KEY_ALL:
            return self.profiles.values()
        elif profile.count('@') > 1:
            raise exceptions.ProfileKeyUnknown
        return [self.profiles[profile]]

    def _getConfig(self, section, name):
        """Get the main configuration option

        @param section: section of the config file (None or '' for DEFAULT)
        @param name: name of the option
        @return: unicode representation of the option
        """
        return unicode(self.memory.getConfig(section, name, ''))

    ## Client management ##

    def setParam(self, name, value, category, security_limit, profile_key):
        """set wanted paramater and notice observers"""
        self.memory.setParam(name, value, category, security_limit, profile_key)

    def isConnected(self, profile_key):
        """Return connection status of profile
        @param profile_key: key_word or profile name to determine profile name
        @return: True if connected
        """
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            log.error(_('asking connection status for a non-existant profile'))
            raise exceptions.ProfileUnknownError(profile_key)
        if profile not in self.profiles:
            return False
        return self.profiles[profile].isConnected()


    ## XMPP methods ##

    def generateMessageXML(self, data):
        """Generate <message/> stanza from message data

        @param data(dict): message data
            domish element will be put in data['xml']
            following keys are needed:
                - from
                - to
                - uid: can be set to '' if uid attribute is not wanted
                - message
                - type
                - subject
                - extra
        @return (dict) message data
        """
        data['xml'] = message_elt = domish.Element((None, 'message'))
        message_elt["to"] = data["to"].full()
        message_elt["from"] = data['from'].full()
        message_elt["type"] = data["type"]
        if data['uid']: # key must be present but can be set to ''
                        # by a plugin to avoid id on purpose
            message_elt['id'] = data['uid']
        for lang, subject in data["subject"].iteritems():
            subject_elt = message_elt.addElement("subject", content=subject)
            if lang:
                subject_elt[(C.NS_XML, 'lang')] = lang
        for lang, message in data["message"].iteritems():
            body_elt = message_elt.addElement("body", content=message)
            if lang:
                body_elt[(C.NS_XML, 'lang')] = lang
        try:
            thread = data['extra']['thread']
        except KeyError:
            if 'thread_parent' in data['extra']:
                raise exceptions.InternalError(u"thread_parent found while there is not associated thread")
        else:
            thread_elt = message_elt.addElement("thread", content=thread)
            try:
                thread_elt["parent"] = data["extra"]["thread_parent"]
            except KeyError:
                pass
        return data

    def _messageSend(self, to_jid_s, message, subject=None, mess_type='auto', extra=None, profile_key=C.PROF_KEY_NONE):
        client = self.getClient(profile_key)
        to_jid = jid.JID(to_jid_s)
        #XXX: we need to use the dictionary comprehension because D-Bus return its own types, and pickle can't manage them. TODO: Need to find a better way
        return self.messageSend(client, to_jid, message, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()})

    def messageSend(self, client, to_jid, message, subject=None, mess_type='auto', extra=None, uid=None, no_trigger=False):
        """Send a message to an entity

        @param to_jid(jid.JID): destinee of the message
        @param message(dict): message body, key is the language (use '' when unknown)
        @param subject(dict): message subject, key is the language (use '' when unknown)
        @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or:
            - auto: for automatic type detection
            - info: for information ("info_type" can be specified in extra)
        @param extra(dict, None): extra data. Key can be:
            - info_type: information type, can be
                TODO
        @param uid(unicode, None): unique id:
            should be unique at least in this XMPP session
            if None, an uuid will be generated
        @param no_trigger (bool): if True, messageSend trigger will no be used
            useful when a message need to be sent without any modification
        """
        profile = client.profile
        if subject is None:
            subject = {}
        if extra is None:
            extra = {}
        data = {  # dict is similar to the one used in client.onMessage
            "from": client.jid,
            "to": to_jid,
            "uid": uid or unicode(uuid.uuid4()),
            "message": message,
            "subject": subject,
            "type": mess_type,
            "extra": extra,
            "timestamp": time.time(),
        }
        pre_xml_treatments = defer.Deferred() # XXX: plugin can add their pre XML treatments to this deferred
        post_xml_treatments = defer.Deferred() # XXX: plugin can add their post XML treatments to this deferred

        if data["type"] == "auto":
            # we try to guess the type
            if data["subject"]:
                data["type"] = 'normal'
            elif not data["to"].resource:  # if to JID has a resource, the type is not 'groupchat'
                # we may have a groupchat message, we check if the we know this jid
                try:
                    entity_type = self.memory.getEntityData(data["to"], ['type'], profile)["type"]
                    #FIXME: should entity_type manage resources ?
                except (exceptions.UnknownEntityError, KeyError):
                    entity_type = "contact"

                if entity_type == "chatroom":
                    data["type"] = 'groupchat'
                else:
                    data["type"] = 'chat'
            else:
                data["type"] == 'chat'
            data["type"] == "chat" if data["subject"] else "normal"

        # FIXME: send_only is used by libervia's OTR plugin to avoid
        #        the triggers from frontend, and no_trigger do the same
        #        thing internally, this could be unified
        send_only = data['extra'].get('send_only', None)

        if not no_trigger and not send_only:
            if not self.trigger.point("messageSend", client, data, pre_xml_treatments, post_xml_treatments):
                return defer.succeed(None)

        log.debug(_(u"Sending message (type {type}, to {to})").format(type=data["type"], to=to_jid.full()))

        pre_xml_treatments.addCallback(lambda dummy: self.generateMessageXML(data))
        pre_xml_treatments.chainDeferred(post_xml_treatments)
        post_xml_treatments.addCallback(self.messageSendToStream, client)
        if send_only:
            log.debug(_("Triggers, storage and echo have been inhibited by the 'send_only' parameter"))
        else:
            post_xml_treatments.addCallback(self.messageAddToHistory, client)
            post_xml_treatments.addCallback(self.messageSendToBridge, client)
            post_xml_treatments.addErrback(self._cancelErrorTrap)
        pre_xml_treatments.callback(data)
        return pre_xml_treatments

    def _cancelErrorTrap(self, failure):
        """A message sending can be cancelled by a plugin treatment"""
        failure.trap(exceptions.CancelError)

    def messageSendToStream(self, data, client):
        """Actualy send the message to the server

        @param data: message data dictionnary
        @param client: profile's client
        """
        client.xmlstream.send(data['xml'])
        return data

    def messageAddToHistory(self, data, client):
        """Store message into database (for local history)

        @param data: message data dictionnary
        @param client: profile's client
        """
        if data["type"] != C.MESS_TYPE_GROUPCHAT:
            # we don't add groupchat message to history, as we get them back
            # and they will be added then
            if data['message'] or data['subject']: # we need a message to store
                self.memory.addToHistory(client, data)
            else:
               log.warning(u"No message found") # empty body should be managed by plugins before this point
        return data

    def messageSendToBridge(self, data, client):
        """Send message to bridge, so frontends can display it

        @param data: message data dictionnary
        @param client: profile's client
        """
        if data["type"] != C.MESS_TYPE_GROUPCHAT:
            # we don't send groupchat message to bridge, as we get them back
            # and they will be added the
            if data['message'] or data['subject']: # we need a message to send something
                # We send back the message, so all frontends are aware of it
                self.bridge.messageNew(data['uid'], data['timestamp'], data['from'].full(), data['to'].full(), data['message'], data['subject'], data['type'], data['extra'], profile=client.profile)
            else:
               log.warning(_("No message found"))
        return data

    def _setPresence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
        return self.setPresence(jid.JID(to) if to else None, show, statuses, profile_key)

    def setPresence(self, to_jid=None, show="", statuses=None, profile_key=C.PROF_KEY_NONE):
        """Send our presence information"""
        if statuses is None:
            statuses = {}
        profile = self.memory.getProfileName(profile_key)
        assert profile
        priority = int(self.memory.getParamA("Priority", "Connection", profile_key=profile))
        self.profiles[profile].presence.available(to_jid, show, statuses, priority)
        #XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not broadcasted to generating resource)
        if '' in statuses:
            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop('')
        self.bridge.presenceUpdate(self.profiles[profile].jid.full(), show,
                                   int(priority), statuses, profile)

    def subscription(self, subs_type, raw_jid, profile_key):
        """Called to manage subscription
        @param subs_type: subsciption type (cf RFC 3921)
        @param raw_jid: unicode entity's jid
        @param profile_key: profile"""
        profile = self.memory.getProfileName(profile_key)
        assert profile
        to_jid = jid.JID(raw_jid)
        log.debug(_(u'subsciption request [%(subs_type)s] for %(jid)s') % {'subs_type': subs_type, 'jid': to_jid.full()})
        if subs_type == "subscribe":
            self.profiles[profile].presence.subscribe(to_jid)
        elif subs_type == "subscribed":
            self.profiles[profile].presence.subscribed(to_jid)
        elif subs_type == "unsubscribe":
            self.profiles[profile].presence.unsubscribe(to_jid)
        elif subs_type == "unsubscribed":
            self.profiles[profile].presence.unsubscribed(to_jid)

    def _addContact(self, to_jid_s, profile_key):
        return self.addContact(jid.JID(to_jid_s), profile_key)

    def addContact(self, to_jid, profile_key):
        """Add a contact in roster list"""
        profile = self.memory.getProfileName(profile_key)
        assert profile
        # presence is sufficient, as a roster push will be sent according to RFC 6121 §3.1.2
        self.profiles[profile].presence.subscribe(to_jid)

    def _updateContact(self, to_jid_s, name, groups, profile_key):
        return self.updateContact(jid.JID(to_jid_s), name, groups, profile_key)

    def updateContact(self, to_jid, name, groups, profile_key):
        """update a contact in roster list"""
        profile = self.memory.getProfileName(profile_key)
        assert profile
        groups = set(groups)
        roster_item = RosterItem(to_jid)
        roster_item.name = name or None
        roster_item.groups = set(groups)
        return self.profiles[profile].roster.setItem(roster_item)

    def _delContact(self, to_jid_s, profile_key):
        return self.delContact(jid.JID(to_jid_s), profile_key)

    def delContact(self, to_jid, profile_key):
        """Remove contact from roster list"""
        profile = self.memory.getProfileName(profile_key)
        assert profile
        self.profiles[profile].presence.unsubscribe(to_jid)  # is not asynchronous
        return self.profiles[profile].roster.removeItem(to_jid)

    ## Discovery ##
    # discovery methods are shortcuts to self.memory.disco
    # the main difference with client.disco is that self.memory.disco manage cache

    def hasFeature(self, *args, **kwargs):
        return self.memory.disco.hasFeature(*args, **kwargs)

    def checkFeature(self, *args, **kwargs):
        return self.memory.disco.checkFeature(*args, **kwargs)

    def checkFeatures(self, *args, **kwargs):
        return self.memory.disco.checkFeatures(*args, **kwargs)

    def getDiscoInfos(self, *args, **kwargs):
        return self.memory.disco.getInfos(*args, **kwargs)

    def getDiscoItems(self, *args, **kwargs):
        return self.memory.disco.getItems(*args, **kwargs)

    def findServiceEntities(self, *args, **kwargs):
        return self.memory.disco.findServiceEntities(*args, **kwargs)

    def findFeaturesSet(self, *args, **kwargs):
        return self.memory.disco.findFeaturesSet(*args, **kwargs)


    ## Generic HMI ##

    def _killAction(self, keep_id, client):
        log.debug(u"Killing action {} for timeout".format(keep_id))
        client.actions[keep_id]

    def actionNew(self, action_data, security_limit=C.NO_SECURITY_LIMIT, keep_id=None, profile=C.PROF_KEY_NONE):
        """Shortcut to bridge.actionNew which generate and id and keep for retrieval

        @param action_data(dict): action data (see bridge documentation)
        @param security_limit: %(doc_security_limit)s
        @param keep_id(None, unicode): if not None, used to keep action for differed retrieval
            must be set to the callback_id
            action will be deleted after 30 min.
        @param profile: %(doc_profile)s
        """
        id_ = unicode(uuid.uuid4())
        if keep_id is not None:
            client = self.getClient(profile)
            action_timer = reactor.callLater(60*30, self._killAction, keep_id, client)
            client.actions[keep_id] = (action_data, id_, security_limit, action_timer)

        self.bridge.actionNew(action_data, id_, security_limit, profile)

    def actionsGet(self, profile):
        """Return current non answered actions

        @param profile: %(doc_profile)s
        """
        client = self.getClient(profile)
        return [action_tuple[:-1] for action_tuple in client.actions.itervalues()]

    def registerProgressCb(self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE):
        """Register a callback called when progress is requested for id"""
        if metadata is None:
            metadata = {}
        client = self.getClient(profile)
        if progress_id in client._progress_cb:
            raise exceptions.ConflictError(u"Progress ID is not unique !")
        client._progress_cb[progress_id] = (callback, metadata)

    def removeProgressCb(self, progress_id, profile):
        """Remove a progress callback"""
        client = self.getClient(profile)
        try:
            del client._progress_cb[progress_id]
        except KeyError:
            log.error(_(u"Trying to remove an unknow progress callback"))

    def _progressGet(self, progress_id, profile):
        data = self.progressGet(progress_id, profile)
        return {k: unicode(v) for k,v in data.iteritems()}

    def progressGet(self, progress_id, profile):
        """Return a dict with progress information

        @param progress_id(unicode): unique id of the progressing element
        @param profile: %(doc_profile)s
        @return (dict): data with the following keys:
            'position' (int): current possition
            'size' (int): end_position
            if id doesn't exists (may be a finished progression), and empty dict is returned
        """
        client = self.getClient(profile)
        try:
            data = client._progress_cb[progress_id][0](progress_id, profile)
        except KeyError:
            data = {}
        return data

    def _progressGetAll(self, profile_key):
        progress_all = self.progressGetAll(profile_key)
        for profile, progress_dict in progress_all.iteritems():
            for progress_id, data in progress_dict.iteritems():
                for key, value in data.iteritems():
                    data[key] = unicode(value)
        return progress_all

    def progressGetAllMetadata(self, profile_key):
        """Return all progress metadata at once

        @param profile_key: %(doc_profile)s
            if C.PROF_KEY_ALL is used, all progress metadata from all profiles are returned
        @return (dict[dict[dict]]): a dict which map profile to progress_dict
            progress_dict map progress_id to progress_data
            progress_metadata is the same dict as sent by [progressStarted]
        """
        clients = self.getClients(profile_key)
        progress_all = {}
        for client in clients:
            profile = client.profile
            progress_dict = {}
            progress_all[profile] = progress_dict
            for progress_id, (dummy, progress_metadata) in client._progress_cb.iteritems():
                progress_dict[progress_id] = progress_metadata
        return progress_all

    def progressGetAll(self, profile_key):
        """Return all progress status at once

        @param profile_key: %(doc_profile)s
            if C.PROF_KEY_ALL is used, all progress status from all profiles are returned
        @return (dict[dict[dict]]): a dict which map profile to progress_dict
            progress_dict map progress_id to progress_data
            progress_data is the same dict as returned by [progressGet]
        """
        clients = self.getClients(profile_key)
        progress_all = {}
        for client in clients:
            profile = client.profile
            progress_dict = {}
            progress_all[profile] = progress_dict
            for progress_id, (progress_cb, dummy) in client._progress_cb.iteritems():
                progress_dict[progress_id] = progress_cb(progress_id, profile)
        return progress_all

    def registerCallback(self, callback, *args, **kwargs):
        """Register a callback.

        Use with_data=True in kwargs if the callback use the optional data dict
        use force_id=id to avoid generated id. Can lead to name conflict, avoid if possible
        use one_shot=True to delete callback once it have been called
        @param callback: any callable
        @return: id of the registered callback
        """
        callback_id = kwargs.pop('force_id', None)
        if callback_id is None:
            callback_id = str(uuid4())
        else:
            if callback_id in self._cb_map:
                raise exceptions.ConflictError(_(u"id already registered"))
        self._cb_map[callback_id] = (callback, args, kwargs)

        if "one_shot" in kwargs: # One Shot callback are removed after 30 min
            def purgeCallback():
                try:
                    self.removeCallback(callback_id)
                except KeyError:
                    pass
            reactor.callLater(1800, purgeCallback)

        return callback_id

    def removeCallback(self, callback_id):
        """ Remove a previously registered callback
        @param callback_id: id returned by [registerCallback] """
        log.debug("Removing callback [%s]" % callback_id)
        del self._cb_map[callback_id]

    def launchCallback(self, callback_id, data=None, profile_key=C.PROF_KEY_NONE):
        """Launch a specific callback
        @param callback_id: id of the action (callback) to launch
        @param data: optional data
        @profile_key: %(doc_profile_key)s
        @return: a deferred which fire a dict where key can be:
            - xmlui: a XMLUI need to be displayed
            - validated: if present, can be used to launch a callback, it can have the values
                - C.BOOL_TRUE
                - C.BOOL_FALSE
        """
        try:
            client = self.getClient(profile_key)
        except exceptions.NotFound:
            # client is not available yet
            profile = self.memory.getProfileName(profile_key)
            if not profile:
                raise exceptions.ProfileUnknownError(_('trying to launch action with a non-existant profile'))
        else:
            profile = client.profile
            # we check if the action is kept, and remove it
            try:
                action_tuple = client.actions[callback_id]
            except KeyError:
                pass
            else:
                action_tuple[-1].cancel() # the last item is the action timer
                del client.actions[callback_id]

        try:
            callback, args, kwargs = self._cb_map[callback_id]
        except KeyError:
            raise exceptions.DataError(u"Unknown callback id {}".format(callback_id))

        if kwargs.get("with_data", False):
            if data is None:
                raise exceptions.DataError("Required data for this callback is missing")
            args,kwargs=list(args)[:],kwargs.copy() # we don't want to modify the original (kw)args
            args.insert(0, data)
            kwargs["profile"] = profile
            del kwargs["with_data"]

        if kwargs.pop('one_shot', False):
            self.removeCallback(callback_id)

        return defer.maybeDeferred(callback, *args, **kwargs)

    #Menus management

    def importMenu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT, help_string="", type_=C.MENU_GLOBAL):
        """register a new menu for frontends

        @param path: path to go to the menu (category/subcategory/.../item), must be an iterable (e.g.: ("File", "Open"))
            /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
        @param callback: method to be called when menuitem is selected, callable or a callback id (string) as returned by [registerCallback]
        @param security_limit: %(doc_security_limit)s
            /!\ security_limit MUST be added to data in launchCallback if used #TODO
        @param help_string: string used to indicate what the menu do (can be show as a tooltip).
            /!\ use D_() instead of _() for translations
        @param type: one of:
            - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g. something like File/Open)
            - C.MENU_ROOM: like a global menu, but only shown in multi-user chat
                menu_data must contain a "room_jid" data
            - C.MENU_SINGLE: like a global menu, but only shown in one2one chat
                menu_data must contain a "jid" data
            - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc commands, jid is already filled)
                menu_data must contain a "jid" data
            - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in roster.
                menu_data must contain a "room_jid" data
            - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish microblog, group is already filled)
                menu_data must contain a "group" data
        @return: menu_id (same as callback_id)
        """

        if callable(callback):
            callback_id = self.registerCallback(callback, with_data=True)
        elif isinstance(callback, basestring):
            # The callback is already registered
            callback_id = callback
            try:
                callback, args, kwargs = self._cb_map[callback_id]
            except KeyError:
                raise exceptions.DataError("Unknown callback id")
            kwargs["with_data"] = True # we have to be sure that we use extra data
        else:
            raise exceptions.DataError("Unknown callback type")

        for menu_data in self._menus.itervalues():
            if menu_data['path'] == path and menu_data['type'] == type_:
                raise exceptions.ConflictError(_("A menu with the same path and type already exists"))

        menu_data = {'path': path,
                     'security_limit': security_limit,
                     'help_string': help_string,
                     'type': type_
                    }

        self._menus[callback_id] = menu_data

        return callback_id

    def getMenus(self, language='', security_limit=C.NO_SECURITY_LIMIT):
        """Return all menus registered

        @param language: language used for translation, or empty string for default
        @param security_limit: %(doc_security_limit)s
        @return: array of tuple with:
            - menu id (same as callback_id)
            - menu type
            - raw menu path (array of strings)
            - translated menu path
            - extra (dict(unicode, unicode)): extra data where key can be:
                - icon: name of the icon to use (TODO)
                - help_url: link to a page with more complete documentation (TODO)
        """
        ret = []
        for menu_id, menu_data in self._menus.iteritems():
            type_ = menu_data['type']
            path = menu_data['path']
            menu_security_limit = menu_data['security_limit']
            if security_limit!=C.NO_SECURITY_LIMIT and (menu_security_limit==C.NO_SECURITY_LIMIT or menu_security_limit>security_limit):
                continue
            languageSwitch(language)
            path_i18n = [_(elt) for elt in path]
            languageSwitch()
            extra = {} # TODO: manage extra data like icon
            ret.append((menu_id, type_, path, path_i18n, extra))

        return ret

    def getMenuHelp(self, menu_id, language=''):
        """
        return the help string of the menu
        @param menu_id: id of the menu (same as callback_id)
        @param language: language used for translation, or empty string for default
        @param return: translated help

        """
        try:
            menu_data = self._menus[menu_id]
        except KeyError:
            raise exceptions.DataError("Trying to access an unknown menu")
        languageSwitch(language)
        help_string = _(menu_data['help_string'])
        languageSwitch()
        return help_string