Mercurial > libervia-backend
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 |