comparison sat_frontends/quick_frontend/quick_app.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 frontends/src/quick_frontend/quick_app.py@0046283a285d
children 2e6864b1d577
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # helper class for making a SAT frontend
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 from sat.core.log import getLogger
21 log = getLogger(__name__)
22
23 from sat.core.i18n import _
24 from sat.core import exceptions
25 from sat.tools import trigger
26 from sat.tools.common import data_format
27
28 from sat_frontends.tools import jid
29 from sat_frontends.quick_frontend import quick_widgets
30 from sat_frontends.quick_frontend import quick_menus
31 from sat_frontends.quick_frontend import quick_blog
32 from sat_frontends.quick_frontend import quick_chat, quick_games
33 from sat_frontends.quick_frontend import quick_contact_list
34 from sat_frontends.quick_frontend.constants import Const as C
35
36 import sys
37 from collections import OrderedDict
38 import time
39
40 try:
41 # FIXME: to be removed when an acceptable solution is here
42 unicode('') # XXX: unicode doesn't exist in pyjamas
43 except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
44 unicode = str
45
46
47 class ProfileManager(object):
48 """Class managing all data relative to one profile, and plugging in mechanism"""
49 # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
50 # and a way to keep some XMLUI request between sessions is expected in backend
51 host = None
52 bridge = None
53 # cache_keys_to_get = ['avatar']
54
55 def __init__(self, profile):
56 self.profile = profile
57 self.connected = False
58 self.whoami = None
59 self.notifications = {} # key: bare jid or '' for general, value: notif data
60
61 @property
62 def autodisconnect(self):
63 try:
64 autodisconnect = self._autodisconnect
65 except AttributeError:
66 autodisconnect = False
67 return autodisconnect
68
69 def plug(self):
70 """Plug the profile to the host"""
71 # we get the essential params
72 self.bridge.asyncGetParamA("JabberID", "Connection", profile_key=self.profile,
73 callback=self._plug_profile_jid, errback=self._getParamError)
74
75 def _plug_profile_jid(self, jid_s):
76 self.whoami = jid.JID(jid_s) # resource might change after the connection
77 self.bridge.isConnected(self.profile, callback=self._plug_profile_isconnected)
78
79 def _autodisconnectEb(self, failure_):
80 # XXX: we ignore error on this parameter, as Libervia can't access it
81 log.warning(_("Error while trying to get autodisconnect param, ignoring: {}").format(failure_))
82 self._plug_profile_autodisconnect("false")
83
84 def _plug_profile_isconnected(self, connected):
85 self.connected = connected
86 self.bridge.asyncGetParamA("autodisconnect", "Connection", profile_key=self.profile,
87 callback=self._plug_profile_autodisconnect, errback=self._autodisconnectEb)
88
89 def _plug_profile_autodisconnect(self, autodisconnect):
90 if C.bool(autodisconnect):
91 self._autodisconnect = True
92 self.bridge.asyncGetParamA("autoconnect", "Connection", profile_key=self.profile,
93 callback=self._plug_profile_autoconnect, errback=self._getParamError)
94
95 def _plug_profile_autoconnect(self, value_str):
96 autoconnect = C.bool(value_str)
97 if autoconnect and not self.connected:
98 self.host.connect(self.profile, callback=lambda dummy: self._plug_profile_afterconnect())
99 else:
100 self._plug_profile_afterconnect()
101
102 def _plug_profile_afterconnect(self):
103 # Profile can be connected or not
104 # we get cached data
105 self.connected = True
106 self.host.bridge.getFeatures(profile_key=self.profile, callback=self._plug_profile_getFeaturesCb, errback=self._plug_profile_getFeaturesEb)
107
108 def _plug_profile_getFeaturesEb(self, failure):
109 log.error(u"Couldn't get features: {}".format(failure))
110 self._plug_profile_getFeaturesCb({})
111
112 def _plug_profile_getFeaturesCb(self, features):
113 self.host.features = features
114 # FIXME: we don't use cached value at the moment, but keep the code for later use
115 # it was previously used for avatars, but as we don't get full path here, it's better to request later
116 # self.host.bridge.getEntitiesData([], ProfileManager.cache_keys_to_get, profile=self.profile, callback=self._plug_profile_gotCachedValues, errback=self._plug_profile_failedCachedValues)
117 self._plug_profile_gotCachedValues({})
118
119 def _plug_profile_failedCachedValues(self, failure):
120 log.error(u"Couldn't get cached values: {}".format(failure))
121 self._plug_profile_gotCachedValues({})
122
123 def _plug_profile_gotCachedValues(self, cached_values):
124 # add the contact list and its listener
125 contact_list = self.host.contact_lists.addProfile(self.profile)
126
127 for entity_s, data in cached_values.iteritems():
128 for key, value in data.iteritems():
129 self.host.entityDataUpdatedHandler(entity_s, key, value, self.profile)
130
131 if not self.connected:
132 self.host.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=self.profile)
133 else:
134
135 contact_list.fill()
136 self.host.setPresenceStatus(profile=self.profile)
137
138 #The waiting subscription requests
139 self.bridge.getWaitingSub(self.profile, callback=self._plug_profile_gotWaitingSub)
140
141 def _plug_profile_gotWaitingSub(self, waiting_sub):
142 for sub in waiting_sub:
143 self.host.subscribeHandler(waiting_sub[sub], sub, self.profile)
144
145 self.bridge.mucGetRoomsJoined(self.profile, callback=self._plug_profile_gotRoomsJoined)
146
147 def _plug_profile_gotRoomsJoined(self, rooms_args):
148 #Now we open the MUC window where we already are:
149 for room_args in rooms_args:
150 self.host.mucRoomJoinedHandler(*room_args, profile=self.profile)
151 #Presence must be requested after rooms are filled
152 self.host.bridge.getPresenceStatuses(self.profile, callback=self._plug_profile_gotPresences)
153
154 def _plug_profile_gotPresences(self, presences):
155 def gotEntityData(data, contact):
156 for key in ('avatar', 'nick'):
157 if key in data:
158 self.host.entityDataUpdatedHandler(contact, key, data[key], self.profile)
159
160 for contact in presences:
161 for res in presences[contact]:
162 jabber_id = (u'%s/%s' % (jid.JID(contact).bare, res)) if res else contact
163 show = presences[contact][res][0]
164 priority = presences[contact][res][1]
165 statuses = presences[contact][res][2]
166 self.host.presenceUpdateHandler(jabber_id, show, priority, statuses, self.profile)
167 self.host.bridge.getEntityData(contact, ['avatar', 'nick'], self.profile, callback=lambda data, contact=contact: gotEntityData(data, contact), errback=lambda failure, contact=contact: log.debug(u"No cache data for {}".format(contact)))
168
169 # At this point, profile should be fully plugged
170 # and we launch frontend specific method
171 self.host.profilePlugged(self.profile)
172
173 def _getParamError(self, failure):
174 log.error(_("Can't get profile parameter: {msg}").format(msg=failure))
175
176
177 class ProfilesManager(object):
178 """Class managing collection of profiles"""
179
180 def __init__(self):
181 self._profiles = {}
182
183 def __contains__(self, profile):
184 return profile in self._profiles
185
186 def __iter__(self):
187 return self._profiles.iterkeys()
188
189 def __getitem__(self, profile):
190 return self._profiles[profile]
191
192 def __len__(self):
193 return len(self._profiles)
194
195 def iteritems(self):
196 return self._profiles.iteritems()
197
198 def plug(self, profile):
199 if profile in self._profiles:
200 raise exceptions.ConflictError('A profile of the name [{}] is already plugged'.format(profile))
201 self._profiles[profile] = ProfileManager(profile)
202 self._profiles[profile].plug()
203
204 def unplug(self, profile):
205 if profile not in self._profiles:
206 raise ValueError('The profile [{}] is not plugged'.format(profile))
207
208 # remove the contact list and its listener
209 host = self._profiles[profile].host
210 host.contact_lists[profile].unplug()
211
212 del self._profiles[profile]
213
214 def chooseOneProfile(self):
215 return self._profiles.keys()[0]
216
217
218 class QuickApp(object):
219 """This class contain the main methods needed for the frontend"""
220 MB_HANDLER = True # Set to False if the frontend doesn't manage microblog
221 AVATARS_HANDLER = True # set to False if avatars are not used
222
223 def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True):
224 """Create a frontend application
225
226 @param bridge_factory: method to use to create the Bridge
227 @param xmlui: xmlui module
228 @param check_options: method to call to check options (usually command line arguments)
229 """
230 self.xmlui = xmlui
231 self.menus = quick_menus.QuickMenusManager(self)
232 ProfileManager.host = self
233 self.profiles = ProfilesManager()
234 self._plugs_in_progress = set() # profiles currently being plugged, used to (un)lock contact list updates
235 self.ready_profiles = set() # profiles which are connected and ready
236 self.signals_cache = {} # used to keep signal received between start of plug_profile and when the profile is actualy ready
237 self.contact_lists = quick_contact_list.QuickContactListHandler(self)
238 self.widgets = quick_widgets.QuickWidgetsManager(self)
239 if check_options is not None:
240 self.options = check_options()
241 else:
242 self.options = None
243
244 # widgets
245 self.selected_widget = None # widget currently selected (must be filled by frontend)
246
247 # listeners
248 self._listeners = {} # key: listener type ("avatar", "selected", etc), value: list of callbacks
249
250 # triggers
251 self.trigger = trigger.TriggerManager() # trigger are used to change the default behaviour
252
253 ## bridge ##
254 self.bridge = bridge_factory()
255 ProfileManager.bridge = self.bridge
256 if connect_bridge:
257 self.connectBridge()
258
259 self._notif_id = 0
260 self._notifications = OrderedDict()
261 self.features = None
262
263 def connectBridge(self):
264 self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb)
265
266 def onBridgeConnected(self):
267 pass
268
269 def _bridgeCb(self):
270 self.registerSignal("connected")
271 self.registerSignal("disconnected")
272 self.registerSignal("actionNew")
273 self.registerSignal("newContact")
274 self.registerSignal("messageNew")
275 self.registerSignal("presenceUpdate")
276 self.registerSignal("subscribe")
277 self.registerSignal("paramUpdate")
278 self.registerSignal("contactDeleted")
279 self.registerSignal("entityDataUpdated")
280 self.registerSignal("progressStarted")
281 self.registerSignal("progressFinished")
282 self.registerSignal("progressError")
283 self.registerSignal("mucRoomJoined", iface="plugin")
284 self.registerSignal("mucRoomLeft", iface="plugin")
285 self.registerSignal("mucRoomUserChangedNick", iface="plugin")
286 self.registerSignal("mucRoomNewSubject", iface="plugin")
287 self.registerSignal("chatStateReceived", iface="plugin")
288 self.registerSignal("messageState", iface="plugin")
289 self.registerSignal("psEvent", iface="plugin")
290
291 # FIXME: do it dynamically
292 quick_games.Tarot.registerSignals(self)
293 quick_games.Quiz.registerSignals(self)
294 quick_games.Radiocol.registerSignals(self)
295 self.onBridgeConnected()
296
297 def _bridgeEb(self, failure):
298 if isinstance(failure, exceptions.BridgeExceptionNoService):
299 print(_(u"Can't connect to SàT backend, are you sure it's launched ?"))
300 sys.exit(1)
301 elif isinstance(failure, exceptions.BridgeInitError):
302 print(_(u"Can't init bridge"))
303 sys.exit(1)
304 else:
305 print(_(u"Error while initialising bridge: {}".format(failure)))
306
307 @property
308 def current_profile(self):
309 """Profile that a user would expect to use"""
310 try:
311 return self.selected_widget.profile
312 except (TypeError, AttributeError):
313 return self.profiles.chooseOneProfile()
314
315 @property
316 def visible_widgets(self):
317 """widgets currently visible (must be implemented by frontend)
318
319 @return (iter[QuickWidget]): iterable on visible widgets
320 """
321 raise NotImplementedError
322
323 def registerSignal(self, function_name, handler=None, iface="core", with_profile=True):
324 """Register a handler for a signal
325
326 @param function_name (str): name of the signal to handle
327 @param handler (instancemethod): method to call when the signal arrive, None for calling an automatically named handler (function_name + 'Handler')
328 @param iface (str): interface of the bridge to use ('core' or 'plugin')
329 @param with_profile (boolean): True if the signal concerns a specific profile, in that case the profile name has to be passed by the caller
330 """
331 log.debug(u"registering signal {name}".format(name = function_name))
332 if handler is None:
333 handler = getattr(self, "{}{}".format(function_name, 'Handler'))
334 if not with_profile:
335 self.bridge.register_signal(function_name, handler, iface)
336 return
337
338 def signalReceived(*args, **kwargs):
339 profile = kwargs.get('profile')
340 if profile is None:
341 if not args:
342 raise exceptions.ProfileNotSetError
343 profile = args[-1]
344 if profile is not None:
345 if not self.check_profile(profile):
346 if profile in self.profiles:
347 # profile is not ready but is in self.profiles, that's mean that it's being connecting and we need to cache the signal
348 self.signals_cache.setdefault(profile, []).append((function_name, handler, args, kwargs))
349 return # we ignore signal for profiles we don't manage
350 handler(*args, **kwargs)
351 self.bridge.register_signal(function_name, signalReceived, iface)
352
353 def addListener(self, type_, callback, profiles_filter=None):
354 """Add a listener for an event
355
356 /!\ don't forget to remove listener when not used anymore (e.g. if you delete a widget)
357 @param type_: type of event, can be:
358 - avatar: called when avatar data is updated
359 args: (entity, avatar file, profile)
360 - nick: called when nick data is updated
361 args: (entity, new_nick, profile)
362 - presence: called when a presence is received
363 args: (entity, show, priority, statuses, profile)
364 - notification: called when a new notification is emited
365 args: (entity, notification_data, profile)
366 - notification_clear: called when notifications are cleared
367 args: (entity, type_, profile)
368 - menu: called when a menu item is added or removed
369 args: (type_, path, path_i18n, item) were values are:
370 type_: same as in [sat.core.sat_main.SAT.importMenu]
371 path: same as in [sat.core.sat_main.SAT.importMenu]
372 path_i18n: translated path (or None if the item is removed)
373 item: instance of quick_menus.MenuItemBase or None if the item is removed
374 - gotMenus: called only once when menu are available (no arg)
375 - progressFinished: called when a progressing action has just finished
376 args: (progress_id, metadata, profile)
377 - progressError: called when a progressing action failed
378 args: (progress_id, error_msg, profile):
379 @param callback: method to call on event
380 @param profiles_filter (set[unicode]): if set and not empty, the
381 listener will be callable only by one of the given profiles.
382 """
383 assert type_ in C.LISTENERS
384 self._listeners.setdefault(type_, OrderedDict())[callback] = profiles_filter
385
386 def removeListener(self, type_, callback):
387 """Remove a callback from listeners
388
389 @param type_: same as for [addListener]
390 @param callback: callback to remove
391 """
392 assert type_ in C.LISTENERS
393 self._listeners[type_].pop(callback)
394
395 def callListeners(self, type_, *args, **kwargs):
396 """Call the methods which listen type_ event. If a profiles filter has
397 been register with a listener and profile argument is not None, the
398 listener will be called only if profile is in the profiles filter list.
399
400 @param type_: same as for [addListener]
401 @param *args: arguments sent to callback
402 @param **kwargs: keywords argument, mainly used to pass "profile" when needed
403 """
404 assert type_ in C.LISTENERS
405 try:
406 listeners = self._listeners[type_]
407 except KeyError:
408 pass
409 else:
410 profile = kwargs.get("profile")
411 for listener, profiles_filter in listeners.iteritems():
412 if profile is None or not profiles_filter or profile in profiles_filter:
413 listener(*args, **kwargs)
414
415 def check_profile(self, profile):
416 """Tell if the profile is currently followed by the application, and ready"""
417 return profile in self.ready_profiles
418
419 def postInit(self, profile_manager):
420 """Must be called after initialization is done, do all automatic task (auto plug profile)
421
422 @param profile_manager: instance of a subclass of Quick_frontend.QuickProfileManager
423 """
424 if self.options and self.options.profile:
425 profile_manager.autoconnect([self.options.profile])
426
427 def profilePlugged(self, profile):
428 """Method called when the profile is fully plugged, to launch frontend specific workflow
429
430 /!\ if you override the method and don't call the parent, be sure to add the profile to ready_profiles !
431 if you don't, all signals will stay in cache
432
433 @param profile(unicode): %(doc_profile)s
434 """
435 self._plugs_in_progress.remove(profile)
436 self.ready_profiles.add(profile)
437
438 # profile is ready, we can call send signals that where is cache
439 cached_signals = self.signals_cache.pop(profile, [])
440 for function_name, handler, args, kwargs in cached_signals:
441 log.debug(u"Calling cached signal [%s] with args %s and kwargs %s" % (function_name, args, kwargs))
442 handler(*args, **kwargs)
443
444 self.callListeners('profilePlugged', profile=profile)
445 if not self._plugs_in_progress:
446 self.contact_lists.lockUpdate(False)
447
448 def connect(self, profile, callback=None, errback=None):
449 if not callback:
450 callback = lambda dummy: None
451 if not errback:
452 def errback(failure):
453 log.error(_(u"Can't connect profile [%s]") % failure)
454 try:
455 module = failure.module
456 except AttributeError:
457 module = ''
458 try:
459 message = failure.message
460 except AttributeError:
461 message = 'error'
462 try:
463 fullname = failure.fullname
464 except AttributeError:
465 fullname = 'error'
466 if module.startswith('twisted.words.protocols.jabber') and failure.condition == "not-authorized":
467 self.launchAction(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
468 else:
469 self.showDialog(message, fullname, 'error')
470 self.bridge.connect(profile, callback=callback, errback=errback)
471
472 def plug_profiles(self, profiles):
473 """Tell application which profiles must be used
474
475 @param profiles: list of valid profile names
476 """
477 self.contact_lists.lockUpdate()
478 self._plugs_in_progress.update(profiles)
479 self.plugging_profiles()
480 for profile in profiles:
481 self.profiles.plug(profile)
482
483 def plugging_profiles(self):
484 """Method to subclass to manage frontend specific things to do
485
486 will be called when profiles are choosen and are to be plugged soon
487 """
488 pass
489
490 def unplug_profile(self, profile):
491 """Tell the application to not follow anymore the profile"""
492 if not profile in self.profiles:
493 raise ValueError("The profile [{}] is not plugged".format(profile))
494 self.profiles.unplug(profile)
495
496 def clear_profile(self):
497 self.profiles.clear()
498
499 def newWidget(self, widget):
500 raise NotImplementedError
501
502 # bridge signals hanlers
503
504 def connectedHandler(self, profile, jid_s):
505 """Called when the connection is made.
506
507 @param jid_s (unicode): the JID that we were assigned by the server,
508 as the resource might differ from the JID we asked for.
509 """
510 log.debug(_("Connected"))
511 self.profiles[profile].whoami = jid.JID(jid_s)
512 self.setPresenceStatus(profile=profile)
513 self.contact_lists[profile].fill()
514
515 def disconnectedHandler(self, profile):
516 """called when the connection is closed"""
517 log.debug(_("Disconnected"))
518 self.contact_lists[profile].disconnect()
519 self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile)
520
521 def actionNewHandler(self, action_data, id_, security_limit, profile):
522 self.actionManager(action_data, user_action=False, profile=profile)
523
524 def newContactHandler(self, jid_s, attributes, groups, profile):
525 entity = jid.JID(jid_s)
526 groups = list(groups)
527 self.contact_lists[profile].setContact(entity, groups, attributes, in_roster=True)
528
529 def messageNewHandler(self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra, profile):
530 from_jid = jid.JID(from_jid_s)
531 to_jid = jid.JID(to_jid_s)
532 if not self.trigger.point("messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile=profile):
533 return
534
535 from_me = from_jid.bare == self.profiles[profile].whoami.bare
536 target = to_jid if from_me else from_jid
537 contact_list = self.contact_lists[profile]
538 # we want to be sure to have at least one QuickChat instance
539 self.widgets.getOrCreateWidget(quick_chat.QuickChat, target, type_=C.CHAT_ONE2ONE, on_new_widget=None, profile=profile)
540
541 if not from_jid in contact_list and from_jid.bare != self.profiles[profile].whoami.bare:
542 #XXX: needed to show entities which haven't sent any
543 # presence information and which are not in roster
544 contact_list.setContact(from_jid)
545
546 # we dispatch the message in the widgets
547 for widget in self.widgets.getWidgets(quick_chat.QuickChat, target=target, profiles=(profile,)):
548 widget.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile)
549
550 def messageStateHandler(self, uid, status, profile):
551 for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)):
552 widget.onMessageState(uid, status, profile)
553
554 def messageSend(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE):
555 if subject is None:
556 subject = {}
557 if extra is None:
558 extra = {}
559 if callback is None:
560 callback = lambda dummy=None: None # FIXME: optional argument is here because pyjamas doesn't support callback without arg with json proxy
561 if errback is None:
562 errback = lambda failure: self.showDialog(failure.fullname, failure.message, "error")
563
564 if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key):
565 return
566
567 self.bridge.messageSend(unicode(to_jid), message, subject, mess_type, extra, profile_key, callback=callback, errback=errback)
568
569 def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE):
570 raise NotImplementedError
571
572 def presenceUpdateHandler(self, entity_s, show, priority, statuses, profile):
573
574 log.debug(_(u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, statuses=%(statuses)s) [profile:%(profile)s]")
575 % {'entity': entity_s, C.PRESENCE_SHOW: show, C.PRESENCE_PRIORITY: priority, C.PRESENCE_STATUSES: statuses, 'profile': profile})
576 entity = jid.JID(entity_s)
577
578 if entity == self.profiles[profile].whoami:
579 if show == C.PRESENCE_UNAVAILABLE:
580 self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile)
581 else:
582 # FIXME: try to retrieve user language status before fallback to default
583 status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
584 self.setPresenceStatus(show, status, profile=profile)
585 return
586
587 self.callListeners('presence', entity, show, priority, statuses, profile=profile)
588
589 def mucRoomJoinedHandler(self, room_jid_s, occupants, user_nick, subject, profile):
590 """Called when a MUC room is joined"""
591 log.debug(u"Room [{room_jid}] joined by {profile}, users presents:{users}".format(room_jid=room_jid_s, profile=profile, users=occupants.keys()))
592 room_jid = jid.JID(room_jid_s)
593 self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, nick=user_nick, occupants=occupants, subject=subject, profile=profile)
594 self.contact_lists[profile].setSpecial(room_jid, C.CONTACT_SPECIAL_GROUP)
595 # chat_widget.update()
596
597 def mucRoomLeftHandler(self, room_jid_s, profile):
598 """Called when a MUC room is left"""
599 log.debug(u"Room [%(room_jid)s] left by %(profile)s" % {'room_jid': room_jid_s, 'profile': profile})
600 room_jid = jid.JID(room_jid_s)
601 chat_widget = self.widgets.getWidget(quick_chat.QuickChat, room_jid, profile)
602 if chat_widget:
603 self.widgets.deleteWidget(chat_widget)
604 self.contact_lists[profile].removeContact(room_jid)
605
606 def mucRoomUserChangedNickHandler(self, room_jid_s, old_nick, new_nick, profile):
607 """Called when an user joined a MUC room"""
608 room_jid = jid.JID(room_jid_s)
609 chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile)
610 chat_widget.changeUserNick(old_nick, new_nick)
611 log.debug(u"user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]" % {'old_nick': old_nick, 'new_nick': new_nick, 'room_jid': room_jid})
612
613 def mucRoomNewSubjectHandler(self, room_jid_s, subject, profile):
614 """Called when subject of MUC room change"""
615 room_jid = jid.JID(room_jid_s)
616 chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile)
617 chat_widget.setSubject(subject)
618 log.debug(u"new subject for room [%(room_jid)s]: %(subject)s" % {'room_jid': room_jid, "subject": subject})
619
620 def chatStateReceivedHandler(self, from_jid_s, state, profile):
621 """Called when a new chat state (XEP-0085) is received.
622
623 @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
624 @param state (unicode): new state
625 @param profile (unicode): current profile
626 """
627 from_jid = jid.JID(from_jid_s)
628 for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)):
629 widget.onChatState(from_jid, state, profile)
630
631 def notify(self, type_, entity=None, message=None, subject=None, callback=None, cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
632 """Trigger an event notification
633
634 @param type_(unicode): notifation kind,
635 one of C.NOTIFY_* constant or any custom type specific to frontend
636 @param entity(jid.JID, None): entity involved in the notification
637 if entity is in contact list, a indicator may be added in front of it
638 @param message(unicode, None): message of the notification
639 @param subject(unicode, None): subject of the notification
640 @param callback(callable, None): method to call when notification is selected
641 @param cb_args(list, None): list of args for callback
642 @param widget(object, None): widget where the notification happened
643 """
644 assert type_ in C.NOTIFY_ALL
645 notif_dict = self.profiles[profile].notifications
646 key = '' if entity is None else entity.bare
647 type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
648 notif_data = {
649 'id': self._notif_id,
650 'time': time.time(),
651 'entity': entity,
652 'callback': callback,
653 'cb_args': cb_args,
654 'message': message,
655 'subject': subject,
656 }
657 if widget is not None:
658 notif_data[widget] = widget
659 type_notifs.append(notif_data)
660 self._notifications[self._notif_id] = notif_data
661 self.callListeners('notification', entity, notif_data, profile=profile)
662
663 def getNotifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE):
664 """return notifications for given entity
665
666 @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
667 bare jid to get all notifications, full jid to filter on resource
668 None to get general notifications
669 C.ENTITY_ALL to get all notifications
670 @param type_(unicode, None): notification type to filter
671 None to get all notifications
672 @param exact_jid(bool, None): if True, only return notifications from
673 exact entity jid (i.e. not including other resources)
674 None for automatic selection (True for full jid, False else)
675 False to get resources notifications
676 False doesn't do anything if entity is not a bare jid
677 @return (iter[dict]): notifications
678 """
679 main_notif_dict = self.profiles[profile].notifications
680
681 if entity is C.ENTITY_ALL:
682 selected_notifs = main_notif_dict.itervalues()
683 exact_jid = False
684 else:
685 if entity is None:
686 key = ''
687 exact_jid = False
688 else:
689 key = entity.bare
690 if exact_jid is None:
691 exact_jid = bool(entity.resource)
692 selected_notifs = (main_notif_dict.setdefault(key, {}),)
693
694 for notifs_from_select in selected_notifs:
695
696 if type_ is None:
697 type_notifs = notifs_from_select.itervalues()
698 else:
699 type_notifs = (notifs_from_select.get(type_, []),)
700
701 for notifs in type_notifs:
702 for notif in notifs:
703 if exact_jid and notif['entity'] != entity:
704 continue
705 yield notif
706
707 def clearNotifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
708 """return notifications for given entity
709
710 @param entity(jid.JID, None): bare jid of the entity to check
711 None to clear general notifications (but keep entities ones)
712 @param type_(unicode, None): notification type to filter
713 None to clear all notifications
714 @return (list[dict]): list of notifications
715 """
716 notif_dict = self.profiles[profile].notifications
717 key = '' if entity is None else entity.bare
718 try:
719 if type_ is None:
720 del notif_dict[key]
721 else:
722 del notif_dict[key][type_]
723 except KeyError:
724 return
725 self.callListeners('notificationsClear', entity, type_, profile=profile)
726
727 def psEventHandler(self, category, service_s, node, event_type, data, profile):
728 """Called when a PubSub event is received.
729
730 @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
731 @param service_s (unicode): pubsub service
732 @param node (unicode): pubsub node
733 @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
734 @param data (dict): event data
735 """
736 service_s = jid.JID(service_s)
737
738 if category == C.PS_MICROBLOG and self.MB_HANDLER:
739 if event_type == C.PS_PUBLISH:
740 if not 'content' in data:
741 log.warning("No content found in microblog data")
742 return
743 _groups = set(data_format.dict2iter('group', data)) or None # FIXME: check if [] make sense (instead of None)
744
745 for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
746 wid.addEntryIfAccepted(service_s, node, data, _groups, profile)
747
748 try:
749 comments_node, comments_service = data['comments_node'], data['comments_service']
750 except KeyError:
751 pass
752 else:
753 self.bridge.mbGet(comments_service, comments_node, C.NO_LIMIT, [], {"subscribe":C.BOOL_TRUE}, profile=profile)
754 elif event_type == C.PS_RETRACT:
755 for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
756 wid.deleteEntryIfPresent(service_s, node, data['id'], profile)
757 pass
758 else:
759 log.warning("Unmanaged PubSub event type {}".format(event_type))
760
761 def progressStartedHandler(self, pid, metadata, profile):
762 log.info(u"Progress {} started".format(pid))
763
764 def progressFinishedHandler(self, pid, metadata, profile):
765 log.info(u"Progress {} finished".format(pid))
766 self.callListeners('progressFinished', pid, metadata, profile=profile)
767
768 def progressErrorHandler(self, pid, err_msg, profile):
769 log.warning(u"Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
770 self.callListeners('progressError', pid, err_msg, profile=profile)
771
772 def _subscribe_cb(self, answer, data):
773 entity, profile = data
774 type_ = "subscribed" if answer else "unsubscribed"
775 self.bridge.subscription(type_, unicode(entity.bare), profile_key=profile)
776
777 def subscribeHandler(self, type, raw_jid, profile):
778 """Called when a subsciption management signal is received"""
779 entity = jid.JID(raw_jid)
780 if type == "subscribed":
781 # this is a subscription confirmation, we just have to inform user
782 # TODO: call self.getEntityMBlog to add the new contact blogs
783 self.showDialog(_("The contact %s has accepted your subscription") % entity.bare, _('Subscription confirmation'))
784 elif type == "unsubscribed":
785 # this is a subscription refusal, we just have to inform user
786 self.showDialog(_("The contact %s has refused your subscription") % entity.bare, _('Subscription refusal'), 'error')
787 elif type == "subscribe":
788 # this is a subscriptionn request, we have to ask for user confirmation
789 # TODO: use sat.stdui.ui_contact_list to display the groups selector
790 self.showDialog(_("The contact %s wants to subscribe to your presence.\nDo you accept ?") % entity.bare, _('Subscription confirmation'), 'yes/no', answer_cb=self._subscribe_cb, answer_data=(entity, profile))
791
792 def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None):
793 raise NotImplementedError
794
795 def showAlert(self, message):
796 pass #FIXME
797
798 def dialogFailure(self, failure):
799 log.warning(u"Failure: {}".format(failure))
800
801 def progressIdHandler(self, progress_id, profile):
802 """Callback used when an action result in a progress id"""
803 log.info(u"Progress ID received: {}".format(progress_id))
804
805 def isHidden(self):
806 """Tells if the frontend window is hidden.
807
808 @return bool
809 """
810 raise NotImplementedError
811
812 def paramUpdateHandler(self, name, value, namespace, profile):
813 log.debug(_(u"param update: [%(namespace)s] %(name)s = %(value)s") % {'namespace': namespace, 'name': name, 'value': value})
814 if (namespace, name) == ("Connection", "JabberID"):
815 log.debug(_(u"Changing JID to %s") % value)
816 self.profiles[profile].whoami = jid.JID(value)
817 elif (namespace, name) == ('General', C.SHOW_OFFLINE_CONTACTS):
818 self.contact_lists[profile].showOfflineContacts(C.bool(value))
819 elif (namespace, name) == ('General', C.SHOW_EMPTY_GROUPS):
820 self.contact_lists[profile].showEmptyGroups(C.bool(value))
821
822 def contactDeletedHandler(self, jid_s, profile):
823 target = jid.JID(jid_s)
824 self.contact_lists[profile].removeContact(target)
825
826 def entityDataUpdatedHandler(self, entity_s, key, value, profile):
827 entity = jid.JID(entity_s)
828 if key == "nick": # this is the roster nick, not the MUC nick
829 if entity in self.contact_lists[profile]:
830 self.contact_lists[profile].setCache(entity, 'nick', value)
831 self.callListeners('nick', entity, value, profile=profile)
832 elif key == "avatar" and self.AVATARS_HANDLER:
833 if value and entity in self.contact_lists[profile]:
834 self.getAvatar(entity, ignore_cache=True, profile=profile)
835
836 def actionManager(self, action_data, callback=None, ui_show_cb=None, user_action=True, profile=C.PROF_KEY_NONE):
837 """Handle backend action
838
839 @param action_data(dict): action dict as sent by launchAction or returned by an UI action
840 @param callback(None, callback): if not None, callback to use on XMLUI answer
841 @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI
842 @param user_action(bool): if True, the action is a result of a user interaction
843 else the action come from backend direclty (i.e. actionNew)
844 """
845 try:
846 xmlui = action_data.pop('xmlui')
847 except KeyError:
848 pass
849 else:
850 ui = self.xmlui.create(self, xml_data=xmlui, flags=("FROM_BACKEND",) if not user_action else None, callback=callback, profile=profile)
851 if ui_show_cb is None:
852 ui.show()
853 else:
854 ui_show_cb(ui)
855
856 try:
857 progress_id = action_data.pop('progress')
858 except KeyError:
859 pass
860 else:
861 self.progressIdHandler(progress_id, profile)
862
863 # we ignore metadata
864 action_data = {k:v for k,v in action_data.iteritems() if not k.startswith("meta_")}
865
866 if action_data:
867 raise exceptions.DataError(u"Not all keys in action_data are managed ({keys})".format(keys=', '.join(action_data.keys())))
868
869
870 def _actionCb(self, data, callback, callback_id, profile):
871 if callback is None:
872 self.actionManager(data, profile=profile)
873 else:
874 callback(data=data, cb_id=callback_id, profile=profile)
875
876 def launchAction(self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE):
877 """Launch a dynamic action
878
879 @param callback_id: id of the action to launch
880 @param data: data needed only for certain actions
881 @param callback(callable, None): will be called with the resut
882 if None, self.actionManager will be called
883 else the callable will be called with the following kw parameters:
884 - data: action_data
885 - cb_id: callback id
886 - profile: %(doc_profile)s
887 @param profile: %(doc_profile)s
888
889 """
890 if data is None:
891 data = dict()
892 action_cb = lambda data: self._actionCb(data, callback, callback_id, profile)
893 self.bridge.launchAction(callback_id, data, profile, callback=action_cb, errback=self.dialogFailure)
894
895 def launchMenu(self, menu_type, path, data=None, callback=None, security_limit=C.SECURITY_LIMIT_MAX, profile=C.PROF_KEY_NONE):
896 """Launch a menu manually
897
898 @param menu_type(unicode): type of the menu to launch
899 @param path(iterable[unicode]): path to the menu
900 @param data: data needed only for certain actions
901 @param callback(callable, None): will be called with the resut
902 if None, self.actionManager will be called
903 else the callable will be called with the following kw parameters:
904 - data: action_data
905 - cb_id: (menu_type, path) tuple
906 - profile: %(doc_profile)s
907 @param profile: %(doc_profile)s
908
909 """
910 if data is None:
911 data = dict()
912 action_cb = lambda data: self._actionCb(data, callback, (menu_type, path), profile)
913 self.bridge.menuLaunch(menu_type, path, data, security_limit, profile, callback=action_cb, errback=self.dialogFailure)
914
915 def _avatarGetCb(self, avatar_path, entity, contact_list, profile):
916 path = avatar_path or self.getDefaultAvatar(entity)
917 contact_list.setCache(entity, "avatar", path)
918 self.callListeners('avatar', entity, path, profile=profile)
919
920 def _avatarGetEb(self, failure, entity, contact_list):
921 log.warning(u"Can't get avatar: {}".format(failure))
922 contact_list.setCache(entity, "avatar", self.getDefaultAvatar(entity))
923
924 def getAvatar(self, entity, cache_only=True, hash_only=False, ignore_cache=False, profile=C.PROF_KEY_NONE):
925 """return avatar path for an entity
926
927 @param entity(jid.JID): entity to get avatar from
928 @param cache_only(bool): if False avatar will be requested if not in cache
929 with current vCard based implementation, it's better to keep True
930 except if we request avatars for roster items
931 @param hash_only(bool): if True avatar hash is returned, else full path
932 @param ignore_cache(bool): if False, won't check local cache and will request backend in every case
933 @return (unicode, None): avatar full path (None if no avatar found)
934 """
935 contact_list = self.contact_lists[profile]
936 if ignore_cache:
937 avatar = None
938 else:
939 avatar = contact_list.getCache(entity, "avatar", bare_default=None)
940 if avatar is None:
941 self.bridge.avatarGet(
942 unicode(entity),
943 cache_only,
944 hash_only,
945 profile=profile,
946 callback=lambda path: self._avatarGetCb(path, entity, contact_list, profile),
947 errback=lambda failure: self._avatarGetEb(failure, entity, contact_list))
948 # we set avatar to empty string to avoid requesting several time the same avatar
949 # while we are waiting for avatarGet result
950 contact_list.setCache(entity, "avatar", "")
951 return avatar
952
953 def getDefaultAvatar(self, entity=None):
954 """return default avatar to use with given entity
955
956 must be implemented by frontend
957 @param entity(jid.JID): entity for which a default avatar is needed
958 """
959 raise NotImplementedError
960
961 def disconnect(self, profile):
962 log.info("disconnecting")
963 self.callListeners('disconnect', profile=profile)
964 self.bridge.disconnect(profile)
965
966 def onExit(self):
967 """Must be called when the frontend is terminating"""
968 to_unplug = []
969 for profile, profile_manager in self.profiles.iteritems():
970 if profile_manager.connected and profile_manager.autodisconnect:
971 #The user wants autodisconnection
972 self.disconnect(profile)
973 to_unplug.append(profile)
974 for profile in to_unplug:
975 self.unplug_profile(profile)