view src/core/sat_main.py @ 755:e3ad48a2aab2

core, frontends: callMenu is now async and don't use callback_id anymore
author Goffi <goffi@goffi.org>
date Tue, 24 Dec 2013 15:18:31 +0100
parents f49945d728de
children 93bd868b8fb6
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# SAT: a jabber client
# Copyright (C) 2009, 2010, 2011, 2012, 2013  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 twisted.application import service
from twisted.internet import defer

from twisted.words.protocols.jabber import jid, xmlstream
from twisted.words.xish import domish

from twisted.internet import reactor

from wokkel import compat
from wokkel.xmppim import RosterItem

from sat.bridge.DBus import DBusBridge
import logging
from logging import debug, info, warning, error

import sys
import os.path

from sat.core.default_config import CONST
from sat.core import xmpp
from sat.core import exceptions
from sat.memory.memory import Memory
from sat.tools.xml_tools import tupleList2dataForm
from sat.tools.misc import TriggerManager
from glob import glob

try:
    from twisted.words.protocols.xmlstream import XMPPHandler
except ImportError:
    from wokkel.subprotocols import XMPPHandler

### logging configuration FIXME: put this elsewhere ###
logging.basicConfig(level=logging.DEBUG,
                    format='%(message)s')
###

sat_id = 0


def sat_next_id():
    global sat_id
    sat_id += 1
    return "sat_id_" + str(sat_id)


class MessageSentAndStored(Exception):
    """ Exception to raise if the message has been already sent and stored in the
    history by the trigger, so the rest of the process should be stopped. This
    should normally be raised by the trigger with the minimal priority """
    pass


class AbortSendMessage(Exception):
    """ Exception to raise if sending the message should be aborted. This can be
    raised by any trigger but a side action should be planned by the trigger
    to inform the user about what happened """
    pass


class SAT(service.Service):

    def get_next_id(self):
        return sat_next_id()

    def get_const(self, name):
        """Return a constant"""
        try:
            _const = os.environ['SAT_CONST_%s' % name]
            if _const:
                debug(_("Constant %(name)s overrided with [%(value)s]") % {'name': name, 'value': _const})
                return _const
        except KeyError:
            pass
        if name not in CONST:
            error(_('Trying to access an undefined constant'))
            raise Exception
        return CONST[name]

    def set_const(self, name, value):
        """Save a constant"""
        if name in CONST:
            error(_('Trying to redefine a constant'))
            raise Exception
        CONST[name] = value

    def __init__(self):
        #TODO: standardize callback system

        self.__general_cb_map = {}  # callback called for general reasons (key = name)
        self.__private_data = {}  # used for internal callbacks (key = id)
        self.profiles = {}
        self.plugins = {}
        self.menus = {}  # dynamic menus. key: (type, category, name), value: menu data (dictionnary)

        self.memory = Memory(self)

        local_dir = self.memory.getConfig('', 'local_dir')
        if not os.path.exists(local_dir):
            os.makedirs(local_dir)

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

        try:
            self.bridge = DBusBridge()
        except exceptions.BridgeInitError:
            print (u"Bridge can't be initialised, can't start SàT core") # reactor is not launched yet, so we can't use error log
            sys.exit(1)
        self.bridge.register("getVersion", lambda: self.get_const('client_version'))
        self.bridge.register("getProfileName", self.memory.getProfileName)
        self.bridge.register("getProfilesList", self.memory.getProfilesList)
        self.bridge.register("getEntityData", lambda _jid, keys, profile: self.memory.getEntityData(jid.JID(_jid), keys, profile))
        self.bridge.register("createProfile", self.memory.createProfile)
        self.bridge.register("asyncCreateProfile", self.memory.asyncCreateProfile)
        self.bridge.register("deleteProfile", self.memory.deleteProfile)
        self.bridge.register("registerNewAccount", self.registerNewAccount)
        self.bridge.register("connect", self.connect)
        self.bridge.register("asyncConnect", self.asyncConnect)
        self.bridge.register("disconnect", self.disconnect)
        self.bridge.register("getContacts", self.getContacts)
        self.bridge.register("getContactsFromGroup", self.getContactsFromGroup)
        self.bridge.register("getLastResource", self.memory.getLastResource)
        self.bridge.register("getPresenceStatus", self.memory.getPresenceStatus)
        self.bridge.register("getWaitingSub", self.memory.getWaitingSub)
        self.bridge.register("getWaitingConf", self.getWaitingConf)
        self.bridge.register("sendMessage", self._sendMessage)
        self.bridge.register("getConfig", self.memory.getConfig)
        self.bridge.register("setParam", self.setParam)
        self.bridge.register("getParamA", self.memory.getStringParamA)
        self.bridge.register("asyncGetParamA", self.memory.asyncGetStringParamA)
        self.bridge.register("getParamsUI", self.memory.getParamsUI)
        self.bridge.register("getParams", self.memory.getParams)
        self.bridge.register("getParamsForCategory", self.memory.getParamsForCategory)
        self.bridge.register("getParamsCategories", self.memory.getParamsCategories)
        self.bridge.register("getHistory", self.memory.getHistory)
        self.bridge.register("setPresence", self.setPresence)
        self.bridge.register("subscription", self.subscription)
        self.bridge.register("addContact", self.addContact)
        self.bridge.register("updateContact", self.updateContact)
        self.bridge.register("delContact", self.delContact)
        self.bridge.register("isConnected", self.isConnected)
        self.bridge.register("launchAction", self.launchAction)
        self.bridge.register("confirmationAnswer", self.confirmationAnswer)
        self.bridge.register("getProgress", self.getProgress)
        self.bridge.register("getMenus", self.getMenus)
        self.bridge.register("getMenuHelp", self.getMenuHelp)
        self.bridge.register("asyncCallMenu", self.callMenu)

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

    def _postMemoryInit(self, ignore):
        """Method called after memory initialization is done"""
        info(_("Memory initialised"))
        self._import_plugins()

    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
            __import__(plugin_path)
            mod = sys.modules[plugin_path]
            plugin_info = mod.PLUGIN_INFO
            __plugins_to_import[plugin_info['import_name']] = (plugin_path, mod, plugin_info)
        while True:
            self._import_plugins_from_dict(__plugins_to_import)
            if not __plugins_to_import:
                break

    def _import_plugins_from_dict(self, plugins_to_import, import_name=None):
        """Recursively import and their dependencies in the right order
        @param plugins_to_import: dict where key=import_name and values= (plugin_path, module, plugin_info)"""
        if import_name in self.plugins:
            debug('Plugin [%s] already imported, passing' % 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:
                raise ImportError(_('Dependency plugin not found: [%s]') % import_name)
            plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
        dependencies = plugin_info.setdefault("dependencies", [])
        for dependency in dependencies:
            if dependency not in self.plugins:
                debug('Recursively import dependency of [%s]: [%s]' % (import_name, dependency))
                self._import_plugins_from_dict(plugins_to_import, dependency)
        info(_("importing plugin: %s"), plugin_info['name'])
        self.plugins[import_name] = getattr(mod, plugin_info['main'])(self)
        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 connect(self, profile_key='@NONE@'):
        """Connect to jabber server"""
        self.asyncConnect(profile_key)

    def asyncConnect(self, profile_key='@NONE@'):
        """Connect to jabber server with asynchronous reply
        @param profile_key: %(doc_profile)s
        """

        profile = self.memory.getProfileName(profile_key)
        if not profile:
            error(_('Trying to connect a non-exsitant profile'))
            raise exceptions.ProfileUnknownError(profile_key)

        if self.isConnected(profile):
            info(_("already connected !"))
            return defer.succeed("None")

        def afterMemoryInit(ignore):
            """This part must be called when we have loaded individual parameters from memory"""
            try:
                port = int(self.memory.getParamA("Port", "Connection", profile_key=profile))
            except ValueError:
                error(_("Can't parse port value, using default value"))
                port = 5222
            current = self.profiles[profile] = xmpp.SatXMPPClient(
                self, profile,
                jid.JID(self.memory.getParamA("JabberID", "Connection", profile_key=profile), profile),
                self.memory.getParamA("Password", "Connection", profile_key=profile),
                self.memory.getParamA("Server", "Connection", profile_key=profile),
                port)

            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(self.get_const('client_name'),
                                                            self.get_const('client_version'))
            current.versionHandler.setHandlerParent(current)

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

            debug(_("setting plugins parents"))

            for plugin in self.plugins.iteritems():
                if plugin[1].is_handler:
                    plugin[1].getHandler(profile).setHandlerParent(current)
                connected_cb = getattr(plugin[1], "profileConnected", None)
                if connected_cb:
                    connected_cb(profile)

            current.startService()

            d = current.getConnectionDeferred()
            d.addCallback(lambda x: current.roster.got_roster)  # we want to be sure that we got the roster
            return d

        self.memory.startProfileSession(profile)
        return self.memory.loadIndividualParams(profile).addCallback(afterMemoryInit)

    def disconnect(self, profile_key):
        """disconnect from jabber server"""
        if not self.isConnected(profile_key):
            info(_("not connected !"))
            return
        profile = self.memory.getProfileName(profile_key)
        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 getContacts(self, profile_key):
        client = self.getClient(profile_key)
        if not client:
            raise exceptions.ProfileUnknownError(_('Asking contacts for a non-existant profile'))
        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

    def getContactsFromGroup(self, group, profile_key):
        client = self.getClient(profile_key)
        if not client:
            raise exceptions.ProfileUnknownError(_("Asking group's contacts for a non-existant profile"))
        return 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:
            error(_("Trying to remove reference to a client not referenced"))
        self.memory.purgeProfileSession(profile)

    def startService(self):
        info("Salut à toi ô mon frère !")
        #TODO: manage autoconnect
        #self.connect()

    def stopService(self):
        info("Salut aussi à Rantanplan")

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

    def stop(self):
        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"""
        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"""
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            return None
        return self.profiles[profile]

    def getClients(self, profile_key):
        """Convenient method to get list of clients from profile key (manage list through profile_key like @ALL@)
        @param profile_key: %(doc_profile_key)s
        @return: list of clients"""
        profile = self.memory.getProfileName(profile_key, True)
        if not profile:
            return []
        if profile == "@ALL@":
            return self.profiles.values()
        if profile.count('@') > 1:
            raise exceptions.ProfileKeyUnknownError
        return [self.profiles[profile]]

    def getClientHostJid(self, profile_key):
        """Convenient method to get the client host from profile key
        @return: host jid or None if it doesn't exist"""
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            return None
        return self.profiles[profile].getHostJid()

    def registerNewAccount(self, login, password, email, server, port=5222, id=None, profile_key='@NONE@'):
        """Connect to a server and create a new account using in-band registration"""
        profile = self.memory.getProfileName(profile_key)
        assert(profile)

        next_id = id or sat_next_id()  # the id is used to send server's answer
        serverRegistrer = xmlstream.XmlStreamFactory(xmpp.RegisteringAuthenticator(self, server, login, password, email, next_id, profile))
        connector = reactor.connectTCP(server, port, serverRegistrer)
        serverRegistrer.clientConnectionLost = lambda conn, reason: connector.disconnect()

        return next_id

    def registerNewAccountCB(self, id, data, profile):
        user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0]
        password = self.memory.getParamA("Password", "Connection", profile_key=profile)
        server = self.memory.getParamA("Server", "Connection", profile_key=profile)

        if not user or not password or not server:
            info(_('No user or server given'))
            #TODO: a proper error message must be sent to frontend
            self.actionResult(id, "ERROR", {'message': _("No user, password or server given, can't register new account.")}, profile)
            return

        confirm_id = sat_next_id()
        self.__private_data[confirm_id] = (id, profile)

        self.askConfirmation(
            confirm_id, "YES/NO",
            {"message": _("Are you sure to register new account [%(user)s] to server %(server)s ?") % {'user': user, 'server': server, 'profile': profile}},
            self.regisConfirmCB, profile)
        print "===============+++++++++++ REGISTER NEW ACCOUNT++++++++++++++============"
        print "id=", id
        print "data=", data

    def regisConfirmCB(self, id, accepted, data, profile):
        print _("register Confirmation CB ! (%s)") % str(accepted)
        action_id, profile = self.__private_data[id]
        del self.__private_data[id]
        if accepted:
            user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0]
            password = self.memory.getParamA("Password", "Connection", profile_key=profile)
            server = self.memory.getParamA("Server", "Connection", profile_key=profile)
            self.registerNewAccount(user, password, None, server, id=action_id)
        else:
            self.actionResult(action_id, "SUPPRESS", {}, profile)

    def submitForm(self, action, target, fields, profile_key):
        """submit a form
        @param target: target jid where we are submitting
        @param fields: list of tuples (name, value)
        @return: tuple: (id, deferred)
        """

        profile = self.memory.getProfileName(profile_key)
        assert(profile)
        to_jid = jid.JID(target)

        iq = compat.IQ(self.profiles[profile].xmlstream, 'set')
        iq["to"] = target
        iq["from"] = self.profiles[profile].jid.full()
        query = iq.addElement(('jabber:iq:register', 'query'))
        if action == 'SUBMIT':
            form = tupleList2dataForm(fields)
            query.addChild(form.toElement())
        elif action == 'CANCEL':
            query.addElement('remove')
        else:
            error(_("FIXME FIXME FIXME: Unmanaged action (%s) in submitForm") % action)
            raise NotImplementedError

        deferred = iq.send(target)
        return (iq['id'], deferred)

    ## Client management ##

    def setParam(self, name, value, category, security_limit, profile_key):
        """set wanted paramater and notice observers"""
        info(_("setting param: %(name)s=%(value)s in category %(category)s") % {'name': name, 'value': value, 'category': category})
        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:
            error(_('asking connection status for a non-existant profile'))
            return
        if profile not in self.profiles:
            return False
        return self.profiles[profile].isConnected()

    def launchAction(self, type, data, profile_key):
        """Launch a specific action asked by client
        @param type: action type (button)
        @param data: needed data to launch the action

        @return: action id for result, or empty string in case or error
        """
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            error(_('trying to launch action with a non-existant profile'))
            raise Exception  # TODO: raise a proper exception
        if type == "button":
            try:
                cb_name = data['callback_id']
            except KeyError:
                error(_("Incomplete data"))
                return ""
            id = sat_next_id()
            self.callGeneralCB(cb_name, id, data, profile=profile)
            return id
        else:
            error(_("Unknown action type"))
            return ""

    ## jabber methods ##

    def getWaitingConf(self, profile_key=None):
        assert(profile_key)
        client = self.getClient(profile_key)
        if not client:
            raise exceptions.ProfileNotInCacheError
        ret = []
        for conf_id in client._waiting_conf:
            conf_type, data = client._waiting_conf[conf_id][:2]
            ret.append((conf_id, conf_type, data))
        return ret

    def _sendMessage(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'):
        to_jid = jid.JID(to_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.sendMessage(to_jid, msg, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()}, profile_key=profile_key)

    def sendMessage(self, to_jid, msg, subject=None, mess_type='auto', extra={}, no_trigger=False, profile_key='@NONE@'):
        #FIXME: check validity of recipient
        profile = self.memory.getProfileName(profile_key)
        assert(profile)
        client = self.profiles[profile]
        current_jid = client.jid
        if extra is None:
            extra = {}
        mess_data = {  # we put data in a dict, so trigger methods can change them
            "to": to_jid,
            "message": msg,
            "subject": subject,
            "type": mess_type,
            "extra": extra,
        }
        treatments = defer.Deferred() # XXX: plugin can add their treatments to this deferred

        if mess_data["type"] == "auto":
            # we try to guess the type
            if mess_data["subject"]:
                mess_data["type"] = 'normal'
            elif not mess_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(mess_data["to"], ['type'], profile)["type"]
                    #FIXME: should entity_type manage ressources ?
                except (exceptions.UnknownEntityError, KeyError):
                    entity_type = "contact"

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

        if not no_trigger:
            if not self.trigger.point("sendMessage", mess_data, treatments, profile):
                return defer.succeed(None)

        debug(_("Sending jabber message of type [%(type)s] to %(to)s...") % {"type": mess_data["type"], "to": to_jid.full()})
        mess_data['xml'] = domish.Element((None, 'message'))
        mess_data['xml']["to"] = mess_data["to"].full()
        mess_data['xml']["from"] = current_jid.full()
        mess_data['xml']["type"] = mess_data["type"]
        if mess_data["subject"]:
            mess_data['xml'].addElement("subject", None, subject)
        # message without body are used to send chat states
        if mess_data["message"]:
            mess_data['xml'].addElement("body", None, mess_data["message"])

        def sendErrback(e):
            text = '%s: %s' % (e.value.__class__.__name__, e.getErrorMessage())
            if e.check(MessageSentAndStored):
                debug(text)
            elif e.check(AbortSendMessage):
                warning(text)
                return e
            else:
                error("Unmanaged exception: %s" % text)
                return e

        treatments.addCallbacks(self.sendAndStoreMessage, sendErrback, [False, profile])
        treatments.callback(mess_data)
        return treatments

    def sendAndStoreMessage(self, mess_data, skip_send=False, profile=None):
        """Actually send and store the message to history, after all the treatments
        have been done. This has been moved outside the main sendMessage method
        because it is used by XEP-0033 to complete a server-side feature not yet
        implemented by the prosody plugin.
        @param mess_data: message data dictionary
        @param skip_send: set to True to skip sending the message to only store it
        @param profile: profile
        """
        try:
            client = self.profiles[profile]
        except KeyError:
            error(_("Trying to send a message with no profile"))
            return
        current_jid = client.jid
        if not skip_send:
            client.xmlstream.send(mess_data['xml'])
        if mess_data["type"] != "groupchat":
            # we don't add groupchat message to history, as we get them back
            # and they will be added then
            if mess_data['message']: # we need a message to save something
                self.memory.addToHistory(current_jid, mess_data['to'],
                                     unicode(mess_data["message"]),
                                     unicode(mess_data["type"]),
                                     mess_data['extra'],
                                     profile=profile)
                # We send back the message, so all clients are aware of it
                self.bridge.newMessage(mess_data['xml']['from'],
                                       unicode(mess_data["message"]),
                                       mess_type=mess_data["type"],
                                       to_jid=mess_data['xml']['to'],
                                       extra=mess_data['extra'],
                                       profile=profile)

    def setPresence(self, to="", show="", priority=0, statuses=None, profile_key='@NONE@'):
        """Send our presence information"""
        if statuses is None:
            statuses = {}
        profile = self.memory.getProfileName(profile_key)
        assert(profile)
        to_jid = jid.JID(to) if to else None
        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['default'] = statuses['']
            del statuses['']
        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)
        debug(_('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, profile_key):
        """Add a contact in roster list"""
        profile = self.memory.getProfileName(profile_key)
        assert(profile)
        to_jid = jid.JID(to)
        #self.profiles[profile].roster.addItem(to_jid)  #XXX: disabled (cf http://wokkel.ik.nu/ticket/56))
        self.profiles[profile].presence.subscribe(to_jid)

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

    def delContact(self, to, profile_key):
        """Remove contact from roster list"""
        profile = self.memory.getProfileName(profile_key)
        assert(profile)
        to_jid = jid.JID(to)
        self.profiles[profile].roster.removeItem(to_jid)
        self.profiles[profile].presence.unsubscribe(to_jid)

    def requestServerDisco(self, feature, jid_=None, cache_only=False, profile_key="@NONE"):
        """Discover if a server or its items offer a given feature
        @param feature: the feature to check
        @param jid_: the jid of the server
        @param cache_only: expect the result to be in cache and don't actually
        make any request to avoid returning a Deferred. This can be used anytime
        for requesting the local server because the data are cached for sure.
        @result: the Deferred entity jid offering the feature, or None
        """
        profile = self.memory.getProfileName(profile_key)

        if not profile:
            return defer.succeed(None)
        if jid_ is None:
            jid_ = self.getClientHostJid(profile)
            cache_only = True
        hasServerFeature = lambda entity: entity if self.memory.hasServerFeature(feature, entity, profile) else None

        def haveItemsFeature(dummy=None):
            entities = self.memory.getAllServerIdentities(jid_, profile)
            if entities is None:
                return None  # no cached data for this server
            for entity in entities:
                if hasServerFeature(entity):
                    return entity
            return None  # data are cached but no entity was found

        entity = hasServerFeature(jid_) or haveItemsFeature()
        if entity:
            return defer.succeed(entity)
        elif entity is False or cache_only:
            return defer.succeed(None)

        # data for this server are not in cache
        disco = self.profiles[profile].disco

        def errback(failure, method, jid_, profile):
            # the target server is not reachable
            logging.error("disco.%s on %s failed! [%s]" % (method.func_name, jid_.userhost(), profile))
            logging.error("reason: %s" % failure.getErrorMessage())
            if method == disco.requestInfo:
                features = self.memory.server_features.setdefault(profile, {})
                features.setdefault(jid_, [])
            elif method == disco.requestItems:
                identities = self.memory.server_identities.setdefault(profile, {})
                identities.setdefault(jid_, {})
            return failure

        def callback(d):
            if hasServerFeature(jid_):
                return jid_
            else:
                d2 = disco.requestItems(jid_).addCallback(self.serverDiscoItems, disco, jid_, profile)
                d2.addErrback(errback, disco.requestItems, jid_, profile)
                return d2.addCallback(haveItemsFeature)

        d = disco.requestInfo(jid_).addCallback(self.serverDisco, jid_, profile)
        d.addCallbacks(callback, errback, [], errbackArgs=[disco.requestInfo, jid_, profile])
        return d

    ## callbacks ##

    def serverDisco(self, disco, jid_=None, profile=None):
        """xep-0030 Discovery Protocol.
        @param disco: result of the disco info query
        @param jid_: the jid of the target server
        @param profile: profile of the user
        """
        if jid_ is None:
            jid_ = self.getClientHostJid(profile)
        debug(_("Requested disco info on %s") % jid_)
        for feature in disco.features:
            debug(_("Feature found: %s") % feature)
            self.memory.addServerFeature(feature, jid_, profile)
        for cat, type_ in disco.identities:
            debug(_("Identity found: [%(category)s/%(type)s] %(identity)s")
                  % {'category': cat, 'type': type_, 'identity': disco.identities[(cat, type_)]})

    def serverDiscoItems(self, disco_result, disco_client, jid_, profile, initialized=None):
        """xep-0030 Discovery Protocol.
        @param disco_result: result of the disco item querry
        @param disco_client: SatDiscoProtocol instance
        @param jid_: the jid of the target server
        @param profile: profile of the user
        @param initialized: deferred which must be chained when everything is done"""

        def _check_entity_cb(result, entity, jid_, profile):
            debug(_("Requested disco info on %s") % entity)
            for category, type_ in result.identities:
                debug(_('Identity added: (%(category)s,%(type)s) ==> %(entity)s [%(profile)s]')
                      % {'category': category, 'type': type_, 'entity': entity, 'profile': profile})
                self.memory.addServerIdentity(category, type_, entity, jid_, profile)
            for feature in result.features:
                self.memory.addServerFeature(feature, entity, profile)

        def _errback(result, entity, jid_, profile):
            warning(_("Can't get information on identity [%(entity)s] for profile [%(profile)s]") % {'entity': entity, 'profile': profile})

        defer_list = []
        for item in disco_result._items:
            if item.entity.full().count('.') == 1:  # XXX: workaround for a bug on jabberfr, tmp
                warning(_('Using jabberfr workaround, be sure your domain has at least two levels (e.g. "example.tld", not "example" alone)'))
                continue
            args = [item.entity, jid_, profile]
            defer_list.append(disco_client.requestInfo(item.entity).addCallbacks(_check_entity_cb, _errback, args, None, args))
        if initialized:
            defer.DeferredList(defer_list).chainDeferred(initialized)

    ## Generic HMI ##

    def actionResult(self, action_id, action_type, data, profile):
        """Send the result of an action
        @param action_id: same action_id used with action
        @param action_type: result action_type ("PARAM", "SUCCESS", "ERROR", "XMLUI")
        @param data: dictionary
        """
        self.bridge.actionResult(action_type, action_id, data, profile)

    def actionResultExt(self, action_id, action_type, data, profile):
        """Send the result of an action, extended version
        @param action_id: same action_id used with action
        @param action_type: result action_type /!\ only "DICT_DICT" for this method
        @param data: dictionary of dictionaries
        """
        if action_type != "DICT_DICT":
            error(_("action_type for actionResultExt must be DICT_DICT, fixing it"))
            action_type = "DICT_DICT"
        self.bridge.actionResultExt(action_type, action_id, data, profile)

    def askConfirmation(self, conf_id, conf_type, data, cb, profile):
        """Add a confirmation callback
        @param conf_id: conf_id used to get answer
        @param conf_type: confirmation conf_type ("YES/NO", "FILE_TRANSFER")
        @param data: data (depend of confirmation conf_type)
        @param cb: callback called with the answer
        """
        client = self.getClient(profile)
        if not client:
            raise exceptions.ProfileUnknownError(_("Asking confirmation a non-existant profile"))
        if conf_id in client._waiting_conf:
            error(_("Attempt to register two callbacks for the same confirmation"))
        else:
            client._waiting_conf[conf_id] = (conf_type, data, cb)
            self.bridge.askConfirmation(conf_id, conf_type, data, profile)

    def confirmationAnswer(self, conf_id, accepted, data, profile):
        """Called by frontends to answer confirmation requests"""
        client = self.getClient(profile)
        if not client:
            raise exceptions.ProfileUnknownError(_("Confirmation answer from a non-existant profile"))
        debug(_("Received confirmation answer for conf_id [%(conf_id)s]: %(success)s") % {'conf_id': conf_id, 'success': _("accepted") if accepted else _("refused")})
        if conf_id not in client._waiting_conf:
            error(_("Received an unknown confirmation (%(id)s for %(profile)s)") % {'id': conf_id, 'profile': profile})
        else:
            cb = client._waiting_conf[conf_id][-1]
            del client._waiting_conf[conf_id]
            cb(conf_id, accepted, data, profile)

    def registerProgressCB(self, progress_id, CB, profile):
        """Register a callback called when progress is requested for id"""
        client = self.getClient(profile)
        if not client:
            raise exceptions.ProfileUnknownError
        client._progress_cb_map[progress_id] = CB

    def removeProgressCB(self, progress_id, profile):
        """Remove a progress callback"""
        client = self.getClient(profile)
        if not client:
            raise exceptions.ProfileUnknownError
        if progress_id not in client._progress_cb_map:
            error(_("Trying to remove an unknow progress callback"))
        else:
            del client._progress_cb_map[progress_id]

    def getProgress(self, progress_id, profile):
        """Return a dict with progress information
        data['position'] : current possition
        data['size'] : end_position
        """
        client = self.getClient(profile)
        if not profile:
            raise exceptions.ProfileNotInCacheError
        data = {}
        try:
            client._progress_cb_map[progress_id](progress_id, data, profile)
        except KeyError:
            pass
            #debug("Requested progress for unknown progress_id")
        return data

    def registerGeneralCB(self, name, CB):
        """Register a callback called for general reason"""
        self.__general_cb_map[name] = CB

    def removeGeneralCB(self, name):
        """Remove a general callback"""
        if name not in self.__general_cb_map:
            error(_("Trying to remove an unknow general callback"))
        else:
            del self.__general_cb_map[name]

    def callGeneralCB(self, name, *args, **kwargs):
        """Call general function back"""
        try:
            return self.__general_cb_map[name](*args, **kwargs)
        except KeyError:
            error(_("Trying to call unknown function (%s)") % name)
            return None

    #Menus management

    def importMenu(self, category, name, callback, callback_args=None, callback_kwargs=None, help_string="", type_="NORMAL"):
        """register a new menu for frontends
        @param category: category of the menu
        @param name: menu item entry
        @param callback: method to be called when menuitem is selected
        @param callback_args: optional arguments to forward to callback
        @param callback_kwargs: optional keywords arguments to forward to callback
        """
        # TODO: manage translations
        if (type_, category, name) in self.menus:
            raise exceptions.ConflictError("Menu already exists")
        menu_data = {'callback': callback, 'help_string': help_string}
        if callback_args is not None:
            assert(isinstance(callback_args, list))
            menu_data['callback_args'] = callback_args
        if callback_kwargs is not None:
            assert(isinstance(callback_kwargs, dict))
            menu_data['callback_kwargs'] = callback_kwargs
        self.menus[(type_, category, name)] = menu_data

    def getMenus(self):
        """Return all menus registered"""
        # TODO: manage translations
        return self.menus.keys()

    def getMenuHelp(self, category, name, type_="NORMAL"):
        """return the help string of the menu"""
        # TODO: manage translations
        try:
            return self.menus[(type_, category, name)]['help_string']
        except KeyError:
            raise exceptions.DataError("Trying to access an unknown menu")

    def callMenu(self, category, name, type_="NORMAL", profile_key='@NONE@'):
        """ Call a dynamic menu
        @param category: category of the menu to call
        @param name: name of the menu to call
        @param type_: type of the menu to call
        @param profile_key: %(doc_profile_key)s
        @return: XMLUI or empty string if it's a one shot menu
        """
        profile = self.memory.getProfileName(profile_key)
        if not profile:
            raise exceptions.ProfileUnknownError
        menu_data = self.menus[(type_, category, name)]
        callback = menu_data['callback']
        args = menu_data.get('callback_args', ())
        kwargs = menu_data.get('callback_kwargs', {}).copy()
        kwargs["profile"] = profile
        try:
            return defer.maybeDeferred(callback, *args, **kwargs)
        except KeyError:
            raise exceptions.DataError("Trying to access an unknown menu (%(type)s/%(category)s/%(name)s)" % {'type': type_, 'category': category, 'name': name})