comparison sat/core/sat_main.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/core/sat_main.py@27539029a662
children 973d4551ffae
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT: a jabber client
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 import sat
21 from sat.core.i18n import _, languageSwitch
22 from twisted.application import service
23 from twisted.internet import defer
24 from twisted.words.protocols.jabber import jid
25 from twisted.internet import reactor
26 from wokkel.xmppim import RosterItem
27 from sat.core import xmpp
28 from sat.core import exceptions
29 from sat.core.log import getLogger
30 log = getLogger(__name__)
31 from sat.core.constants import Const as C
32 from sat.memory import memory
33 from sat.memory import cache
34 from sat.tools import trigger
35 from sat.tools import utils
36 from sat.tools.common import dynamic_import
37 from sat.tools.common import regex
38 from sat.stdui import ui_contact_list, ui_profile_manager
39 import sat.plugins
40 from glob import glob
41 import sys
42 import os.path
43 import uuid
44
45 try:
46 from collections import OrderedDict # only available from python 2.7
47 except ImportError:
48 from ordereddict import OrderedDict
49
50
51 class SAT(service.Service):
52
53 def __init__(self):
54 self._cb_map = {} # map from callback_id to callbacks
55 self._menus = OrderedDict() # dynamic menus. key: callback_id, value: menu data (dictionnary)
56 self._menus_paths = {} # path to id. key: (menu_type, lower case tuple of path), value: menu id
57 self.initialised = defer.Deferred()
58 self.profiles = {}
59 self.plugins = {}
60 self.ns_map = {u'x-data': u'jabber:x:data'} # map for short name to whole namespace,
61 # extended by plugins with registerNamespace
62 self.memory = memory.Memory(self)
63 self.trigger = trigger.TriggerManager() # trigger are used to change SàT behaviour
64
65 bridge_name = self.memory.getConfig('', 'bridge', 'dbus')
66
67 bridge_module = dynamic_import.bridge(bridge_name)
68 if bridge_module is None:
69 log.error(u"Can't find bridge module of name {}".format(bridge_name))
70 sys.exit(1)
71 log.info(u"using {} bridge".format(bridge_name))
72 try:
73 self.bridge = bridge_module.Bridge()
74 except exceptions.BridgeInitError:
75 log.error(u"Bridge can't be initialised, can't start SàT core")
76 sys.exit(1)
77 self.bridge.register_method("getReady", lambda: self.initialised)
78 self.bridge.register_method("getVersion", lambda: self.full_version)
79 self.bridge.register_method("getFeatures", self.getFeatures)
80 self.bridge.register_method("profileNameGet", self.memory.getProfileName)
81 self.bridge.register_method("profilesListGet", self.memory.getProfilesList)
82 self.bridge.register_method("getEntityData", lambda jid_, keys, profile: self.memory.getEntityData(jid.JID(jid_), keys, profile))
83 self.bridge.register_method("getEntitiesData", self.memory._getEntitiesData)
84 self.bridge.register_method("profileCreate", self.memory.createProfile)
85 self.bridge.register_method("asyncDeleteProfile", self.memory.asyncDeleteProfile)
86 self.bridge.register_method("profileStartSession", self.memory.startSession)
87 self.bridge.register_method("profileIsSessionStarted", self.memory._isSessionStarted)
88 self.bridge.register_method("profileSetDefault", self.memory.profileSetDefault)
89 self.bridge.register_method("connect", self._connect)
90 self.bridge.register_method("disconnect", self.disconnect)
91 self.bridge.register_method("getContacts", self.getContacts)
92 self.bridge.register_method("getContactsFromGroup", self.getContactsFromGroup)
93 self.bridge.register_method("getMainResource", self.memory._getMainResource)
94 self.bridge.register_method("getPresenceStatuses", self.memory._getPresenceStatuses)
95 self.bridge.register_method("getWaitingSub", self.memory.getWaitingSub)
96 self.bridge.register_method("messageSend", self._messageSend)
97 self.bridge.register_method("getConfig", self._getConfig)
98 self.bridge.register_method("setParam", self.setParam)
99 self.bridge.register_method("getParamA", self.memory.getStringParamA)
100 self.bridge.register_method("asyncGetParamA", self.memory.asyncGetStringParamA)
101 self.bridge.register_method("asyncGetParamsValuesFromCategory", self.memory.asyncGetParamsValuesFromCategory)
102 self.bridge.register_method("getParamsUI", self.memory.getParamsUI)
103 self.bridge.register_method("getParamsCategories", self.memory.getParamsCategories)
104 self.bridge.register_method("paramsRegisterApp", self.memory.paramsRegisterApp)
105 self.bridge.register_method("historyGet", self.memory._historyGet)
106 self.bridge.register_method("setPresence", self._setPresence)
107 self.bridge.register_method("subscription", self.subscription)
108 self.bridge.register_method("addContact", self._addContact)
109 self.bridge.register_method("updateContact", self._updateContact)
110 self.bridge.register_method("delContact", self._delContact)
111 self.bridge.register_method("isConnected", self.isConnected)
112 self.bridge.register_method("launchAction", self.launchCallback)
113 self.bridge.register_method("actionsGet", self.actionsGet)
114 self.bridge.register_method("progressGet", self._progressGet)
115 self.bridge.register_method("progressGetAll", self._progressGetAll)
116 self.bridge.register_method("menusGet", self.getMenus)
117 self.bridge.register_method("menuHelpGet", self.getMenuHelp)
118 self.bridge.register_method("menuLaunch", self._launchMenu)
119 self.bridge.register_method("discoInfos", self.memory.disco._discoInfos)
120 self.bridge.register_method("discoItems", self.memory.disco._discoItems)
121 self.bridge.register_method("discoFindByFeatures", self._findByFeatures)
122 self.bridge.register_method("saveParamsTemplate", self.memory.save_xml)
123 self.bridge.register_method("loadParamsTemplate", self.memory.load_xml)
124 self.bridge.register_method("sessionInfosGet", self.getSessionInfos)
125 self.bridge.register_method("namespacesGet", self.getNamespaces)
126
127 self.memory.initialized.addCallback(self._postMemoryInit)
128
129 @property
130 def version(self):
131 """Return the short version of SàT"""
132 return C.APP_VERSION
133
134 @property
135 def full_version(self):
136 """Return the full version of SàT (with release name and extra data when in development mode)"""
137 version = self.version
138 if version[-1] == 'D':
139 # we are in debug version, we add extra data
140 try:
141 return self._version_cache
142 except AttributeError:
143 self._version_cache = u"{} « {} » ({})".format(version, C.APP_RELEASE_NAME, utils.getRepositoryData(sat))
144 return self._version_cache
145 else:
146 return version
147
148 @property
149 def bridge_name(self):
150 return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
151
152 def _postMemoryInit(self, ignore):
153 """Method called after memory initialization is done"""
154 self.common_cache = cache.Cache(self, None)
155 log.info(_("Memory initialised"))
156 try:
157 self._import_plugins()
158 ui_contact_list.ContactList(self)
159 ui_profile_manager.ProfileManager(self)
160 except Exception as e:
161 log.error(_(u"Could not initialize backend: {reason}").format(
162 reason = str(e).decode('utf-8', 'ignore')))
163 sys.exit(1)
164 self.initialised.callback(None)
165 log.info(_(u"Backend is ready"))
166
167 def _unimport_plugin(self, plugin_path):
168 """remove a plugin from sys.modules if it is there"""
169 try:
170 del sys.modules[plugin_path]
171 except KeyError:
172 pass
173
174 def _import_plugins(self):
175 """Import all plugins found in plugins directory"""
176 # FIXME: module imported but cancelled should be deleted
177 # TODO: make this more generic and reusable in tools.common
178 # FIXME: should use imp
179 # TODO: do not import all plugins if no needed: component plugins are not needed if we
180 # just use a client, and plugin blacklisting should be possible in sat.conf
181 plugins_path = os.path.dirname(sat.plugins.__file__)
182 plugin_glob = "plugin*." + C.PLUGIN_EXT
183 plug_lst = [os.path.splitext(plugin)[0] for plugin in map(os.path.basename, glob(os.path.join(plugins_path, plugin_glob)))]
184 plugins_to_import = {} # plugins we still have to import
185 for plug in plug_lst:
186 plugin_path = 'sat.plugins.' + plug
187 try:
188 __import__(plugin_path)
189 except exceptions.MissingModule as e:
190 self._unimport_plugin(plugin_path)
191 log.warning(u"Can't import plugin [{path}] because of an unavailale third party module:\n{msg}".format(
192 path=plugin_path, msg=e))
193 continue
194 except exceptions.CancelError as e:
195 log.info(u"Plugin [{path}] cancelled its own import: {msg}".format(path=plugin_path, msg=e))
196 self._unimport_plugin(plugin_path)
197 continue
198 except Exception as e:
199 import traceback
200 log.error(_(u"Can't import plugin [{path}]:\n{error}").format(path=plugin_path, error=traceback.format_exc()))
201 self._unimport_plugin(plugin_path)
202 continue
203 mod = sys.modules[plugin_path]
204 plugin_info = mod.PLUGIN_INFO
205 import_name = plugin_info['import_name']
206
207 plugin_modes = plugin_info[u'modes'] = set(plugin_info.setdefault(u"modes", C.PLUG_MODE_DEFAULT))
208
209 # if the plugin is an entry point, it must work in component mode
210 if plugin_info[u'type'] == C.PLUG_TYPE_ENTRY_POINT:
211 # if plugin is an entrypoint, we cache it
212 if C.PLUG_MODE_COMPONENT not in plugin_modes:
213 log.error(_(u"{type} type must be used with {mode} mode, ignoring plugin").format(
214 type = C.PLUG_TYPE_ENTRY_POINT, mode = C.PLUG_MODE_COMPONENT))
215 self._unimport_plugin(plugin_path)
216 continue
217
218 if import_name in plugins_to_import:
219 log.error(_(u"Name conflict for import name [{import_name}], can't import plugin [{name}]").format(**plugin_info))
220 continue
221 plugins_to_import[import_name] = (plugin_path, mod, plugin_info)
222 while True:
223 try:
224 self._import_plugins_from_dict(plugins_to_import)
225 except ImportError:
226 pass
227 if not plugins_to_import:
228 break
229
230 def _import_plugins_from_dict(self, plugins_to_import, import_name=None, optional=False):
231 """Recursively import and their dependencies in the right order
232
233 @param plugins_to_import(dict): key=import_name and values=(plugin_path, module, plugin_info)
234 @param import_name(unicode, None): name of the plugin to import as found in PLUGIN_INFO['import_name']
235 @param optional(bool): if False and plugin is not found, an ImportError exception is raised
236 """
237 if import_name in self.plugins:
238 log.debug(u'Plugin {} already imported, passing'.format(import_name))
239 return
240 if not import_name:
241 import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem()
242 else:
243 if not import_name in plugins_to_import:
244 if optional:
245 log.warning(_(u"Recommended plugin not found: {}").format(import_name))
246 return
247 msg = u"Dependency not found: {}".format(import_name)
248 log.error(msg)
249 raise ImportError(msg)
250 plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
251 dependencies = plugin_info.setdefault("dependencies", [])
252 recommendations = plugin_info.setdefault("recommendations", [])
253 for to_import in dependencies + recommendations:
254 if to_import not in self.plugins:
255 log.debug(u'Recursively import dependency of [%s]: [%s]' % (import_name, to_import))
256 try:
257 self._import_plugins_from_dict(plugins_to_import, to_import, to_import not in dependencies)
258 except ImportError as e:
259 log.warning(_(u"Can't import plugin {name}: {error}").format(name=plugin_info['name'], error=e))
260 if optional:
261 return
262 raise e
263 log.info("importing plugin: {}".format(plugin_info['name']))
264 # we instanciate the plugin here
265 try:
266 self.plugins[import_name] = getattr(mod, plugin_info['main'])(self)
267 except Exception as e:
268 log.warning(u'Error while loading plugin "{name}", ignoring it: {error}'
269 .format(name=plugin_info['name'], error=e))
270 if optional:
271 return
272 raise ImportError(u"Error during initiation")
273 if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
274 self.plugins[import_name].is_handler = True
275 else:
276 self.plugins[import_name].is_handler = False
277 # we keep metadata as a Class attribute
278 self.plugins[import_name]._info = plugin_info
279 #TODO: test xmppclient presence and register handler parent
280
281 def pluginsUnload(self):
282 """Call unload method on every loaded plugin, if exists
283
284 @return (D): A deferred which return None when all method have been called
285 """
286 # TODO: in the futur, it should be possible to hot unload a plugin
287 # pluging depending on the unloaded one should be unloaded too
288 # for now, just a basic call on plugin.unload is done
289 defers_list = []
290 for plugin in self.plugins.itervalues():
291 try:
292 unload = plugin.unload
293 except AttributeError:
294 continue
295 else:
296 defers_list.append(defer.maybeDeferred(unload))
297 return defers_list
298
299 def _connect(self, profile_key, password='', options=None):
300 profile = self.memory.getProfileName(profile_key)
301 return self.connect(profile, password, options)
302
303 def connect(self, profile, password='', options=None, max_retries=C.XMPP_MAX_RETRIES):
304 """Connect a profile (i.e. connect client.component to XMPP server)
305
306 Retrieve the individual parameters, authenticate the profile
307 and initiate the connection to the associated XMPP server.
308 @param profile: %(doc_profile)s
309 @param password (string): the SàT profile password
310 @param options (dict): connection options. Key can be:
311 -
312 @param max_retries (int): max number of connection retries
313 @return (D(bool)):
314 - True if the XMPP connection was already established
315 - False if the XMPP connection has been initiated (it may still fail)
316 @raise exceptions.PasswordError: Profile password is wrong
317 """
318 if options is None:
319 options={}
320 def connectProfile(dummy=None):
321 if self.isConnected(profile):
322 log.info(_("already connected !"))
323 return True
324
325 if self.memory.isComponent(profile):
326 d = xmpp.SatXMPPComponent.startConnection(self, profile, max_retries)
327 else:
328 d = xmpp.SatXMPPClient.startConnection(self, profile, max_retries)
329 return d.addCallback(lambda dummy: False)
330
331 d = self.memory.startSession(password, profile)
332 d.addCallback(connectProfile)
333 return d
334
335 def disconnect(self, profile_key):
336 """disconnect from jabber server"""
337 # FIXME: client should not be deleted if only disconnected
338 # it shoud be deleted only when session is finished
339 if not self.isConnected(profile_key):
340 # isConnected is checked here and not on client
341 # because client is deleted when session is ended
342 log.info(_(u"not connected !"))
343 return defer.succeed(None)
344 client = self.getClient(profile_key)
345 return client.entityDisconnect()
346
347 def getFeatures(self, profile_key=C.PROF_KEY_NONE):
348 """Get available features
349
350 Return list of activated plugins and plugin specific data
351 @param profile_key: %(doc_profile_key)s
352 C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile dependent)
353 @return (dict)[Deferred]: features data where:
354 - key is plugin import name, present only for activated plugins
355 - value is a an other dict, when meaning is specific to each plugin.
356 this dict is return by plugin's getFeature method.
357 If this method doesn't exists, an empty dict is returned.
358 """
359 try:
360 # FIXME: there is no method yet to check profile session
361 # as soon as one is implemented, it should be used here
362 self.getClient(profile_key)
363 except KeyError:
364 log.warning("Requesting features for a profile outside a session")
365 profile_key = C.PROF_KEY_NONE
366 except exceptions.ProfileNotSetError:
367 pass
368
369 features = []
370 for import_name, plugin in self.plugins.iteritems():
371 try:
372 features_d = defer.maybeDeferred(plugin.getFeatures, profile_key)
373 except AttributeError:
374 features_d = defer.succeed({})
375 features.append(features_d)
376
377 d_list = defer.DeferredList(features)
378 def buildFeatures(result, import_names):
379 assert len(result) == len(import_names)
380 ret = {}
381 for name, (success, data) in zip (import_names, result):
382 if success:
383 ret[name] = data
384 else:
385 log.warning(u"Error while getting features for {name}: {failure}".format(
386 name=name, failure=data))
387 ret[name] = {}
388 return ret
389
390 d_list.addCallback(buildFeatures, self.plugins.keys())
391 return d_list
392
393 def getContacts(self, profile_key):
394 client = self.getClient(profile_key)
395 def got_roster(dummy):
396 ret = []
397 for item in client.roster.getItems(): # we get all items for client's roster
398 # and convert them to expected format
399 attr = client.roster.getAttributes(item)
400 ret.append([item.jid.userhost(), attr, item.groups])
401 return ret
402
403 return client.roster.got_roster.addCallback(got_roster)
404
405 def getContactsFromGroup(self, group, profile_key):
406 client = self.getClient(profile_key)
407 return [jid_.full() for jid_ in client.roster.getJidsFromGroup(group)]
408
409 def purgeEntity(self, profile):
410 """Remove reference to a profile client/component and purge cache
411
412 the garbage collector can then free the memory
413 """
414 try:
415 del self.profiles[profile]
416 except KeyError:
417 log.error(_("Trying to remove reference to a client not referenced"))
418 else:
419 self.memory.purgeProfileSession(profile)
420
421 def startService(self):
422 log.info(u"Salut à toi ô mon frère !")
423
424 def stopService(self):
425 log.info(u"Salut aussi à Rantanplan")
426 return self.pluginsUnload()
427
428 def run(self):
429 log.debug(_("running app"))
430 reactor.run()
431
432 def stop(self):
433 log.debug(_("stopping app"))
434 reactor.stop()
435
436 ## Misc methods ##
437
438 def getJidNStream(self, profile_key):
439 """Convenient method to get jid and stream from profile key
440 @return: tuple (jid, xmlstream) from profile, can be None"""
441 # TODO: deprecate this method (getClient is enough)
442 profile = self.memory.getProfileName(profile_key)
443 if not profile or not self.profiles[profile].isConnected():
444 return (None, None)
445 return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
446
447 def getClient(self, profile_key):
448 """Convenient method to get client from profile key
449
450 @return: client or None if it doesn't exist
451 @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist
452 @raise exceptions.NotFound: client is not available
453 This happen if profile has not been used yet
454 """
455 profile = self.memory.getProfileName(profile_key)
456 if not profile:
457 raise exceptions.ProfileKeyUnknown
458 try:
459 return self.profiles[profile]
460 except KeyError:
461 raise exceptions.NotFound(profile_key)
462
463 def getClients(self, profile_key):
464 """Convenient method to get list of clients from profile key (manage list through profile_key like C.PROF_KEY_ALL)
465
466 @param profile_key: %(doc_profile_key)s
467 @return: list of clients
468 """
469 if not profile_key:
470 raise exceptions.DataError(_(u'profile_key must not be empty'))
471 try:
472 profile = self.memory.getProfileName(profile_key, True)
473 except exceptions.ProfileUnknownError:
474 return []
475 if profile == C.PROF_KEY_ALL:
476 return self.profiles.values()
477 elif profile[0] == '@': # only profile keys can start with "@"
478 raise exceptions.ProfileKeyUnknown
479 return [self.profiles[profile]]
480
481 def _getConfig(self, section, name):
482 """Get the main configuration option
483
484 @param section: section of the config file (None or '' for DEFAULT)
485 @param name: name of the option
486 @return: unicode representation of the option
487 """
488 return unicode(self.memory.getConfig(section, name, ''))
489
490 def logErrback(self, failure_):
491 """generic errback logging
492
493 can be used as last errback to show unexpected error
494 """
495 log.error(_(u"Unexpected error: {}".format(failure_)))
496 return failure_
497
498 # namespaces
499
500 def registerNamespace(self, short_name, namespace):
501 """associate a namespace to a short name"""
502 if short_name in self.ns_map:
503 raise exceptions.ConflictError(u'this short name is already used')
504 self.ns_map[short_name] = namespace
505
506 def getNamespaces(self):
507 return self.ns_map
508
509 def getSessionInfos(self, profile_key):
510 """compile interesting data on current profile session"""
511 client = self.getClient(profile_key)
512 data = {
513 "jid": client.jid.full(),
514 "started": unicode(int(client.started)),
515 }
516 return defer.succeed(data)
517
518 # local dirs
519
520 def getLocalPath(self, client, dir_name, *extra_path, **kwargs):
521 """retrieve path for local data
522
523 if path doesn't exist, it will be created
524 @param client(SatXMPPClient, None): client instance
525 used when profile is set, can be None if profile is False
526 @param dir_name(unicode): name of the main path directory
527 @param component(bool): if True, path will be prefixed with C.COMPONENTS_DIR
528 @param profile(bool): if True, path will be suffixed by profile name
529 @param *extra_path: extra path element(s) to use
530 @return (unicode): path
531 """
532 # FIXME: component and profile are parsed with **kwargs because of python 2 limitations
533 # once moved to python 3, this can be fixed
534 component = kwargs.pop('component', False)
535 profile = kwargs.pop('profile', True)
536 assert not kwargs
537
538 path_elts = [self.memory.getConfig('', 'local_dir')]
539 if component:
540 path_elts.append(C.COMPONENTS_DIR)
541 path_elts.append(regex.pathEscape(dir_name))
542 if extra_path:
543 path_elts.extend([regex.pathEscape(p) for p in extra_path])
544 if profile:
545 regex.pathEscape(client.profile)
546 path = os.path.join(*path_elts)
547 if not os.path.exists(path):
548 os.makedirs(path)
549 return path
550
551 ## Client management ##
552
553 def setParam(self, name, value, category, security_limit, profile_key):
554 """set wanted paramater and notice observers"""
555 self.memory.setParam(name, value, category, security_limit, profile_key)
556
557 def isConnected(self, profile_key):
558 """Return connection status of profile
559 @param profile_key: key_word or profile name to determine profile name
560 @return: True if connected
561 """
562 profile = self.memory.getProfileName(profile_key)
563 if not profile:
564 log.error(_('asking connection status for a non-existant profile'))
565 raise exceptions.ProfileUnknownError(profile_key)
566 if profile not in self.profiles:
567 return False
568 return self.profiles[profile].isConnected()
569
570 ## XMPP methods ##
571
572 def _messageSend(self, to_jid_s, message, subject=None, mess_type='auto', extra=None, profile_key=C.PROF_KEY_NONE):
573 client = self.getClient(profile_key)
574 to_jid = jid.JID(to_jid_s)
575 #XXX: we need to use the dictionary comprehension because D-Bus return its own types, and pickle can't manage them. TODO: Need to find a better way
576 return client.sendMessage(to_jid, message, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()})
577
578 def _setPresence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
579 return self.setPresence(jid.JID(to) if to else None, show, statuses, profile_key)
580
581 def setPresence(self, to_jid=None, show="", statuses=None, profile_key=C.PROF_KEY_NONE):
582 """Send our presence information"""
583 if statuses is None:
584 statuses = {}
585 profile = self.memory.getProfileName(profile_key)
586 assert profile
587 priority = int(self.memory.getParamA("Priority", "Connection", profile_key=profile))
588 self.profiles[profile].presence.available(to_jid, show, statuses, priority)
589 #XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not broadcasted to generating resource)
590 if '' in statuses:
591 statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop('')
592 self.bridge.presenceUpdate(self.profiles[profile].jid.full(), show,
593 int(priority), statuses, profile)
594
595 def subscription(self, subs_type, raw_jid, profile_key):
596 """Called to manage subscription
597 @param subs_type: subsciption type (cf RFC 3921)
598 @param raw_jid: unicode entity's jid
599 @param profile_key: profile"""
600 profile = self.memory.getProfileName(profile_key)
601 assert profile
602 to_jid = jid.JID(raw_jid)
603 log.debug(_(u'subsciption request [%(subs_type)s] for %(jid)s') % {'subs_type': subs_type, 'jid': to_jid.full()})
604 if subs_type == "subscribe":
605 self.profiles[profile].presence.subscribe(to_jid)
606 elif subs_type == "subscribed":
607 self.profiles[profile].presence.subscribed(to_jid)
608 elif subs_type == "unsubscribe":
609 self.profiles[profile].presence.unsubscribe(to_jid)
610 elif subs_type == "unsubscribed":
611 self.profiles[profile].presence.unsubscribed(to_jid)
612
613 def _addContact(self, to_jid_s, profile_key):
614 return self.addContact(jid.JID(to_jid_s), profile_key)
615
616 def addContact(self, to_jid, profile_key):
617 """Add a contact in roster list"""
618 profile = self.memory.getProfileName(profile_key)
619 assert profile
620 # presence is sufficient, as a roster push will be sent according to RFC 6121 §3.1.2
621 self.profiles[profile].presence.subscribe(to_jid)
622
623 def _updateContact(self, to_jid_s, name, groups, profile_key):
624 return self.updateContact(jid.JID(to_jid_s), name, groups, profile_key)
625
626 def updateContact(self, to_jid, name, groups, profile_key):
627 """update a contact in roster list"""
628 profile = self.memory.getProfileName(profile_key)
629 assert profile
630 groups = set(groups)
631 roster_item = RosterItem(to_jid)
632 roster_item.name = name or None
633 roster_item.groups = set(groups)
634 return self.profiles[profile].roster.setItem(roster_item)
635
636 def _delContact(self, to_jid_s, profile_key):
637 return self.delContact(jid.JID(to_jid_s), profile_key)
638
639 def delContact(self, to_jid, profile_key):
640 """Remove contact from roster list"""
641 profile = self.memory.getProfileName(profile_key)
642 assert profile
643 self.profiles[profile].presence.unsubscribe(to_jid) # is not asynchronous
644 return self.profiles[profile].roster.removeItem(to_jid)
645
646 ## Discovery ##
647 # discovery methods are shortcuts to self.memory.disco
648 # the main difference with client.disco is that self.memory.disco manage cache
649
650 def hasFeature(self, *args, **kwargs):
651 return self.memory.disco.hasFeature(*args, **kwargs)
652
653 def checkFeature(self, *args, **kwargs):
654 return self.memory.disco.checkFeature(*args, **kwargs)
655
656 def checkFeatures(self, *args, **kwargs):
657 return self.memory.disco.checkFeatures(*args, **kwargs)
658
659 def getDiscoInfos(self, *args, **kwargs):
660 return self.memory.disco.getInfos(*args, **kwargs)
661
662 def getDiscoItems(self, *args, **kwargs):
663 return self.memory.disco.getItems(*args, **kwargs)
664
665 def findServiceEntity(self, *args, **kwargs):
666 return self.memory.disco.findServiceEntity(*args, **kwargs)
667
668 def findServiceEntities(self, *args, **kwargs):
669 return self.memory.disco.findServiceEntities(*args, **kwargs)
670
671 def findFeaturesSet(self, *args, **kwargs):
672 return self.memory.disco.findFeaturesSet(*args, **kwargs)
673
674 def _findByFeatures(self, namespaces, identities, bare_jids, service, roster, own_jid, profile_key):
675 client = self.getClient(profile_key)
676 return self.findByFeatures(client, namespaces, identities, bare_jids, service, roster, own_jid)
677
678 @defer.inlineCallbacks
679 def findByFeatures(self, client, namespaces, identities=None, bare_jids=False, service=True, roster=True, own_jid=True):
680 """retrieve all services or contacts managing a set a features
681
682 @param namespaces(list[unicode]): features which must be handled
683 @param identities(list[tuple[unicode,unicode]], None): if not None or empty, only keep those identities
684 tuple must by (category, type)
685 @param bare_jids(bool): retrieve only bare_jids if True
686 if False, retrieve full jid of connected devices
687 @param service(bool): if True return service from our roster
688 @param roster(bool): if True, return entities in roster
689 full jid of all matching resources available will be returned
690 @param own_jid(bool): if True, return profile's jid resources
691 @return (tuple(dict[jid.JID(), tuple[unicode, unicode, unicode]]*3)): found entities in a tuple with:
692 - service entities
693 - own entities
694 - roster entities
695 """
696 if not identities:
697 identities = None
698 if not namespaces and not identities:
699 raise exceptions.DataError("at least one namespace or one identity must be set")
700 found_service = {}
701 found_own = {}
702 found_roster = {}
703 if service:
704 services_jids = yield self.findFeaturesSet(client, namespaces)
705 for service_jid in services_jids:
706 infos = yield self.getDiscoInfos(client, service_jid)
707 if identities is not None and not set(infos.identities.keys()).issuperset(identities):
708 continue
709 found_identities = [(cat, type_, name or u'') for (cat, type_), name in infos.identities.iteritems()]
710 found_service[service_jid.full()] = found_identities
711
712 jids = []
713 if roster:
714 jids.extend(client.roster.getJids())
715 if own_jid:
716 jids.append(client.jid.userhostJID())
717
718 for found, jids in ((found_own, [client.jid.userhostJID()]),
719 (found_roster, client.roster.getJids())):
720 for jid_ in jids:
721 if jid_.resource:
722 if bare_jids:
723 continue
724 resources = [jid_.resource]
725 else:
726 if bare_jids:
727 resources = [None]
728 else:
729 try:
730 resources = self.memory.getAllResources(client, jid_)
731 except exceptions.UnknownEntityError:
732 continue
733 for resource in resources:
734 full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
735 infos = yield self.getDiscoInfos(client, full_jid)
736 if infos.features.issuperset(namespaces):
737 if identities is not None and not set(infos.identities.keys()).issuperset(identities):
738 continue
739 found_identities = [(cat, type_, name or u'') for (cat, type_), name in infos.identities.iteritems()]
740 found[full_jid.full()] = found_identities
741
742 defer.returnValue((found_service, found_own, found_roster))
743
744 ## Generic HMI ##
745
746 def _killAction(self, keep_id, client):
747 log.debug(u"Killing action {} for timeout".format(keep_id))
748 client.actions[keep_id]
749
750 def actionNew(self, action_data, security_limit=C.NO_SECURITY_LIMIT, keep_id=None, profile=C.PROF_KEY_NONE):
751 """Shortcut to bridge.actionNew which generate and id and keep for retrieval
752
753 @param action_data(dict): action data (see bridge documentation)
754 @param security_limit: %(doc_security_limit)s
755 @param keep_id(None, unicode): if not None, used to keep action for differed retrieval
756 must be set to the callback_id
757 action will be deleted after 30 min.
758 @param profile: %(doc_profile)s
759 """
760 id_ = unicode(uuid.uuid4())
761 if keep_id is not None:
762 client = self.getClient(profile)
763 action_timer = reactor.callLater(60*30, self._killAction, keep_id, client)
764 client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
765
766 self.bridge.actionNew(action_data, id_, security_limit, profile)
767
768 def actionsGet(self, profile):
769 """Return current non answered actions
770
771 @param profile: %(doc_profile)s
772 """
773 client = self.getClient(profile)
774 return [action_tuple[:-1] for action_tuple in client.actions.itervalues()]
775
776 def registerProgressCb(self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE):
777 """Register a callback called when progress is requested for id"""
778 if metadata is None:
779 metadata = {}
780 client = self.getClient(profile)
781 if progress_id in client._progress_cb:
782 raise exceptions.ConflictError(u"Progress ID is not unique !")
783 client._progress_cb[progress_id] = (callback, metadata)
784
785 def removeProgressCb(self, progress_id, profile):
786 """Remove a progress callback"""
787 client = self.getClient(profile)
788 try:
789 del client._progress_cb[progress_id]
790 except KeyError:
791 log.error(_(u"Trying to remove an unknow progress callback"))
792
793 def _progressGet(self, progress_id, profile):
794 data = self.progressGet(progress_id, profile)
795 return {k: unicode(v) for k,v in data.iteritems()}
796
797 def progressGet(self, progress_id, profile):
798 """Return a dict with progress information
799
800 @param progress_id(unicode): unique id of the progressing element
801 @param profile: %(doc_profile)s
802 @return (dict): data with the following keys:
803 'position' (int): current possition
804 'size' (int): end_position
805 if id doesn't exists (may be a finished progression), and empty dict is returned
806 """
807 client = self.getClient(profile)
808 try:
809 data = client._progress_cb[progress_id][0](progress_id, profile)
810 except KeyError:
811 data = {}
812 return data
813
814 def _progressGetAll(self, profile_key):
815 progress_all = self.progressGetAll(profile_key)
816 for profile, progress_dict in progress_all.iteritems():
817 for progress_id, data in progress_dict.iteritems():
818 for key, value in data.iteritems():
819 data[key] = unicode(value)
820 return progress_all
821
822 def progressGetAllMetadata(self, profile_key):
823 """Return all progress metadata at once
824
825 @param profile_key: %(doc_profile)s
826 if C.PROF_KEY_ALL is used, all progress metadata from all profiles are returned
827 @return (dict[dict[dict]]): a dict which map profile to progress_dict
828 progress_dict map progress_id to progress_data
829 progress_metadata is the same dict as sent by [progressStarted]
830 """
831 clients = self.getClients(profile_key)
832 progress_all = {}
833 for client in clients:
834 profile = client.profile
835 progress_dict = {}
836 progress_all[profile] = progress_dict
837 for progress_id, (dummy, progress_metadata) in client._progress_cb.iteritems():
838 progress_dict[progress_id] = progress_metadata
839 return progress_all
840
841 def progressGetAll(self, profile_key):
842 """Return all progress status at once
843
844 @param profile_key: %(doc_profile)s
845 if C.PROF_KEY_ALL is used, all progress status from all profiles are returned
846 @return (dict[dict[dict]]): a dict which map profile to progress_dict
847 progress_dict map progress_id to progress_data
848 progress_data is the same dict as returned by [progressGet]
849 """
850 clients = self.getClients(profile_key)
851 progress_all = {}
852 for client in clients:
853 profile = client.profile
854 progress_dict = {}
855 progress_all[profile] = progress_dict
856 for progress_id, (progress_cb, dummy) in client._progress_cb.iteritems():
857 progress_dict[progress_id] = progress_cb(progress_id, profile)
858 return progress_all
859
860 def registerCallback(self, callback, *args, **kwargs):
861 """Register a callback.
862
863 @param callback(callable): method to call
864 @param kwargs: can contain:
865 with_data(bool): True if the callback use the optional data dict
866 force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid if possible
867 one_shot(bool): True to delete callback once it have been called
868 @return: id of the registered callback
869 """
870 callback_id = kwargs.pop('force_id', None)
871 if callback_id is None:
872 callback_id = str(uuid.uuid4())
873 else:
874 if callback_id in self._cb_map:
875 raise exceptions.ConflictError(_(u"id already registered"))
876 self._cb_map[callback_id] = (callback, args, kwargs)
877
878 if "one_shot" in kwargs: # One Shot callback are removed after 30 min
879 def purgeCallback():
880 try:
881 self.removeCallback(callback_id)
882 except KeyError:
883 pass
884 reactor.callLater(1800, purgeCallback)
885
886 return callback_id
887
888 def removeCallback(self, callback_id):
889 """ Remove a previously registered callback
890 @param callback_id: id returned by [registerCallback] """
891 log.debug("Removing callback [%s]" % callback_id)
892 del self._cb_map[callback_id]
893
894 def launchCallback(self, callback_id, data=None, profile_key=C.PROF_KEY_NONE):
895 """Launch a specific callback
896
897 @param callback_id: id of the action (callback) to launch
898 @param data: optional data
899 @profile_key: %(doc_profile_key)s
900 @return: a deferred which fire a dict where key can be:
901 - xmlui: a XMLUI need to be displayed
902 - validated: if present, can be used to launch a callback, it can have the values
903 - C.BOOL_TRUE
904 - C.BOOL_FALSE
905 """
906 # FIXME: security limit need to be checked here
907 try:
908 client = self.getClient(profile_key)
909 except exceptions.NotFound:
910 # client is not available yet
911 profile = self.memory.getProfileName(profile_key)
912 if not profile:
913 raise exceptions.ProfileUnknownError(_(u'trying to launch action with a non-existant profile'))
914 else:
915 profile = client.profile
916 # we check if the action is kept, and remove it
917 try:
918 action_tuple = client.actions[callback_id]
919 except KeyError:
920 pass
921 else:
922 action_tuple[-1].cancel() # the last item is the action timer
923 del client.actions[callback_id]
924
925 try:
926 callback, args, kwargs = self._cb_map[callback_id]
927 except KeyError:
928 raise exceptions.DataError(u"Unknown callback id {}".format(callback_id))
929
930 if kwargs.get("with_data", False):
931 if data is None:
932 raise exceptions.DataError("Required data for this callback is missing")
933 args,kwargs=list(args)[:],kwargs.copy() # we don't want to modify the original (kw)args
934 args.insert(0, data)
935 kwargs["profile"] = profile
936 del kwargs["with_data"]
937
938 if kwargs.pop('one_shot', False):
939 self.removeCallback(callback_id)
940
941 return defer.maybeDeferred(callback, *args, **kwargs)
942
943 #Menus management
944
945 def _getMenuCanonicalPath(self, path):
946 """give canonical form of path
947
948 canonical form is a tuple of the path were every element is stripped and lowercase
949 @param path(iterable[unicode]): untranslated path to menu
950 @return (tuple[unicode]): canonical form of path
951 """
952 return tuple((p.lower().strip() for p in path))
953
954 def importMenu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT, help_string="", type_=C.MENU_GLOBAL):
955 """register a new menu for frontends
956
957 @param path(iterable[unicode]): path to go to the menu (category/subcategory/.../item) (e.g.: ("File", "Open"))
958 /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
959 untranslated/lower case path can be used to identity a menu, for this reason it must be unique independently of case.
960 @param callback(callable): method to be called when menuitem is selected, callable or a callback id (string) as returned by [registerCallback]
961 @param security_limit(int): %(doc_security_limit)s
962 /!\ security_limit MUST be added to data in launchCallback if used #TODO
963 @param help_string(unicode): string used to indicate what the menu do (can be show as a tooltip).
964 /!\ use D_() instead of _() for translations
965 @param type(unicode): one of:
966 - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g. something like File/Open)
967 - C.MENU_ROOM: like a global menu, but only shown in multi-user chat
968 menu_data must contain a "room_jid" data
969 - C.MENU_SINGLE: like a global menu, but only shown in one2one chat
970 menu_data must contain a "jid" data
971 - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc commands, jid is already filled)
972 menu_data must contain a "jid" data
973 - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in roster.
974 menu_data must contain a "room_jid" data
975 - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish microblog, group is already filled)
976 menu_data must contain a "group" data
977 @return (unicode): menu_id (same as callback_id)
978 """
979
980 if callable(callback):
981 callback_id = self.registerCallback(callback, with_data=True)
982 elif isinstance(callback, basestring):
983 # The callback is already registered
984 callback_id = callback
985 try:
986 callback, args, kwargs = self._cb_map[callback_id]
987 except KeyError:
988 raise exceptions.DataError("Unknown callback id")
989 kwargs["with_data"] = True # we have to be sure that we use extra data
990 else:
991 raise exceptions.DataError("Unknown callback type")
992
993 for menu_data in self._menus.itervalues():
994 if menu_data['path'] == path and menu_data['type'] == type_:
995 raise exceptions.ConflictError(_("A menu with the same path and type already exists"))
996
997 path_canonical = self._getMenuCanonicalPath(path)
998 menu_key = (type_, path_canonical)
999
1000 if menu_key in self._menus_paths:
1001 raise exceptions.ConflictError(u"this menu path is already used: {path} ({menu_key})".format(
1002 path=path_canonical, menu_key=menu_key))
1003
1004 menu_data = {'path': tuple(path),
1005 'path_canonical': path_canonical,
1006 'security_limit': security_limit,
1007 'help_string': help_string,
1008 'type': type_
1009 }
1010
1011 self._menus[callback_id] = menu_data
1012 self._menus_paths[menu_key] = callback_id
1013
1014 return callback_id
1015
1016 def getMenus(self, language='', security_limit=C.NO_SECURITY_LIMIT):
1017 """Return all menus registered
1018
1019 @param language: language used for translation, or empty string for default
1020 @param security_limit: %(doc_security_limit)s
1021 @return: array of tuple with:
1022 - menu id (same as callback_id)
1023 - menu type
1024 - raw menu path (array of strings)
1025 - translated menu path
1026 - extra (dict(unicode, unicode)): extra data where key can be:
1027 - icon: name of the icon to use (TODO)
1028 - help_url: link to a page with more complete documentation (TODO)
1029 """
1030 ret = []
1031 for menu_id, menu_data in self._menus.iteritems():
1032 type_ = menu_data['type']
1033 path = menu_data['path']
1034 menu_security_limit = menu_data['security_limit']
1035 if security_limit!=C.NO_SECURITY_LIMIT and (menu_security_limit==C.NO_SECURITY_LIMIT or menu_security_limit>security_limit):
1036 continue
1037 languageSwitch(language)
1038 path_i18n = [_(elt) for elt in path]
1039 languageSwitch()
1040 extra = {} # TODO: manage extra data like icon
1041 ret.append((menu_id, type_, path, path_i18n, extra))
1042
1043 return ret
1044
1045 def _launchMenu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE):
1046 client = self.getClient(profile_key)
1047 return self.launchMenu(client, menu_type, path, data, security_limit)
1048
1049 def launchMenu(self, client, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT):
1050 """launch action a menu action
1051
1052 @param menu_type(unicode): type of menu to launch
1053 @param path(iterable[unicode]): canonical path of the menu
1054 @params data(dict): menu data
1055 @raise NotFound: this path is not known
1056 """
1057 # FIXME: manage security_limit here
1058 # defaut security limit should be high instead of C.NO_SECURITY_LIMIT
1059 canonical_path = self._getMenuCanonicalPath(path)
1060 menu_key = (menu_type, canonical_path)
1061 try:
1062 callback_id = self._menus_paths[menu_key]
1063 except KeyError:
1064 raise exceptions.NotFound(u"Can't find menu {path} ({menu_type})".format(
1065 path=canonical_path, menu_type=menu_type))
1066 return self.launchCallback(callback_id, data, client.profile)
1067
1068 def getMenuHelp(self, menu_id, language=''):
1069 """return the help string of the menu
1070
1071 @param menu_id: id of the menu (same as callback_id)
1072 @param language: language used for translation, or empty string for default
1073 @param return: translated help
1074
1075 """
1076 try:
1077 menu_data = self._menus[menu_id]
1078 except KeyError:
1079 raise exceptions.DataError("Trying to access an unknown menu")
1080 languageSwitch(language)
1081 help_string = _(menu_data['help_string'])
1082 languageSwitch()
1083 return help_string