1969 lines
80 KiB
Python
1969 lines
80 KiB
Python
#!/usr/bin/env python3
|
||
|
||
# Libervia: an XMPP client
|
||
# Copyright (C) 2009-2026 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, 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 (
|
||
ComponentSettings,
|
||
)
|
||
|
||
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, self._post_init)
|
||
|
||
# before/shutdown event is called on backend shutdown, when it's still possible to
|
||
# call Deferred. it's used to call all cleaning methods (per-profile's
|
||
# "profile_disconnected", and per-plugin's "unload").
|
||
reactor.addSystemEventTrigger(
|
||
"before", "shutdown", lambda: defer.ensureDeferred(self._on_shutdown())
|
||
)
|
||
log.info("Shutdown trigger registered.")
|
||
|
||
@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]
|
||
|
||
def _post_init(self) -> None:
|
||
d = defer.ensureDeferred(self.post_init())
|
||
d.addErrback(lambda e: log.exception(f"Post Init error: {e}"))
|
||
|
||
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(
|
||
"history_last_messages_metadata_get",
|
||
self.memory.history_last_messages_metadata_get,
|
||
)
|
||
self.bridge.register_method(
|
||
"messages_display_state_set",
|
||
self.memory._messages_display_state_set,
|
||
)
|
||
self.bridge.register_method(
|
||
"messages_display_state_get",
|
||
self.memory._messages_display_state_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
|
||
# the bridge must be exported only once the plugins have been imported, so
|
||
# that clients can introspect the whole plugin interface
|
||
if hasattr(self.bridge, "export"):
|
||
await self.bridge.export()
|
||
|
||
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.stop(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
|
||
|
||
async def plugins_unload(self) -> None:
|
||
"""Call ``unload`` method (if any) on every loaded plugin.
|
||
|
||
Unloaded plugins are removed them from self.plugins, so ``unload`` is not called
|
||
twice.
|
||
"""
|
||
# TODO: in the futur, it should be possible to hot unload a plugin.
|
||
# Plugins depending on the unloaded one should be unloaded too; for now,
|
||
# just a basic call on plugin.unload is done.
|
||
if not self.plugins:
|
||
return
|
||
defers_list = []
|
||
for plugin in self.plugins.values():
|
||
try:
|
||
unload = plugin.unload
|
||
except AttributeError:
|
||
continue
|
||
else:
|
||
defers_list.append(utils.as_deferred(unload))
|
||
await defer.DeferredList(defers_list)
|
||
self.plugins.clear()
|
||
|
||
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 !")
|
||
|
||
async def _on_shutdown(self) -> None:
|
||
"""Clean up resources on shutdown.
|
||
|
||
Disconnect all profiles first so profile_disconnected callbacks fire, then unload
|
||
plugins and storage.
|
||
"""
|
||
await async_process.stop_all()
|
||
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.")
|
||
disconnect_defers = [
|
||
client.entity_disconnect()
|
||
for client in self.profiles.values()
|
||
if client.is_connected()
|
||
]
|
||
if disconnect_defers:
|
||
await defer.DeferredList(disconnect_defers)
|
||
await self.plugins_unload()
|
||
await G.storage.unload()
|
||
|
||
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_), identities_map in disco.identities.items():
|
||
for lang, identity in identities_map.items():
|
||
identities = res_data.setdefault("identities", [])
|
||
identity_entry = {
|
||
"name": identity.name,
|
||
"category": identity.category,
|
||
"type": identity.type,
|
||
}
|
||
if lang is not None:
|
||
identity_entry["lang"] = lang
|
||
identities.append(identity_entry)
|
||
|
||
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,
|
||
) -> defer.Deferred[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 cast(defer.Deferred[None], 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 defer.ensureDeferred(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: list[str],
|
||
identities: list[list[str]] | None,
|
||
bare_jids: bool,
|
||
service: bool,
|
||
roster: bool,
|
||
own_jid: bool,
|
||
local_device: bool,
|
||
profile_key: str,
|
||
) -> defer.Deferred[
|
||
tuple[
|
||
dict[jid.JID, list[tuple[str, str, str, str]]],
|
||
dict[jid.JID, list[tuple[str, str, str, str]]],
|
||
dict[jid.JID, list[tuple[str, str, str, str]]],
|
||
]
|
||
]:
|
||
client = self.get_client(profile_key)
|
||
identities_typed = cast(
|
||
list[tuple[str, str]] | None,
|
||
[tuple(i) for i in identities] if identities else None,
|
||
)
|
||
return defer.ensureDeferred(
|
||
self.find_by_features(
|
||
client,
|
||
namespaces,
|
||
identities_typed,
|
||
bare_jids,
|
||
service,
|
||
roster,
|
||
own_jid,
|
||
local_device,
|
||
)
|
||
)
|
||
|
||
async def find_by_features(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
namespaces: list[str],
|
||
identities: list[tuple[str, str]] | None = None,
|
||
bare_jids: bool = False,
|
||
service: bool = True,
|
||
roster: bool = True,
|
||
own_jid: bool = True,
|
||
local_device: bool = False,
|
||
) -> tuple[
|
||
dict[jid.JID, list[tuple[str, str, str, str]]],
|
||
dict[jid.JID, list[tuple[str, str, str, str]]],
|
||
dict[jid.JID, list[tuple[str, 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, lang 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_, lang or "", identity.name or "")
|
||
for (cat, type_), lang_identities in infos.identities.items()
|
||
for lang, identity in lang_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_, lang, identity.name or "")
|
||
for (cat, type_), lang_identities in infos.identities.items()
|
||
for lang, identity in lang_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
|