view libervia/frontends/quick_frontend/quick_app.py @ 4326:5fd6a4dc2122

cli (output/std): use `rich` to output JSON.
author Goffi <goffi@goffi.org>
date Wed, 20 Nov 2024 11:38:44 +0100
parents 0d7bb4df2343
children
line wrap: on
line source

#!/usr/bin/env python3

# helper class for making a SAT frontend
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import sys
import time
from typing import Callable

from libervia.backend.core import exceptions
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.tools import trigger
from libervia.backend.tools.common import data_format
from libervia.frontends.quick_frontend import quick_widgets
from libervia.frontends.quick_frontend import quick_menus
from libervia.frontends.quick_frontend import quick_blog
from libervia.frontends.quick_frontend import quick_chat, quick_games
from libervia.frontends.quick_frontend import quick_contact_list
from libervia.frontends.quick_frontend.constants import Const as C
from libervia.frontends.tools import jid


log = getLogger(__name__)


class ProfileManager(object):
    """Class managing all data relative to one profile, and plugging in mechanism"""

    # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
    #       and a way to keep some XMLUI request between sessions is expected in backend
    host = None
    bridge = None
    cache_keys_to_get = ["avatar", "nicknames"]

    def __init__(self, profile):
        self.profile = profile
        self.connected = False
        self.whoami = None
        self.notifications = {}  # key: bare jid or '' for general, value: notif data

    @property
    def autodisconnect(self):
        try:
            autodisconnect = self._autodisconnect
        except AttributeError:
            autodisconnect = False
        return autodisconnect

    def plug(self):
        """Plug the profile to the host"""
        # first of all we create the contact lists
        self.host.contact_lists.add_profile(self.profile)

        # we get the essential params
        self.bridge.param_get_a_async(
            "JabberID",
            "Connection",
            profile_key=self.profile,
            callback=self._plug_profile_jid,
            errback=self._get_param_error,
        )

    def _plug_profile_jid(self, jid_s):
        self.whoami = jid.JID(jid_s)  # resource might change after the connection
        log.info(f"Our current jid is: {self.whoami}")
        self.bridge.is_connected(self.profile, callback=self._plug_profile_isconnected)

    def _autodisconnect_eb(self, failure_):
        # XXX: we ignore error on this parameter, as Libervia can't access it
        log.warning(
            _("Error while trying to get autodisconnect param, ignoring: {}").format(
                failure_
            )
        )
        self._plug_profile_autodisconnect("false")

    def _plug_profile_isconnected(self, connected):
        self.connected = connected
        if connected:
            self.host.profile_connected(self.profile)
        self.bridge.param_get_a_async(
            "autodisconnect",
            "Connection",
            profile_key=self.profile,
            callback=self._plug_profile_autodisconnect,
            errback=self._autodisconnect_eb,
        )

    def _plug_profile_autodisconnect(self, autodisconnect):
        if C.bool(autodisconnect):
            self._autodisconnect = True
        self.bridge.param_get_a_async(
            "autoconnect",
            "Connection",
            profile_key=self.profile,
            callback=self._plug_profile_autoconnect,
            errback=self._get_param_error,
        )

    def _plug_profile_autoconnect(self, value_str):
        autoconnect = C.bool(value_str)
        if autoconnect and not self.connected:
            self.host.connect(
                self.profile, callback=lambda __: self._plug_profile_afterconnect()
            )
        else:
            self._plug_profile_afterconnect()

    def _plug_profile_afterconnect(self):
        # Profile can be connected or not
        # we get cached data
        self.connected = True
        self.host.bridge.features_get(
            profile_key=self.profile,
            callback=self._plug_profile_get_features_cb,
            errback=self._plug_profile_get_features_eb,
        )

    def _plug_profile_get_features_eb(self, failure):
        log.error("Couldn't get features: {}".format(failure))
        self._plug_profile_get_features_cb({})

    def _plug_profile_get_features_cb(self, features):
        self.host.features = features
        self.host.bridge.entities_data_get(
            [],
            ProfileManager.cache_keys_to_get,
            profile=self.profile,
            callback=self._plug_profile_got_cached_values,
            errback=self._plug_profile_failed_cached_values,
        )

    def _plug_profile_failed_cached_values(self, failure):
        log.error("Couldn't get cached values: {}".format(failure))
        self._plug_profile_got_cached_values({})

    def _plug_profile_got_cached_values(self, cached_values):
        contact_list = self.host.contact_lists[self.profile]
        # add the contact list and its listener
        for entity_s, data in cached_values.items():
            for key, value in data.items():
                self.host.entity_data_updated_handler(entity_s, key, value, self.profile)

        if not self.connected:
            self.host.set_presence_status(
                C.PRESENCE_UNAVAILABLE, "", profile=self.profile
            )
        else:

            contact_list.fill()
            self.host.set_presence_status(profile=self.profile)

            # The waiting subscription requests
            self.bridge.sub_waiting_get(
                self.profile, callback=self._plug_profile_got_waiting_sub
            )

    def _plug_profile_got_waiting_sub(self, waiting_sub):
        for sub in waiting_sub:
            self.host.subscribe_handler(waiting_sub[sub], sub, self.profile)

        self.bridge.muc_get_rooms_joined(
            self.profile, callback=self._plug_profile_got_rooms_joined
        )

    def _plug_profile_got_rooms_joined(self, rooms_args):
        # Now we open the MUC window where we already are:
        for room_args in rooms_args:
            self.host.muc_room_joined_handler(*room_args, profile=self.profile)
        # Presence must be requested after rooms are filled
        self.host.bridge.presence_statuses_get(
            self.profile, callback=self._plug_profile_got_presences
        )

    def _plug_profile_got_presences(self, presences):
        for contact in presences:
            for res in presences[contact]:
                jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact
                show = presences[contact][res][0]
                priority = presences[contact][res][1]
                statuses = presences[contact][res][2]
                self.host.presence_update_handler(
                    jabber_id, show, priority, statuses, self.profile
                )

        # At this point, profile should be fully plugged
        # and we launch frontend specific method
        self.host.profile_plugged(self.profile)

    def _get_param_error(self, failure):
        log.error(_("Can't get profile parameter: {msg}").format(msg=failure))


class ProfilesManager(object):
    """Class managing collection of profiles"""

    def __init__(self):
        self._profiles = {}

    def __contains__(self, profile):
        return profile in self._profiles

    def __iter__(self):
        return iter(self._profiles.keys())

    def __getitem__(self, profile):
        return self._profiles[profile]

    def __len__(self):
        return len(self._profiles)

    def items(self):
        return self._profiles.items()

    def values(self):
        return self._profiles.values()

    def plug(self, profile):
        if profile in self._profiles:
            raise exceptions.ConflictError(
                "A profile of the name [{}] is already plugged".format(profile)
            )
        self._profiles[profile] = ProfileManager(profile)
        self._profiles[profile].plug()

    def unplug(self, profile):
        if profile not in self._profiles:
            raise ValueError("The profile [{}] is not plugged".format(profile))

        # remove the contact list and its listener
        host = self._profiles[profile].host
        host.contact_lists[profile].unplug()

        del self._profiles[profile]

    def choose_one_profile(self):
        return list(self._profiles.keys())[0]


class QuickApp:
    """This class contain the main methods needed for the frontend"""

    MB_HANDLER = True  #: Set to False if the frontend doesn't manage microblog
    AVATARS_HANDLER = True  #: set to False if avatars are not used
    ENCRYPTION_HANDLERS = True  #: set to False if encryption is handled separatly
    #: if True, QuickApp will call resync itself, on all widgets at the same time
    #: if False, frontend must call resync itself when suitable (e.g. widget is being
    #: visible)
    AUTO_RESYNC = True

    def __init__(
        self,
        bridge_factory,
        xmlui,
        check_options=None,
        connect_bridge=True,
        async_bridge_factory=None,
    ):
        """Create a frontend application

        @param bridge_factory: method to use to create the bridge
        @param xmlui: xmlui module
        @param check_options: method to call to check options (usually command line
            arguments)
        """
        self.xmlui = xmlui
        self.menus = quick_menus.QuickMenusManager(self)
        ProfileManager.host = self
        self.profiles = ProfilesManager()
        # profiles currently being plugged, used to (un)lock contact list updates
        self._plugs_in_progress = set()
        self.ready_profiles = set()  # profiles which are connected and ready
        self.signals_cache = {}  # used to keep signal received between start of
        # plug_profile and when the profile is actualy ready
        self.contact_lists = quick_contact_list.QuickContactListHandler(self)
        self.widgets = quick_widgets.QuickWidgetsManager(self)
        if check_options is not None:
            self.options = check_options()
        else:
            self.options = None

        # see selected_widget setter and getter
        self._selected_widget = None

        # listeners are callable watching events
        self._listeners = {}  # key: listener type ("avatar", "selected", etc),
        # value: list of callbacks

        # cf. [register_action_handler]
        self._action_handlers: dict[str, Callable[[dict, str, int, str], None]] = {}

        # triggers
        self.trigger = (
            trigger.TriggerManager()
        )  # trigger are used to change the default behaviour

        ## bridge ##
        self.bridge = bridge_factory()
        if async_bridge_factory is None:
            log.warning("No async bridge specified")
            self.a_bridge = None
        else:
            self.a_bridge = async_bridge_factory()
        ProfileManager.bridge = self.bridge
        if connect_bridge:
            self.connect_bridge()

        # frontend notifications
        self._notif_id = 0
        self._notifications = {}
        # watched progresses and associated callbacks
        self._progress_ids = {}
        # available features
        # FIXME: features are profile specific, to be checked
        self.features = None
        #: map of short name to namespaces
        self.ns_map = {}
        #: available encryptions
        self.encryption_plugins = []
        # state of synchronisation with backend
        self._sync = True

    def connect_bridge(self):
        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)

    def _namespaces_get_cb(self, ns_map):
        self.ns_map = ns_map

    def _namespaces_get_eb(self, failure_):
        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))

    def _encryption_plugins_get_cb(self, plugins_ser):
        self.encryption_plugins = data_format.deserialise(plugins_ser, type_check=list)

    def _encryption_plugins_get_eb(self, failure_):
        log.warning(_("Can't retrieve encryption plugins: {msg}").format(msg=failure_))

    def on_bridge_connected(self):
        self.bridge.ready_get(self.on_backend_ready)

    def _bridge_cb(self):
        self.register_signal("connected")
        self.register_signal("disconnected")
        self.register_signal("action_new")
        self.register_signal("contact_new")
        self.register_signal("message_new")
        if self.ENCRYPTION_HANDLERS:
            self.register_signal("message_encryption_started")
            self.register_signal("message_encryption_stopped")
        self.register_signal("presence_update")
        self.register_signal("subscribe")
        self.register_signal("param_update")
        self.register_signal("contact_deleted")
        self.register_signal("entity_data_updated")
        self.register_signal("progress_started")
        self.register_signal("progress_finished")
        self.register_signal("progress_error")
        self.register_signal("muc_room_joined", iface="plugin")
        self.register_signal("muc_room_left", iface="plugin")
        self.register_signal("muc_room_user_changed_nick", iface="plugin")
        self.register_signal("muc_room_new_subject", iface="plugin")
        self.register_signal("chat_state_received", iface="plugin")
        self.register_signal("message_state", iface="plugin")
        self.register_signal("ps_event", iface="plugin")
        # useful for debugging
        self.register_signal("_debug", iface="core")

        # FIXME: do it dynamically
        quick_games.Tarot.register_signals(self)
        quick_games.Quiz.register_signals(self)
        quick_games.Radiocol.register_signals(self)
        self.on_bridge_connected()

    def _bridge_eb(self, failure):
        if isinstance(failure, exceptions.BridgeExceptionNoService):
            print(
                _("Unable to connect to the Libervia backend. Are you sure it's running?")
            )
            sys.exit(C.EXIT_BACKEND_NOT_FOUND)
        elif isinstance(failure, exceptions.BridgeInitError):
            print((_("Can't init bridge")))
            sys.exit(C.EXIT_BRIDGE_ERROR)
        else:
            print((_("Error while initialising bridge: {}".format(failure))))

    def on_backend_ready(self):
        log.info("backend is ready")
        self.bridge.namespaces_get(
            callback=self._namespaces_get_cb, errback=self._namespaces_get_eb
        )
        # we cache available encryption plugins, as we'll use them on each
        # new chat widget
        self.bridge.encryption_plugins_get(
            callback=self._encryption_plugins_get_cb,
            errback=self._encryption_plugins_get_eb,
        )

    @property
    def current_profile(self):
        """Profile that a user would expect to use"""
        try:
            return self.selected_widget.profile
        except (TypeError, AttributeError):
            return self.profiles.choose_one_profile()

    @property
    def visible_widgets(self):
        """Widgets currently visible

        This must be implemented by frontend
        @return (iter[object]): iterable on visible widgets
            widgets can be QuickWidgets or not
        """
        raise NotImplementedError

    @property
    def visible_quick_widgets(self):
        """QuickWidgets currently visible

        This generator iterate only on QuickWidgets, discarding other kinds of
        widget the frontend may have.
        @return (iter[object]): iterable on visible widgets
        """
        for w in self.visisble_widgets:
            if isinstance(w, quick_widgets.QuickWidget):
                return w

    @property
    def selected_widget(self):
        """widget currently selected

        This must be set by frontend using setter.
        """
        return self._selected_widget

    @selected_widget.setter
    def selected_widget(self, wid):
        """Set the currently selected widget

        Must be set by frontend
        """
        if self._selected_widget == wid:
            return
        self._selected_widget = wid
        try:
            on_selected = wid.on_selected
        except AttributeError:
            pass
        else:
            on_selected()

        self.call_listeners("selected", wid)

    # backend state management

    @property
    def sync(self):
        """Synchronization flag

        True if this frontend is synchronised with backend
        """
        return self._sync

    @sync.setter
    def sync(self, state):
        """Called when backend is desynchronised or resynchronising

        @param state(bool): True: if the backend is resynchronising
            False when we lose synchronisation, for instance if frontend is going to sleep
            or if connection has been lost and a reconnection is needed
        """
        if state:
            log.debug("we are synchronised with server")
            if self.AUTO_RESYNC:
                # we are resynchronising all widgets
                log.debug("doing a full widgets resynchronisation")
                for w in self.widgets:
                    try:
                        resync = w.resync
                    except AttributeError:
                        pass
                    else:
                        resync()
                self.contact_lists.fill()

            self._sync = state
        else:
            log.debug("we have lost synchronisation with server")
            self._sync = state
            # we've lost synchronisation, all widgets must be notified
            # note: this is always called independently of AUTO_RESYNC
            for w in self.widgets:
                try:
                    w.sync = False
                except AttributeError:
                    pass

    def register_signal(
        self, function_name, handler=None, iface="core", with_profile=True
    ):
        """Register a handler for a signal

        @param function_name (str): name of the signal to handle
        @param handler (instancemethod): method to call when the signal arrive,
            None for calling an automatically named handler (function_name + 'Handler')
        @param iface (str): interface of the bridge to use ('core' or 'plugin')
        @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
        """
        log.debug("registering signal {name}".format(name=function_name))
        if handler is None:
            handler = getattr(self, "{}{}".format(function_name, "_handler"))
        if not with_profile:
            self.bridge.register_signal(function_name, handler, iface)
            return

        def signal_received(*args, **kwargs):
            profile = kwargs.get("profile")
            if profile is None:
                if not args:
                    raise exceptions.ProfileNotSetError
                profile = args[-1]
            if profile is not None:
                if not self.check_profile(profile):
                    if profile in self.profiles:
                        # profile is not ready but is in self.profiles, that's mean that
                        # it's being connecting and we need to cache the signal
                        self.signals_cache.setdefault(profile, []).append(
                            (function_name, handler, args, kwargs)
                        )
                    return  # we ignore signal for profiles we don't manage
            handler(*args, **kwargs)

        self.bridge.register_signal(function_name, signal_received, iface)

    def addListener(self, type_, callback, profiles_filter=None):
        """Add a listener for an event

        /!\ don't forget to remove listener when not used anymore (e.g. if you delete a
            widget)
        @param type_: type of event, can be:
            - contactsFilled: called when contact have been fully filled for a profiles
                kwargs: profile
            - avatar: called when avatar data is updated
                args: (entity, avatar_data, profile)
            - nicknames: called when nicknames data is updated
                args: (entity, nicknames, profile)
            - presence: called when a presence is received
                args: (entity, show, priority, statuses, profile)
            - selected: called when a widget is selected
                args: (selected_widget,)
            - notification: called when a new notification is emited
                args: (entity, notification_data, profile)
            - notificationsClear: called when notifications are cleared
                args: (entity, type_, profile)
            - widgetNew: a new QuickWidget has been created
                args: (widget,)
            - widgetDeleted: all instances of a widget with specific hash have been
                deleted
                args: (widget_deleted,)
            - menu: called when a menu item is added or removed
                args: (type_, path, path_i18n, item) were values are:
                    type_: same as in [sat.core.sat_main.SAT.import_menu]
                    path: same as in [sat.core.sat_main.SAT.import_menu]
                    path_i18n: translated path (or None if the item is removed)
                    item: instance of quick_menus.MenuItemBase or None if the item is
                          removed
            - gotMenus: called only once when menu are available (no arg)
            - progress_finished: called when a progressing action has just finished
                args:  (progress_id, metadata, profile)
            - progress_error: called when a progressing action failed
                args: (progress_id, error_msg, profile):
        @param callback: method to call on event
        @param profiles_filter (set[unicode]): if set and not empty, the
            listener will be callable only by one of the given profiles.
        """
        assert type_ in C.LISTENERS
        self._listeners.setdefault(type_, {})[callback] = profiles_filter

    def removeListener(self, type_, callback, ignore_missing=False):
        """Remove a callback from listeners

        @param type_(str): same as for [addListener]
        @param callback(callable): callback to remove
        @param ignore_missing(bool): if True, don't log error if the listener doesn't
            exist
        """
        assert type_ in C.LISTENERS
        try:
            self._listeners[type_].pop(callback)
        except KeyError:
            if not ignore_missing:
                log.error(
                    f"Trying to remove an inexisting listener (type = {type_}): "
                    f"{callback}"
                )

    def call_listeners(self, type_, *args, **kwargs):
        """Call the methods which listen type_ event. If a profiles filter has
        been register with a listener and profile argument is not None, the
        listener will be called only if profile is in the profiles filter list.

        @param type_: same as for [addListener]
        @param *args: arguments sent to callback
        @param **kwargs: keywords argument, mainly used to pass "profile" when needed
        """
        assert type_ in C.LISTENERS
        try:
            listeners = self._listeners[type_]
        except KeyError:
            pass
        else:
            profile = kwargs.get("profile")
            for listener, profiles_filter in list(listeners.items()):
                if profile is None or not profiles_filter or profile in profiles_filter:
                    listener(*args, **kwargs)

    def check_profile(self, profile):
        """Tell if the profile is currently followed by the application, and ready"""
        return profile in self.ready_profiles

    def post_init(self, profile_manager):
        """Must be called after initialization is done, do all automatic task

        (auto plug profile)
        @param profile_manager: instance of a subclass of
            Quick_frontend.QuickProfileManager
        """
        if self.options and self.options.profile:
            profile_manager.autoconnect([self.options.profile])

    def profile_plugged(self, profile):
        """Method called when the profile is fully plugged

        This will launch frontend specific workflow

        /!\\ if you override the method and don't call the parent, be sure to add the
            profile to ready_profiles ! if you don't, all signals will stay in cache

        @param profile(unicode): %(doc_profile)s
        """
        self._plugs_in_progress.remove(profile)
        self.ready_profiles.add(profile)

        # profile is ready, we can call send signals that where is cache
        cached_signals = self.signals_cache.pop(profile, [])
        for function_name, handler, args, kwargs in cached_signals:
            log.debug(
                "Calling cached signal [%s] with args %s and kwargs %s"
                % (function_name, args, kwargs)
            )
            handler(*args, **kwargs)

        self.call_listeners("profile_plugged", profile=profile)
        if not self._plugs_in_progress:
            self.contact_lists.lock_update(False)

    def profile_connected(self, profile):
        """Called when a plugged profile is connected

        it is called independently of profile_plugged (may be called before or after
        profile_plugged)
        """
        pass

    def connect(self, profile, callback=None, errback=None):
        if not callback:
            callback = lambda __: None
        if not errback:

            def errback(failure):
                log.error(_("Can't connect profile [%s]") % failure)
                try:
                    module = failure.module
                except AttributeError:
                    module = ""
                try:
                    message = failure.message
                except AttributeError:
                    message = "error"
                try:
                    fullname = failure.fullname
                except AttributeError:
                    fullname = "error"
                if (
                    module.startswith("twisted.words.protocols.jabber")
                    and failure.condition == "not-authorized"
                ):
                    self.action_launch(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
                else:
                    self.show_dialog(message, fullname, "error")

        self.bridge.connect(profile, callback=callback, errback=errback)

    def plug_profiles(self, profiles):
        """Tell application which profiles must be used

        @param profiles: list of valid profile names
        """
        self.contact_lists.lock_update()
        self._plugs_in_progress.update(profiles)
        self.plugging_profiles()
        for profile in profiles:
            self.profiles.plug(profile)

    def plugging_profiles(self):
        """Method to subclass to manage frontend specific things to do

        will be called when profiles are choosen and are to be plugged soon
        """
        pass

    def unplug_profile(self, profile):
        """Tell the application to not follow anymore the profile"""
        if not profile in self.profiles:
            raise ValueError("The profile [{}] is not plugged".format(profile))
        self.profiles.unplug(profile)

    def clear_profile(self):
        self.profiles.clear()

    def new_widget(self, widget):
        raise NotImplementedError

    # bridge signals hanlers

    def connected_handler(self, jid_s, profile):
        """Called when the connection is made.

        @param jid_s (unicode): the JID that we were assigned by the server,
            as the resource might differ from the JID we asked for.
        """
        log.debug(_("Connected"))
        self.profiles[profile].whoami = jid.JID(jid_s)
        self.set_presence_status(profile=profile)
        # FIXME: fill() is already called for all profiles when doing self.sync = True
        #        a per-profile fill() should be done once, see below note
        self.contact_lists[profile].fill()
        # if we were already displaying widgets, they must be resynchronized
        # FIXME: self.sync is for all profiles
        #        while (dis)connection is per-profile.
        #        A mechanism similar to sync should be available
        #        on a per-profile basis
        self.sync = True
        self.profile_connected(profile)

    def disconnected_handler(self, profile):
        """called when the connection is closed"""
        log.debug(_("Disconnected"))
        self.contact_lists[profile].disconnect()
        # FIXME: see note on connected_handler
        self.sync = False
        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)

    def action_new_handler(self, action_data_s, action_id, security_limit, profile):
        action_data = data_format.deserialise(action_data_s)
        action_type = action_data.get("type")
        action_handler = self._action_handlers.get(action_type)
        if action_handler is not None:
            action_handler(action_data, action_id, security_limit, profile)
        else:
            self.action_manager(
                action_data, user_action=False, action_id=action_id, profile=profile
            )

    def contact_new_handler(self, jid_s, attributes, groups, profile):
        entity = jid.JID(jid_s)
        groups = list(groups)
        self.contact_lists[profile].set_contact(
            entity, groups, attributes, in_roster=True
        )

    def message_new_handler(
        self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra_s, profile
    ):
        from_jid = jid.JID(from_jid_s)
        to_jid = jid.JID(to_jid_s)
        extra = data_format.deserialise(extra_s)
        if not self.trigger.point(
            "messageNewTrigger",
            uid,
            timestamp,
            from_jid,
            to_jid,
            msg,
            subject,
            type_,
            extra,
            profile=profile,
        ):
            return

        from_me = from_jid.bare == self.profiles[profile].whoami.bare
        mess_to_jid = to_jid if from_me else from_jid
        target = mess_to_jid.bare
        contact_list = self.contact_lists[profile]

        try:
            is_room = contact_list.is_room(target)
        except exceptions.NotFound:
            is_room = False

        if target.resource and not is_room:
            # we avoid resource locking, but we must keep resource for private MUC
            # messages
            target = target
        # we want to be sure to have at least one QuickChat instance
        self.widgets.get_or_create_widget(
            quick_chat.QuickChat,
            target,
            type_=C.CHAT_GROUP if is_room else C.CHAT_ONE2ONE,
            on_new_widget=None,
            profile=profile,
        )

        if (
            not from_jid in contact_list
            and from_jid.bare != self.profiles[profile].whoami.bare
        ):
            # XXX: needed to show entities which haven't sent any
            #     presence information and which are not in roster
            contact_list.set_contact(from_jid)

        # we dispatch the message in the widgets
        for widget in self.widgets.get_widgets(
            quick_chat.QuickChat, target=target, profiles=(profile,)
        ):
            widget.message_new(
                uid, timestamp, from_jid, mess_to_jid, msg, subject, type_, extra, profile
            )

    def message_encryption_started_handler(self, destinee_jid_s, plugin_data, profile):
        destinee_jid = jid.JID(destinee_jid_s)
        plugin_data = data_format.deserialise(plugin_data)
        for widget in self.widgets.get_widgets(
            quick_chat.QuickChat, target=destinee_jid.bare, profiles=(profile,)
        ):
            widget.message_encryption_started(plugin_data)

    def message_encryption_stopped_handler(self, destinee_jid_s, plugin_data, profile):
        destinee_jid = jid.JID(destinee_jid_s)
        for widget in self.widgets.get_widgets(
            quick_chat.QuickChat, target=destinee_jid.bare, profiles=(profile,)
        ):
            widget.message_encryption_stopped(plugin_data)

    def message_state_handler(self, uid, status, profile):
        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
            widget.on_message_state(uid, status, profile)

    def message_send(
        self,
        to_jid,
        message,
        subject=None,
        mess_type="auto",
        extra=None,
        callback=None,
        errback=None,
        profile_key=C.PROF_KEY_NONE,
    ):
        if not subject and not extra and (not message or message == {"": ""}):
            log.debug("Not sending empty message")
            return

        if subject is None:
            subject = {}
        if extra is None:
            extra = {}
        if callback is None:
            callback = (
                lambda __=None: None
            )  # FIXME: optional argument is here because pyjamas doesn't support callback
            #        without arg with json proxy
        if errback is None:
            errback = lambda failure: self.show_dialog(
                message=failure.message, title=failure.fullname, type="error"
            )

        if not self.trigger.point(
            "messageSendTrigger",
            to_jid,
            message,
            subject,
            mess_type,
            extra,
            callback,
            errback,
            profile_key=profile_key,
        ):
            return

        self.bridge.message_send(
            str(to_jid),
            message,
            subject,
            mess_type,
            data_format.serialise(extra),
            profile_key,
            callback=callback,
            errback=errback,
        )

    def set_presence_status(self, show="", status=None, profile=C.PROF_KEY_NONE):
        raise NotImplementedError

    def presence_update_handler(self, entity_s, show, priority, statuses, profile):
        # XXX: this log is commented because it's really too verbose even for DEBUG logs
        #      but it is kept here as it may still be useful for troubleshooting
        # log.debug(
        #     _(
        #         u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, "
        #         u"statuses=%(statuses)s) [profile:%(profile)s]"
        #     )
        #     % {
        #         "entity": entity_s,
        #         C.PRESENCE_SHOW: show,
        #         C.PRESENCE_PRIORITY: priority,
        #         C.PRESENCE_STATUSES: statuses,
        #         "profile": profile,
        #     }
        # )
        entity = jid.JID(entity_s)

        if entity == self.profiles[profile].whoami:
            if show == C.PRESENCE_UNAVAILABLE:
                self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
            else:
                # FIXME: try to retrieve user language status before fallback to default
                status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
                self.set_presence_status(show, status, profile=profile)
            return

        self.call_listeners("presence", entity, show, priority, statuses, profile=profile)

    def muc_room_joined_handler(
        self, room_jid_s, occupants, user_nick, subject, statuses, profile
    ):
        """Called when a MUC room is joined"""
        log.debug(
            "Room [{room_jid}] joined by {profile}, users presents:{users}".format(
                room_jid=room_jid_s, profile=profile, users=list(occupants.keys())
            )
        )
        room_jid = jid.JID(room_jid_s)
        self.contact_lists[profile].set_special(room_jid, C.CONTACT_SPECIAL_GROUP)
        self.widgets.get_or_create_widget(
            quick_chat.QuickChat,
            room_jid,
            type_=C.CHAT_GROUP,
            nick=user_nick,
            occupants=occupants,
            subject=subject,
            statuses=statuses,
            profile=profile,
        )

    def muc_room_left_handler(self, room_jid_s, profile):
        """Called when a MUC room is left"""
        log.debug(
            "Room [%(room_jid)s] left by %(profile)s"
            % {"room_jid": room_jid_s, "profile": profile}
        )
        room_jid = jid.JID(room_jid_s)
        chat_widget = self.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
        if chat_widget:
            self.widgets.delete_widget(
                chat_widget, all_instances=True, explicit_close=True
            )
        self.contact_lists[profile].remove_contact(room_jid)

    def muc_room_user_changed_nick_handler(self, room_jid_s, old_nick, new_nick, profile):
        """Called when an user joined a MUC room"""
        room_jid = jid.JID(room_jid_s)
        chat_widget = self.widgets.get_or_create_widget(
            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
        )
        chat_widget.change_user_nick(old_nick, new_nick)
        log.debug(
            "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}
        )

    def muc_room_new_subject_handler(self, room_jid_s, subject, profile):
        """Called when subject of MUC room change"""
        room_jid = jid.JID(room_jid_s)
        chat_widget = self.widgets.get_or_create_widget(
            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
        )
        chat_widget.set_subject(subject)
        log.debug(
            "new subject for room [%(room_jid)s]: %(subject)s"
            % {"room_jid": room_jid, "subject": subject}
        )

    def chat_state_received_handler(self, from_jid_s, state, profile):
        """Called when a new chat state (XEP-0085) is received.

        @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
        @param state (unicode): new state
        @param profile (unicode): current profile
        """
        from_jid = jid.JID(from_jid_s)
        for widget in self.widgets.get_widgets(
            quick_chat.QuickChat, target=from_jid.bare, profiles=(profile,)
        ):
            widget.on_chat_state(from_jid, state, profile)

    def notify(
        self,
        type_,
        entity=None,
        message=None,
        subject=None,
        callback=None,
        cb_args=None,
        widget=None,
        profile=C.PROF_KEY_NONE,
    ):
        """Trigger an event notification

        @param type_(unicode): notifation kind,
            one of C.NOTIFY_* constant or any custom type specific to frontend
        @param entity(jid.JID, None): entity involved in the notification
            if entity is in contact list, a indicator may be added in front of it
        @param message(unicode, None): message of the notification
        @param subject(unicode, None): subject of the notification
        @param callback(callable, None): method to call when notification is selected
        @param cb_args(list, None): list of args for callback
        @param widget(object, None): widget where the notification happened
        """
        assert type_ in C.NOTIFY_ALL
        notif_dict = self.profiles[profile].notifications
        key = "" if entity is None else entity.bare
        type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
        notif_data = {
            "id": self._notif_id,
            "time": time.time(),
            "entity": entity,
            "callback": callback,
            "cb_args": cb_args,
            "message": message,
            "subject": subject,
        }
        if widget is not None:
            notif_data[widget] = widget
        type_notifs.append(notif_data)
        self._notifications[self._notif_id] = notif_data
        self._notif_id += 1
        self.call_listeners("notification", entity, notif_data, profile=profile)

    def get_notifs(
        self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE
    ):
        """return notifications for given entity

        @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
            bare jid to get all notifications, full jid to filter on resource
            None to get general notifications
            C.ENTITY_ALL to get all notifications
        @param type_(unicode, None): notification type to filter
            None to get all notifications
        @param exact_jid(bool, None): if True, only return notifications from
            exact entity jid (i.e. not including other resources)
            None for automatic selection (True for full jid, False else)
            False to get resources notifications
            False doesn't do anything if entity is not a bare jid
        @return (iter[dict]): notifications
        """
        main_notif_dict = self.profiles[profile].notifications

        if entity is C.ENTITY_ALL:
            selected_notifs = iter(main_notif_dict.values())
            exact_jid = False
        else:
            if entity is None:
                key = ""
                exact_jid = False
            else:
                key = entity.bare
                if exact_jid is None:
                    exact_jid = bool(entity.resource)
            selected_notifs = (main_notif_dict.setdefault(key, {}),)

        for notifs_from_select in selected_notifs:

            if type_ is None:
                type_notifs = iter(notifs_from_select.values())
            else:
                type_notifs = (notifs_from_select.get(type_, []),)

            for notifs in type_notifs:
                for notif in notifs:
                    if exact_jid and notif["entity"] != entity:
                        continue
                    yield notif

    def clear_notifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
        """return notifications for given entity

        @param entity(jid.JID, None): bare jid of the entity to check
            None to clear general notifications (but keep entities ones)
        @param type_(unicode, None): notification type to filter
            None to clear all notifications
        @return (list[dict]): list of notifications
        """
        notif_dict = self.profiles[profile].notifications
        key = "" if entity is None else entity.bare
        try:
            if type_ is None:
                del notif_dict[key]
            else:
                del notif_dict[key][type_]
        except KeyError:
            return
        self.call_listeners("notificationsClear", entity, type_, profile=profile)

    def ps_event_handler(self, category, service_s, node, event_type, data, profile):
        """Called when a PubSub event is received.

        @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
        @param service_s (unicode): pubsub service
        @param node (unicode): pubsub node
        @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
        @param data (serialised_dict): event data
        """
        data = data_format.deserialise(data)
        service_s = jid.JID(service_s)

        if category == C.PS_MICROBLOG and self.MB_HANDLER:
            if event_type == C.PS_PUBLISH:
                if not "content" in data:
                    log.warning("No content found in microblog data")
                    return

                # FIXME: check if [] make sense (instead of None)
                _groups = data.get("group")

                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
                    wid.add_entry_if_accepted(service_s, node, data, _groups, profile)

                try:
                    comments_node, comments_service = (
                        data["comments_node"],
                        data["comments_service"],
                    )
                except KeyError:
                    pass
                else:
                    self.bridge.mb_get(
                        comments_service,
                        comments_node,
                        C.NO_LIMIT,
                        [],
                        {"subscribe": C.BOOL_TRUE},
                        profile=profile,
                    )
            elif event_type == C.PS_RETRACT:
                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
                    wid.delete_entry_if_present(service_s, node, data["id"], profile)
                pass
            else:
                log.warning("Unmanaged PubSub event type {}".format(event_type))

    def register_progress_cbs(self, progress_id, callback, errback):
        """Register progression callbacks

        @param progress_id(unicode): id of the progression to check
        @param callback(callable, None): method to call when progressing action
            successfuly finished.
            None to ignore
        @param errback(callable, None): method to call when progressions action failed
            None to ignore
        """
        callbacks = self._progress_ids.setdefault(progress_id, [])
        callbacks.append((callback, errback))

    def progress_started_handler(self, pid, metadata, profile):
        log.info("Progress {} started".format(pid))

    def progress_finished_handler(self, pid, metadata, profile):
        log.info("Progress {} finished".format(pid))
        try:
            callbacks = self._progress_ids.pop(pid)
        except KeyError:
            pass
        else:
            for callback, __ in callbacks:
                if callback is not None:
                    callback(metadata, profile=profile)
        self.call_listeners("progress_finished", pid, metadata, profile=profile)

    def progress_error_handler(self, pid, err_msg, profile):
        log.warning("Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
        try:
            callbacks = self._progress_ids.pop(pid)
        except KeyError:
            pass
        else:
            for __, errback in callbacks:
                if errback is not None:
                    errback(err_msg, profile=profile)
        self.call_listeners("progress_error", pid, err_msg, profile=profile)

    def _subscribe_cb(self, answer, data):
        entity, profile = data
        type_ = "subscribed" if answer else "unsubscribed"
        self.bridge.subscription(type_, str(entity.bare), profile_key=profile)

    def subscribe_handler(self, type, raw_jid, profile):
        """Called when a subsciption management signal is received"""
        entity = jid.JID(raw_jid)
        if type == "subscribed":
            # this is a subscription confirmation, we just have to inform user
            # TODO: call self.getEntityMBlog to add the new contact blogs
            self.show_dialog(
                _("The contact {contact} has accepted your subscription").format(
                    contact=entity.bare
                ),
                _("Subscription confirmation"),
            )
        elif type == "unsubscribed":
            # this is a subscription refusal, we just have to inform user
            self.show_dialog(
                _("The contact {contact} has refused your subscription").format(
                    contact=entity.bare
                ),
                _("Subscription refusal"),
                "error",
            )
        elif type == "subscribe":
            # this is a subscriptionn request, we have to ask for user confirmation
            # TODO: use sat.stdui.ui_contact_list to display the groups selector
            self.show_dialog(
                _(
                    "The contact {contact} wants to subscribe to your presence"
                    ".\nDo you accept ?"
                ).format(contact=entity.bare),
                _("Subscription confirmation"),
                "yes/no",
                answer_cb=self._subscribe_cb,
                answer_data=(entity, profile),
            )

    def _debug_handler(self, action, parameters, profile):
        if action == "widgets_dump":
            from pprint import pformat

            log.info("Widgets dump:\n{data}".format(data=pformat(self.widgets._widgets)))
        else:
            log.warning("Unknown debug action: {action}".format(action=action))

    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
        """Show a dialog to user

        Frontends must override this method
        @param message(unicode): body of the dialog
        @param title(unicode): title of the dialog
        @param type(unicode): one of:
            - "info": information dialog (callbacks not used)
            - "warning": important information to notice (callbacks not used)
            - "error": something went wrong (callbacks not used)
            - "yes/no": a dialog with 2 choices (yes and no)
        @param answer_cb(callable): method to call on answer.
            Arguments depend on dialog type:
            - "yes/no": argument is a boolean (True for yes)
        @param answer_data(object): data to link on callback
        """
        # FIXME: misnamed method + types are not well chosen. Need to be rethought
        raise NotImplementedError

    def show_alert(self, message):
        # FIXME: doesn't seems used anymore, to remove?
        pass  # FIXME

    def dialog_failure(self, failure):
        log.warning("Failure: {}".format(failure))

    def progress_id_handler(self, progress_id, profile):
        """Callback used when an action result in a progress id"""
        log.info("Progress ID received: {}".format(progress_id))

    def is_hidden(self):
        """Tells if the frontend window is hidden.

        @return bool
        """
        raise NotImplementedError

    def param_update_handler(self, name, value, namespace, profile):
        log.debug(
            _("param update: [%(namespace)s] %(name)s = %(value)s")
            % {"namespace": namespace, "name": name, "value": value}
        )
        if (namespace, name) == ("Connection", "JabberID"):
            log.debug(_("Changing JID to %s") % value)
            self.profiles[profile].whoami = jid.JID(value)
        elif (namespace, name) == ("General", C.SHOW_OFFLINE_CONTACTS):
            self.contact_lists[profile].show_offline_contacts(C.bool(value))
        elif (namespace, name) == ("General", C.SHOW_EMPTY_GROUPS):
            self.contact_lists[profile].show_empty_groups(C.bool(value))

    def contact_deleted_handler(self, jid_s, profile):
        target = jid.JID(jid_s)
        self.contact_lists[profile].remove_contact(target)

    def entity_data_updated_handler(self, entity_s, key, value_raw, profile):
        entity = jid.JID(entity_s)
        value = data_format.deserialise(value_raw, type_check=None)
        if key == "nicknames":
            assert isinstance(value, list) or value is None
            if entity in self.contact_lists[profile]:
                self.contact_lists[profile].set_cache(entity, "nicknames", value)
                self.call_listeners("nicknames", entity, value, profile=profile)
        elif key == "avatar" and self.AVATARS_HANDLER:
            assert isinstance(value, dict) or value is None
            self.contact_lists[profile].set_cache(entity, "avatar", value)
            self.call_listeners("avatar", entity, value, profile=profile)

    def register_action_handler(
        self, action_type: str, handler: Callable[[dict, str, int, str], None]
    ) -> None:
        """Register a handler for action type.

        If an action of this type is received, the handler will be used, otherwise,
        generic ``action_manager`` is used
        @param action_type: type of action that the handler manage
        @param handler: method to call when an actipn of ``action_type`` is received.
            Will have following args:
            ``action_data``
              Data of the action.
            ``action_id``
              ID of the action.
            ``security_limit``
              Security limit, used to check if this action can be used in current security
              context.
            ``profile``
              Profile name.
        """
        if handler in self._action_handlers:
            raise exceptions.ConflictError(
                f"There is already a registered handler for {action_type} actions: "
                f"{handler}"
            )
        self._action_handlers[action_type] = handler

    def action_manager(
        self,
        action_data: dict,
        callback: Callable | None = None,
        ui_show_cb: Callable | None = None,
        user_action: bool = True,
        action_id: str | None = None,
        progress_cb: Callable | None = None,
        progress_eb: Callable | None = None,
        profile: str = C.PROF_KEY_NONE,
    ) -> None:
        """Handle backend action

        @param action_data: action dict as sent by action_launch or returned by an
            UI action
        @param callback: if not None, callback to use on XMLUI answer
        @param ui_show_cb: if not None, method to call to show the XMLUI
        @param user_action: if True, the action is a result of a user interaction
            else the action come from backend direclty (i.e. action_new).
            This is useful to know if the frontend can display a popup immediately (if
            True) or if it should add it to a queue that the user can activate later.
        @param action_id: ID of the action.
        @param progress_cb: method to call when progression is finished.
            Only make sense if a progress is expected in this action
        @param progress_eb: method to call when something went wrong
            during progression.
            Only make sense if a progress is expected in this action
        """
        try:
            xmlui = action_data.pop("xmlui")
        except KeyError:
            pass
        else:
            ui = self.xmlui.create(
                self,
                xml_data=xmlui,
                flags=("FROM_BACKEND",) if not user_action else None,
                callback=callback,
                profile=profile,
            )
            if ui_show_cb is None:
                ui.show()
            else:
                ui_show_cb(ui)

        try:
            progress_id = action_data.pop("progress")
        except KeyError:
            pass
        else:
            if progress_cb or progress_eb:
                self.register_progress_cbs(progress_id, progress_cb, progress_eb)
            self.progress_id_handler(progress_id, profile)

    def _action_cb(self, data, callback, callback_id, profile):
        if callback is None:
            self.action_manager(data, profile=profile)
        else:
            callback(data=data, cb_id=callback_id, profile=profile)

    def action_launch(
        self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE
    ):
        """Launch a dynamic action

        @param callback_id: id of the action to launch
        @param data: data needed only for certain actions
        @param callback(callable, None): will be called with the resut
            if None, self.action_manager will be called
            else the callable will be called with the following kw parameters:
                - data: action_data
                - cb_id: callback id
                - profile: %(doc_profile)s
        @param profile: %(doc_profile)s

        """
        if data is None:
            data = dict()
        action_cb = lambda data: self._action_cb(
            data_format.deserialise(data), callback, callback_id, profile
        )
        self.bridge.action_launch(
            callback_id,
            data_format.serialise(data),
            profile,
            callback=action_cb,
            errback=self.dialog_failure,
        )

    def launch_menu(
        self,
        menu_type,
        path,
        data=None,
        callback=None,
        security_limit=C.SECURITY_LIMIT_MAX,
        profile=C.PROF_KEY_NONE,
    ):
        """Launch a menu manually

        @param menu_type(unicode): type of the menu to launch
        @param path(iterable[unicode]): path to the menu
        @param data: data needed only for certain actions
        @param callback(callable, None): will be called with the resut
            if None, self.action_manager will be called
            else the callable will be called with the following kw parameters:
                - data: action_data
                - cb_id: (menu_type, path) tuple
                - profile: %(doc_profile)s
        @param profile: %(doc_profile)s

        """
        if data is None:
            data = dict()
        action_cb = lambda data: self._action_cb(
            data, callback, (menu_type, path), profile
        )
        self.bridge.menu_launch(
            menu_type,
            path,
            data,
            security_limit,
            profile,
            callback=action_cb,
            errback=self.dialog_failure,
        )

    def disconnect(self, profile):
        log.info("disconnecting")
        self.call_listeners("disconnect", profile=profile)
        self.bridge.disconnect(profile)

    def on_exit(self):
        """Must be called when the frontend is terminating"""
        to_unplug = []
        for profile, profile_manager in self.profiles.items():
            if profile_manager.connected and profile_manager.autodisconnect:
                # The user wants autodisconnection
                self.disconnect(profile)
            to_unplug.append(profile)
        for profile in to_unplug:
            self.unplug_profile(profile)