New `jid`, `secret` and `autoconnect` field are used for component settings. When used, backend will automatically create the profile and set its relevant parameters, or check that existing profile is in sync (and update it if necessary). This make component installation easier to Libervia, as it is not necessary anymore to use the CLI frontend to install a component. rel 476
1918 lines
77 KiB
Python
1918 lines
77 KiB
Python
#!/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 libervia import backend
|
||
from libervia.backend.core.i18n import _, D_, language_switch
|
||
from libervia.backend.core import patches
|
||
from libervia.backend.models.tor import TorData
|
||
from libervia.backend.tools.config import (
|
||
BridgeSettings,
|
||
ComponentSettings,
|
||
DBusBridgeSettings,
|
||
PbBridgeSettings,
|
||
)
|
||
|
||
patches.apply()
|
||
from twisted.application import app, service
|
||
from twisted.internet import defer
|
||
from twisted.words.protocols.jabber import jid
|
||
from twisted.internet import reactor
|
||
from tx_xmpp.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 SatXMPPClient, 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 = {}
|
||
self._bridge = None
|
||
# map for short name to whole namespace,
|
||
# extended by plugins with register_namespace
|
||
self.ns_map = {
|
||
"disco#info": xmpp.NS_DISCO_INFO,
|
||
}
|
||
|
||
self.memory = memory.Memory(self)
|
||
|
||
# trigger are used to change Libervia behaviour
|
||
self.trigger = trigger.TriggerManager()
|
||
|
||
# FIXME: Seems not used anymore, maybe delete?
|
||
if self.memory.config.common.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
|
||
|
||
# XXX: we use callLater here to be sure that the reactor is running when doing
|
||
# post initialisation. This is notably important in background mode where
|
||
# reactor is run later.
|
||
reactor.callLater(0, lambda: 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(self):
|
||
assert self._bridge is not None
|
||
return self._bridge
|
||
|
||
@property
|
||
def bridge_name(self):
|
||
return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
|
||
|
||
async def _post_init(self):
|
||
try:
|
||
self._bridge, __ = await dynamic_import.load_and_init_bridge(
|
||
G.config, is_backend=True
|
||
)
|
||
except exceptions.BridgeInitError:
|
||
log.exception("bridge can't be initialised, can't start Libervia Backend")
|
||
# 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
|
||
)
|
||
self.bridge.register_method("tor_infos_get", self._get_tor_infos)
|
||
self.bridge.register_method("tor_mode_get", self._tor_mode_get)
|
||
|
||
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 exceptions.ConfigError as e:
|
||
log.error(
|
||
f"Configuration error, please check and fix your configuration: {e}"
|
||
)
|
||
self.stop(exit_code=C.EXIT_BAD_ARG)
|
||
return
|
||
except Exception:
|
||
log.exception(f"Could not initialize backend")
|
||
self.stop(exit_code=C.EXIT_ERROR)
|
||
return
|
||
self._add_base_menus()
|
||
|
||
self.init_pre_script.callback(None)
|
||
|
||
if (init_script := G.config.common.init_script) is not None:
|
||
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)
|
||
|
||
await self._sync_components_profiles()
|
||
|
||
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)
|
||
|
||
# We reparse configuration to handle plugin settings before initialising them.
|
||
self.memory.reparse_configuration()
|
||
|
||
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
|
||
|
||
async def _sync_components_profiles(self) -> None:
|
||
"""Synchronize components profiles data with settings.
|
||
|
||
``jid`` and ``secret`` are used in settings as helpers to easily create
|
||
autoconnecting components. This method syncs those settings with actual profiles.
|
||
"""
|
||
log.debug("Synchronizing component settings.")
|
||
for component_field_name in G.config.component.__class__.model_fields:
|
||
component_settings = cast(
|
||
ComponentSettings, getattr(G.config.component, component_field_name)
|
||
)
|
||
|
||
if component_settings.jid is not None:
|
||
# Automatic setting fields are used
|
||
assert component_settings.secret is not None
|
||
entry_point = component_settings._import_name
|
||
assert entry_point is not None
|
||
profile = entry_point
|
||
if not G.storage.has_profile(entry_point):
|
||
log.info(f"Creating profile {profile!r}")
|
||
await G.host.memory.create_profile(profile, "", entry_point)
|
||
# FIXME: why do we need to start session to set parameters? To check
|
||
# after parameters refactoring.
|
||
await G.host.memory.start_session("", profile)
|
||
await G.host.memory.param_set(
|
||
"JabberID",
|
||
str(component_settings.jid),
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
await G.host.memory.param_set(
|
||
"Password",
|
||
component_settings.secret,
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
await G.host.memory.param_set(
|
||
"autoconnect_backend",
|
||
component_settings.autoconnect,
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
log.info(f"Component profile {profile!r} set for auto-connection.")
|
||
else:
|
||
# Profile exists, check if values correspond to settings
|
||
try:
|
||
# FIXME: As for new profile condition above, we need to check if
|
||
# we can skip session starting to set profile parameters.
|
||
await G.host.memory.start_session("", profile)
|
||
except exceptions.PasswordError:
|
||
# Component passwords should be empty to allow for autoconnection.
|
||
log.warning(
|
||
f"Can't set automatically component profile for {profile}: "
|
||
"the password is not empty as expected."
|
||
)
|
||
continue
|
||
current_jid = await G.host.memory.param_get_a_async(
|
||
"JabberID", "Connection", profile_key=profile
|
||
)
|
||
if current_jid != str(component_settings.jid):
|
||
log.warning(
|
||
f"Component profile {profile!r} JID mismatch: "
|
||
f"current={current_jid}, settings={component_settings.jid}, "
|
||
f"updating from settings"
|
||
)
|
||
await G.host.memory.param_set(
|
||
"JabberID",
|
||
str(component_settings.jid),
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
|
||
current_password = await G.host.memory.param_get_a_async(
|
||
"Password", "Connection", profile_key=profile
|
||
)
|
||
if current_password != component_settings.secret:
|
||
log.warning(
|
||
f"Component profile {profile!r} secret mismatch, "
|
||
f"updating from settings"
|
||
)
|
||
await G.host.memory.param_set(
|
||
"Password",
|
||
component_settings.secret,
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
|
||
current_autoconnect = await G.host.memory.param_get_a_async(
|
||
"autoconnect_backend", "Connection", profile_key=profile
|
||
)
|
||
expected_autoconnect = component_settings.autoconnect
|
||
if current_autoconnect != expected_autoconnect:
|
||
log.warning(
|
||
f"Component profile {profile!r} autoconnect setting"
|
||
f" mismatch, updating from settings"
|
||
)
|
||
await G.host.memory.param_set(
|
||
"autoconnect_backend",
|
||
expected_autoconnect,
|
||
"Connection",
|
||
profile_key=profile,
|
||
)
|
||
|
||
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, exit_code: int | None = None):
|
||
log.debug(_("stopping app"))
|
||
if exit_code:
|
||
app._exit_code = exit_code
|
||
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) -> str:
|
||
"""Get the main configuration option
|
||
|
||
@param section: section of the config
|
||
@param name: name of the option
|
||
@return: Serialised otpion value
|
||
"""
|
||
# FIXME: This is a quick adaptation of old code.
|
||
if not section or section == "DEFAULT":
|
||
section = "common"
|
||
try:
|
||
value = getattr(getattr(G.config, section), name)
|
||
except:
|
||
raise exceptions.InternalError(f"Can't retrieve value {section=} {name=}.")
|
||
else:
|
||
return data_format.serialise(value)
|
||
|
||
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)
|
||
|
||
# infos
|
||
|
||
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
|
||
|
||
def _get_tor_infos(self, extra_s: str, profile_key: str) -> defer.Deferred[str]:
|
||
client = self.get_client(profile_key)
|
||
d = defer.ensureDeferred(
|
||
self.get_tor_infos(client, data_format.deserialise(extra_s))
|
||
)
|
||
d.addCallback(lambda tor_data: tor_data.model_dump_json())
|
||
return cast(defer.Deferred[str], d)
|
||
|
||
async def get_tor_infos(self, client: SatXMPPClient, extra: dict) -> TorData:
|
||
"""Return various informations on Tor daemon and its state with current profile.
|
||
|
||
@param client: Client instance.
|
||
@param extra: Extra data (reserver for future use).
|
||
@return: Tor data
|
||
@raise exceptions.FeatureNotFound: Tor is not available for this profile.
|
||
"""
|
||
return await TorData.from_client(client)
|
||
|
||
def _tor_mode_get(self, profile_key: str) -> defer.Deferred[str]:
|
||
client = self.get_client(profile_key)
|
||
return defer.succeed(client.tor_mode)
|
||
|
||
# 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 = G.config.common.local_dir
|
||
path_elts: list[Path | str] = [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)
|
||
d = client.sendMessage(
|
||
to_jid, message, subject, mess_type, data_format.deserialise(extra_s)
|
||
)
|
||
# sendMessage returns message data, but we must return nothing.
|
||
d.addCallback(lambda __: None)
|
||
return d
|
||
|
||
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 defer.ensureDeferred(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 has_feature(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
|