comparison libervia/backend/core/main.py @ 4073:7c5654c54fed

refactoring: rename `core.sat_main` to `core.main`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 12:59:21 +0200
parents libervia/backend/core/sat_main.py@040095a5dc7f
children 10b6ad569157
comparison
equal deleted inserted replaced
4072:040095a5dc7f 4073:7c5654c54fed
1 #!/usr/bin/env python3
2
3 # Libervia: an XMPP client
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import sys
20 import os.path
21 import uuid
22 import hashlib
23 import copy
24 from pathlib import Path
25 from typing import Optional, List, Tuple, Dict
26
27 from wokkel.data_form import Option
28 from libervia import backend
29 from libervia.backend.core.i18n import _, D_, language_switch
30 from libervia.backend.core import patches
31 patches.apply()
32 from twisted.application import service
33 from twisted.internet import defer
34 from twisted.words.protocols.jabber import jid
35 from twisted.internet import reactor
36 from wokkel.xmppim import RosterItem
37 from libervia.backend.core import xmpp
38 from libervia.backend.core import exceptions
39 from libervia.backend.core.core_types import SatXMPPEntity
40 from libervia.backend.core.log import getLogger
41
42 from libervia.backend.core.constants import Const as C
43 from libervia.backend.memory import memory
44 from libervia.backend.memory import cache
45 from libervia.backend.memory import encryption
46 from libervia.backend.tools import async_trigger as trigger
47 from libervia.backend.tools import utils
48 from libervia.backend.tools import image
49 from libervia.backend.tools.common import dynamic_import
50 from libervia.backend.tools.common import regex
51 from libervia.backend.tools.common import data_format
52 from libervia.backend.stdui import ui_contact_list, ui_profile_manager
53 import libervia.backend.plugins
54
55
56 log = getLogger(__name__)
57
58 class LiberviaBackend(service.Service):
59
60 def _init(self):
61 # we don't use __init__ to avoid doule initialisation with twistd
62 # this _init is called in startService
63 log.info(f"{C.APP_NAME} {self.full_version}")
64 self._cb_map = {} # map from callback_id to callbacks
65 # dynamic menus. key: callback_id, value: menu data (dictionnary)
66 self._menus = {}
67 self._menus_paths = {} # path to id. key: (menu_type, lower case tuple of path),
68 # value: menu id
69 self.initialised = defer.Deferred()
70 self.profiles = {}
71 self.plugins = {}
72 # map for short name to whole namespace,
73 # extended by plugins with register_namespace
74 self.ns_map = {
75 "x-data": xmpp.NS_X_DATA,
76 "disco#info": xmpp.NS_DISCO_INFO,
77 }
78
79 self.memory = memory.Memory(self)
80
81 # trigger are used to change Libervia behaviour
82 self.trigger = (
83 trigger.TriggerManager()
84 )
85
86 bridge_name = (
87 os.getenv("LIBERVIA_BRIDGE_NAME")
88 or self.memory.config_get("", "bridge", "dbus")
89 )
90
91 bridge_module = dynamic_import.bridge(bridge_name)
92 if bridge_module is None:
93 log.error(f"Can't find bridge module of name {bridge_name}")
94 sys.exit(1)
95 log.info(f"using {bridge_name} bridge")
96 try:
97 self.bridge = bridge_module.bridge()
98 except exceptions.BridgeInitError:
99 log.exception("bridge can't be initialised, can't start Libervia Backend")
100 sys.exit(1)
101
102 defer.ensureDeferred(self._post_init())
103
104 @property
105 def version(self):
106 """Return the short version of Libervia"""
107 return C.APP_VERSION
108
109 @property
110 def full_version(self):
111 """Return the full version of Libervia
112
113 In developement mode, release name and extra data are returned too
114 """
115 version = self.version
116 if version[-1] == "D":
117 # we are in debug version, we add extra data
118 try:
119 return self._version_cache
120 except AttributeError:
121 self._version_cache = "{} « {} » ({})".format(
122 version, C.APP_RELEASE_NAME, utils.get_repository_data(backend)
123 )
124 return self._version_cache
125 else:
126 return version
127
128 @property
129 def bridge_name(self):
130 return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
131
132 async def _post_init(self):
133 try:
134 bridge_pi = self.bridge.post_init
135 except AttributeError:
136 pass
137 else:
138 try:
139 await bridge_pi()
140 except Exception:
141 log.exception("Could not initialize bridge")
142 # because init is not complete at this stage, we use callLater
143 reactor.callLater(0, self.stop)
144 return
145
146 self.bridge.register_method("ready_get", lambda: self.initialised)
147 self.bridge.register_method("version_get", lambda: self.full_version)
148 self.bridge.register_method("features_get", self.features_get)
149 self.bridge.register_method("profile_name_get", self.memory.get_profile_name)
150 self.bridge.register_method("profiles_list_get", self.memory.get_profiles_list)
151 self.bridge.register_method("entity_data_get", self.memory._get_entity_data)
152 self.bridge.register_method("entities_data_get", self.memory._get_entities_data)
153 self.bridge.register_method("profile_create", self.memory.create_profile)
154 self.bridge.register_method("profile_delete_async", self.memory.profile_delete_async)
155 self.bridge.register_method("profile_start_session", self.memory.start_session)
156 self.bridge.register_method(
157 "profile_is_session_started", self.memory._is_session_started
158 )
159 self.bridge.register_method("profile_set_default", self.memory.profile_set_default)
160 self.bridge.register_method("connect", self._connect)
161 self.bridge.register_method("disconnect", self.disconnect)
162 self.bridge.register_method("contact_get", self._contact_get)
163 self.bridge.register_method("contacts_get", self.contacts_get)
164 self.bridge.register_method("contacts_get_from_group", self.contacts_get_from_group)
165 self.bridge.register_method("main_resource_get", self.memory._get_main_resource)
166 self.bridge.register_method(
167 "presence_statuses_get", self.memory._get_presence_statuses
168 )
169 self.bridge.register_method("sub_waiting_get", self.memory.sub_waiting_get)
170 self.bridge.register_method("message_send", self._message_send)
171 self.bridge.register_method("message_encryption_start",
172 self._message_encryption_start)
173 self.bridge.register_method("message_encryption_stop",
174 self._message_encryption_stop)
175 self.bridge.register_method("message_encryption_get",
176 self._message_encryption_get)
177 self.bridge.register_method("encryption_namespace_get",
178 self._encryption_namespace_get)
179 self.bridge.register_method("encryption_plugins_get", self._encryption_plugins_get)
180 self.bridge.register_method("encryption_trust_ui_get", self._encryption_trust_ui_get)
181 self.bridge.register_method("config_get", self._get_config)
182 self.bridge.register_method("param_set", self.param_set)
183 self.bridge.register_method("param_get_a", self.memory.get_string_param_a)
184 self.bridge.register_method("private_data_get", self.memory._private_data_get)
185 self.bridge.register_method("private_data_set", self.memory._private_data_set)
186 self.bridge.register_method("private_data_delete", self.memory._private_data_delete)
187 self.bridge.register_method("param_get_a_async", self.memory.async_get_string_param_a)
188 self.bridge.register_method(
189 "params_values_from_category_get_async",
190 self.memory._get_params_values_from_category,
191 )
192 self.bridge.register_method("param_ui_get", self.memory._get_params_ui)
193 self.bridge.register_method(
194 "params_categories_get", self.memory.params_categories_get
195 )
196 self.bridge.register_method("params_register_app", self.memory.params_register_app)
197 self.bridge.register_method("history_get", self.memory._history_get)
198 self.bridge.register_method("presence_set", self._set_presence)
199 self.bridge.register_method("subscription", self.subscription)
200 self.bridge.register_method("contact_add", self._add_contact)
201 self.bridge.register_method("contact_update", self._update_contact)
202 self.bridge.register_method("contact_del", self._del_contact)
203 self.bridge.register_method("roster_resync", self._roster_resync)
204 self.bridge.register_method("is_connected", self.is_connected)
205 self.bridge.register_method("action_launch", self._action_launch)
206 self.bridge.register_method("actions_get", self.actions_get)
207 self.bridge.register_method("progress_get", self._progress_get)
208 self.bridge.register_method("progress_get_all", self._progress_get_all)
209 self.bridge.register_method("menus_get", self.get_menus)
210 self.bridge.register_method("menu_help_get", self.get_menu_help)
211 self.bridge.register_method("menu_launch", self._launch_menu)
212 self.bridge.register_method("disco_infos", self.memory.disco._disco_infos)
213 self.bridge.register_method("disco_items", self.memory.disco._disco_items)
214 self.bridge.register_method("disco_find_by_features", self._find_by_features)
215 self.bridge.register_method("params_template_save", self.memory.save_xml)
216 self.bridge.register_method("params_template_load", self.memory.load_xml)
217 self.bridge.register_method("session_infos_get", self.get_session_infos)
218 self.bridge.register_method("devices_infos_get", self._get_devices_infos)
219 self.bridge.register_method("namespaces_get", self.get_namespaces)
220 self.bridge.register_method("image_check", self._image_check)
221 self.bridge.register_method("image_resize", self._image_resize)
222 self.bridge.register_method("image_generate_preview", self._image_generate_preview)
223 self.bridge.register_method("image_convert", self._image_convert)
224
225
226 await self.memory.initialise()
227 self.common_cache = cache.Cache(self, None)
228 log.info(_("Memory initialised"))
229 try:
230 self._import_plugins()
231 ui_contact_list.ContactList(self)
232 ui_profile_manager.ProfileManager(self)
233 except Exception as e:
234 log.error(f"Could not initialize backend: {e}")
235 sys.exit(1)
236 self._add_base_menus()
237
238 self.initialised.callback(None)
239 log.info(_("Backend is ready"))
240
241 # profile autoconnection must be done after self.initialised is called because
242 # start_session waits for it.
243 autoconnect_dict = await self.memory.storage.get_ind_param_values(
244 category='Connection', name='autoconnect_backend',
245 )
246 profiles_autoconnect = [p for p, v in autoconnect_dict.items() if C.bool(v)]
247 if not self.trigger.point("profilesAutoconnect", profiles_autoconnect):
248 return
249 if profiles_autoconnect:
250 log.info(D_(
251 "Following profiles will be connected automatically: {profiles}"
252 ).format(profiles= ', '.join(profiles_autoconnect)))
253 connect_d_list = []
254 for profile in profiles_autoconnect:
255 connect_d_list.append(defer.ensureDeferred(self.connect(profile)))
256
257 if connect_d_list:
258 results = await defer.DeferredList(connect_d_list)
259 for idx, (success, result) in enumerate(results):
260 if not success:
261 profile = profiles_autoconnect[0]
262 log.warning(
263 _("Can't autoconnect profile {profile}: {reason}").format(
264 profile = profile,
265 reason = result)
266 )
267
268 def _add_base_menus(self):
269 """Add base menus"""
270 encryption.EncryptionHandler._import_menus(self)
271
272 def _unimport_plugin(self, plugin_path):
273 """remove a plugin from sys.modules if it is there"""
274 try:
275 del sys.modules[plugin_path]
276 except KeyError:
277 pass
278
279 def _import_plugins(self):
280 """import all plugins found in plugins directory"""
281 # FIXME: module imported but cancelled should be deleted
282 # TODO: make this more generic and reusable in tools.common
283 # FIXME: should use imp
284 # TODO: do not import all plugins if no needed: component plugins are not needed
285 # if we just use a client, and plugin blacklisting should be possible in
286 # libervia.conf
287 plugins_path = Path(libervia.backend.plugins.__file__).parent
288 plugins_to_import = {} # plugins we still have to import
289 for plug_path in plugins_path.glob("plugin_*"):
290 if plug_path.is_dir():
291 init_path = plug_path / f"__init__.{C.PLUGIN_EXT}"
292 if not init_path.exists():
293 log.warning(
294 f"{plug_path} doesn't appear to be a package, can't load it")
295 continue
296 plug_name = plug_path.name
297 elif plug_path.is_file():
298 if plug_path.suffix != f".{C.PLUGIN_EXT}":
299 continue
300 plug_name = plug_path.stem
301 else:
302 log.warning(
303 f"{plug_path} is not a file or a dir, ignoring it")
304 continue
305 if not plug_name.isidentifier():
306 log.warning(
307 f"{plug_name!r} is not a valid name for a plugin, ignoring it")
308 continue
309 plugin_path = f"libervia.backend.plugins.{plug_name}"
310 try:
311 __import__(plugin_path)
312 except exceptions.MissingModule as e:
313 self._unimport_plugin(plugin_path)
314 log.warning(
315 "Can't import plugin [{path}] because of an unavailale third party "
316 "module:\n{msg}".format(
317 path=plugin_path, msg=e
318 )
319 )
320 continue
321 except exceptions.CancelError as e:
322 log.info(
323 "Plugin [{path}] cancelled its own import: {msg}".format(
324 path=plugin_path, msg=e
325 )
326 )
327 self._unimport_plugin(plugin_path)
328 continue
329 except Exception:
330 import traceback
331
332 log.error(
333 _("Can't import plugin [{path}]:\n{error}").format(
334 path=plugin_path, error=traceback.format_exc()
335 )
336 )
337 self._unimport_plugin(plugin_path)
338 continue
339 mod = sys.modules[plugin_path]
340 plugin_info = mod.PLUGIN_INFO
341 import_name = plugin_info["import_name"]
342
343 plugin_modes = plugin_info["modes"] = set(
344 plugin_info.setdefault("modes", C.PLUG_MODE_DEFAULT)
345 )
346 if not plugin_modes.intersection(C.PLUG_MODE_BOTH):
347 log.error(
348 f"Can't import plugin at {plugin_path}, invalid {C.PI_MODES!r} "
349 f"value: {plugin_modes!r}"
350 )
351 continue
352
353 # if the plugin is an entry point, it must work in component mode
354 if plugin_info["type"] == C.PLUG_TYPE_ENTRY_POINT:
355 # if plugin is an entrypoint, we cache it
356 if C.PLUG_MODE_COMPONENT not in plugin_modes:
357 log.error(
358 _(
359 "{type} type must be used with {mode} mode, ignoring plugin"
360 ).format(type=C.PLUG_TYPE_ENTRY_POINT, mode=C.PLUG_MODE_COMPONENT)
361 )
362 self._unimport_plugin(plugin_path)
363 continue
364
365 if import_name in plugins_to_import:
366 log.error(
367 _(
368 "Name conflict for import name [{import_name}], can't import "
369 "plugin [{name}]"
370 ).format(**plugin_info)
371 )
372 continue
373 plugins_to_import[import_name] = (plugin_path, mod, plugin_info)
374 while True:
375 try:
376 self._import_plugins_from_dict(plugins_to_import)
377 except ImportError:
378 pass
379 if not plugins_to_import:
380 break
381
382 def _import_plugins_from_dict(
383 self, plugins_to_import, import_name=None, optional=False
384 ):
385 """Recursively import and their dependencies in the right order
386
387 @param plugins_to_import(dict): key=import_name and values=(plugin_path, module,
388 plugin_info)
389 @param import_name(unicode, None): name of the plugin to import as found in
390 PLUGIN_INFO['import_name']
391 @param optional(bool): if False and plugin is not found, an ImportError exception
392 is raised
393 """
394 if import_name in self.plugins:
395 log.debug("Plugin {} already imported, passing".format(import_name))
396 return
397 if not import_name:
398 import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem()
399 else:
400 if not import_name in plugins_to_import:
401 if optional:
402 log.warning(
403 _("Recommended plugin not found: {}").format(import_name)
404 )
405 return
406 msg = "Dependency not found: {}".format(import_name)
407 log.error(msg)
408 raise ImportError(msg)
409 plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
410 dependencies = plugin_info.setdefault("dependencies", [])
411 recommendations = plugin_info.setdefault("recommendations", [])
412 for to_import in dependencies + recommendations:
413 if to_import not in self.plugins:
414 log.debug(
415 "Recursively import dependency of [%s]: [%s]"
416 % (import_name, to_import)
417 )
418 try:
419 self._import_plugins_from_dict(
420 plugins_to_import, to_import, to_import not in dependencies
421 )
422 except ImportError as e:
423 log.warning(
424 _("Can't import plugin {name}: {error}").format(
425 name=plugin_info["name"], error=e
426 )
427 )
428 if optional:
429 return
430 raise e
431 log.info("importing plugin: {}".format(plugin_info["name"]))
432 # we instanciate the plugin here
433 try:
434 self.plugins[import_name] = getattr(mod, plugin_info["main"])(self)
435 except Exception as e:
436 log.exception(
437 f"Can't load plugin \"{plugin_info['name']}\", ignoring it: {e}"
438 )
439 if optional:
440 return
441 raise ImportError("Error during initiation")
442 if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
443 self.plugins[import_name].is_handler = True
444 else:
445 self.plugins[import_name].is_handler = False
446 # we keep metadata as a Class attribute
447 self.plugins[import_name]._info = plugin_info
448 # TODO: test xmppclient presence and register handler parent
449
450 def plugins_unload(self):
451 """Call unload method on every loaded plugin, if exists
452
453 @return (D): A deferred which return None when all method have been called
454 """
455 # TODO: in the futur, it should be possible to hot unload a plugin
456 # pluging depending on the unloaded one should be unloaded too
457 # for now, just a basic call on plugin.unload is done
458 defers_list = []
459 for plugin in self.plugins.values():
460 try:
461 unload = plugin.unload
462 except AttributeError:
463 continue
464 else:
465 defers_list.append(utils.as_deferred(unload))
466 return defers_list
467
468 def _connect(self, profile_key, password="", options=None):
469 profile = self.memory.get_profile_name(profile_key)
470 return defer.ensureDeferred(self.connect(profile, password, options))
471
472 async def connect(
473 self, profile, password="", options=None, max_retries=C.XMPP_MAX_RETRIES):
474 """Connect a profile (i.e. connect client.component to XMPP server)
475
476 Retrieve the individual parameters, authenticate the profile
477 and initiate the connection to the associated XMPP server.
478 @param profile: %(doc_profile)s
479 @param password (string): the Libervia profile password
480 @param options (dict): connection options. Key can be:
481 -
482 @param max_retries (int): max number of connection retries
483 @return (D(bool)):
484 - True if the XMPP connection was already established
485 - False if the XMPP connection has been initiated (it may still fail)
486 @raise exceptions.PasswordError: Profile password is wrong
487 """
488 if options is None:
489 options = {}
490
491 await self.memory.start_session(password, profile)
492
493 if self.is_connected(profile):
494 log.info(_("already connected !"))
495 return True
496
497 if self.memory.is_component(profile):
498 await xmpp.SatXMPPComponent.start_connection(self, profile, max_retries)
499 else:
500 await xmpp.SatXMPPClient.start_connection(self, profile, max_retries)
501
502 return False
503
504 def disconnect(self, profile_key):
505 """disconnect from jabber server"""
506 # FIXME: client should not be deleted if only disconnected
507 # it shoud be deleted only when session is finished
508 if not self.is_connected(profile_key):
509 # is_connected is checked here and not on client
510 # because client is deleted when session is ended
511 log.info(_("not connected !"))
512 return defer.succeed(None)
513 client = self.get_client(profile_key)
514 return client.entity_disconnect()
515
516 def features_get(self, profile_key=C.PROF_KEY_NONE):
517 """Get available features
518
519 Return list of activated plugins and plugin specific data
520 @param profile_key: %(doc_profile_key)s
521 C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile
522 dependent)
523 @return (dict)[Deferred]: features data where:
524 - key is plugin import name, present only for activated plugins
525 - value is a an other dict, when meaning is specific to each plugin.
526 this dict is return by plugin's getFeature method.
527 If this method doesn't exists, an empty dict is returned.
528 """
529 try:
530 # FIXME: there is no method yet to check profile session
531 # as soon as one is implemented, it should be used here
532 self.get_client(profile_key)
533 except KeyError:
534 log.warning("Requesting features for a profile outside a session")
535 profile_key = C.PROF_KEY_NONE
536 except exceptions.ProfileNotSetError:
537 pass
538
539 features = []
540 for import_name, plugin in self.plugins.items():
541 try:
542 features_d = utils.as_deferred(plugin.features_get, profile_key)
543 except AttributeError:
544 features_d = defer.succeed({})
545 features.append(features_d)
546
547 d_list = defer.DeferredList(features)
548
549 def build_features(result, import_names):
550 assert len(result) == len(import_names)
551 ret = {}
552 for name, (success, data) in zip(import_names, result):
553 if success:
554 ret[name] = data
555 else:
556 log.warning(
557 "Error while getting features for {name}: {failure}".format(
558 name=name, failure=data
559 )
560 )
561 ret[name] = {}
562 return ret
563
564 d_list.addCallback(build_features, list(self.plugins.keys()))
565 return d_list
566
567 def _contact_get(self, entity_jid_s, profile_key):
568 client = self.get_client(profile_key)
569 entity_jid = jid.JID(entity_jid_s)
570 return defer.ensureDeferred(self.get_contact(client, entity_jid))
571
572 async def get_contact(self, client, entity_jid):
573 # we want to be sure that roster has been received
574 await client.roster.got_roster
575 item = client.roster.get_item(entity_jid)
576 if item is None:
577 raise exceptions.NotFound(f"{entity_jid} is not in roster!")
578 return (client.roster.get_attributes(item), list(item.groups))
579
580 def contacts_get(self, profile_key):
581 client = self.get_client(profile_key)
582
583 def got_roster(__):
584 ret = []
585 for item in client.roster.get_items(): # we get all items for client's roster
586 # and convert them to expected format
587 attr = client.roster.get_attributes(item)
588 # we use full() and not userhost() because jid with resources are allowed
589 # in roster, even if it's not common.
590 ret.append([item.entity.full(), attr, list(item.groups)])
591 return ret
592
593 return client.roster.got_roster.addCallback(got_roster)
594
595 def contacts_get_from_group(self, group, profile_key):
596 client = self.get_client(profile_key)
597 return [jid_.full() for jid_ in client.roster.get_jids_from_group(group)]
598
599 def purge_entity(self, profile):
600 """Remove reference to a profile client/component and purge cache
601
602 the garbage collector can then free the memory
603 """
604 try:
605 del self.profiles[profile]
606 except KeyError:
607 log.error(_("Trying to remove reference to a client not referenced"))
608 else:
609 self.memory.purge_profile_session(profile)
610
611 def startService(self):
612 self._init()
613 log.info("Salut à toi ô mon frère !")
614
615 def stopService(self):
616 log.info("Salut aussi à Rantanplan")
617 return self.plugins_unload()
618
619 def run(self):
620 log.debug(_("running app"))
621 reactor.run()
622
623 def stop(self):
624 log.debug(_("stopping app"))
625 reactor.stop()
626
627 ## Misc methods ##
628
629 def get_jid_n_stream(self, profile_key):
630 """Convenient method to get jid and stream from profile key
631 @return: tuple (jid, xmlstream) from profile, can be None"""
632 # TODO: deprecate this method (get_client is enough)
633 profile = self.memory.get_profile_name(profile_key)
634 if not profile or not self.profiles[profile].is_connected():
635 return (None, None)
636 return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
637
638 def get_client(self, profile_key: str) -> xmpp.SatXMPPClient:
639 """Convenient method to get client from profile key
640
641 @return: the client
642 @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist
643 @raise exceptions.NotFound: client is not available
644 This happen if profile has not been used yet
645 """
646 profile = self.memory.get_profile_name(profile_key)
647 if not profile:
648 raise exceptions.ProfileKeyUnknown
649 try:
650 return self.profiles[profile]
651 except KeyError:
652 raise exceptions.NotFound(profile_key)
653
654 def get_clients(self, profile_key):
655 """Convenient method to get list of clients from profile key
656
657 Manage list through profile_key like C.PROF_KEY_ALL
658 @param profile_key: %(doc_profile_key)s
659 @return: list of clients
660 """
661 if not profile_key:
662 raise exceptions.DataError(_("profile_key must not be empty"))
663 try:
664 profile = self.memory.get_profile_name(profile_key, True)
665 except exceptions.ProfileUnknownError:
666 return []
667 if profile == C.PROF_KEY_ALL:
668 return list(self.profiles.values())
669 elif profile[0] == "@": #  only profile keys can start with "@"
670 raise exceptions.ProfileKeyUnknown
671 return [self.profiles[profile]]
672
673 def _get_config(self, section, name):
674 """Get the main configuration option
675
676 @param section: section of the config file (None or '' for DEFAULT)
677 @param name: name of the option
678 @return: unicode representation of the option
679 """
680 return str(self.memory.config_get(section, name, ""))
681
682 def log_errback(self, failure_, msg=_("Unexpected error: {failure_}")):
683 """Generic errback logging
684
685 @param msg(unicode): error message ("failure_" key will be use for format)
686 can be used as last errback to show unexpected error
687 """
688 log.error(msg.format(failure_=failure_))
689 return failure_
690
691 #  namespaces
692
693 def register_namespace(self, short_name, namespace):
694 """associate a namespace to a short name"""
695 if short_name in self.ns_map:
696 raise exceptions.ConflictError("this short name is already used")
697 log.debug(f"registering namespace {short_name} => {namespace}")
698 self.ns_map[short_name] = namespace
699
700 def get_namespaces(self):
701 return self.ns_map
702
703 def get_namespace(self, short_name):
704 try:
705 return self.ns_map[short_name]
706 except KeyError:
707 raise exceptions.NotFound("namespace {short_name} is not registered"
708 .format(short_name=short_name))
709
710 def get_session_infos(self, profile_key):
711 """compile interesting data on current profile session"""
712 client = self.get_client(profile_key)
713 data = {
714 "jid": client.jid.full(),
715 "started": str(int(client.started))
716 }
717 return defer.succeed(data)
718
719 def _get_devices_infos(self, bare_jid, profile_key):
720 client = self.get_client(profile_key)
721 if not bare_jid:
722 bare_jid = None
723 d = defer.ensureDeferred(self.get_devices_infos(client, bare_jid))
724 d.addCallback(lambda data: data_format.serialise(data))
725 return d
726
727 async def get_devices_infos(self, client, bare_jid=None):
728 """compile data on an entity devices
729
730 @param bare_jid(jid.JID, None): bare jid of entity to check
731 None to use client own jid
732 @return (list[dict]): list of data, one item per resource.
733 Following keys can be set:
734 - resource(str): resource name
735 """
736 own_jid = client.jid.userhostJID()
737 if bare_jid is None:
738 bare_jid = own_jid
739 else:
740 bare_jid = jid.JID(bare_jid)
741 resources = self.memory.get_all_resources(client, bare_jid)
742 if bare_jid == own_jid:
743 # our own jid is not stored in memory's cache
744 resources.add(client.jid.resource)
745 ret_data = []
746 for resource in resources:
747 res_jid = copy.copy(bare_jid)
748 res_jid.resource = resource
749 cache_data = self.memory.entity_data_get(client, res_jid)
750 res_data = {
751 "resource": resource,
752 }
753 try:
754 presence = cache_data['presence']
755 except KeyError:
756 pass
757 else:
758 res_data['presence'] = {
759 "show": presence.show,
760 "priority": presence.priority,
761 "statuses": presence.statuses,
762 }
763
764 disco = await self.get_disco_infos(client, res_jid)
765
766 for (category, type_), name in disco.identities.items():
767 identities = res_data.setdefault('identities', [])
768 identities.append({
769 "name": name,
770 "category": category,
771 "type": type_,
772 })
773
774 ret_data.append(res_data)
775
776 return ret_data
777
778 # images
779
780 def _image_check(self, path):
781 report = image.check(self, path)
782 return data_format.serialise(report)
783
784 def _image_resize(self, path, width, height):
785 d = image.resize(path, (width, height))
786 d.addCallback(lambda new_image_path: str(new_image_path))
787 return d
788
789 def _image_generate_preview(self, path, profile_key):
790 client = self.get_client(profile_key)
791 d = defer.ensureDeferred(self.image_generate_preview(client, Path(path)))
792 d.addCallback(lambda preview_path: str(preview_path))
793 return d
794
795 async def image_generate_preview(self, client, path):
796 """Helper method to generate in cache a preview of an image
797
798 @param path(Path): path to the image
799 @return (Path): path to the generated preview
800 """
801 report = image.check(self, path, max_size=(300, 300))
802
803 if not report['too_large']:
804 # in the unlikely case that image is already smaller than a preview
805 preview_path = path
806 else:
807 # we use hash as id, to re-use potentially existing preview
808 path_hash = hashlib.sha256(str(path).encode()).hexdigest()
809 uid = f"{path.stem}_{path_hash}_preview"
810 filename = f"{uid}{path.suffix.lower()}"
811 metadata = client.cache.get_metadata(uid=uid)
812 if metadata is not None:
813 preview_path = metadata['path']
814 else:
815 with client.cache.cache_data(
816 source='HOST_PREVIEW',
817 uid=uid,
818 filename=filename) as cache_f:
819
820 preview_path = await image.resize(
821 path,
822 new_size=report['recommended_size'],
823 dest=cache_f
824 )
825
826 return preview_path
827
828 def _image_convert(self, source, dest, extra, profile_key):
829 client = self.get_client(profile_key) if profile_key else None
830 source = Path(source)
831 dest = None if not dest else Path(dest)
832 extra = data_format.deserialise(extra)
833 d = defer.ensureDeferred(self.image_convert(client, source, dest, extra))
834 d.addCallback(lambda dest_path: str(dest_path))
835 return d
836
837 async def image_convert(self, client, source, dest=None, extra=None):
838 """Helper method to convert an image from one format to an other
839
840 @param client(SatClient, None): client to use for caching
841 this parameter is only used if dest is None
842 if client is None, common cache will be used insted of profile cache
843 @param source(Path): path to the image to convert
844 @param dest(None, Path, file): where to save the converted file
845 - None: use a cache file (uid generated from hash of source)
846 file will be converted to PNG
847 - Path: path to the file to create/overwrite
848 - file: a file object which must be opened for writing in binary mode
849 @param extra(dict, None): conversion options
850 see [image.convert] for details
851 @return (Path): path to the converted image
852 @raise ValueError: an issue happened with source of dest
853 """
854 if not source.is_file:
855 raise ValueError(f"Source file {source} doesn't exist!")
856 if dest is None:
857 # we use hash as id, to re-use potentially existing conversion
858 path_hash = hashlib.sha256(str(source).encode()).hexdigest()
859 uid = f"{source.stem}_{path_hash}_convert_png"
860 filename = f"{uid}.png"
861 if client is None:
862 cache = self.common_cache
863 else:
864 cache = client.cache
865 metadata = cache.get_metadata(uid=uid)
866 if metadata is not None:
867 # there is already a conversion for this image in cache
868 return metadata['path']
869 else:
870 with cache.cache_data(
871 source='HOST_IMAGE_CONVERT',
872 uid=uid,
873 filename=filename) as cache_f:
874
875 converted_path = await image.convert(
876 source,
877 dest=cache_f,
878 extra=extra
879 )
880 return converted_path
881 else:
882 return await image.convert(source, dest, extra)
883
884
885 # local dirs
886
887 def get_local_path(
888 self,
889 client: Optional[SatXMPPEntity],
890 dir_name: str,
891 *extra_path: str,
892 component: bool = False,
893 ) -> Path:
894 """Retrieve path for local data
895
896 if path doesn't exist, it will be created
897 @param client: client instance
898 if not none, client.profile will be used as last path element
899 @param dir_name: name of the main path directory
900 @param *extra_path: extra path element(s) to use
901 @param component: if True, path will be prefixed with C.COMPONENTS_DIR
902 @return: path
903 """
904 local_dir = self.memory.config_get("", "local_dir")
905 if not local_dir:
906 raise exceptions.InternalError("local_dir must be set")
907 path_elts = []
908 if component:
909 path_elts.append(C.COMPONENTS_DIR)
910 path_elts.append(regex.path_escape(dir_name))
911 if extra_path:
912 path_elts.extend([regex.path_escape(p) for p in extra_path])
913 if client is not None:
914 path_elts.append(regex.path_escape(client.profile))
915 local_path = Path(*path_elts)
916 local_path.mkdir(0o700, parents=True, exist_ok=True)
917 return local_path
918
919 ## Client management ##
920
921 def param_set(self, name, value, category, security_limit, profile_key):
922 """set wanted paramater and notice observers"""
923 self.memory.param_set(name, value, category, security_limit, profile_key)
924
925 def is_connected(self, profile_key):
926 """Return connection status of profile
927
928 @param profile_key: key_word or profile name to determine profile name
929 @return: True if connected
930 """
931 profile = self.memory.get_profile_name(profile_key)
932 if not profile:
933 log.error(_("asking connection status for a non-existant profile"))
934 raise exceptions.ProfileUnknownError(profile_key)
935 if profile not in self.profiles:
936 return False
937 return self.profiles[profile].is_connected()
938
939 ## Encryption ##
940
941 def register_encryption_plugin(self, *args, **kwargs):
942 return encryption.EncryptionHandler.register_plugin(*args, **kwargs)
943
944 def _message_encryption_start(self, to_jid_s, namespace, replace=False,
945 profile_key=C.PROF_KEY_NONE):
946 client = self.get_client(profile_key)
947 to_jid = jid.JID(to_jid_s)
948 return defer.ensureDeferred(
949 client.encryption.start(to_jid, namespace or None, replace))
950
951 def _message_encryption_stop(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
952 client = self.get_client(profile_key)
953 to_jid = jid.JID(to_jid_s)
954 return defer.ensureDeferred(
955 client.encryption.stop(to_jid))
956
957 def _message_encryption_get(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
958 client = self.get_client(profile_key)
959 to_jid = jid.JID(to_jid_s)
960 session_data = client.encryption.getSession(to_jid)
961 return client.encryption.get_bridge_data(session_data)
962
963 def _encryption_namespace_get(self, name):
964 return encryption.EncryptionHandler.get_ns_from_name(name)
965
966 def _encryption_plugins_get(self):
967 plugins = encryption.EncryptionHandler.getPlugins()
968 ret = []
969 for p in plugins:
970 ret.append({
971 "name": p.name,
972 "namespace": p.namespace,
973 "priority": p.priority,
974 "directed": p.directed,
975 })
976 return data_format.serialise(ret)
977
978 def _encryption_trust_ui_get(self, to_jid_s, namespace, profile_key):
979 client = self.get_client(profile_key)
980 to_jid = jid.JID(to_jid_s)
981 d = defer.ensureDeferred(
982 client.encryption.get_trust_ui(to_jid, namespace=namespace or None))
983 d.addCallback(lambda xmlui: xmlui.toXml())
984 return d
985
986 ## XMPP methods ##
987
988 def _message_send(
989 self, to_jid_s, message, subject=None, mess_type="auto", extra_s="",
990 profile_key=C.PROF_KEY_NONE):
991 client = self.get_client(profile_key)
992 to_jid = jid.JID(to_jid_s)
993 return client.sendMessage(
994 to_jid,
995 message,
996 subject,
997 mess_type,
998 data_format.deserialise(extra_s)
999 )
1000
1001 def _set_presence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
1002 return self.presence_set(jid.JID(to) if to else None, show, statuses, profile_key)
1003
1004 def presence_set(self, to_jid=None, show="", statuses=None,
1005 profile_key=C.PROF_KEY_NONE):
1006 """Send our presence information"""
1007 if statuses is None:
1008 statuses = {}
1009 profile = self.memory.get_profile_name(profile_key)
1010 assert profile
1011 priority = int(
1012 self.memory.param_get_a("Priority", "Connection", profile_key=profile)
1013 )
1014 self.profiles[profile].presence.available(to_jid, show, statuses, priority)
1015 # XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not
1016 # broadcasted to generating resource)
1017 if "" in statuses:
1018 statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop("")
1019 self.bridge.presence_update(
1020 self.profiles[profile].jid.full(), show, int(priority), statuses, profile
1021 )
1022
1023 def subscription(self, subs_type, raw_jid, profile_key):
1024 """Called to manage subscription
1025 @param subs_type: subsciption type (cf RFC 3921)
1026 @param raw_jid: unicode entity's jid
1027 @param profile_key: profile"""
1028 profile = self.memory.get_profile_name(profile_key)
1029 assert profile
1030 to_jid = jid.JID(raw_jid)
1031 log.debug(
1032 _("subsciption request [%(subs_type)s] for %(jid)s")
1033 % {"subs_type": subs_type, "jid": to_jid.full()}
1034 )
1035 if subs_type == "subscribe":
1036 self.profiles[profile].presence.subscribe(to_jid)
1037 elif subs_type == "subscribed":
1038 self.profiles[profile].presence.subscribed(to_jid)
1039 elif subs_type == "unsubscribe":
1040 self.profiles[profile].presence.unsubscribe(to_jid)
1041 elif subs_type == "unsubscribed":
1042 self.profiles[profile].presence.unsubscribed(to_jid)
1043
1044 def _add_contact(self, to_jid_s, profile_key):
1045 return self.contact_add(jid.JID(to_jid_s), profile_key)
1046
1047 def contact_add(self, to_jid, profile_key):
1048 """Add a contact in roster list"""
1049 profile = self.memory.get_profile_name(profile_key)
1050 assert profile
1051 # presence is sufficient, as a roster push will be sent according to
1052 # RFC 6121 §3.1.2
1053 self.profiles[profile].presence.subscribe(to_jid)
1054
1055 def _update_contact(self, to_jid_s, name, groups, profile_key):
1056 client = self.get_client(profile_key)
1057 return self.contact_update(client, jid.JID(to_jid_s), name, groups)
1058
1059 def contact_update(self, client, to_jid, name, groups):
1060 """update a contact in roster list"""
1061 roster_item = RosterItem(to_jid)
1062 roster_item.name = name or u''
1063 roster_item.groups = set(groups)
1064 if not self.trigger.point("roster_update", client, roster_item):
1065 return
1066 return client.roster.setItem(roster_item)
1067
1068 def _del_contact(self, to_jid_s, profile_key):
1069 return self.contact_del(jid.JID(to_jid_s), profile_key)
1070
1071 def contact_del(self, to_jid, profile_key):
1072 """Remove contact from roster list"""
1073 profile = self.memory.get_profile_name(profile_key)
1074 assert profile
1075 self.profiles[profile].presence.unsubscribe(to_jid) # is not asynchronous
1076 return self.profiles[profile].roster.removeItem(to_jid)
1077
1078 def _roster_resync(self, profile_key):
1079 client = self.get_client(profile_key)
1080 return client.roster.resync()
1081
1082 ## Discovery ##
1083 # discovery methods are shortcuts to self.memory.disco
1084 # the main difference with client.disco is that self.memory.disco manage cache
1085
1086 def hasFeature(self, *args, **kwargs):
1087 return self.memory.disco.hasFeature(*args, **kwargs)
1088
1089 def check_feature(self, *args, **kwargs):
1090 return self.memory.disco.check_feature(*args, **kwargs)
1091
1092 def check_features(self, *args, **kwargs):
1093 return self.memory.disco.check_features(*args, **kwargs)
1094
1095 def has_identity(self, *args, **kwargs):
1096 return self.memory.disco.has_identity(*args, **kwargs)
1097
1098 def get_disco_infos(self, *args, **kwargs):
1099 return self.memory.disco.get_infos(*args, **kwargs)
1100
1101 def getDiscoItems(self, *args, **kwargs):
1102 return self.memory.disco.get_items(*args, **kwargs)
1103
1104 def find_service_entity(self, *args, **kwargs):
1105 return self.memory.disco.find_service_entity(*args, **kwargs)
1106
1107 def find_service_entities(self, *args, **kwargs):
1108 return self.memory.disco.find_service_entities(*args, **kwargs)
1109
1110 def find_features_set(self, *args, **kwargs):
1111 return self.memory.disco.find_features_set(*args, **kwargs)
1112
1113 def _find_by_features(self, namespaces, identities, bare_jids, service, roster, own_jid,
1114 local_device, profile_key):
1115 client = self.get_client(profile_key)
1116 identities = [tuple(i) for i in identities] if identities else None
1117 return defer.ensureDeferred(self.find_by_features(
1118 client, namespaces, identities, bare_jids, service, roster, own_jid,
1119 local_device))
1120
1121 async def find_by_features(
1122 self,
1123 client: SatXMPPEntity,
1124 namespaces: List[str],
1125 identities: Optional[List[Tuple[str, str]]]=None,
1126 bare_jids: bool=False,
1127 service: bool=True,
1128 roster: bool=True,
1129 own_jid: bool=True,
1130 local_device: bool=False
1131 ) -> Tuple[
1132 Dict[jid.JID, Tuple[str, str, str]],
1133 Dict[jid.JID, Tuple[str, str, str]],
1134 Dict[jid.JID, Tuple[str, str, str]]
1135 ]:
1136 """Retrieve all services or contacts managing a set a features
1137
1138 @param namespaces: features which must be handled
1139 @param identities: if not None or empty,
1140 only keep those identities
1141 tuple must be (category, type)
1142 @param bare_jids: retrieve only bare_jids if True
1143 if False, retrieve full jid of connected devices
1144 @param service: if True return service from our server
1145 @param roster: if True, return entities in roster
1146 full jid of all matching resources available will be returned
1147 @param own_jid: if True, return profile's jid resources
1148 @param local_device: if True, return profile's jid local resource
1149 (i.e. client.jid)
1150 @return: found entities in a tuple with:
1151 - service entities
1152 - own entities
1153 - roster entities
1154 Each element is a dict mapping from jid to a tuple with category, type and
1155 name of the entity
1156 """
1157 assert isinstance(namespaces, list)
1158 if not identities:
1159 identities = None
1160 if not namespaces and not identities:
1161 raise exceptions.DataError(
1162 "at least one namespace or one identity must be set"
1163 )
1164 found_service = {}
1165 found_own = {}
1166 found_roster = {}
1167 if service:
1168 services_jids = await self.find_features_set(client, namespaces)
1169 services_jids = list(services_jids) # we need a list to map results below
1170 services_infos = await defer.DeferredList(
1171 [self.get_disco_infos(client, service_jid) for service_jid in services_jids]
1172 )
1173
1174 for idx, (success, infos) in enumerate(services_infos):
1175 service_jid = services_jids[idx]
1176 if not success:
1177 log.warning(
1178 _("Can't find features for service {service_jid}, ignoring")
1179 .format(service_jid=service_jid.full()))
1180 continue
1181 if (identities is not None
1182 and not set(infos.identities.keys()).issuperset(identities)):
1183 continue
1184 found_identities = [
1185 (cat, type_, name or "")
1186 for (cat, type_), name in infos.identities.items()
1187 ]
1188 found_service[service_jid.full()] = found_identities
1189
1190 to_find = []
1191 if own_jid:
1192 to_find.append((found_own, [client.jid.userhostJID()]))
1193 if roster:
1194 to_find.append((found_roster, client.roster.get_jids()))
1195
1196 for found, jids in to_find:
1197 full_jids = []
1198 disco_defers = []
1199
1200 for jid_ in jids:
1201 if jid_.resource:
1202 if bare_jids:
1203 continue
1204 resources = [jid_.resource]
1205 else:
1206 if bare_jids:
1207 resources = [None]
1208 else:
1209 try:
1210 resources = self.memory.get_available_resources(client, jid_)
1211 except exceptions.UnknownEntityError:
1212 continue
1213 if not resources and jid_ == client.jid.userhostJID() and own_jid:
1214 # small hack to avoid missing our own resource when this
1215 # method is called at the very beginning of the session
1216 # and our presence has not been received yet
1217 resources = [client.jid.resource]
1218 for resource in resources:
1219 full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
1220 if full_jid == client.jid and not local_device:
1221 continue
1222 full_jids.append(full_jid)
1223
1224 disco_defers.append(self.get_disco_infos(client, full_jid))
1225
1226 d_list = defer.DeferredList(disco_defers)
1227 # XXX: 10 seconds may be too low for slow connections (e.g. mobiles)
1228 # but for discovery, that's also the time the user will wait the first time
1229 # before seing the page, if something goes wrong.
1230 d_list.addTimeout(10, reactor)
1231 infos_data = await d_list
1232
1233 for idx, (success, infos) in enumerate(infos_data):
1234 full_jid = full_jids[idx]
1235 if not success:
1236 log.warning(
1237 _("Can't retrieve {full_jid} infos, ignoring")
1238 .format(full_jid=full_jid.full()))
1239 continue
1240 if infos.features.issuperset(namespaces):
1241 if identities is not None and not set(
1242 infos.identities.keys()
1243 ).issuperset(identities):
1244 continue
1245 found_identities = [
1246 (cat, type_, name or "")
1247 for (cat, type_), name in infos.identities.items()
1248 ]
1249 found[full_jid.full()] = found_identities
1250
1251 return (found_service, found_own, found_roster)
1252
1253 ## Generic HMI ##
1254
1255 def _kill_action(self, keep_id, client):
1256 log.debug("Killing action {} for timeout".format(keep_id))
1257 client.actions[keep_id]
1258
1259 def action_new(
1260 self,
1261 action_data,
1262 security_limit=C.NO_SECURITY_LIMIT,
1263 keep_id=None,
1264 profile=C.PROF_KEY_NONE,
1265 ):
1266 """Shortcut to bridge.action_new which generate an id and keep for retrieval
1267
1268 @param action_data(dict): action data (see bridge documentation)
1269 @param security_limit: %(doc_security_limit)s
1270 @param keep_id(None, unicode): if not None, used to keep action for differed
1271 retrieval. The value will be used as callback_id, be sure to use an unique
1272 value.
1273 Action will be deleted after 30 min.
1274 @param profile: %(doc_profile)s
1275 """
1276 if keep_id is not None:
1277 id_ = keep_id
1278 client = self.get_client(profile)
1279 action_timer = reactor.callLater(60 * 30, self._kill_action, keep_id, client)
1280 client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
1281 else:
1282 id_ = str(uuid.uuid4())
1283
1284 self.bridge.action_new(
1285 data_format.serialise(action_data), id_, security_limit, profile
1286 )
1287
1288 def actions_get(self, profile):
1289 """Return current non answered actions
1290
1291 @param profile: %(doc_profile)s
1292 """
1293 client = self.get_client(profile)
1294 return [
1295 (data_format.serialise(action_tuple[0]), *action_tuple[1:-1])
1296 for action_tuple in client.actions.values()
1297 ]
1298
1299 def register_progress_cb(
1300 self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE
1301 ):
1302 """Register a callback called when progress is requested for id"""
1303 if metadata is None:
1304 metadata = {}
1305 client = self.get_client(profile)
1306 if progress_id in client._progress_cb:
1307 raise exceptions.ConflictError("Progress ID is not unique !")
1308 client._progress_cb[progress_id] = (callback, metadata)
1309
1310 def remove_progress_cb(self, progress_id, profile):
1311 """Remove a progress callback"""
1312 client = self.get_client(profile)
1313 try:
1314 del client._progress_cb[progress_id]
1315 except KeyError:
1316 log.error(_("Trying to remove an unknow progress callback"))
1317
1318 def _progress_get(self, progress_id, profile):
1319 data = self.progress_get(progress_id, profile)
1320 return {k: str(v) for k, v in data.items()}
1321
1322 def progress_get(self, progress_id, profile):
1323 """Return a dict with progress information
1324
1325 @param progress_id(unicode): unique id of the progressing element
1326 @param profile: %(doc_profile)s
1327 @return (dict): data with the following keys:
1328 'position' (int): current possition
1329 'size' (int): end_position
1330 if id doesn't exists (may be a finished progression), and empty dict is
1331 returned
1332 """
1333 client = self.get_client(profile)
1334 try:
1335 data = client._progress_cb[progress_id][0](progress_id, profile)
1336 except KeyError:
1337 data = {}
1338 return data
1339
1340 def _progress_get_all(self, profile_key):
1341 progress_all = self.progress_get_all(profile_key)
1342 for profile, progress_dict in progress_all.items():
1343 for progress_id, data in progress_dict.items():
1344 for key, value in data.items():
1345 data[key] = str(value)
1346 return progress_all
1347
1348 def progress_get_all_metadata(self, profile_key):
1349 """Return all progress metadata at once
1350
1351 @param profile_key: %(doc_profile)s
1352 if C.PROF_KEY_ALL is used, all progress metadata from all profiles are
1353 returned
1354 @return (dict[dict[dict]]): a dict which map profile to progress_dict
1355 progress_dict map progress_id to progress_data
1356 progress_metadata is the same dict as sent by [progress_started]
1357 """
1358 clients = self.get_clients(profile_key)
1359 progress_all = {}
1360 for client in clients:
1361 profile = client.profile
1362 progress_dict = {}
1363 progress_all[profile] = progress_dict
1364 for (
1365 progress_id,
1366 (__, progress_metadata),
1367 ) in client._progress_cb.items():
1368 progress_dict[progress_id] = progress_metadata
1369 return progress_all
1370
1371 def progress_get_all(self, profile_key):
1372 """Return all progress status at once
1373
1374 @param profile_key: %(doc_profile)s
1375 if C.PROF_KEY_ALL is used, all progress status from all profiles are returned
1376 @return (dict[dict[dict]]): a dict which map profile to progress_dict
1377 progress_dict map progress_id to progress_data
1378 progress_data is the same dict as returned by [progress_get]
1379 """
1380 clients = self.get_clients(profile_key)
1381 progress_all = {}
1382 for client in clients:
1383 profile = client.profile
1384 progress_dict = {}
1385 progress_all[profile] = progress_dict
1386 for progress_id, (progress_cb, __) in client._progress_cb.items():
1387 progress_dict[progress_id] = progress_cb(progress_id, profile)
1388 return progress_all
1389
1390 def register_callback(self, callback, *args, **kwargs):
1391 """Register a callback.
1392
1393 @param callback(callable): method to call
1394 @param kwargs: can contain:
1395 with_data(bool): True if the callback use the optional data dict
1396 force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid
1397 if possible
1398 one_shot(bool): True to delete callback once it has been called
1399 @return: id of the registered callback
1400 """
1401 callback_id = kwargs.pop("force_id", None)
1402 if callback_id is None:
1403 callback_id = str(uuid.uuid4())
1404 else:
1405 if callback_id in self._cb_map:
1406 raise exceptions.ConflictError(_("id already registered"))
1407 self._cb_map[callback_id] = (callback, args, kwargs)
1408
1409 if "one_shot" in kwargs: # One Shot callback are removed after 30 min
1410
1411 def purge_callback():
1412 try:
1413 self.remove_callback(callback_id)
1414 except KeyError:
1415 pass
1416
1417 reactor.callLater(1800, purge_callback)
1418
1419 return callback_id
1420
1421 def remove_callback(self, callback_id):
1422 """ Remove a previously registered callback
1423 @param callback_id: id returned by [register_callback] """
1424 log.debug("Removing callback [%s]" % callback_id)
1425 del self._cb_map[callback_id]
1426
1427 def _action_launch(
1428 self,
1429 callback_id: str,
1430 data_s: str,
1431 profile_key: str
1432 ) -> defer.Deferred:
1433 d = self.launch_callback(
1434 callback_id,
1435 data_format.deserialise(data_s),
1436 profile_key
1437 )
1438 d.addCallback(data_format.serialise)
1439 return d
1440
1441 def launch_callback(
1442 self,
1443 callback_id: str,
1444 data: Optional[dict] = None,
1445 profile_key: str = C.PROF_KEY_NONE
1446 ) -> defer.Deferred:
1447 """Launch a specific callback
1448
1449 @param callback_id: id of the action (callback) to launch
1450 @param data: optional data
1451 @profile_key: %(doc_profile_key)s
1452 @return: a deferred which fire a dict where key can be:
1453 - xmlui: a XMLUI need to be displayed
1454 - validated: if present, can be used to launch a callback, it can have the
1455 values
1456 - C.BOOL_TRUE
1457 - C.BOOL_FALSE
1458 """
1459 # FIXME: is it possible to use this method without profile connected? If not,
1460 # client must be used instead of profile_key
1461 # FIXME: security limit need to be checked here
1462 try:
1463 client = self.get_client(profile_key)
1464 except exceptions.NotFound:
1465 # client is not available yet
1466 profile = self.memory.get_profile_name(profile_key)
1467 if not profile:
1468 raise exceptions.ProfileUnknownError(
1469 _("trying to launch action with a non-existant profile")
1470 )
1471 else:
1472 profile = client.profile
1473 # we check if the action is kept, and remove it
1474 try:
1475 action_tuple = client.actions[callback_id]
1476 except KeyError:
1477 pass
1478 else:
1479 action_tuple[-1].cancel() # the last item is the action timer
1480 del client.actions[callback_id]
1481
1482 try:
1483 callback, args, kwargs = self._cb_map[callback_id]
1484 except KeyError:
1485 raise exceptions.DataError("Unknown callback id {}".format(callback_id))
1486
1487 if kwargs.get("with_data", False):
1488 if data is None:
1489 raise exceptions.DataError("Required data for this callback is missing")
1490 args, kwargs = (
1491 list(args)[:],
1492 kwargs.copy(),
1493 ) # we don't want to modify the original (kw)args
1494 args.insert(0, data)
1495 kwargs["profile"] = profile
1496 del kwargs["with_data"]
1497
1498 if kwargs.pop("one_shot", False):
1499 self.remove_callback(callback_id)
1500
1501 return utils.as_deferred(callback, *args, **kwargs)
1502
1503 # Menus management
1504
1505 def _get_menu_canonical_path(self, path):
1506 """give canonical form of path
1507
1508 canonical form is a tuple of the path were every element is stripped and lowercase
1509 @param path(iterable[unicode]): untranslated path to menu
1510 @return (tuple[unicode]): canonical form of path
1511 """
1512 return tuple((p.lower().strip() for p in path))
1513
1514 def import_menu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT,
1515 help_string="", type_=C.MENU_GLOBAL):
1516 r"""register a new menu for frontends
1517
1518 @param path(iterable[unicode]): path to go to the menu
1519 (category/subcategory/.../item) (e.g.: ("File", "Open"))
1520 /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
1521 untranslated/lower case path can be used to identity a menu, for this reason
1522 it must be unique independently of case.
1523 @param callback(callable): method to be called when menuitem is selected, callable
1524 or a callback id (string) as returned by [register_callback]
1525 @param security_limit(int): %(doc_security_limit)s
1526 /!\ security_limit MUST be added to data in launch_callback if used #TODO
1527 @param help_string(unicode): string used to indicate what the menu do (can be
1528 show as a tooltip).
1529 /!\ use D_() instead of _() for translations
1530 @param type(unicode): one of:
1531 - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g.
1532 something like File/Open)
1533 - C.MENU_ROOM: like a global menu, but only shown in multi-user chat
1534 menu_data must contain a "room_jid" data
1535 - C.MENU_SINGLE: like a global menu, but only shown in one2one chat
1536 menu_data must contain a "jid" data
1537 - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc
1538 commands, jid is already filled)
1539 menu_data must contain a "jid" data
1540 - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in
1541 roster.
1542 menu_data must contain a "room_jid" data
1543 - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish
1544 microblog, group is already filled)
1545 menu_data must contain a "group" data
1546 @return (unicode): menu_id (same as callback_id)
1547 """
1548
1549 if callable(callback):
1550 callback_id = self.register_callback(callback, with_data=True)
1551 elif isinstance(callback, str):
1552 # The callback is already registered
1553 callback_id = callback
1554 try:
1555 callback, args, kwargs = self._cb_map[callback_id]
1556 except KeyError:
1557 raise exceptions.DataError("Unknown callback id")
1558 kwargs["with_data"] = True # we have to be sure that we use extra data
1559 else:
1560 raise exceptions.DataError("Unknown callback type")
1561
1562 for menu_data in self._menus.values():
1563 if menu_data["path"] == path and menu_data["type"] == type_:
1564 raise exceptions.ConflictError(
1565 _("A menu with the same path and type already exists")
1566 )
1567
1568 path_canonical = self._get_menu_canonical_path(path)
1569 menu_key = (type_, path_canonical)
1570
1571 if menu_key in self._menus_paths:
1572 raise exceptions.ConflictError(
1573 "this menu path is already used: {path} ({menu_key})".format(
1574 path=path_canonical, menu_key=menu_key
1575 )
1576 )
1577
1578 menu_data = {
1579 "path": tuple(path),
1580 "path_canonical": path_canonical,
1581 "security_limit": security_limit,
1582 "help_string": help_string,
1583 "type": type_,
1584 }
1585
1586 self._menus[callback_id] = menu_data
1587 self._menus_paths[menu_key] = callback_id
1588
1589 return callback_id
1590
1591 def get_menus(self, language="", security_limit=C.NO_SECURITY_LIMIT):
1592 """Return all menus registered
1593
1594 @param language: language used for translation, or empty string for default
1595 @param security_limit: %(doc_security_limit)s
1596 @return: array of tuple with:
1597 - menu id (same as callback_id)
1598 - menu type
1599 - raw menu path (array of strings)
1600 - translated menu path
1601 - extra (dict(unicode, unicode)): extra data where key can be:
1602 - icon: name of the icon to use (TODO)
1603 - help_url: link to a page with more complete documentation (TODO)
1604 """
1605 ret = []
1606 for menu_id, menu_data in self._menus.items():
1607 type_ = menu_data["type"]
1608 path = menu_data["path"]
1609 menu_security_limit = menu_data["security_limit"]
1610 if security_limit != C.NO_SECURITY_LIMIT and (
1611 menu_security_limit == C.NO_SECURITY_LIMIT
1612 or menu_security_limit > security_limit
1613 ):
1614 continue
1615 language_switch(language)
1616 path_i18n = [_(elt) for elt in path]
1617 language_switch()
1618 extra = {} # TODO: manage extra data like icon
1619 ret.append((menu_id, type_, path, path_i18n, extra))
1620
1621 return ret
1622
1623 def _launch_menu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT,
1624 profile_key=C.PROF_KEY_NONE):
1625 client = self.get_client(profile_key)
1626 return self.launch_menu(client, menu_type, path, data, security_limit)
1627
1628 def launch_menu(self, client, menu_type, path, data=None,
1629 security_limit=C.NO_SECURITY_LIMIT):
1630 """launch action a menu action
1631
1632 @param menu_type(unicode): type of menu to launch
1633 @param path(iterable[unicode]): canonical path of the menu
1634 @params data(dict): menu data
1635 @raise NotFound: this path is not known
1636 """
1637 # FIXME: manage security_limit here
1638 # defaut security limit should be high instead of C.NO_SECURITY_LIMIT
1639 canonical_path = self._get_menu_canonical_path(path)
1640 menu_key = (menu_type, canonical_path)
1641 try:
1642 callback_id = self._menus_paths[menu_key]
1643 except KeyError:
1644 raise exceptions.NotFound(
1645 "Can't find menu {path} ({menu_type})".format(
1646 path=canonical_path, menu_type=menu_type
1647 )
1648 )
1649 return self.launch_callback(callback_id, data, client.profile)
1650
1651 def get_menu_help(self, menu_id, language=""):
1652 """return the help string of the menu
1653
1654 @param menu_id: id of the menu (same as callback_id)
1655 @param language: language used for translation, or empty string for default
1656 @param return: translated help
1657
1658 """
1659 try:
1660 menu_data = self._menus[menu_id]
1661 except KeyError:
1662 raise exceptions.DataError("Trying to access an unknown menu")
1663 language_switch(language)
1664 help_string = _(menu_data["help_string"])
1665 language_switch()
1666 return help_string