changeset 4073:7c5654c54fed

refactoring: rename `core.sat_main` to `core.main`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 12:59:21 +0200
parents 040095a5dc7f
children 26b7ed2817da
files libervia/backend/core/main.py libervia/backend/core/sat_main.py libervia/backend/plugins/plugin_xep_0082.py libervia/backend/plugins/plugin_xep_0373.py libervia/backend/plugins/plugin_xep_0374.py libervia/backend/plugins/plugin_xep_0384.py libervia/backend/plugins/plugin_xep_0420.py libervia/backend/tools/stream.py tests/unit/conftest.py twisted/plugins/sat_plugin.py
diffstat 10 files changed, 1674 insertions(+), 1674 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/main.py	Fri Jun 02 12:59:21 2023 +0200
@@ -0,0 +1,1666 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import os.path
+import uuid
+import hashlib
+import copy
+from pathlib import Path
+from typing import Optional, List, Tuple, Dict
+
+from wokkel.data_form import Option
+from libervia import backend
+from libervia.backend.core.i18n import _, D_, language_switch
+from libervia.backend.core import patches
+patches.apply()
+from twisted.application import service
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.internet import reactor
+from wokkel.xmppim import RosterItem
+from libervia.backend.core import xmpp
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory import memory
+from libervia.backend.memory import cache
+from libervia.backend.memory import encryption
+from libervia.backend.tools import async_trigger as trigger
+from libervia.backend.tools import utils
+from libervia.backend.tools import image
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common import data_format
+from libervia.backend.stdui import ui_contact_list, ui_profile_manager
+import libervia.backend.plugins
+
+
+log = getLogger(__name__)
+
+class LiberviaBackend(service.Service):
+
+    def _init(self):
+        # we don't use __init__ to avoid doule initialisation with twistd
+        # this _init is called in startService
+        log.info(f"{C.APP_NAME} {self.full_version}")
+        self._cb_map = {}  # map from callback_id to callbacks
+        # dynamic menus. key: callback_id, value: menu data (dictionnary)
+        self._menus = {}
+        self._menus_paths = {}  # path to id. key: (menu_type, lower case tuple of path),
+                                # value: menu id
+        self.initialised = defer.Deferred()
+        self.profiles = {}
+        self.plugins = {}
+        # map for short name to whole namespace,
+        # extended by plugins with register_namespace
+        self.ns_map = {
+            "x-data": xmpp.NS_X_DATA,
+            "disco#info": xmpp.NS_DISCO_INFO,
+        }
+
+        self.memory = memory.Memory(self)
+
+        # trigger are used to change Libervia behaviour
+        self.trigger = (
+            trigger.TriggerManager()
+        )
+
+        bridge_name = (
+            os.getenv("LIBERVIA_BRIDGE_NAME")
+            or self.memory.config_get("", "bridge", "dbus")
+        )
+
+        bridge_module = dynamic_import.bridge(bridge_name)
+        if bridge_module is None:
+            log.error(f"Can't find bridge module of name {bridge_name}")
+            sys.exit(1)
+        log.info(f"using {bridge_name} bridge")
+        try:
+            self.bridge = bridge_module.bridge()
+        except exceptions.BridgeInitError:
+            log.exception("bridge can't be initialised, can't start Libervia Backend")
+            sys.exit(1)
+
+        defer.ensureDeferred(self._post_init())
+
+    @property
+    def version(self):
+        """Return the short version of Libervia"""
+        return C.APP_VERSION
+
+    @property
+    def full_version(self):
+        """Return the full version of Libervia
+
+        In developement mode, release name and extra data are returned too
+        """
+        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 = "{} « {} » ({})".format(
+                    version, C.APP_RELEASE_NAME, utils.get_repository_data(backend)
+                )
+                return self._version_cache
+        else:
+            return version
+
+    @property
+    def bridge_name(self):
+        return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
+
+    async def _post_init(self):
+        try:
+            bridge_pi = self.bridge.post_init
+        except AttributeError:
+            pass
+        else:
+            try:
+                await bridge_pi()
+            except Exception:
+                log.exception("Could not initialize bridge")
+                # because init is not complete at this stage, we use callLater
+                reactor.callLater(0, self.stop)
+                return
+
+        self.bridge.register_method("ready_get", lambda: self.initialised)
+        self.bridge.register_method("version_get", lambda: self.full_version)
+        self.bridge.register_method("features_get", self.features_get)
+        self.bridge.register_method("profile_name_get", self.memory.get_profile_name)
+        self.bridge.register_method("profiles_list_get", self.memory.get_profiles_list)
+        self.bridge.register_method("entity_data_get", self.memory._get_entity_data)
+        self.bridge.register_method("entities_data_get", self.memory._get_entities_data)
+        self.bridge.register_method("profile_create", self.memory.create_profile)
+        self.bridge.register_method("profile_delete_async", self.memory.profile_delete_async)
+        self.bridge.register_method("profile_start_session", self.memory.start_session)
+        self.bridge.register_method(
+            "profile_is_session_started", self.memory._is_session_started
+        )
+        self.bridge.register_method("profile_set_default", self.memory.profile_set_default)
+        self.bridge.register_method("connect", self._connect)
+        self.bridge.register_method("disconnect", self.disconnect)
+        self.bridge.register_method("contact_get", self._contact_get)
+        self.bridge.register_method("contacts_get", self.contacts_get)
+        self.bridge.register_method("contacts_get_from_group", self.contacts_get_from_group)
+        self.bridge.register_method("main_resource_get", self.memory._get_main_resource)
+        self.bridge.register_method(
+            "presence_statuses_get", self.memory._get_presence_statuses
+        )
+        self.bridge.register_method("sub_waiting_get", self.memory.sub_waiting_get)
+        self.bridge.register_method("message_send", self._message_send)
+        self.bridge.register_method("message_encryption_start",
+                                    self._message_encryption_start)
+        self.bridge.register_method("message_encryption_stop",
+                                    self._message_encryption_stop)
+        self.bridge.register_method("message_encryption_get",
+                                    self._message_encryption_get)
+        self.bridge.register_method("encryption_namespace_get",
+                                    self._encryption_namespace_get)
+        self.bridge.register_method("encryption_plugins_get", self._encryption_plugins_get)
+        self.bridge.register_method("encryption_trust_ui_get", self._encryption_trust_ui_get)
+        self.bridge.register_method("config_get", self._get_config)
+        self.bridge.register_method("param_set", self.param_set)
+        self.bridge.register_method("param_get_a", self.memory.get_string_param_a)
+        self.bridge.register_method("private_data_get", self.memory._private_data_get)
+        self.bridge.register_method("private_data_set", self.memory._private_data_set)
+        self.bridge.register_method("private_data_delete", self.memory._private_data_delete)
+        self.bridge.register_method("param_get_a_async", self.memory.async_get_string_param_a)
+        self.bridge.register_method(
+            "params_values_from_category_get_async",
+            self.memory._get_params_values_from_category,
+        )
+        self.bridge.register_method("param_ui_get", self.memory._get_params_ui)
+        self.bridge.register_method(
+            "params_categories_get", self.memory.params_categories_get
+        )
+        self.bridge.register_method("params_register_app", self.memory.params_register_app)
+        self.bridge.register_method("history_get", self.memory._history_get)
+        self.bridge.register_method("presence_set", self._set_presence)
+        self.bridge.register_method("subscription", self.subscription)
+        self.bridge.register_method("contact_add", self._add_contact)
+        self.bridge.register_method("contact_update", self._update_contact)
+        self.bridge.register_method("contact_del", self._del_contact)
+        self.bridge.register_method("roster_resync", self._roster_resync)
+        self.bridge.register_method("is_connected", self.is_connected)
+        self.bridge.register_method("action_launch", self._action_launch)
+        self.bridge.register_method("actions_get", self.actions_get)
+        self.bridge.register_method("progress_get", self._progress_get)
+        self.bridge.register_method("progress_get_all", self._progress_get_all)
+        self.bridge.register_method("menus_get", self.get_menus)
+        self.bridge.register_method("menu_help_get", self.get_menu_help)
+        self.bridge.register_method("menu_launch", self._launch_menu)
+        self.bridge.register_method("disco_infos", self.memory.disco._disco_infos)
+        self.bridge.register_method("disco_items", self.memory.disco._disco_items)
+        self.bridge.register_method("disco_find_by_features", self._find_by_features)
+        self.bridge.register_method("params_template_save", self.memory.save_xml)
+        self.bridge.register_method("params_template_load", self.memory.load_xml)
+        self.bridge.register_method("session_infos_get", self.get_session_infos)
+        self.bridge.register_method("devices_infos_get", self._get_devices_infos)
+        self.bridge.register_method("namespaces_get", self.get_namespaces)
+        self.bridge.register_method("image_check", self._image_check)
+        self.bridge.register_method("image_resize", self._image_resize)
+        self.bridge.register_method("image_generate_preview", self._image_generate_preview)
+        self.bridge.register_method("image_convert", self._image_convert)
+
+
+        await self.memory.initialise()
+        self.common_cache = cache.Cache(self, None)
+        log.info(_("Memory initialised"))
+        try:
+            self._import_plugins()
+            ui_contact_list.ContactList(self)
+            ui_profile_manager.ProfileManager(self)
+        except Exception as e:
+            log.error(f"Could not initialize backend: {e}")
+            sys.exit(1)
+        self._add_base_menus()
+
+        self.initialised.callback(None)
+        log.info(_("Backend is ready"))
+
+        # profile autoconnection must be done after self.initialised is called because
+        # start_session waits for it.
+        autoconnect_dict = await self.memory.storage.get_ind_param_values(
+            category='Connection', name='autoconnect_backend',
+        )
+        profiles_autoconnect = [p for p, v in autoconnect_dict.items() if C.bool(v)]
+        if not self.trigger.point("profilesAutoconnect", profiles_autoconnect):
+            return
+        if profiles_autoconnect:
+            log.info(D_(
+                "Following profiles will be connected automatically: {profiles}"
+                ).format(profiles= ', '.join(profiles_autoconnect)))
+        connect_d_list = []
+        for profile in profiles_autoconnect:
+            connect_d_list.append(defer.ensureDeferred(self.connect(profile)))
+
+        if connect_d_list:
+            results = await defer.DeferredList(connect_d_list)
+            for idx, (success, result) in enumerate(results):
+                if not success:
+                    profile = profiles_autoconnect[0]
+                    log.warning(
+                        _("Can't autoconnect profile {profile}: {reason}").format(
+                            profile = profile,
+                            reason = result)
+                    )
+
+    def _add_base_menus(self):
+        """Add base menus"""
+        encryption.EncryptionHandler._import_menus(self)
+
+    def _unimport_plugin(self, plugin_path):
+        """remove a plugin from sys.modules if it is there"""
+        try:
+            del sys.modules[plugin_path]
+        except KeyError:
+            pass
+
+    def _import_plugins(self):
+        """import all plugins found in plugins directory"""
+        # FIXME: module imported but cancelled should be deleted
+        # TODO: make this more generic and reusable in tools.common
+        # FIXME: should use imp
+        # TODO: do not import all plugins if no needed: component plugins are not needed
+        #       if we just use a client, and plugin blacklisting should be possible in
+        #       libervia.conf
+        plugins_path = Path(libervia.backend.plugins.__file__).parent
+        plugins_to_import = {}  # plugins we still have to import
+        for plug_path in plugins_path.glob("plugin_*"):
+            if plug_path.is_dir():
+                init_path = plug_path / f"__init__.{C.PLUGIN_EXT}"
+                if not init_path.exists():
+                    log.warning(
+                        f"{plug_path} doesn't appear to be a package, can't load it")
+                    continue
+                plug_name = plug_path.name
+            elif plug_path.is_file():
+                if plug_path.suffix != f".{C.PLUGIN_EXT}":
+                    continue
+                plug_name = plug_path.stem
+            else:
+                log.warning(
+                    f"{plug_path} is not a file or a dir, ignoring it")
+                continue
+            if not plug_name.isidentifier():
+                log.warning(
+                    f"{plug_name!r} is not a valid name for a plugin, ignoring it")
+                continue
+            plugin_path = f"libervia.backend.plugins.{plug_name}"
+            try:
+                __import__(plugin_path)
+            except exceptions.MissingModule as e:
+                self._unimport_plugin(plugin_path)
+                log.warning(
+                    "Can't import plugin [{path}] because of an unavailale third party "
+                    "module:\n{msg}".format(
+                        path=plugin_path, msg=e
+                    )
+                )
+                continue
+            except exceptions.CancelError as e:
+                log.info(
+                    "Plugin [{path}] cancelled its own import: {msg}".format(
+                        path=plugin_path, msg=e
+                    )
+                )
+                self._unimport_plugin(plugin_path)
+                continue
+            except Exception:
+                import traceback
+
+                log.error(
+                    _("Can't import plugin [{path}]:\n{error}").format(
+                        path=plugin_path, error=traceback.format_exc()
+                    )
+                )
+                self._unimport_plugin(plugin_path)
+                continue
+            mod = sys.modules[plugin_path]
+            plugin_info = mod.PLUGIN_INFO
+            import_name = plugin_info["import_name"]
+
+            plugin_modes = plugin_info["modes"] = set(
+                plugin_info.setdefault("modes", C.PLUG_MODE_DEFAULT)
+            )
+            if not plugin_modes.intersection(C.PLUG_MODE_BOTH):
+                log.error(
+                    f"Can't import plugin at {plugin_path}, invalid {C.PI_MODES!r} "
+                    f"value: {plugin_modes!r}"
+                )
+                continue
+
+            # if the plugin is an entry point, it must work in component mode
+            if plugin_info["type"] == C.PLUG_TYPE_ENTRY_POINT:
+                # if plugin is an entrypoint, we cache it
+                if C.PLUG_MODE_COMPONENT not in plugin_modes:
+                    log.error(
+                        _(
+                            "{type} type must be used with {mode} mode, ignoring plugin"
+                        ).format(type=C.PLUG_TYPE_ENTRY_POINT, mode=C.PLUG_MODE_COMPONENT)
+                    )
+                    self._unimport_plugin(plugin_path)
+                    continue
+
+            if import_name in plugins_to_import:
+                log.error(
+                    _(
+                        "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("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(
+                        _("Recommended plugin not found: {}").format(import_name)
+                    )
+                    return
+                msg = "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(
+                    "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(
+                        _("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.exception(
+                f"Can't load plugin \"{plugin_info['name']}\", ignoring it: {e}"
+            )
+            if optional:
+                return
+            raise ImportError("Error during initiation")
+        if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
+            self.plugins[import_name].is_handler = True
+        else:
+            self.plugins[import_name].is_handler = False
+        # we keep metadata as a Class attribute
+        self.plugins[import_name]._info = plugin_info
+        # TODO: test xmppclient presence and register handler parent
+
+    def plugins_unload(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.values():
+            try:
+                unload = plugin.unload
+            except AttributeError:
+                continue
+            else:
+                defers_list.append(utils.as_deferred(unload))
+        return defers_list
+
+    def _connect(self, profile_key, password="", options=None):
+        profile = self.memory.get_profile_name(profile_key)
+        return defer.ensureDeferred(self.connect(profile, password, options))
+
+    async def connect(
+        self, profile, password="", options=None, max_retries=C.XMPP_MAX_RETRIES):
+        """Connect a profile (i.e. connect client.component to XMPP server)
+
+        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 Libervia profile password
+        @param options (dict): connection options. Key can be:
+            -
+        @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
+        """
+        if options is None:
+            options = {}
+
+        await self.memory.start_session(password, profile)
+
+        if self.is_connected(profile):
+            log.info(_("already connected !"))
+            return True
+
+        if self.memory.is_component(profile):
+            await xmpp.SatXMPPComponent.start_connection(self, profile, max_retries)
+        else:
+            await xmpp.SatXMPPClient.start_connection(self, profile, max_retries)
+
+        return False
+
+    def disconnect(self, profile_key):
+        """disconnect from jabber server"""
+        # FIXME: client should not be deleted if only disconnected
+        #        it shoud be deleted only when session is finished
+        if not self.is_connected(profile_key):
+            # is_connected is checked here and not on client
+            # because client is deleted when session is ended
+            log.info(_("not connected !"))
+            return defer.succeed(None)
+        client = self.get_client(profile_key)
+        return client.entity_disconnect()
+
+    def features_get(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.get_client(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.items():
+            try:
+                features_d = utils.as_deferred(plugin.features_get, profile_key)
+            except AttributeError:
+                features_d = defer.succeed({})
+            features.append(features_d)
+
+        d_list = defer.DeferredList(features)
+
+        def build_features(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(
+                        "Error while getting features for {name}: {failure}".format(
+                            name=name, failure=data
+                        )
+                    )
+                    ret[name] = {}
+            return ret
+
+        d_list.addCallback(build_features, list(self.plugins.keys()))
+        return d_list
+
+    def _contact_get(self, entity_jid_s, profile_key):
+        client = self.get_client(profile_key)
+        entity_jid = jid.JID(entity_jid_s)
+        return defer.ensureDeferred(self.get_contact(client, entity_jid))
+
+    async def get_contact(self, client, entity_jid):
+        # we want to be sure that roster has been received
+        await client.roster.got_roster
+        item = client.roster.get_item(entity_jid)
+        if item is None:
+            raise exceptions.NotFound(f"{entity_jid} is not in roster!")
+        return (client.roster.get_attributes(item), list(item.groups))
+
+    def contacts_get(self, profile_key):
+        client = self.get_client(profile_key)
+
+        def got_roster(__):
+            ret = []
+            for item in client.roster.get_items():  # we get all items for client's roster
+                # and convert them to expected format
+                attr = client.roster.get_attributes(item)
+                # we use full() and not userhost() because jid with resources are allowed
+                # in roster, even if it's not common.
+                ret.append([item.entity.full(), attr, list(item.groups)])
+            return ret
+
+        return client.roster.got_roster.addCallback(got_roster)
+
+    def contacts_get_from_group(self, group, profile_key):
+        client = self.get_client(profile_key)
+        return [jid_.full() for jid_ in client.roster.get_jids_from_group(group)]
+
+    def purge_entity(self, profile):
+        """Remove reference to a profile client/component 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"))
+        else:
+            self.memory.purge_profile_session(profile)
+
+    def startService(self):
+        self._init()
+        log.info("Salut à toi ô mon frère !")
+
+    def stopService(self):
+        log.info("Salut aussi à Rantanplan")
+        return self.plugins_unload()
+
+    def run(self):
+        log.debug(_("running app"))
+        reactor.run()
+
+    def stop(self):
+        log.debug(_("stopping app"))
+        reactor.stop()
+
+    ## Misc methods ##
+
+    def get_jid_n_stream(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 (get_client is enough)
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile or not self.profiles[profile].is_connected():
+            return (None, None)
+        return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
+
+    def get_client(self, profile_key: str) -> xmpp.SatXMPPClient:
+        """Convenient method to get client from profile key
+
+        @return: the client
+        @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 used yet
+        """
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile:
+            raise exceptions.ProfileKeyUnknown
+        try:
+            return self.profiles[profile]
+        except KeyError:
+            raise exceptions.NotFound(profile_key)
+
+    def get_clients(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
+        """
+        if not profile_key:
+            raise exceptions.DataError(_("profile_key must not be empty"))
+        try:
+            profile = self.memory.get_profile_name(profile_key, True)
+        except exceptions.ProfileUnknownError:
+            return []
+        if profile == C.PROF_KEY_ALL:
+            return list(self.profiles.values())
+        elif profile[0] == "@":  #  only profile keys can start with "@"
+            raise exceptions.ProfileKeyUnknown
+        return [self.profiles[profile]]
+
+    def _get_config(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 str(self.memory.config_get(section, name, ""))
+
+    def log_errback(self, failure_, msg=_("Unexpected error: {failure_}")):
+        """Generic errback logging
+
+        @param msg(unicode): error message ("failure_" key will be use for format)
+        can be used as last errback to show unexpected error
+        """
+        log.error(msg.format(failure_=failure_))
+        return failure_
+
+    #  namespaces
+
+    def register_namespace(self, short_name, namespace):
+        """associate a namespace to a short name"""
+        if short_name in self.ns_map:
+            raise exceptions.ConflictError("this short name is already used")
+        log.debug(f"registering namespace {short_name} => {namespace}")
+        self.ns_map[short_name] = namespace
+
+    def get_namespaces(self):
+        return self.ns_map
+
+    def get_namespace(self, short_name):
+        try:
+            return self.ns_map[short_name]
+        except KeyError:
+            raise exceptions.NotFound("namespace {short_name} is not registered"
+                                      .format(short_name=short_name))
+
+    def get_session_infos(self, profile_key):
+        """compile interesting data on current profile session"""
+        client = self.get_client(profile_key)
+        data = {
+            "jid": client.jid.full(),
+            "started": str(int(client.started))
+            }
+        return defer.succeed(data)
+
+    def _get_devices_infos(self, bare_jid, profile_key):
+        client = self.get_client(profile_key)
+        if not bare_jid:
+            bare_jid = None
+        d = defer.ensureDeferred(self.get_devices_infos(client, bare_jid))
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    async def get_devices_infos(self, client, bare_jid=None):
+        """compile data on an entity devices
+
+        @param bare_jid(jid.JID, None): bare jid of entity to check
+            None to use client own jid
+        @return (list[dict]): list of data, one item per resource.
+            Following keys can be set:
+                - resource(str): resource name
+        """
+        own_jid = client.jid.userhostJID()
+        if bare_jid is None:
+            bare_jid = own_jid
+        else:
+            bare_jid = jid.JID(bare_jid)
+        resources = self.memory.get_all_resources(client, bare_jid)
+        if bare_jid == own_jid:
+            # our own jid is not stored in memory's cache
+            resources.add(client.jid.resource)
+        ret_data = []
+        for resource in resources:
+            res_jid = copy.copy(bare_jid)
+            res_jid.resource = resource
+            cache_data = self.memory.entity_data_get(client, res_jid)
+            res_data = {
+                "resource": resource,
+            }
+            try:
+                presence = cache_data['presence']
+            except KeyError:
+                pass
+            else:
+                res_data['presence'] = {
+                    "show": presence.show,
+                    "priority": presence.priority,
+                    "statuses": presence.statuses,
+                }
+
+            disco = await self.get_disco_infos(client, res_jid)
+
+            for (category, type_), name in disco.identities.items():
+                identities = res_data.setdefault('identities', [])
+                identities.append({
+                    "name": name,
+                    "category": category,
+                    "type": type_,
+                })
+
+            ret_data.append(res_data)
+
+        return ret_data
+
+    # images
+
+    def _image_check(self, path):
+        report = image.check(self, path)
+        return data_format.serialise(report)
+
+    def _image_resize(self, path, width, height):
+        d = image.resize(path, (width, height))
+        d.addCallback(lambda new_image_path: str(new_image_path))
+        return d
+
+    def _image_generate_preview(self, path, profile_key):
+        client = self.get_client(profile_key)
+        d = defer.ensureDeferred(self.image_generate_preview(client, Path(path)))
+        d.addCallback(lambda preview_path: str(preview_path))
+        return d
+
+    async def image_generate_preview(self, client, path):
+        """Helper method to generate in cache a preview of an image
+
+        @param path(Path): path to the image
+        @return (Path): path to the generated preview
+        """
+        report = image.check(self, path, max_size=(300, 300))
+
+        if not report['too_large']:
+            # in the unlikely case that image is already smaller than a preview
+            preview_path = path
+        else:
+            # we use hash as id, to re-use potentially existing preview
+            path_hash = hashlib.sha256(str(path).encode()).hexdigest()
+            uid = f"{path.stem}_{path_hash}_preview"
+            filename = f"{uid}{path.suffix.lower()}"
+            metadata = client.cache.get_metadata(uid=uid)
+            if metadata is not None:
+                preview_path = metadata['path']
+            else:
+                with client.cache.cache_data(
+                    source='HOST_PREVIEW',
+                    uid=uid,
+                    filename=filename) as cache_f:
+
+                    preview_path = await image.resize(
+                        path,
+                        new_size=report['recommended_size'],
+                        dest=cache_f
+                    )
+
+        return preview_path
+
+    def _image_convert(self, source, dest, extra, profile_key):
+        client = self.get_client(profile_key) if profile_key else None
+        source = Path(source)
+        dest = None if not dest else Path(dest)
+        extra = data_format.deserialise(extra)
+        d = defer.ensureDeferred(self.image_convert(client, source, dest, extra))
+        d.addCallback(lambda dest_path: str(dest_path))
+        return d
+
+    async def image_convert(self, client, source, dest=None, extra=None):
+        """Helper method to convert an image from one format to an other
+
+        @param client(SatClient, None): client to use for caching
+            this parameter is only used if dest is None
+            if client is None, common cache will be used insted of profile cache
+        @param source(Path): path to the image to convert
+        @param dest(None, Path, file): where to save the converted file
+            - None: use a cache file (uid generated from hash of source)
+                file will be converted to PNG
+            - Path: path to the file to create/overwrite
+            - file: a file object which must be opened for writing in binary mode
+        @param extra(dict, None): conversion options
+            see [image.convert] for details
+        @return (Path): path to the converted image
+        @raise ValueError: an issue happened with source of dest
+        """
+        if not source.is_file:
+            raise ValueError(f"Source file {source} doesn't exist!")
+        if dest is None:
+            # we use hash as id, to re-use potentially existing conversion
+            path_hash = hashlib.sha256(str(source).encode()).hexdigest()
+            uid = f"{source.stem}_{path_hash}_convert_png"
+            filename = f"{uid}.png"
+            if client is None:
+                cache = self.common_cache
+            else:
+                cache = client.cache
+            metadata = cache.get_metadata(uid=uid)
+            if metadata is not None:
+                # there is already a conversion for this image in cache
+                return metadata['path']
+            else:
+                with cache.cache_data(
+                    source='HOST_IMAGE_CONVERT',
+                    uid=uid,
+                    filename=filename) as cache_f:
+
+                    converted_path = await image.convert(
+                        source,
+                        dest=cache_f,
+                        extra=extra
+                    )
+                return converted_path
+        else:
+            return await image.convert(source, dest, extra)
+
+
+    # local dirs
+
+    def get_local_path(
+        self,
+        client: Optional[SatXMPPEntity],
+        dir_name: str,
+        *extra_path: str,
+        component: bool = False,
+    ) -> Path:
+        """Retrieve path for local data
+
+        if path doesn't exist, it will be created
+        @param client: client instance
+            if not none, client.profile will be used as last path element
+        @param dir_name: name of the main path directory
+        @param *extra_path: extra path element(s) to use
+        @param component: if True, path will be prefixed with C.COMPONENTS_DIR
+        @return: path
+        """
+        local_dir = self.memory.config_get("", "local_dir")
+        if not local_dir:
+            raise exceptions.InternalError("local_dir must be set")
+        path_elts = []
+        if component:
+            path_elts.append(C.COMPONENTS_DIR)
+        path_elts.append(regex.path_escape(dir_name))
+        if extra_path:
+            path_elts.extend([regex.path_escape(p) for p in extra_path])
+        if client is not None:
+            path_elts.append(regex.path_escape(client.profile))
+        local_path = Path(*path_elts)
+        local_path.mkdir(0o700, parents=True, exist_ok=True)
+        return local_path
+
+    ## Client management ##
+
+    def param_set(self, name, value, category, security_limit, profile_key):
+        """set wanted paramater and notice observers"""
+        self.memory.param_set(name, value, category, security_limit, profile_key)
+
+    def is_connected(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.get_profile_name(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].is_connected()
+
+    ## Encryption ##
+
+    def register_encryption_plugin(self, *args, **kwargs):
+        return encryption.EncryptionHandler.register_plugin(*args, **kwargs)
+
+    def _message_encryption_start(self, to_jid_s, namespace, replace=False,
+                                profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return defer.ensureDeferred(
+            client.encryption.start(to_jid, namespace or None, replace))
+
+    def _message_encryption_stop(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return defer.ensureDeferred(
+            client.encryption.stop(to_jid))
+
+    def _message_encryption_get(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        session_data = client.encryption.getSession(to_jid)
+        return client.encryption.get_bridge_data(session_data)
+
+    def _encryption_namespace_get(self, name):
+        return encryption.EncryptionHandler.get_ns_from_name(name)
+
+    def _encryption_plugins_get(self):
+        plugins = encryption.EncryptionHandler.getPlugins()
+        ret = []
+        for p in plugins:
+            ret.append({
+                "name": p.name,
+                "namespace": p.namespace,
+                "priority": p.priority,
+                "directed": p.directed,
+                })
+        return data_format.serialise(ret)
+
+    def _encryption_trust_ui_get(self, to_jid_s, namespace, profile_key):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        d = defer.ensureDeferred(
+            client.encryption.get_trust_ui(to_jid, namespace=namespace or None))
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    ## XMPP methods ##
+
+    def _message_send(
+            self, to_jid_s, message, subject=None, mess_type="auto", extra_s="",
+            profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return client.sendMessage(
+            to_jid,
+            message,
+            subject,
+            mess_type,
+            data_format.deserialise(extra_s)
+        )
+
+    def _set_presence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
+        return self.presence_set(jid.JID(to) if to else None, show, statuses, profile_key)
+
+    def presence_set(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.get_profile_name(profile_key)
+        assert profile
+        priority = int(
+            self.memory.param_get_a("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.presence_update(
+            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.get_profile_name(profile_key)
+        assert profile
+        to_jid = jid.JID(raw_jid)
+        log.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 _add_contact(self, to_jid_s, profile_key):
+        return self.contact_add(jid.JID(to_jid_s), profile_key)
+
+    def contact_add(self, to_jid, profile_key):
+        """Add a contact in roster list"""
+        profile = self.memory.get_profile_name(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 _update_contact(self, to_jid_s, name, groups, profile_key):
+        client = self.get_client(profile_key)
+        return self.contact_update(client, jid.JID(to_jid_s), name, groups)
+
+    def contact_update(self, client, to_jid, name, groups):
+        """update a contact in roster list"""
+        roster_item = RosterItem(to_jid)
+        roster_item.name = name or u''
+        roster_item.groups = set(groups)
+        if not self.trigger.point("roster_update", client, roster_item):
+            return
+        return client.roster.setItem(roster_item)
+
+    def _del_contact(self, to_jid_s, profile_key):
+        return self.contact_del(jid.JID(to_jid_s), profile_key)
+
+    def contact_del(self, to_jid, profile_key):
+        """Remove contact from roster list"""
+        profile = self.memory.get_profile_name(profile_key)
+        assert profile
+        self.profiles[profile].presence.unsubscribe(to_jid)  # is not asynchronous
+        return self.profiles[profile].roster.removeItem(to_jid)
+
+    def _roster_resync(self, profile_key):
+        client = self.get_client(profile_key)
+        return client.roster.resync()
+
+    ## 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 check_feature(self, *args, **kwargs):
+        return self.memory.disco.check_feature(*args, **kwargs)
+
+    def check_features(self, *args, **kwargs):
+        return self.memory.disco.check_features(*args, **kwargs)
+
+    def has_identity(self, *args, **kwargs):
+        return self.memory.disco.has_identity(*args, **kwargs)
+
+    def get_disco_infos(self, *args, **kwargs):
+        return self.memory.disco.get_infos(*args, **kwargs)
+
+    def getDiscoItems(self, *args, **kwargs):
+        return self.memory.disco.get_items(*args, **kwargs)
+
+    def find_service_entity(self, *args, **kwargs):
+        return self.memory.disco.find_service_entity(*args, **kwargs)
+
+    def find_service_entities(self, *args, **kwargs):
+        return self.memory.disco.find_service_entities(*args, **kwargs)
+
+    def find_features_set(self, *args, **kwargs):
+        return self.memory.disco.find_features_set(*args, **kwargs)
+
+    def _find_by_features(self, namespaces, identities, bare_jids, service, roster, own_jid,
+                        local_device, profile_key):
+        client = self.get_client(profile_key)
+        identities = [tuple(i) for i in identities] if identities else None
+        return defer.ensureDeferred(self.find_by_features(
+            client, namespaces, identities, bare_jids, service, roster, own_jid,
+            local_device))
+
+    async def find_by_features(
+        self,
+        client: SatXMPPEntity,
+        namespaces: List[str],
+        identities: Optional[List[Tuple[str, str]]]=None,
+        bare_jids: bool=False,
+        service: bool=True,
+        roster: bool=True,
+        own_jid: bool=True,
+        local_device: bool=False
+    ) -> Tuple[
+        Dict[jid.JID, Tuple[str, str, str]],
+        Dict[jid.JID, Tuple[str, str, str]],
+        Dict[jid.JID, Tuple[str, str, str]]
+    ]:
+        """Retrieve all services or contacts managing a set a features
+
+        @param namespaces: features which must be handled
+        @param identities: if not None or empty,
+            only keep those identities
+            tuple must be (category, type)
+        @param bare_jids: retrieve only bare_jids if True
+            if False, retrieve full jid of connected devices
+        @param service: if True return service from our server
+        @param roster: if True, return entities in roster
+            full jid of all matching resources available will be returned
+        @param own_jid: if True, return profile's jid resources
+        @param local_device: if True, return profile's jid local resource
+            (i.e. client.jid)
+        @return: found entities in a tuple with:
+            - service entities
+            - own entities
+            - roster entities
+            Each element is a dict mapping from jid to a tuple with category, type and
+            name of the entity
+        """
+        assert isinstance(namespaces, list)
+        if not identities:
+            identities = None
+        if not namespaces and not identities:
+            raise exceptions.DataError(
+                "at least one namespace or one identity must be set"
+            )
+        found_service = {}
+        found_own = {}
+        found_roster = {}
+        if service:
+            services_jids = await self.find_features_set(client, namespaces)
+            services_jids = list(services_jids)  # we need a list to map results below
+            services_infos  = await defer.DeferredList(
+                [self.get_disco_infos(client, service_jid) for service_jid in services_jids]
+            )
+
+            for idx, (success, infos) in enumerate(services_infos):
+                service_jid = services_jids[idx]
+                if not success:
+                    log.warning(
+                        _("Can't find features for service {service_jid}, ignoring")
+                        .format(service_jid=service_jid.full()))
+                    continue
+                if (identities is not None
+                    and not set(infos.identities.keys()).issuperset(identities)):
+                    continue
+                found_identities = [
+                    (cat, type_, name or "")
+                    for (cat, type_), name in infos.identities.items()
+                ]
+                found_service[service_jid.full()] = found_identities
+
+        to_find = []
+        if own_jid:
+            to_find.append((found_own, [client.jid.userhostJID()]))
+        if roster:
+            to_find.append((found_roster, client.roster.get_jids()))
+
+        for found, jids in to_find:
+            full_jids = []
+            disco_defers = []
+
+            for jid_ in jids:
+                if jid_.resource:
+                    if bare_jids:
+                        continue
+                    resources = [jid_.resource]
+                else:
+                    if bare_jids:
+                        resources = [None]
+                    else:
+                        try:
+                            resources = self.memory.get_available_resources(client, jid_)
+                        except exceptions.UnknownEntityError:
+                            continue
+                        if not resources and jid_ == client.jid.userhostJID() and own_jid:
+                            # small hack to avoid missing our own resource when this
+                            # method is called at the very beginning of the session
+                            # and our presence has not been received yet
+                            resources = [client.jid.resource]
+                for resource in resources:
+                    full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
+                    if full_jid == client.jid and not local_device:
+                        continue
+                    full_jids.append(full_jid)
+
+                    disco_defers.append(self.get_disco_infos(client, full_jid))
+
+            d_list = defer.DeferredList(disco_defers)
+            # XXX: 10 seconds may be too low for slow connections (e.g. mobiles)
+            #      but for discovery, that's also the time the user will wait the first time
+            #      before seing the page, if something goes wrong.
+            d_list.addTimeout(10, reactor)
+            infos_data = await d_list
+
+            for idx, (success, infos) in enumerate(infos_data):
+                full_jid = full_jids[idx]
+                if not success:
+                    log.warning(
+                        _("Can't retrieve {full_jid} infos, ignoring")
+                        .format(full_jid=full_jid.full()))
+                    continue
+                if infos.features.issuperset(namespaces):
+                    if identities is not None and not set(
+                        infos.identities.keys()
+                    ).issuperset(identities):
+                        continue
+                    found_identities = [
+                        (cat, type_, name or "")
+                        for (cat, type_), name in infos.identities.items()
+                    ]
+                    found[full_jid.full()] = found_identities
+
+        return (found_service, found_own, found_roster)
+
+    ## Generic HMI ##
+
+    def _kill_action(self, keep_id, client):
+        log.debug("Killing action {} for timeout".format(keep_id))
+        client.actions[keep_id]
+
+    def action_new(
+        self,
+        action_data,
+        security_limit=C.NO_SECURITY_LIMIT,
+        keep_id=None,
+        profile=C.PROF_KEY_NONE,
+    ):
+        """Shortcut to bridge.action_new which generate an 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. The value will be used as callback_id, be sure to use an unique
+            value.
+            Action will be deleted after 30 min.
+        @param profile: %(doc_profile)s
+        """
+        if keep_id is not None:
+            id_ = keep_id
+            client = self.get_client(profile)
+            action_timer = reactor.callLater(60 * 30, self._kill_action, keep_id, client)
+            client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
+        else:
+            id_ = str(uuid.uuid4())
+
+        self.bridge.action_new(
+            data_format.serialise(action_data), id_, security_limit, profile
+        )
+
+    def actions_get(self, profile):
+        """Return current non answered actions
+
+        @param profile: %(doc_profile)s
+        """
+        client = self.get_client(profile)
+        return [
+            (data_format.serialise(action_tuple[0]), *action_tuple[1:-1])
+            for action_tuple in client.actions.values()
+        ]
+
+    def register_progress_cb(
+        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.get_client(profile)
+        if progress_id in client._progress_cb:
+            raise exceptions.ConflictError("Progress ID is not unique !")
+        client._progress_cb[progress_id] = (callback, metadata)
+
+    def remove_progress_cb(self, progress_id, profile):
+        """Remove a progress callback"""
+        client = self.get_client(profile)
+        try:
+            del client._progress_cb[progress_id]
+        except KeyError:
+            log.error(_("Trying to remove an unknow progress callback"))
+
+    def _progress_get(self, progress_id, profile):
+        data = self.progress_get(progress_id, profile)
+        return {k: str(v) for k, v in data.items()}
+
+    def progress_get(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.get_client(profile)
+        try:
+            data = client._progress_cb[progress_id][0](progress_id, profile)
+        except KeyError:
+            data = {}
+        return data
+
+    def _progress_get_all(self, profile_key):
+        progress_all = self.progress_get_all(profile_key)
+        for profile, progress_dict in progress_all.items():
+            for progress_id, data in progress_dict.items():
+                for key, value in data.items():
+                    data[key] = str(value)
+        return progress_all
+
+    def progress_get_all_metadata(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 [progress_started]
+        """
+        clients = self.get_clients(profile_key)
+        progress_all = {}
+        for client in clients:
+            profile = client.profile
+            progress_dict = {}
+            progress_all[profile] = progress_dict
+            for (
+                progress_id,
+                (__, progress_metadata),
+            ) in client._progress_cb.items():
+                progress_dict[progress_id] = progress_metadata
+        return progress_all
+
+    def progress_get_all(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 [progress_get]
+        """
+        clients = self.get_clients(profile_key)
+        progress_all = {}
+        for client in clients:
+            profile = client.profile
+            progress_dict = {}
+            progress_all[profile] = progress_dict
+            for progress_id, (progress_cb, __) in client._progress_cb.items():
+                progress_dict[progress_id] = progress_cb(progress_id, profile)
+        return progress_all
+
+    def register_callback(self, callback, *args, **kwargs):
+        """Register a callback.
+
+        @param callback(callable): method to call
+        @param kwargs: can contain:
+            with_data(bool): True if the callback use the optional data dict
+            force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid
+                               if possible
+            one_shot(bool): True to delete callback once it has been called
+        @return: id of the registered callback
+        """
+        callback_id = kwargs.pop("force_id", None)
+        if callback_id is None:
+            callback_id = str(uuid.uuid4())
+        else:
+            if callback_id in self._cb_map:
+                raise exceptions.ConflictError(_("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 purge_callback():
+                try:
+                    self.remove_callback(callback_id)
+                except KeyError:
+                    pass
+
+            reactor.callLater(1800, purge_callback)
+
+        return callback_id
+
+    def remove_callback(self, callback_id):
+        """ Remove a previously registered callback
+        @param callback_id: id returned by [register_callback] """
+        log.debug("Removing callback [%s]" % callback_id)
+        del self._cb_map[callback_id]
+
+    def _action_launch(
+        self,
+        callback_id: str,
+        data_s: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        d = self.launch_callback(
+            callback_id,
+            data_format.deserialise(data_s),
+            profile_key
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    def launch_callback(
+        self,
+        callback_id: str,
+        data: Optional[dict] = None,
+        profile_key: str = C.PROF_KEY_NONE
+    ) -> defer.Deferred:
+        """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
+        """
+        # FIXME: is it possible to use this method without profile connected? If not,
+        #     client must be used instead of profile_key
+        # FIXME: security limit need to be checked here
+        try:
+            client = self.get_client(profile_key)
+        except exceptions.NotFound:
+            # client is not available yet
+            profile = self.memory.get_profile_name(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("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.remove_callback(callback_id)
+
+        return utils.as_deferred(callback, *args, **kwargs)
+
+    # Menus management
+
+    def _get_menu_canonical_path(self, path):
+        """give canonical form of path
+
+        canonical form is a tuple of the path were every element is stripped and lowercase
+        @param path(iterable[unicode]): untranslated path to menu
+        @return (tuple[unicode]): canonical form of path
+        """
+        return tuple((p.lower().strip() for p in path))
+
+    def import_menu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT,
+                   help_string="", type_=C.MENU_GLOBAL):
+        r"""register a new menu for frontends
+
+        @param path(iterable[unicode]): path to go to the menu
+            (category/subcategory/.../item) (e.g.: ("File", "Open"))
+            /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
+            untranslated/lower case path can be used to identity a menu, for this reason
+            it must be unique independently of case.
+        @param callback(callable): method to be called when menuitem is selected, callable
+            or a callback id (string) as returned by [register_callback]
+        @param security_limit(int): %(doc_security_limit)s
+            /!\ security_limit MUST be added to data in launch_callback if used #TODO
+        @param help_string(unicode): string used to indicate what the menu do (can be
+            show as a tooltip).
+            /!\ use D_() instead of _() for translations
+        @param type(unicode): 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 (unicode): menu_id (same as callback_id)
+        """
+
+        if callable(callback):
+            callback_id = self.register_callback(callback, with_data=True)
+        elif isinstance(callback, str):
+            # 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.values():
+            if menu_data["path"] == path and menu_data["type"] == type_:
+                raise exceptions.ConflictError(
+                    _("A menu with the same path and type already exists")
+                )
+
+        path_canonical = self._get_menu_canonical_path(path)
+        menu_key = (type_, path_canonical)
+
+        if menu_key in self._menus_paths:
+            raise exceptions.ConflictError(
+                "this menu path is already used: {path} ({menu_key})".format(
+                    path=path_canonical, menu_key=menu_key
+                )
+            )
+
+        menu_data = {
+            "path": tuple(path),
+            "path_canonical": path_canonical,
+            "security_limit": security_limit,
+            "help_string": help_string,
+            "type": type_,
+        }
+
+        self._menus[callback_id] = menu_data
+        self._menus_paths[menu_key] = callback_id
+
+        return callback_id
+
+    def get_menus(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.items():
+            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
+            language_switch(language)
+            path_i18n = [_(elt) for elt in path]
+            language_switch()
+            extra = {}  # TODO: manage extra data like icon
+            ret.append((menu_id, type_, path, path_i18n, extra))
+
+        return ret
+
+    def _launch_menu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT,
+                    profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        return self.launch_menu(client, menu_type, path, data, security_limit)
+
+    def launch_menu(self, client, menu_type, path, data=None,
+        security_limit=C.NO_SECURITY_LIMIT):
+        """launch action a menu action
+
+        @param menu_type(unicode): type of menu to launch
+        @param path(iterable[unicode]): canonical path of the menu
+        @params data(dict): menu data
+        @raise NotFound: this path is not known
+        """
+        # FIXME: manage security_limit here
+        #        defaut security limit should be high instead of C.NO_SECURITY_LIMIT
+        canonical_path = self._get_menu_canonical_path(path)
+        menu_key = (menu_type, canonical_path)
+        try:
+            callback_id = self._menus_paths[menu_key]
+        except KeyError:
+            raise exceptions.NotFound(
+                "Can't find menu {path} ({menu_type})".format(
+                    path=canonical_path, menu_type=menu_type
+                )
+            )
+        return self.launch_callback(callback_id, data, client.profile)
+
+    def get_menu_help(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")
+        language_switch(language)
+        help_string = _(menu_data["help_string"])
+        language_switch()
+        return help_string
--- a/libervia/backend/core/sat_main.py	Fri Jun 02 11:55:48 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1666 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import sys
-import os.path
-import uuid
-import hashlib
-import copy
-from pathlib import Path
-from typing import Optional, List, Tuple, Dict
-
-from wokkel.data_form import Option
-from libervia import backend
-from libervia.backend.core.i18n import _, D_, language_switch
-from libervia.backend.core import patches
-patches.apply()
-from twisted.application import service
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.internet import reactor
-from wokkel.xmppim import RosterItem
-from libervia.backend.core import xmpp
-from libervia.backend.core import exceptions
-from libervia.backend.core.core_types import SatXMPPEntity
-from libervia.backend.core.log import getLogger
-
-from libervia.backend.core.constants import Const as C
-from libervia.backend.memory import memory
-from libervia.backend.memory import cache
-from libervia.backend.memory import encryption
-from libervia.backend.tools import async_trigger as trigger
-from libervia.backend.tools import utils
-from libervia.backend.tools import image
-from libervia.backend.tools.common import dynamic_import
-from libervia.backend.tools.common import regex
-from libervia.backend.tools.common import data_format
-from libervia.backend.stdui import ui_contact_list, ui_profile_manager
-import libervia.backend.plugins
-
-
-log = getLogger(__name__)
-
-class LiberviaBackend(service.Service):
-
-    def _init(self):
-        # we don't use __init__ to avoid doule initialisation with twistd
-        # this _init is called in startService
-        log.info(f"{C.APP_NAME} {self.full_version}")
-        self._cb_map = {}  # map from callback_id to callbacks
-        # dynamic menus. key: callback_id, value: menu data (dictionnary)
-        self._menus = {}
-        self._menus_paths = {}  # path to id. key: (menu_type, lower case tuple of path),
-                                # value: menu id
-        self.initialised = defer.Deferred()
-        self.profiles = {}
-        self.plugins = {}
-        # map for short name to whole namespace,
-        # extended by plugins with register_namespace
-        self.ns_map = {
-            "x-data": xmpp.NS_X_DATA,
-            "disco#info": xmpp.NS_DISCO_INFO,
-        }
-
-        self.memory = memory.Memory(self)
-
-        # trigger are used to change Libervia behaviour
-        self.trigger = (
-            trigger.TriggerManager()
-        )
-
-        bridge_name = (
-            os.getenv("LIBERVIA_BRIDGE_NAME")
-            or self.memory.config_get("", "bridge", "dbus")
-        )
-
-        bridge_module = dynamic_import.bridge(bridge_name)
-        if bridge_module is None:
-            log.error(f"Can't find bridge module of name {bridge_name}")
-            sys.exit(1)
-        log.info(f"using {bridge_name} bridge")
-        try:
-            self.bridge = bridge_module.bridge()
-        except exceptions.BridgeInitError:
-            log.exception("bridge can't be initialised, can't start Libervia Backend")
-            sys.exit(1)
-
-        defer.ensureDeferred(self._post_init())
-
-    @property
-    def version(self):
-        """Return the short version of Libervia"""
-        return C.APP_VERSION
-
-    @property
-    def full_version(self):
-        """Return the full version of Libervia
-
-        In developement mode, release name and extra data are returned too
-        """
-        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 = "{} « {} » ({})".format(
-                    version, C.APP_RELEASE_NAME, utils.get_repository_data(backend)
-                )
-                return self._version_cache
-        else:
-            return version
-
-    @property
-    def bridge_name(self):
-        return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
-
-    async def _post_init(self):
-        try:
-            bridge_pi = self.bridge.post_init
-        except AttributeError:
-            pass
-        else:
-            try:
-                await bridge_pi()
-            except Exception:
-                log.exception("Could not initialize bridge")
-                # because init is not complete at this stage, we use callLater
-                reactor.callLater(0, self.stop)
-                return
-
-        self.bridge.register_method("ready_get", lambda: self.initialised)
-        self.bridge.register_method("version_get", lambda: self.full_version)
-        self.bridge.register_method("features_get", self.features_get)
-        self.bridge.register_method("profile_name_get", self.memory.get_profile_name)
-        self.bridge.register_method("profiles_list_get", self.memory.get_profiles_list)
-        self.bridge.register_method("entity_data_get", self.memory._get_entity_data)
-        self.bridge.register_method("entities_data_get", self.memory._get_entities_data)
-        self.bridge.register_method("profile_create", self.memory.create_profile)
-        self.bridge.register_method("profile_delete_async", self.memory.profile_delete_async)
-        self.bridge.register_method("profile_start_session", self.memory.start_session)
-        self.bridge.register_method(
-            "profile_is_session_started", self.memory._is_session_started
-        )
-        self.bridge.register_method("profile_set_default", self.memory.profile_set_default)
-        self.bridge.register_method("connect", self._connect)
-        self.bridge.register_method("disconnect", self.disconnect)
-        self.bridge.register_method("contact_get", self._contact_get)
-        self.bridge.register_method("contacts_get", self.contacts_get)
-        self.bridge.register_method("contacts_get_from_group", self.contacts_get_from_group)
-        self.bridge.register_method("main_resource_get", self.memory._get_main_resource)
-        self.bridge.register_method(
-            "presence_statuses_get", self.memory._get_presence_statuses
-        )
-        self.bridge.register_method("sub_waiting_get", self.memory.sub_waiting_get)
-        self.bridge.register_method("message_send", self._message_send)
-        self.bridge.register_method("message_encryption_start",
-                                    self._message_encryption_start)
-        self.bridge.register_method("message_encryption_stop",
-                                    self._message_encryption_stop)
-        self.bridge.register_method("message_encryption_get",
-                                    self._message_encryption_get)
-        self.bridge.register_method("encryption_namespace_get",
-                                    self._encryption_namespace_get)
-        self.bridge.register_method("encryption_plugins_get", self._encryption_plugins_get)
-        self.bridge.register_method("encryption_trust_ui_get", self._encryption_trust_ui_get)
-        self.bridge.register_method("config_get", self._get_config)
-        self.bridge.register_method("param_set", self.param_set)
-        self.bridge.register_method("param_get_a", self.memory.get_string_param_a)
-        self.bridge.register_method("private_data_get", self.memory._private_data_get)
-        self.bridge.register_method("private_data_set", self.memory._private_data_set)
-        self.bridge.register_method("private_data_delete", self.memory._private_data_delete)
-        self.bridge.register_method("param_get_a_async", self.memory.async_get_string_param_a)
-        self.bridge.register_method(
-            "params_values_from_category_get_async",
-            self.memory._get_params_values_from_category,
-        )
-        self.bridge.register_method("param_ui_get", self.memory._get_params_ui)
-        self.bridge.register_method(
-            "params_categories_get", self.memory.params_categories_get
-        )
-        self.bridge.register_method("params_register_app", self.memory.params_register_app)
-        self.bridge.register_method("history_get", self.memory._history_get)
-        self.bridge.register_method("presence_set", self._set_presence)
-        self.bridge.register_method("subscription", self.subscription)
-        self.bridge.register_method("contact_add", self._add_contact)
-        self.bridge.register_method("contact_update", self._update_contact)
-        self.bridge.register_method("contact_del", self._del_contact)
-        self.bridge.register_method("roster_resync", self._roster_resync)
-        self.bridge.register_method("is_connected", self.is_connected)
-        self.bridge.register_method("action_launch", self._action_launch)
-        self.bridge.register_method("actions_get", self.actions_get)
-        self.bridge.register_method("progress_get", self._progress_get)
-        self.bridge.register_method("progress_get_all", self._progress_get_all)
-        self.bridge.register_method("menus_get", self.get_menus)
-        self.bridge.register_method("menu_help_get", self.get_menu_help)
-        self.bridge.register_method("menu_launch", self._launch_menu)
-        self.bridge.register_method("disco_infos", self.memory.disco._disco_infos)
-        self.bridge.register_method("disco_items", self.memory.disco._disco_items)
-        self.bridge.register_method("disco_find_by_features", self._find_by_features)
-        self.bridge.register_method("params_template_save", self.memory.save_xml)
-        self.bridge.register_method("params_template_load", self.memory.load_xml)
-        self.bridge.register_method("session_infos_get", self.get_session_infos)
-        self.bridge.register_method("devices_infos_get", self._get_devices_infos)
-        self.bridge.register_method("namespaces_get", self.get_namespaces)
-        self.bridge.register_method("image_check", self._image_check)
-        self.bridge.register_method("image_resize", self._image_resize)
-        self.bridge.register_method("image_generate_preview", self._image_generate_preview)
-        self.bridge.register_method("image_convert", self._image_convert)
-
-
-        await self.memory.initialise()
-        self.common_cache = cache.Cache(self, None)
-        log.info(_("Memory initialised"))
-        try:
-            self._import_plugins()
-            ui_contact_list.ContactList(self)
-            ui_profile_manager.ProfileManager(self)
-        except Exception as e:
-            log.error(f"Could not initialize backend: {e}")
-            sys.exit(1)
-        self._add_base_menus()
-
-        self.initialised.callback(None)
-        log.info(_("Backend is ready"))
-
-        # profile autoconnection must be done after self.initialised is called because
-        # start_session waits for it.
-        autoconnect_dict = await self.memory.storage.get_ind_param_values(
-            category='Connection', name='autoconnect_backend',
-        )
-        profiles_autoconnect = [p for p, v in autoconnect_dict.items() if C.bool(v)]
-        if not self.trigger.point("profilesAutoconnect", profiles_autoconnect):
-            return
-        if profiles_autoconnect:
-            log.info(D_(
-                "Following profiles will be connected automatically: {profiles}"
-                ).format(profiles= ', '.join(profiles_autoconnect)))
-        connect_d_list = []
-        for profile in profiles_autoconnect:
-            connect_d_list.append(defer.ensureDeferred(self.connect(profile)))
-
-        if connect_d_list:
-            results = await defer.DeferredList(connect_d_list)
-            for idx, (success, result) in enumerate(results):
-                if not success:
-                    profile = profiles_autoconnect[0]
-                    log.warning(
-                        _("Can't autoconnect profile {profile}: {reason}").format(
-                            profile = profile,
-                            reason = result)
-                    )
-
-    def _add_base_menus(self):
-        """Add base menus"""
-        encryption.EncryptionHandler._import_menus(self)
-
-    def _unimport_plugin(self, plugin_path):
-        """remove a plugin from sys.modules if it is there"""
-        try:
-            del sys.modules[plugin_path]
-        except KeyError:
-            pass
-
-    def _import_plugins(self):
-        """import all plugins found in plugins directory"""
-        # FIXME: module imported but cancelled should be deleted
-        # TODO: make this more generic and reusable in tools.common
-        # FIXME: should use imp
-        # TODO: do not import all plugins if no needed: component plugins are not needed
-        #       if we just use a client, and plugin blacklisting should be possible in
-        #       libervia.conf
-        plugins_path = Path(libervia.backend.plugins.__file__).parent
-        plugins_to_import = {}  # plugins we still have to import
-        for plug_path in plugins_path.glob("plugin_*"):
-            if plug_path.is_dir():
-                init_path = plug_path / f"__init__.{C.PLUGIN_EXT}"
-                if not init_path.exists():
-                    log.warning(
-                        f"{plug_path} doesn't appear to be a package, can't load it")
-                    continue
-                plug_name = plug_path.name
-            elif plug_path.is_file():
-                if plug_path.suffix != f".{C.PLUGIN_EXT}":
-                    continue
-                plug_name = plug_path.stem
-            else:
-                log.warning(
-                    f"{plug_path} is not a file or a dir, ignoring it")
-                continue
-            if not plug_name.isidentifier():
-                log.warning(
-                    f"{plug_name!r} is not a valid name for a plugin, ignoring it")
-                continue
-            plugin_path = f"libervia.backend.plugins.{plug_name}"
-            try:
-                __import__(plugin_path)
-            except exceptions.MissingModule as e:
-                self._unimport_plugin(plugin_path)
-                log.warning(
-                    "Can't import plugin [{path}] because of an unavailale third party "
-                    "module:\n{msg}".format(
-                        path=plugin_path, msg=e
-                    )
-                )
-                continue
-            except exceptions.CancelError as e:
-                log.info(
-                    "Plugin [{path}] cancelled its own import: {msg}".format(
-                        path=plugin_path, msg=e
-                    )
-                )
-                self._unimport_plugin(plugin_path)
-                continue
-            except Exception:
-                import traceback
-
-                log.error(
-                    _("Can't import plugin [{path}]:\n{error}").format(
-                        path=plugin_path, error=traceback.format_exc()
-                    )
-                )
-                self._unimport_plugin(plugin_path)
-                continue
-            mod = sys.modules[plugin_path]
-            plugin_info = mod.PLUGIN_INFO
-            import_name = plugin_info["import_name"]
-
-            plugin_modes = plugin_info["modes"] = set(
-                plugin_info.setdefault("modes", C.PLUG_MODE_DEFAULT)
-            )
-            if not plugin_modes.intersection(C.PLUG_MODE_BOTH):
-                log.error(
-                    f"Can't import plugin at {plugin_path}, invalid {C.PI_MODES!r} "
-                    f"value: {plugin_modes!r}"
-                )
-                continue
-
-            # if the plugin is an entry point, it must work in component mode
-            if plugin_info["type"] == C.PLUG_TYPE_ENTRY_POINT:
-                # if plugin is an entrypoint, we cache it
-                if C.PLUG_MODE_COMPONENT not in plugin_modes:
-                    log.error(
-                        _(
-                            "{type} type must be used with {mode} mode, ignoring plugin"
-                        ).format(type=C.PLUG_TYPE_ENTRY_POINT, mode=C.PLUG_MODE_COMPONENT)
-                    )
-                    self._unimport_plugin(plugin_path)
-                    continue
-
-            if import_name in plugins_to_import:
-                log.error(
-                    _(
-                        "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("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(
-                        _("Recommended plugin not found: {}").format(import_name)
-                    )
-                    return
-                msg = "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(
-                    "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(
-                        _("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.exception(
-                f"Can't load plugin \"{plugin_info['name']}\", ignoring it: {e}"
-            )
-            if optional:
-                return
-            raise ImportError("Error during initiation")
-        if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
-            self.plugins[import_name].is_handler = True
-        else:
-            self.plugins[import_name].is_handler = False
-        # we keep metadata as a Class attribute
-        self.plugins[import_name]._info = plugin_info
-        # TODO: test xmppclient presence and register handler parent
-
-    def plugins_unload(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.values():
-            try:
-                unload = plugin.unload
-            except AttributeError:
-                continue
-            else:
-                defers_list.append(utils.as_deferred(unload))
-        return defers_list
-
-    def _connect(self, profile_key, password="", options=None):
-        profile = self.memory.get_profile_name(profile_key)
-        return defer.ensureDeferred(self.connect(profile, password, options))
-
-    async def connect(
-        self, profile, password="", options=None, max_retries=C.XMPP_MAX_RETRIES):
-        """Connect a profile (i.e. connect client.component to XMPP server)
-
-        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 Libervia profile password
-        @param options (dict): connection options. Key can be:
-            -
-        @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
-        """
-        if options is None:
-            options = {}
-
-        await self.memory.start_session(password, profile)
-
-        if self.is_connected(profile):
-            log.info(_("already connected !"))
-            return True
-
-        if self.memory.is_component(profile):
-            await xmpp.SatXMPPComponent.start_connection(self, profile, max_retries)
-        else:
-            await xmpp.SatXMPPClient.start_connection(self, profile, max_retries)
-
-        return False
-
-    def disconnect(self, profile_key):
-        """disconnect from jabber server"""
-        # FIXME: client should not be deleted if only disconnected
-        #        it shoud be deleted only when session is finished
-        if not self.is_connected(profile_key):
-            # is_connected is checked here and not on client
-            # because client is deleted when session is ended
-            log.info(_("not connected !"))
-            return defer.succeed(None)
-        client = self.get_client(profile_key)
-        return client.entity_disconnect()
-
-    def features_get(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.get_client(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.items():
-            try:
-                features_d = utils.as_deferred(plugin.features_get, profile_key)
-            except AttributeError:
-                features_d = defer.succeed({})
-            features.append(features_d)
-
-        d_list = defer.DeferredList(features)
-
-        def build_features(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(
-                        "Error while getting features for {name}: {failure}".format(
-                            name=name, failure=data
-                        )
-                    )
-                    ret[name] = {}
-            return ret
-
-        d_list.addCallback(build_features, list(self.plugins.keys()))
-        return d_list
-
-    def _contact_get(self, entity_jid_s, profile_key):
-        client = self.get_client(profile_key)
-        entity_jid = jid.JID(entity_jid_s)
-        return defer.ensureDeferred(self.get_contact(client, entity_jid))
-
-    async def get_contact(self, client, entity_jid):
-        # we want to be sure that roster has been received
-        await client.roster.got_roster
-        item = client.roster.get_item(entity_jid)
-        if item is None:
-            raise exceptions.NotFound(f"{entity_jid} is not in roster!")
-        return (client.roster.get_attributes(item), list(item.groups))
-
-    def contacts_get(self, profile_key):
-        client = self.get_client(profile_key)
-
-        def got_roster(__):
-            ret = []
-            for item in client.roster.get_items():  # we get all items for client's roster
-                # and convert them to expected format
-                attr = client.roster.get_attributes(item)
-                # we use full() and not userhost() because jid with resources are allowed
-                # in roster, even if it's not common.
-                ret.append([item.entity.full(), attr, list(item.groups)])
-            return ret
-
-        return client.roster.got_roster.addCallback(got_roster)
-
-    def contacts_get_from_group(self, group, profile_key):
-        client = self.get_client(profile_key)
-        return [jid_.full() for jid_ in client.roster.get_jids_from_group(group)]
-
-    def purge_entity(self, profile):
-        """Remove reference to a profile client/component 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"))
-        else:
-            self.memory.purge_profile_session(profile)
-
-    def startService(self):
-        self._init()
-        log.info("Salut à toi ô mon frère !")
-
-    def stopService(self):
-        log.info("Salut aussi à Rantanplan")
-        return self.plugins_unload()
-
-    def run(self):
-        log.debug(_("running app"))
-        reactor.run()
-
-    def stop(self):
-        log.debug(_("stopping app"))
-        reactor.stop()
-
-    ## Misc methods ##
-
-    def get_jid_n_stream(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 (get_client is enough)
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile or not self.profiles[profile].is_connected():
-            return (None, None)
-        return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
-
-    def get_client(self, profile_key: str) -> xmpp.SatXMPPClient:
-        """Convenient method to get client from profile key
-
-        @return: the client
-        @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 used yet
-        """
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.ProfileKeyUnknown
-        try:
-            return self.profiles[profile]
-        except KeyError:
-            raise exceptions.NotFound(profile_key)
-
-    def get_clients(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
-        """
-        if not profile_key:
-            raise exceptions.DataError(_("profile_key must not be empty"))
-        try:
-            profile = self.memory.get_profile_name(profile_key, True)
-        except exceptions.ProfileUnknownError:
-            return []
-        if profile == C.PROF_KEY_ALL:
-            return list(self.profiles.values())
-        elif profile[0] == "@":  #  only profile keys can start with "@"
-            raise exceptions.ProfileKeyUnknown
-        return [self.profiles[profile]]
-
-    def _get_config(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 str(self.memory.config_get(section, name, ""))
-
-    def log_errback(self, failure_, msg=_("Unexpected error: {failure_}")):
-        """Generic errback logging
-
-        @param msg(unicode): error message ("failure_" key will be use for format)
-        can be used as last errback to show unexpected error
-        """
-        log.error(msg.format(failure_=failure_))
-        return failure_
-
-    #  namespaces
-
-    def register_namespace(self, short_name, namespace):
-        """associate a namespace to a short name"""
-        if short_name in self.ns_map:
-            raise exceptions.ConflictError("this short name is already used")
-        log.debug(f"registering namespace {short_name} => {namespace}")
-        self.ns_map[short_name] = namespace
-
-    def get_namespaces(self):
-        return self.ns_map
-
-    def get_namespace(self, short_name):
-        try:
-            return self.ns_map[short_name]
-        except KeyError:
-            raise exceptions.NotFound("namespace {short_name} is not registered"
-                                      .format(short_name=short_name))
-
-    def get_session_infos(self, profile_key):
-        """compile interesting data on current profile session"""
-        client = self.get_client(profile_key)
-        data = {
-            "jid": client.jid.full(),
-            "started": str(int(client.started))
-            }
-        return defer.succeed(data)
-
-    def _get_devices_infos(self, bare_jid, profile_key):
-        client = self.get_client(profile_key)
-        if not bare_jid:
-            bare_jid = None
-        d = defer.ensureDeferred(self.get_devices_infos(client, bare_jid))
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    async def get_devices_infos(self, client, bare_jid=None):
-        """compile data on an entity devices
-
-        @param bare_jid(jid.JID, None): bare jid of entity to check
-            None to use client own jid
-        @return (list[dict]): list of data, one item per resource.
-            Following keys can be set:
-                - resource(str): resource name
-        """
-        own_jid = client.jid.userhostJID()
-        if bare_jid is None:
-            bare_jid = own_jid
-        else:
-            bare_jid = jid.JID(bare_jid)
-        resources = self.memory.get_all_resources(client, bare_jid)
-        if bare_jid == own_jid:
-            # our own jid is not stored in memory's cache
-            resources.add(client.jid.resource)
-        ret_data = []
-        for resource in resources:
-            res_jid = copy.copy(bare_jid)
-            res_jid.resource = resource
-            cache_data = self.memory.entity_data_get(client, res_jid)
-            res_data = {
-                "resource": resource,
-            }
-            try:
-                presence = cache_data['presence']
-            except KeyError:
-                pass
-            else:
-                res_data['presence'] = {
-                    "show": presence.show,
-                    "priority": presence.priority,
-                    "statuses": presence.statuses,
-                }
-
-            disco = await self.get_disco_infos(client, res_jid)
-
-            for (category, type_), name in disco.identities.items():
-                identities = res_data.setdefault('identities', [])
-                identities.append({
-                    "name": name,
-                    "category": category,
-                    "type": type_,
-                })
-
-            ret_data.append(res_data)
-
-        return ret_data
-
-    # images
-
-    def _image_check(self, path):
-        report = image.check(self, path)
-        return data_format.serialise(report)
-
-    def _image_resize(self, path, width, height):
-        d = image.resize(path, (width, height))
-        d.addCallback(lambda new_image_path: str(new_image_path))
-        return d
-
-    def _image_generate_preview(self, path, profile_key):
-        client = self.get_client(profile_key)
-        d = defer.ensureDeferred(self.image_generate_preview(client, Path(path)))
-        d.addCallback(lambda preview_path: str(preview_path))
-        return d
-
-    async def image_generate_preview(self, client, path):
-        """Helper method to generate in cache a preview of an image
-
-        @param path(Path): path to the image
-        @return (Path): path to the generated preview
-        """
-        report = image.check(self, path, max_size=(300, 300))
-
-        if not report['too_large']:
-            # in the unlikely case that image is already smaller than a preview
-            preview_path = path
-        else:
-            # we use hash as id, to re-use potentially existing preview
-            path_hash = hashlib.sha256(str(path).encode()).hexdigest()
-            uid = f"{path.stem}_{path_hash}_preview"
-            filename = f"{uid}{path.suffix.lower()}"
-            metadata = client.cache.get_metadata(uid=uid)
-            if metadata is not None:
-                preview_path = metadata['path']
-            else:
-                with client.cache.cache_data(
-                    source='HOST_PREVIEW',
-                    uid=uid,
-                    filename=filename) as cache_f:
-
-                    preview_path = await image.resize(
-                        path,
-                        new_size=report['recommended_size'],
-                        dest=cache_f
-                    )
-
-        return preview_path
-
-    def _image_convert(self, source, dest, extra, profile_key):
-        client = self.get_client(profile_key) if profile_key else None
-        source = Path(source)
-        dest = None if not dest else Path(dest)
-        extra = data_format.deserialise(extra)
-        d = defer.ensureDeferred(self.image_convert(client, source, dest, extra))
-        d.addCallback(lambda dest_path: str(dest_path))
-        return d
-
-    async def image_convert(self, client, source, dest=None, extra=None):
-        """Helper method to convert an image from one format to an other
-
-        @param client(SatClient, None): client to use for caching
-            this parameter is only used if dest is None
-            if client is None, common cache will be used insted of profile cache
-        @param source(Path): path to the image to convert
-        @param dest(None, Path, file): where to save the converted file
-            - None: use a cache file (uid generated from hash of source)
-                file will be converted to PNG
-            - Path: path to the file to create/overwrite
-            - file: a file object which must be opened for writing in binary mode
-        @param extra(dict, None): conversion options
-            see [image.convert] for details
-        @return (Path): path to the converted image
-        @raise ValueError: an issue happened with source of dest
-        """
-        if not source.is_file:
-            raise ValueError(f"Source file {source} doesn't exist!")
-        if dest is None:
-            # we use hash as id, to re-use potentially existing conversion
-            path_hash = hashlib.sha256(str(source).encode()).hexdigest()
-            uid = f"{source.stem}_{path_hash}_convert_png"
-            filename = f"{uid}.png"
-            if client is None:
-                cache = self.common_cache
-            else:
-                cache = client.cache
-            metadata = cache.get_metadata(uid=uid)
-            if metadata is not None:
-                # there is already a conversion for this image in cache
-                return metadata['path']
-            else:
-                with cache.cache_data(
-                    source='HOST_IMAGE_CONVERT',
-                    uid=uid,
-                    filename=filename) as cache_f:
-
-                    converted_path = await image.convert(
-                        source,
-                        dest=cache_f,
-                        extra=extra
-                    )
-                return converted_path
-        else:
-            return await image.convert(source, dest, extra)
-
-
-    # local dirs
-
-    def get_local_path(
-        self,
-        client: Optional[SatXMPPEntity],
-        dir_name: str,
-        *extra_path: str,
-        component: bool = False,
-    ) -> Path:
-        """Retrieve path for local data
-
-        if path doesn't exist, it will be created
-        @param client: client instance
-            if not none, client.profile will be used as last path element
-        @param dir_name: name of the main path directory
-        @param *extra_path: extra path element(s) to use
-        @param component: if True, path will be prefixed with C.COMPONENTS_DIR
-        @return: path
-        """
-        local_dir = self.memory.config_get("", "local_dir")
-        if not local_dir:
-            raise exceptions.InternalError("local_dir must be set")
-        path_elts = []
-        if component:
-            path_elts.append(C.COMPONENTS_DIR)
-        path_elts.append(regex.path_escape(dir_name))
-        if extra_path:
-            path_elts.extend([regex.path_escape(p) for p in extra_path])
-        if client is not None:
-            path_elts.append(regex.path_escape(client.profile))
-        local_path = Path(*path_elts)
-        local_path.mkdir(0o700, parents=True, exist_ok=True)
-        return local_path
-
-    ## Client management ##
-
-    def param_set(self, name, value, category, security_limit, profile_key):
-        """set wanted paramater and notice observers"""
-        self.memory.param_set(name, value, category, security_limit, profile_key)
-
-    def is_connected(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.get_profile_name(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].is_connected()
-
-    ## Encryption ##
-
-    def register_encryption_plugin(self, *args, **kwargs):
-        return encryption.EncryptionHandler.register_plugin(*args, **kwargs)
-
-    def _message_encryption_start(self, to_jid_s, namespace, replace=False,
-                                profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return defer.ensureDeferred(
-            client.encryption.start(to_jid, namespace or None, replace))
-
-    def _message_encryption_stop(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return defer.ensureDeferred(
-            client.encryption.stop(to_jid))
-
-    def _message_encryption_get(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        session_data = client.encryption.getSession(to_jid)
-        return client.encryption.get_bridge_data(session_data)
-
-    def _encryption_namespace_get(self, name):
-        return encryption.EncryptionHandler.get_ns_from_name(name)
-
-    def _encryption_plugins_get(self):
-        plugins = encryption.EncryptionHandler.getPlugins()
-        ret = []
-        for p in plugins:
-            ret.append({
-                "name": p.name,
-                "namespace": p.namespace,
-                "priority": p.priority,
-                "directed": p.directed,
-                })
-        return data_format.serialise(ret)
-
-    def _encryption_trust_ui_get(self, to_jid_s, namespace, profile_key):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        d = defer.ensureDeferred(
-            client.encryption.get_trust_ui(to_jid, namespace=namespace or None))
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    ## XMPP methods ##
-
-    def _message_send(
-            self, to_jid_s, message, subject=None, mess_type="auto", extra_s="",
-            profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return client.sendMessage(
-            to_jid,
-            message,
-            subject,
-            mess_type,
-            data_format.deserialise(extra_s)
-        )
-
-    def _set_presence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
-        return self.presence_set(jid.JID(to) if to else None, show, statuses, profile_key)
-
-    def presence_set(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.get_profile_name(profile_key)
-        assert profile
-        priority = int(
-            self.memory.param_get_a("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.presence_update(
-            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.get_profile_name(profile_key)
-        assert profile
-        to_jid = jid.JID(raw_jid)
-        log.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 _add_contact(self, to_jid_s, profile_key):
-        return self.contact_add(jid.JID(to_jid_s), profile_key)
-
-    def contact_add(self, to_jid, profile_key):
-        """Add a contact in roster list"""
-        profile = self.memory.get_profile_name(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 _update_contact(self, to_jid_s, name, groups, profile_key):
-        client = self.get_client(profile_key)
-        return self.contact_update(client, jid.JID(to_jid_s), name, groups)
-
-    def contact_update(self, client, to_jid, name, groups):
-        """update a contact in roster list"""
-        roster_item = RosterItem(to_jid)
-        roster_item.name = name or u''
-        roster_item.groups = set(groups)
-        if not self.trigger.point("roster_update", client, roster_item):
-            return
-        return client.roster.setItem(roster_item)
-
-    def _del_contact(self, to_jid_s, profile_key):
-        return self.contact_del(jid.JID(to_jid_s), profile_key)
-
-    def contact_del(self, to_jid, profile_key):
-        """Remove contact from roster list"""
-        profile = self.memory.get_profile_name(profile_key)
-        assert profile
-        self.profiles[profile].presence.unsubscribe(to_jid)  # is not asynchronous
-        return self.profiles[profile].roster.removeItem(to_jid)
-
-    def _roster_resync(self, profile_key):
-        client = self.get_client(profile_key)
-        return client.roster.resync()
-
-    ## 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 check_feature(self, *args, **kwargs):
-        return self.memory.disco.check_feature(*args, **kwargs)
-
-    def check_features(self, *args, **kwargs):
-        return self.memory.disco.check_features(*args, **kwargs)
-
-    def has_identity(self, *args, **kwargs):
-        return self.memory.disco.has_identity(*args, **kwargs)
-
-    def get_disco_infos(self, *args, **kwargs):
-        return self.memory.disco.get_infos(*args, **kwargs)
-
-    def getDiscoItems(self, *args, **kwargs):
-        return self.memory.disco.get_items(*args, **kwargs)
-
-    def find_service_entity(self, *args, **kwargs):
-        return self.memory.disco.find_service_entity(*args, **kwargs)
-
-    def find_service_entities(self, *args, **kwargs):
-        return self.memory.disco.find_service_entities(*args, **kwargs)
-
-    def find_features_set(self, *args, **kwargs):
-        return self.memory.disco.find_features_set(*args, **kwargs)
-
-    def _find_by_features(self, namespaces, identities, bare_jids, service, roster, own_jid,
-                        local_device, profile_key):
-        client = self.get_client(profile_key)
-        identities = [tuple(i) for i in identities] if identities else None
-        return defer.ensureDeferred(self.find_by_features(
-            client, namespaces, identities, bare_jids, service, roster, own_jid,
-            local_device))
-
-    async def find_by_features(
-        self,
-        client: SatXMPPEntity,
-        namespaces: List[str],
-        identities: Optional[List[Tuple[str, str]]]=None,
-        bare_jids: bool=False,
-        service: bool=True,
-        roster: bool=True,
-        own_jid: bool=True,
-        local_device: bool=False
-    ) -> Tuple[
-        Dict[jid.JID, Tuple[str, str, str]],
-        Dict[jid.JID, Tuple[str, str, str]],
-        Dict[jid.JID, Tuple[str, str, str]]
-    ]:
-        """Retrieve all services or contacts managing a set a features
-
-        @param namespaces: features which must be handled
-        @param identities: if not None or empty,
-            only keep those identities
-            tuple must be (category, type)
-        @param bare_jids: retrieve only bare_jids if True
-            if False, retrieve full jid of connected devices
-        @param service: if True return service from our server
-        @param roster: if True, return entities in roster
-            full jid of all matching resources available will be returned
-        @param own_jid: if True, return profile's jid resources
-        @param local_device: if True, return profile's jid local resource
-            (i.e. client.jid)
-        @return: found entities in a tuple with:
-            - service entities
-            - own entities
-            - roster entities
-            Each element is a dict mapping from jid to a tuple with category, type and
-            name of the entity
-        """
-        assert isinstance(namespaces, list)
-        if not identities:
-            identities = None
-        if not namespaces and not identities:
-            raise exceptions.DataError(
-                "at least one namespace or one identity must be set"
-            )
-        found_service = {}
-        found_own = {}
-        found_roster = {}
-        if service:
-            services_jids = await self.find_features_set(client, namespaces)
-            services_jids = list(services_jids)  # we need a list to map results below
-            services_infos  = await defer.DeferredList(
-                [self.get_disco_infos(client, service_jid) for service_jid in services_jids]
-            )
-
-            for idx, (success, infos) in enumerate(services_infos):
-                service_jid = services_jids[idx]
-                if not success:
-                    log.warning(
-                        _("Can't find features for service {service_jid}, ignoring")
-                        .format(service_jid=service_jid.full()))
-                    continue
-                if (identities is not None
-                    and not set(infos.identities.keys()).issuperset(identities)):
-                    continue
-                found_identities = [
-                    (cat, type_, name or "")
-                    for (cat, type_), name in infos.identities.items()
-                ]
-                found_service[service_jid.full()] = found_identities
-
-        to_find = []
-        if own_jid:
-            to_find.append((found_own, [client.jid.userhostJID()]))
-        if roster:
-            to_find.append((found_roster, client.roster.get_jids()))
-
-        for found, jids in to_find:
-            full_jids = []
-            disco_defers = []
-
-            for jid_ in jids:
-                if jid_.resource:
-                    if bare_jids:
-                        continue
-                    resources = [jid_.resource]
-                else:
-                    if bare_jids:
-                        resources = [None]
-                    else:
-                        try:
-                            resources = self.memory.get_available_resources(client, jid_)
-                        except exceptions.UnknownEntityError:
-                            continue
-                        if not resources and jid_ == client.jid.userhostJID() and own_jid:
-                            # small hack to avoid missing our own resource when this
-                            # method is called at the very beginning of the session
-                            # and our presence has not been received yet
-                            resources = [client.jid.resource]
-                for resource in resources:
-                    full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
-                    if full_jid == client.jid and not local_device:
-                        continue
-                    full_jids.append(full_jid)
-
-                    disco_defers.append(self.get_disco_infos(client, full_jid))
-
-            d_list = defer.DeferredList(disco_defers)
-            # XXX: 10 seconds may be too low for slow connections (e.g. mobiles)
-            #      but for discovery, that's also the time the user will wait the first time
-            #      before seing the page, if something goes wrong.
-            d_list.addTimeout(10, reactor)
-            infos_data = await d_list
-
-            for idx, (success, infos) in enumerate(infos_data):
-                full_jid = full_jids[idx]
-                if not success:
-                    log.warning(
-                        _("Can't retrieve {full_jid} infos, ignoring")
-                        .format(full_jid=full_jid.full()))
-                    continue
-                if infos.features.issuperset(namespaces):
-                    if identities is not None and not set(
-                        infos.identities.keys()
-                    ).issuperset(identities):
-                        continue
-                    found_identities = [
-                        (cat, type_, name or "")
-                        for (cat, type_), name in infos.identities.items()
-                    ]
-                    found[full_jid.full()] = found_identities
-
-        return (found_service, found_own, found_roster)
-
-    ## Generic HMI ##
-
-    def _kill_action(self, keep_id, client):
-        log.debug("Killing action {} for timeout".format(keep_id))
-        client.actions[keep_id]
-
-    def action_new(
-        self,
-        action_data,
-        security_limit=C.NO_SECURITY_LIMIT,
-        keep_id=None,
-        profile=C.PROF_KEY_NONE,
-    ):
-        """Shortcut to bridge.action_new which generate an 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. The value will be used as callback_id, be sure to use an unique
-            value.
-            Action will be deleted after 30 min.
-        @param profile: %(doc_profile)s
-        """
-        if keep_id is not None:
-            id_ = keep_id
-            client = self.get_client(profile)
-            action_timer = reactor.callLater(60 * 30, self._kill_action, keep_id, client)
-            client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
-        else:
-            id_ = str(uuid.uuid4())
-
-        self.bridge.action_new(
-            data_format.serialise(action_data), id_, security_limit, profile
-        )
-
-    def actions_get(self, profile):
-        """Return current non answered actions
-
-        @param profile: %(doc_profile)s
-        """
-        client = self.get_client(profile)
-        return [
-            (data_format.serialise(action_tuple[0]), *action_tuple[1:-1])
-            for action_tuple in client.actions.values()
-        ]
-
-    def register_progress_cb(
-        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.get_client(profile)
-        if progress_id in client._progress_cb:
-            raise exceptions.ConflictError("Progress ID is not unique !")
-        client._progress_cb[progress_id] = (callback, metadata)
-
-    def remove_progress_cb(self, progress_id, profile):
-        """Remove a progress callback"""
-        client = self.get_client(profile)
-        try:
-            del client._progress_cb[progress_id]
-        except KeyError:
-            log.error(_("Trying to remove an unknow progress callback"))
-
-    def _progress_get(self, progress_id, profile):
-        data = self.progress_get(progress_id, profile)
-        return {k: str(v) for k, v in data.items()}
-
-    def progress_get(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.get_client(profile)
-        try:
-            data = client._progress_cb[progress_id][0](progress_id, profile)
-        except KeyError:
-            data = {}
-        return data
-
-    def _progress_get_all(self, profile_key):
-        progress_all = self.progress_get_all(profile_key)
-        for profile, progress_dict in progress_all.items():
-            for progress_id, data in progress_dict.items():
-                for key, value in data.items():
-                    data[key] = str(value)
-        return progress_all
-
-    def progress_get_all_metadata(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 [progress_started]
-        """
-        clients = self.get_clients(profile_key)
-        progress_all = {}
-        for client in clients:
-            profile = client.profile
-            progress_dict = {}
-            progress_all[profile] = progress_dict
-            for (
-                progress_id,
-                (__, progress_metadata),
-            ) in client._progress_cb.items():
-                progress_dict[progress_id] = progress_metadata
-        return progress_all
-
-    def progress_get_all(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 [progress_get]
-        """
-        clients = self.get_clients(profile_key)
-        progress_all = {}
-        for client in clients:
-            profile = client.profile
-            progress_dict = {}
-            progress_all[profile] = progress_dict
-            for progress_id, (progress_cb, __) in client._progress_cb.items():
-                progress_dict[progress_id] = progress_cb(progress_id, profile)
-        return progress_all
-
-    def register_callback(self, callback, *args, **kwargs):
-        """Register a callback.
-
-        @param callback(callable): method to call
-        @param kwargs: can contain:
-            with_data(bool): True if the callback use the optional data dict
-            force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid
-                               if possible
-            one_shot(bool): True to delete callback once it has been called
-        @return: id of the registered callback
-        """
-        callback_id = kwargs.pop("force_id", None)
-        if callback_id is None:
-            callback_id = str(uuid.uuid4())
-        else:
-            if callback_id in self._cb_map:
-                raise exceptions.ConflictError(_("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 purge_callback():
-                try:
-                    self.remove_callback(callback_id)
-                except KeyError:
-                    pass
-
-            reactor.callLater(1800, purge_callback)
-
-        return callback_id
-
-    def remove_callback(self, callback_id):
-        """ Remove a previously registered callback
-        @param callback_id: id returned by [register_callback] """
-        log.debug("Removing callback [%s]" % callback_id)
-        del self._cb_map[callback_id]
-
-    def _action_launch(
-        self,
-        callback_id: str,
-        data_s: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        d = self.launch_callback(
-            callback_id,
-            data_format.deserialise(data_s),
-            profile_key
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    def launch_callback(
-        self,
-        callback_id: str,
-        data: Optional[dict] = None,
-        profile_key: str = C.PROF_KEY_NONE
-    ) -> defer.Deferred:
-        """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
-        """
-        # FIXME: is it possible to use this method without profile connected? If not,
-        #     client must be used instead of profile_key
-        # FIXME: security limit need to be checked here
-        try:
-            client = self.get_client(profile_key)
-        except exceptions.NotFound:
-            # client is not available yet
-            profile = self.memory.get_profile_name(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("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.remove_callback(callback_id)
-
-        return utils.as_deferred(callback, *args, **kwargs)
-
-    # Menus management
-
-    def _get_menu_canonical_path(self, path):
-        """give canonical form of path
-
-        canonical form is a tuple of the path were every element is stripped and lowercase
-        @param path(iterable[unicode]): untranslated path to menu
-        @return (tuple[unicode]): canonical form of path
-        """
-        return tuple((p.lower().strip() for p in path))
-
-    def import_menu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT,
-                   help_string="", type_=C.MENU_GLOBAL):
-        r"""register a new menu for frontends
-
-        @param path(iterable[unicode]): path to go to the menu
-            (category/subcategory/.../item) (e.g.: ("File", "Open"))
-            /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
-            untranslated/lower case path can be used to identity a menu, for this reason
-            it must be unique independently of case.
-        @param callback(callable): method to be called when menuitem is selected, callable
-            or a callback id (string) as returned by [register_callback]
-        @param security_limit(int): %(doc_security_limit)s
-            /!\ security_limit MUST be added to data in launch_callback if used #TODO
-        @param help_string(unicode): string used to indicate what the menu do (can be
-            show as a tooltip).
-            /!\ use D_() instead of _() for translations
-        @param type(unicode): 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 (unicode): menu_id (same as callback_id)
-        """
-
-        if callable(callback):
-            callback_id = self.register_callback(callback, with_data=True)
-        elif isinstance(callback, str):
-            # 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.values():
-            if menu_data["path"] == path and menu_data["type"] == type_:
-                raise exceptions.ConflictError(
-                    _("A menu with the same path and type already exists")
-                )
-
-        path_canonical = self._get_menu_canonical_path(path)
-        menu_key = (type_, path_canonical)
-
-        if menu_key in self._menus_paths:
-            raise exceptions.ConflictError(
-                "this menu path is already used: {path} ({menu_key})".format(
-                    path=path_canonical, menu_key=menu_key
-                )
-            )
-
-        menu_data = {
-            "path": tuple(path),
-            "path_canonical": path_canonical,
-            "security_limit": security_limit,
-            "help_string": help_string,
-            "type": type_,
-        }
-
-        self._menus[callback_id] = menu_data
-        self._menus_paths[menu_key] = callback_id
-
-        return callback_id
-
-    def get_menus(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.items():
-            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
-            language_switch(language)
-            path_i18n = [_(elt) for elt in path]
-            language_switch()
-            extra = {}  # TODO: manage extra data like icon
-            ret.append((menu_id, type_, path, path_i18n, extra))
-
-        return ret
-
-    def _launch_menu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT,
-                    profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        return self.launch_menu(client, menu_type, path, data, security_limit)
-
-    def launch_menu(self, client, menu_type, path, data=None,
-        security_limit=C.NO_SECURITY_LIMIT):
-        """launch action a menu action
-
-        @param menu_type(unicode): type of menu to launch
-        @param path(iterable[unicode]): canonical path of the menu
-        @params data(dict): menu data
-        @raise NotFound: this path is not known
-        """
-        # FIXME: manage security_limit here
-        #        defaut security limit should be high instead of C.NO_SECURITY_LIMIT
-        canonical_path = self._get_menu_canonical_path(path)
-        menu_key = (menu_type, canonical_path)
-        try:
-            callback_id = self._menus_paths[menu_key]
-        except KeyError:
-            raise exceptions.NotFound(
-                "Can't find menu {path} ({menu_type})".format(
-                    path=canonical_path, menu_type=menu_type
-                )
-            )
-        return self.launch_callback(callback_id, data, client.profile)
-
-    def get_menu_help(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")
-        language_switch(language)
-        help_string = _(menu_data["help_string"])
-        language_switch()
-        return help_string
--- a/libervia/backend/plugins/plugin_xep_0082.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0082.py	Fri Jun 02 12:59:21 2023 +0200
@@ -19,7 +19,7 @@
 
 from libervia.backend.core.constants import Const as C
 from libervia.backend.core.i18n import D_
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.tools import xmpp_datetime
 
 
--- a/libervia/backend/plugins/plugin_xep_0373.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0373.py	Fri Jun 02 12:59:21 2023 +0200
@@ -35,7 +35,7 @@
 from libervia.backend.core.core_types import SatXMPPEntity
 from libervia.backend.core.i18n import _, D_
 from libervia.backend.core.log import getLogger, Logger
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.core.xmpp import SatXMPPClient
 from libervia.backend.memory import persistent
 from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
--- a/libervia/backend/plugins/plugin_xep_0374.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0374.py	Fri Jun 02 12:59:21 2023 +0200
@@ -26,7 +26,7 @@
 from libervia.backend.core.core_types import SatXMPPEntity
 from libervia.backend.core.i18n import _, D_
 from libervia.backend.core.log import getLogger, Logger
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.core.xmpp import SatXMPPClient
 from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
 from libervia.backend.plugins.plugin_xep_0334 import XEP_0334
--- a/libervia/backend/plugins/plugin_xep_0384.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0384.py	Fri Jun 02 12:59:21 2023 +0200
@@ -36,7 +36,7 @@
 from libervia.backend.core.core_types import MessageData, SatXMPPEntity
 from libervia.backend.core.i18n import _, D_
 from libervia.backend.core.log import getLogger, Logger
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.core.xmpp import SatXMPPClient
 from libervia.backend.memory import persistent
 from libervia.backend.plugins.plugin_misc_text_commands import TextCommands
--- a/libervia/backend/plugins/plugin_xep_0420.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/plugins/plugin_xep_0420.py	Fri Jun 02 12:59:21 2023 +0200
@@ -30,7 +30,7 @@
 from libervia.backend.core.constants import Const as C
 from libervia.backend.core.i18n import D_
 from libervia.backend.core.log import Logger, getLogger
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.tools.xml_tools import ElementParser
 from libervia.backend.plugins.plugin_xep_0033 import NS_ADDRESS
 from libervia.backend.plugins.plugin_xep_0082 import XEP_0082
--- a/libervia/backend/tools/stream.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/libervia/backend/tools/stream.py	Fri Jun 02 12:59:21 2023 +0200
@@ -31,7 +31,7 @@
 from twisted.protocols import basic
 from twisted.internet import interfaces
 
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 
 log = getLogger(__name__)
 
--- a/tests/unit/conftest.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/tests/unit/conftest.py	Fri Jun 02 12:59:21 2023 +0200
@@ -21,7 +21,7 @@
 from pytest import fixture
 from twisted.internet import defer
 from twisted.words.protocols.jabber import jid
-from libervia.backend.core.sat_main import LiberviaBackend
+from libervia.backend.core.main import LiberviaBackend
 from libervia.backend.tools import async_trigger as trigger
 from libervia.backend.core import xmpp
 
--- a/twisted/plugins/sat_plugin.py	Fri Jun 02 11:55:48 2023 +0200
+++ b/twisted/plugins/sat_plugin.py	Fri Jun 02 12:59:21 2023 +0200
@@ -69,7 +69,7 @@
         # XXX: Libervia must be imported after log configuration,
         #      because it write stuff to logs
         initialise(options.parent)
-        from libervia.backend.core.sat_main import LiberviaBackend
+        from libervia.backend.core.main import LiberviaBackend
         return LiberviaBackend()