Mercurial > libervia-backend
view libervia/backend/core/main.py @ 4370:0eaa50f21efb
plugin XEP-0461: Message Replies implementation:
Implement message replies. Thread ID are always added when a reply is initiated from
Libervia, so a thread can continue the reply.
rel 457
author | Goffi <goffi@goffi.org> |
---|---|
date | Tue, 06 May 2025 00:34:01 +0200 |
parents | d34b17bce612 |
children |
line wrap: on
line source
#!/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 import shutil from typing import Optional, List, Tuple, Dict, cast 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 import G 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.tools.common import async_process 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}") G.set_host(self) 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 # like initialised, but launched before init script is done, mainly useful for CLI # frontend, so it can be used in init script, while other frontends are waiting. self.init_pre_script = defer.Deferred() 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) # If set, a temporary dir in cache will be used to share files between backend and # frontends, useful when they are separated (e.g. when using containers). If # unset, a temporary dir will be automatically created in os-relevant location. use_local_shared_tmp: bool = C.bool( self.memory.config_get("", "use_local_shared_tmp", C.BOOL_FALSE) ) if use_local_shared_tmp: self.local_shared_path = self.get_local_path( None, C.CACHE_DIR, C.LOCAL_SHARED_DIR ) shutil.rmtree(self.local_shared_path, ignore_errors=True) self.local_shared_path.mkdir(0o700, parents=True, exist_ok=True) else: self.local_shared_path = None 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.split(".")[-1] == "dev0": # 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("init_pre_script", lambda: self.init_pre_script) 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) self.bridge.register_method("notification_add", self.memory._add_notification) self.bridge.register_method("notifications_get", self.memory._get_notifications) self.bridge.register_method( "notification_delete", self.memory._delete_notification ) self.bridge.register_method( "notifications_expired_clean", self.memory._notifications_expired_clean ) 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.init_pre_script.callback(None) init_script = self.memory.config_get(None, "init_script_path") if init_script is not None: init_script = cast(str, init_script) script_path = Path(init_script) if not script_path.exists(): log.error(f"Init script doesn't exist: {init_script}") sys.exit(C.EXIT_BAD_ARG) elif not script_path.is_file(): log.error(f"Init script is not a file: {init_script}") sys.exit(C.EXIT_BAD_ARG) else: log.info(f"Running init script {init_script!r}.") try: await async_process.run(str(init_script), verbose=True) except RuntimeError as e: log.error(f"Init script failed: {e}") self.stopService() sys.exit(C.EXIT_ERROR) 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 (ModuleNotFoundError, 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 (exceptions.NotFound, Exception) as e: if isinstance(e, exceptions.NotFound): # A warning is enough for a missing dependency. log.warning( f"Can't load plugin \"{plugin_info['name']}\", due to missing " f"dependency, ignoring it: {e}" ) else: log.exception(f"Can't load plugin \"{plugin_info['name']}\", ignoring it") if optional: return raise ImportError("Error during initiation") if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)): self.plugins[import_name].has_handlers = True else: self.plugins[import_name].has_handlers = 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): if self.local_shared_path is not None: log.debug("Cleaning shared temporary dir.") shutil.rmtree(self.local_shared_path, ignore_errors=True) 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( f"This short name {short_name!r} 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 = [local_dir] 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 "" 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 defer.ensureDeferred(self.memory.disco.has_feature(*args, **kwargs)) def check_feature(self, *args, **kwargs): return defer.ensureDeferred(self.memory.disco.check_feature(*args, **kwargs)) def check_features(self, *args, **kwargs): return defer.ensureDeferred(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) async def find_features_set(self, *args, **kwargs): return await 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.memory.disco.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