view sat_frontends/jp/base.py @ 3166:122075ceaa53

quick frontend (app): replaced OrderedDict by regular dict: since Python 3.7 dict are officially ordered, so there is no need to use a specific class anymore.
author Goffi <goffi@goffi.org>
date Wed, 12 Feb 2020 19:40:48 +0100
parents 9d0df638c8b4
children 67e306cae157
line wrap: on
line source

#!/usr/bin/env python3

# jp: a SAT command line tool
# Copyright (C) 2009-2020 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 asyncio
from sat.core.i18n import _

### logging ###
import logging as log
log.basicConfig(level=log.WARNING,
                format='[%(name)s] %(message)s')
###

import sys
import os
import os.path
import argparse
import inspect
import tty
import termios
from pathlib import Path
from glob import iglob
from importlib import import_module
from sat_frontends.tools.jid import JID
from sat.tools import config
from sat.tools.common import dynamic_import
from sat.tools.common import uri
from sat.tools.common import date_utils
from sat.tools.common import utils
from sat.tools.common.ansi import ANSI as A
from sat.core import exceptions
import sat_frontends.jp
from sat_frontends.jp.loops import QuitException, getJPLoop
from sat_frontends.jp.constants import Const as C
from sat_frontends.bridge.bridge_frontend import BridgeException
from sat_frontends.tools import misc
import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
from collections import OrderedDict

## bridge handling
# we get bridge name from conf and initialise the right class accordingly
main_config = config.parseMainConf()
bridge_name = config.getConfig(main_config, '', 'bridge', 'dbus')
JPLoop = getJPLoop(bridge_name)


try:
    import progressbar
except ImportError:
    msg = (_('ProgressBar not available, please download it at '
             'http://pypi.python.org/pypi/progressbar\n'
             'Progress bar deactivated\n--\n'))
    print(msg, file=sys.stderr)
    progressbar=None

#consts
PROG_NAME = "jp"
DESCRIPTION = """This software is a command line tool for XMPP.
Get the latest version at """ + C.APP_URL

COPYLEFT = """Copyright (C) 2009-2020 Jérôme Poisson, Adrien Cossa
This program comes with ABSOLUTELY NO WARRANTY;
This is free software, and you are welcome to redistribute it under certain conditions.
"""

PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s


def date_decoder(arg):
    return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)


class Jp(object):
    """
    This class can be use to establish a connection with the
    bridge. Moreover, it should manage a main loop.

    To use it, you mainly have to redefine the method run to perform
    specify what kind of operation you want to perform.

    """
    def __init__(self):
        """

        @attribute quit_on_progress_end (bool): set to False if you manage yourself
            exiting, or if you want the user to stop by himself
        @attribute progress_success(callable): method to call when progress just started
            by default display a message
        @attribute progress_success(callable): method to call when progress is
            successfully finished by default display a message
        @attribute progress_failure(callable): method to call when progress failed
            by default display a message
        """
        self.sat_conf = main_config
        self.set_color_theme()
        bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge')
        if bridge_module is None:
            log.error("Can't import {} bridge".format(bridge_name))
            sys.exit(1)

        self.bridge = bridge_module.AIOBridge()
        self._onQuitCallbacks = []

    def get_config(self, name, section='jp', default=None):
        """Retrieve a setting value from sat.conf"""
        return config.getConfig(self.sat_conf, section, name, default=default)

    def guess_background(self):
        stdin_fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(stdin_fd)
        try:
            tty.setraw(sys.stdin.fileno())
            # we request background color
            sys.stdout.write("\033]11;?\a")
            sys.stdout.flush()
            expected = "\033]11;rgb:"
            for c in expected:
                ch = sys.stdin.read(1)
                if ch != c:
                    # background id is not supported, we default to "dark"
                    # TODO: log something?
                    return 'dark'
            red, green, blue = [int(c, 16)/65535 for c in sys.stdin.read(14).split('/')]
            # '\a' is the last character
            sys.stdin.read(1)
        finally:
            termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)

        lum = utils.per_luminance(red, green, blue)
        if lum <= 0.5:
            return 'dark'
        else:
            return 'light'

    def set_color_theme(self):
        background = self.get_config('background', default='auto')
        if background == 'auto':
            background = self.guess_background()
        if background not in ('dark', 'light'):
            raise exceptions.ConfigError(_(
                f'Invalid value set for "background" ({background!r}), please check '
                f'your settings in sat.conf'))
        if background == 'light':
            C.A_HEADER = A.FG_MAGENTA
            C.A_SUBHEADER = A.BOLD + A.FG_RED
            C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
            C.A_SUCCESS = A.FG_GREEN
            C.A_FAILURE = A.BOLD + A.FG_RED
            C.A_WARNING = A.FG_RED
            C.A_PROMPT_PATH = A.FG_BLUE
            C.A_PROMPT_SUF = A.BOLD
            C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
            C.A_FILE = A.FG_BLACK

    def _bridgeConnected(self):
        self.parser = argparse.ArgumentParser(
            formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
        self._make_parents()
        self.add_parser_options()
        self.subparsers = self.parser.add_subparsers(
            title=_('Available commands'), dest='command', required=True)

        # progress attributes
        self._progress_id = None # TODO: manage several progress ids
        self.quit_on_progress_end = True

        # outputs
        self._outputs = {}
        for type_ in C.OUTPUT_TYPES:
            self._outputs[type_] = OrderedDict()
        self.default_output = {}

        self.own_jid = None  # must be filled at runtime if needed

    @property
    def progress_id(self):
        return self._progress_id

    async def set_progress_id(self, progress_id):
        # because we use async, we need an explicit setter
        self._progress_id = progress_id
        await self.replayCache('progress_ids_cache')

    @property
    def watch_progress(self):
        try:
            self.pbar
        except AttributeError:
            return False
        else:
            return True

    @watch_progress.setter
    def watch_progress(self, watch_progress):
        if watch_progress:
            self.pbar = None

    @property
    def verbosity(self):
        try:
            return self.args.verbose
        except AttributeError:
            return 0

    async def replayCache(self, cache_attribute):
        """Replay cached signals

        @param cache_attribute(str): name of the attribute containing the cache
            if the attribute doesn't exist, there is no cache and the call is ignored
            else the cache must be a list of tuples containing the replay callback as
            first item, then the arguments to use
        """
        try:
            cache = getattr(self, cache_attribute)
        except AttributeError:
            pass
        else:
            for cache_data in cache:
                await cache_data[0](*cache_data[1:])

    def disp(self, msg, verbosity=0, error=False, no_lf=False):
        """Print a message to user

        @param msg(unicode): message to print
        @param verbosity(int): minimal verbosity to display the message
        @param error(bool): if True, print to stderr instead of stdout
        @param no_lf(bool): if True, do not emit line feed at the end of line
        """
        if self.verbosity >= verbosity:
            if error:
                if no_lf:
                    print(msg, end=' ', file=sys.stderr)
                else:
                    print(msg, file=sys.stderr)
            else:
                if no_lf:
                    print(msg, end=' ')
                else:
                    print(msg)

    async def output(self, type_, name, extra_outputs, data):
        if name in extra_outputs:
            method = extra_outputs[name]
        else:
            method = self._outputs[type_][name]['callback']

        ret = method(data)
        if inspect.isawaitable(ret):
            await ret

    def addOnQuitCallback(self, callback, *args, **kwargs):
        """Add a callback which will be called on quit command

        @param callback(callback): method to call
        """
        self._onQuitCallbacks.append((callback, args, kwargs))

    def getOutputChoices(self, output_type):
        """Return valid output filters for output_type

        @param output_type: True for default,
            else can be any registered type
        """
        return list(self._outputs[output_type].keys())

    def _make_parents(self):
        self.parents = {}

        # we have a special case here as the start-session option is present only if
        # connection is not needed, so we create two similar parents, one with the
        # option, the other one without it
        for parent_name in ('profile', 'profile_session'):
            parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
            parent.add_argument(
                "-p", "--profile", action="store", type=str, default='@DEFAULT@',
                help=_("Use PROFILE profile key (default: %(default)s)"))
            parent.add_argument(
                "--pwd", action="store", metavar='PASSWORD',
                help=_("Password used to connect profile, if necessary"))

        profile_parent, profile_session_parent = (self.parents['profile'],
                                                  self.parents['profile_session'])

        connect_short, connect_long, connect_action, connect_help = (
            "-c", "--connect", "store_true",
            _("Connect the profile before doing anything else")
        )
        profile_parent.add_argument(
            connect_short, connect_long, action=connect_action, help=connect_help)

        profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
        profile_session_connect_group.add_argument(
            connect_short, connect_long, action=connect_action, help=connect_help)
        profile_session_connect_group.add_argument(
            "--start-session", action="store_true",
            help=_("Start a profile session without connecting"))

        progress_parent = self.parents['progress'] = argparse.ArgumentParser(
            add_help=False)
        if progressbar:
            progress_parent.add_argument(
                "-P", "--progress", action="store_true", help=_("Show progress bar"))

        verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
        verbose_parent.add_argument(
            '--verbose', '-v', action='count', default=0,
            help=_("Add a verbosity level (can be used multiple times)"))

        draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
        draft_group = draft_parent.add_argument_group(_('draft handling'))
        draft_group.add_argument(
            "-D", "--current", action="store_true", help=_("load current draft"))
        draft_group.add_argument(
            "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))


    def make_pubsub_group(self, flags, defaults):
        """generate pubsub options according to flags

        @param flags(iterable[unicode]): see [CommandBase.__init__]
        @param defaults(dict[unicode, unicode]): help text for default value
            key can be "service" or "node"
            value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
        @return (ArgumentParser): parser to add
        """
        flags = misc.FlagsHandler(flags)
        parent = argparse.ArgumentParser(add_help=False)
        pubsub_group = parent.add_argument_group('pubsub')
        pubsub_group.add_argument("-u", "--pubsub-url",
                                  help=_("Pubsub URL (xmpp or http)"))

        service_help = _("JID of the PubSub service")
        if not flags.service:
            default = defaults.pop('service', _('PEP service'))
            if default is not None:
                service_help += _(" (DEFAULT: {default})".format(default=default))
        pubsub_group.add_argument("-s", "--service", default='',
                                  help=service_help)

        node_help = _("node to request")
        if not flags.node:
            default = defaults.pop('node', _('standard node'))
            if default is not None:
                node_help += _(" (DEFAULT: {default})".format(default=default))
        pubsub_group.add_argument("-n", "--node", default='', help=node_help)

        if flags.single_item:
            item_help = ("item to retrieve")
            if not flags.item:
                default = defaults.pop('item', _('last item'))
                if default is not None:
                    item_help += _(" (DEFAULT: {default})".format(default=default))
            pubsub_group.add_argument("-i", "--item", default='',
                                      help=item_help)
            pubsub_group.add_argument(
                "-L", "--last-item", action='store_true', help=_('retrieve last item'))
        elif flags.multi_items:
            # mutiple items, this activate several features: max-items, RSM, MAM
            # and Orbder-by
            pubsub_group.add_argument(
                "-i", "--item", action='append', dest='items', default=[],
                help=_("items to retrieve (DEFAULT: all)"))
            if not flags.no_max:
                max_group = pubsub_group.add_mutually_exclusive_group()
                # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
                max_group.add_argument(
                    "-M", "--max-items", dest="max", type=int,
                    help=_("maximum number of items to get ({no_limit} to get all items)"
                           .format(no_limit=C.NO_LIMIT)))
                # FIXME: it could be possible to no duplicate max (between pubsub
                #        max-items and RSM max)should not be duplicated, RSM could be
                #        used when available and pubsub max otherwise
                max_group.add_argument(
                    "-m", "--max", dest="rsm_max", type=int,
                    help=_("maximum number of items to get per page (DEFAULT: 10)"))

            # RSM

            rsm_page_group = pubsub_group.add_mutually_exclusive_group()
            rsm_page_group.add_argument(
                "-a", "--after", dest="rsm_after",
                help=_("find page after this item"), metavar='ITEM_ID')
            rsm_page_group.add_argument(
                "-b", "--before", dest="rsm_before",
                help=_("find page before this item"), metavar='ITEM_ID')
            rsm_page_group.add_argument(
                "--index", dest="rsm_index", type=int,
                help=_("index of the page to retrieve"))


            # MAM

            pubsub_group.add_argument(
                "-f", "--filter", dest='mam_filters', nargs=2,
                action='append', default=[], help=_("MAM filters to use"),
                metavar=("FILTER_NAME", "VALUE")
            )

            # Order-By

            # TODO: order-by should be a list to handle several levels of ordering
            #       but this is not yet done in SàT (and not really useful with
            #       current specifications, as only "creation" and "modification" are
            #       available)
            pubsub_group.add_argument(
                "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
                                             C.ORDER_BY_MODIFICATION],
                help=_("how items should be ordered"))

        if not flags.all_used:
            raise exceptions.InternalError('unknown flags: {flags}'.format(
                flags=', '.join(flags.unused)))
        if defaults:
            raise exceptions.InternalError(f'unused defaults: {defaults}')

        return parent

    def add_parser_options(self):
        self.parser.add_argument(
            '--version',
            action='version',
            version=("{name} {version} {copyleft}".format(
                name = PROG_NAME,
                version = self.version,
                copyleft = COPYLEFT))
        )

    def register_output(self, type_, name, callback, description="", default=False):
        if type_ not in C.OUTPUT_TYPES:
            log.error("Invalid output type {}".format(type_))
            return
        self._outputs[type_][name] = {'callback': callback,
                                      'description': description
                                     }
        if default:
            if type_ in self.default_output:
                self.disp(
                    _(f'there is already a default output for {type_}, ignoring new one')
                )
            else:
                self.default_output[type_] = name


    def parse_output_options(self):
        options = self.command.args.output_opts
        options_dict = {}
        for option in options:
            try:
                key, value = option.split('=', 1)
            except ValueError:
                key, value = option, None
            options_dict[key.strip()] = value.strip() if value is not None else None
        return options_dict

    def check_output_options(self, accepted_set, options):
        if not accepted_set.issuperset(options):
            self.disp(
                _("The following output options are invalid: {invalid_options}").format(
                invalid_options = ', '.join(set(options).difference(accepted_set))),
                error=True)
            self.quit(C.EXIT_BAD_ARG)

    def import_plugins(self):
        """Automaticaly import commands and outputs in jp

        looks from modules names cmd_*.py in jp path and import them
        """
        path = os.path.dirname(sat_frontends.jp.__file__)
        # XXX: outputs must be imported before commands as they are used for arguments
        for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
                               (C.PLUGIN_CMD, 'cmd_*.py')):
            modules = (
                os.path.splitext(module)[0]
                for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
            for module_name in modules:
                module_path = "sat_frontends.jp." + module_name
                try:
                    module = import_module(module_path)
                    self.import_plugin_module(module, type_)
                except ImportError as e:
                    self.disp(
                        _(f"Can't import {module_path} plugin, ignoring it: {e}"),
                        error=True)
                except exceptions.CancelError:
                    continue
                except exceptions.MissingModule as e:
                    self.disp(_("Missing module for plugin {name}: {missing}".format(
                        name = module_path,
                        missing = e)), error=True)


    def import_plugin_module(self, module, type_):
        """add commands or outpus from a module to jp

        @param module: module containing commands or outputs
        @param type_(str): one of C_PLUGIN_*
        """
        try:
            class_names =  getattr(module, '__{}__'.format(type_))
        except AttributeError:
            log.disp(_(f"Invalid plugin module [{type_}] {module}"), error=True)
            raise ImportError
        else:
            for class_name in class_names:
                cls = getattr(module, class_name)
                cls(self)

    def get_xmpp_uri_from_http(self, http_url):
        """parse HTML page at http(s) URL, and looks for xmpp: uri"""
        if http_url.startswith('https'):
            scheme = 'https'
        elif http_url.startswith('http'):
            scheme = 'http'
        else:
            raise exceptions.InternalError('An HTTP scheme is expected in this method')
        self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
        # HTTP URL, we try to find xmpp: links
        try:
            from lxml import etree
        except ImportError:
            self.disp(
                "lxml module must be installed to use http(s) scheme, please install it "
                "with \"pip install lxml\"",
                error=True)
            self.quit(1)
        import urllib.request, urllib.error, urllib.parse
        parser = etree.HTMLParser()
        try:
            root = etree.parse(urllib.request.urlopen(http_url), parser)
        except etree.XMLSyntaxError as e:
            self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
            links = []
        else:
            links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
        if not links:
            self.disp(
                _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
                  'PubSub node/item'),
                error=True)
            self.quit(1)
        xmpp_uri = links[0].get('href')
        return xmpp_uri

    def parse_pubsub_args(self):
        if self.args.pubsub_url is not None:
            url = self.args.pubsub_url

            if url.startswith('http'):
                # http(s) URL, we try to retrieve xmpp one from there
                url = self.get_xmpp_uri_from_http(url)

            try:
                uri_data = uri.parseXMPPUri(url)
            except ValueError:
                self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
            else:
                if uri_data['type'] == 'pubsub':
                    # URL is alright, we only set data not already set by other options
                    if not self.args.service:
                        self.args.service = uri_data['path']
                    if not self.args.node:
                        self.args.node = uri_data['node']
                    uri_item = uri_data.get('item')
                    if uri_item:
                        # there is an item in URI
                        # we use it only if item is not already set
                        # and item_last is not used either
                        try:
                            item = self.args.item
                        except AttributeError:
                            try:
                                items = self.args.items
                            except AttributeError:
                                self.disp(
                                    _("item specified in URL but not needed in command, "
                                      "ignoring it"),
                                    error=True)
                            else:
                                if not items:
                                    self.args.items = [uri_item]
                        else:
                            if not item:
                                try:
                                    item_last = self.args.item_last
                                except AttributeError:
                                    item_last = False
                                if not item_last:
                                    self.args.item = uri_item
                else:
                    self.parser.error(_(f'XMPP URL is not a pubsub one: {url}'))
        flags = self.args._cmd._pubsub_flags
        # we check required arguments here instead of using add_arguments' required option
        # because the required argument can be set in URL
        if C.SERVICE in flags and not self.args.service:
            self.parser.error(_("argument -s/--service is required"))
        if C.NODE in flags and not self.args.node:
            self.parser.error(_("argument -n/--node is required"))
        if C.ITEM in flags and not self.args.item:
            self.parser.error(_("argument -i/--item is required"))

        # FIXME: mutually groups can't be nested in a group and don't support title
        #        so we check conflict here. This may be fixed in Python 3, to be checked
        try:
            if self.args.item and self.args.item_last:
                self.parser.error(
                    _("--item and --item-last can't be used at the same time"))
        except AttributeError:
            pass

        try:
            max_items = self.args.max
            rsm_max = self.args.rsm_max
        except AttributeError:
            pass
        else:
            # we need to set a default value for max, but we need to know if we want
            # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
            # argument is set
            if max_items is None and rsm_max is None:
                to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
                            'rsm_index')
                if any((getattr(self.args, name) for name in to_check)):
                    # we use RSM
                    self.args.rsm_max = 10
                else:
                    # we use pubsub without RSM
                    self.args.max = 10
            if self.args.max is None:
                self.args.max = C.NO_LIMIT

    async def main(self, args, namespace):
        try:
            await self.bridge.bridgeConnect()
        except Exception as e:
            if isinstance(e, exceptions.BridgeExceptionNoService):
                print((_("Can't connect to SàT backend, are you sure it's launched ?")))
            elif isinstance(e, exceptions.BridgeInitError):
                print((_("Can't init bridge")))
            else:
                print((_(f"Error while initialising bridge: {e}")))
            self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
            return
        self.version = await self.bridge.getVersion()
        self._bridgeConnected()
        self.import_plugins()
        try:
            self.args = self.parser.parse_args(args, namespace=None)
            if self.args._cmd._use_pubsub:
                self.parse_pubsub_args()
            await self.args._cmd.run()
        except SystemExit as e:
            self.quit(e.code, raise_exc=False)
            return
        except QuitException:
            return

    def run(self, args=None, namespace=None):
        self.loop = JPLoop()
        self.loop.run(self, args, namespace)

    def _read_stdin(self, stdin_fut):
        """Callback called by ainput to read stdin"""
        line = sys.stdin.readline()
        if line:
            stdin_fut.set_result(line.rstrip(os.linesep))
        else:
            stdin_fut.set_exception(EOFError())

    async def ainput(self, msg=''):
        """Asynchronous version of buildin "input" function"""
        self.disp(msg, no_lf=True)
        sys.stdout.flush()
        loop = asyncio.get_running_loop()
        stdin_fut = loop.create_future()
        loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
        return await stdin_fut

    async def confirmOrQuit(self, message, cancel_message=_("action cancelled by user")):
        """Request user to confirm action, and quit if he doesn't"""

        res = await self.ainput(f"{message} (y/N)? ")
        if res not in ("y", "Y"):
            self.disp(cancel_message)
            self.quit(C.EXIT_USER_CANCELLED)

    def quitFromSignal(self, exit_code=0):
        r"""Same as self.quit, but from a signal handler

        /!\: return must be used after calling this method !
        """
        # XXX: python-dbus will show a traceback if we exit in a signal handler
        # so we use this little timeout trick to avoid it
        self.loop.call_later(0, self.quit, exit_code)

    def quit(self, exit_code=0, raise_exc=True):
        """Terminate the execution with specified exit_code

        This will stop the loop.
        @param exit_code(int): code to return when quitting the program
        @param raise_exp(boolean): if True raise a QuitException to stop code execution
            The default value should be used most of time.
        """
        # first the onQuitCallbacks
        try:
            callbacks_list = self._onQuitCallbacks
        except AttributeError:
            pass
        else:
            for callback, args, kwargs in callbacks_list:
                callback(*args, **kwargs)

        self.loop.quit(exit_code)
        if raise_exc:
            raise QuitException

    async def check_jids(self, jids):
        """Check jids validity, transform roster name to corresponding jids

        @param profile: profile name
        @param jids: list of jids
        @return: List of jids

        """
        names2jid = {}
        nodes2jid = {}

        for contact in await self.bridge.getContacts(self.profile):
            jid_s, attr, groups = contact
            _jid = JID(jid_s)
            try:
                names2jid[attr["name"].lower()] = jid_s
            except KeyError:
                pass

            if _jid.node:
                nodes2jid[_jid.node.lower()] = jid_s

        def expand_jid(jid):
            _jid = jid.lower()
            if _jid in names2jid:
                expanded = names2jid[_jid]
            elif _jid in nodes2jid:
                expanded = nodes2jid[_jid]
            else:
                expanded = jid
            return expanded

        def check(jid):
            if not jid.is_valid:
                log.error (_("%s is not a valid JID !"), jid)
                self.quit(1)

        dest_jids=[]
        try:
            for i in range(len(jids)):
                dest_jids.append(expand_jid(jids[i]))
                check(dest_jids[i])
        except AttributeError:
            pass

        return dest_jids

    async def a_pwd_input(self, msg=''):
        """Like ainput but with echo disabled (useful for passwords)"""
        # we disable echo, code adapted from getpass standard module which has been
        # written by Piers Lauder (original), Guido van Rossum (Windows support and
        # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
        # to them (and for all the amazing work on Python).
        stdin_fd = sys.stdin.fileno()
        old = termios.tcgetattr(sys.stdin)
        new = old[:]
        new[3] &= ~termios.ECHO
        tcsetattr_flags = termios.TCSAFLUSH
        if hasattr(termios, 'TCSASOFT'):
            tcsetattr_flags |= termios.TCSASOFT
        try:
            termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
            pwd = await self.ainput(msg=msg)
        finally:
            termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
            sys.stderr.flush()
        self.disp('')
        return pwd

    async def connectOrPrompt(self, method, err_msg=None):
        """Try to connect/start profile session and prompt for password if needed

        @param method(callable): bridge method to either connect or start profile session
            It will be called with password as sole argument, use lambda to do the call
            properly
        @param err_msg(str): message to show if connection fail
        """
        password = self.args.pwd
        while True:
            try:
                await method(password or '')
            except Exception as e:
                if ((isinstance(e, BridgeException)
                     and e.classname == 'PasswordError'
                     and self.args.pwd is None)):
                    if password is not None:
                        self.disp(A.color(C.A_WARNING, _("invalid password")))
                    password = await self.a_pwd_input(
                        _("please enter profile password:"))
                else:
                    self.disp(err_msg.format(profile=self.profile, e=e), error=True)
                    self.quit(C.EXIT_ERROR)
            else:
                break

    async def connect_profile(self):
        """Check if the profile is connected and do it if requested

        @exit: - 1 when profile is not connected and --connect is not set
               - 1 when the profile doesn't exists
               - 1 when there is a connection error
        """
        # FIXME: need better exit codes

        self.profile = await self.bridge.profileNameGet(self.args.profile)

        if not self.profile:
            log.error(_(f"The profile [{self.args.profile}] doesn't exist"))
            self.quit(C.EXIT_ERROR)

        try:
            start_session = self.args.start_session
        except AttributeError:
            pass
        else:
            if start_session:
                await self.connectOrPrompt(
                    lambda pwd: self.bridge.profileStartSession(pwd, self.profile),
                    err_msg="Can't start {profile}'s session: {e}"
                )
                return
            elif not await self.bridge.profileIsSessionStarted(self.profile):
                if not self.args.connect:
                    self.disp(_(
                        f"Session for [{self.profile}] is not started, please start it "
                        f"before using jp, or use either --start-session or --connect "
                        f"option"), error=True)
                    self.quit(1)
            elif not getattr(self.args, "connect", False):
                return


        if not hasattr(self.args, 'connect'):
            # a profile can be present without connect option (e.g. on profile
            # creation/deletion)
            return
        elif self.args.connect is True:  # if connection is asked, we connect the profile
            await self.connectOrPrompt(
                lambda pwd: self.bridge.connect(self.profile, pwd, {}),
                err_msg = 'Can\'t connect profile "{profile!s}": {e}'
            )
            return
        else:
            if not await self.bridge.isConnected(self.profile):
                log.error(
                    _(f"Profile [{self.profile}] is not connected, please connect it "
                      f"before using jp, or use --connect option"))
                self.quit(1)

    async def get_full_jid(self, param_jid):
        """Return the full jid if possible (add main resource when find a bare jid)"""
        _jid = JID(param_jid)
        if not _jid.resource:
            #if the resource is not given, we try to add the main resource
            main_resource = await self.bridge.getMainResource(param_jid, self.profile)
            if main_resource:
                return f"{_jid.bare}/{main_resource}"
        return param_jid


class CommandBase(object):

    def __init__(self, host, name, use_profile=True, use_output=False, extra_outputs=None,
                       need_connect=None, help=None, **kwargs):
        """Initialise CommandBase

        @param host: Jp instance
        @param name(unicode): name of the new command
        @param use_profile(bool): if True, add profile selection/connection commands
        @param use_output(bool, unicode): if not False, add --output option
        @param extra_outputs(dict): list of command specific outputs:
            key is output name ("default" to use as main output)
            value is a callable which will format the output (data will be used as only
            argument)
            if a key already exists with normal outputs, the extra one will be used
        @param need_connect(bool, None): True if profile connection is needed
            False else (profile session must still be started)
            None to set auto value (i.e. True if use_profile is set)
            Can't be set if use_profile is False
        @param help(unicode): help message to display
        @param **kwargs: args passed to ArgumentParser
            use_* are handled directly, they can be:
            - use_progress(bool): if True, add progress bar activation option
                progress* signals will be handled
            - use_verbose(bool): if True, add verbosity option
            - use_pubsub(bool): if True, add pubsub options
                mandatory arguments are controlled by pubsub_req
            - use_draft(bool): if True, add draft handling options
            ** other arguments **
            - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
              can be:
                C.SERVICE: service is required
                C.NODE: node is required
                C.ITEM: item is required
                C.SINGLE_ITEM: only one item is allowed
        """
        try: # If we have subcommands, host is a CommandBase and we need to use host.host
            self.host = host.host
        except AttributeError:
            self.host = host

        # --profile option
        parents = kwargs.setdefault('parents', set())
        if use_profile:
            # self.host.parents['profile'] is an ArgumentParser with profile connection
            # arguments
            if need_connect is None:
                need_connect = True
            parents.add(
                self.host.parents['profile' if need_connect else 'profile_session'])
        else:
            assert need_connect is None
        self.need_connect = need_connect
        # from this point, self.need_connect is None if connection is not needed at all
        # False if session starting is needed, and True if full connection is needed

        # --output option
        if use_output:
            if extra_outputs is None:
                extra_outputs = {}
            self.extra_outputs = extra_outputs
            if use_output == True:
                use_output = C.OUTPUT_TEXT
            assert use_output in C.OUTPUT_TYPES
            self._output_type = use_output
            output_parent = argparse.ArgumentParser(add_help=False)
            choices = set(self.host.getOutputChoices(use_output))
            choices.update(extra_outputs)
            if not choices:
                raise exceptions.InternalError(
                    "No choice found for {} output type".format(use_output))
            try:
                default = self.host.default_output[use_output]
            except KeyError:
                if 'default' in choices:
                    default = 'default'
                elif 'simple' in choices:
                    default = 'simple'
                else:
                    default = list(choices)[0]
            output_parent.add_argument(
                '--output', '-O', choices=sorted(choices), default=default,
                help=_("select output format (default: {})".format(default)))
            output_parent.add_argument(
                '--output-option', '--oo', action="append", dest='output_opts',
                default=[], help=_("output specific option"))
            parents.add(output_parent)
        else:
            assert extra_outputs is None

        self._use_pubsub = kwargs.pop('use_pubsub', False)
        if self._use_pubsub:
            flags = kwargs.pop('pubsub_flags', [])
            defaults = kwargs.pop('pubsub_defaults', {})
            parents.add(self.host.make_pubsub_group(flags, defaults))
            self._pubsub_flags = flags

        # other common options
        use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
        for param, do_use in use_opts.items():
            opt=param[4:] # if param is use_verbose, opt is verbose
            if opt not in self.host.parents:
                raise exceptions.InternalError("Unknown parent option {}".format(opt))
            del kwargs[param]
            if do_use:
                parents.add(self.host.parents[opt])

        self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
        if hasattr(self, "subcommands"):
            self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
        else:
            self.parser.set_defaults(_cmd=self)
        self.add_parser_options()

    @property
    def sat_conf(self):
        return self.host.sat_conf

    @property
    def args(self):
        return self.host.args

    @property
    def profile(self):
        return self.host.profile

    @property
    def verbosity(self):
        return self.host.verbosity

    @property
    def progress_id(self):
        return self.host.progress_id

    async def set_progress_id(self, progress_id):
        return await self.host.set_progress_id(progress_id)

    async def progressStartedHandler(self, uid, metadata, profile):
        if profile != self.profile:
            return
        if self.progress_id is None:
            # the progress started message can be received before the id
            # so we keep progressStarted signals in cache to replay they
            # when the progress_id is received
            cache_data = (self.progressStartedHandler, uid, metadata, profile)
            try:
                cache = self.host.progress_ids_cache
            except AttributeError:
                cache = self.host.progress_ids_cache = []
            cache.append(cache_data)
        else:
            if self.host.watch_progress and uid == self.progress_id:
                await self.onProgressStarted(metadata)
                while True:
                    await asyncio.sleep(PROGRESS_DELAY)
                    cont = await self.progressUpdate()
                    if not cont:
                        break

    async def progressFinishedHandler(self, uid, metadata, profile):
        if profile != self.profile:
            return
        if uid == self.progress_id:
            try:
                self.host.pbar.finish()
            except AttributeError:
                pass
            await self.onProgressFinished(metadata)
            if self.host.quit_on_progress_end:
                self.host.quitFromSignal()

    async def progressErrorHandler(self, uid, message, profile):
        if profile != self.profile:
            return
        if uid == self.progress_id:
            if self.args.progress:
                self.disp('') # progress is not finished, so we skip a line
            if self.host.quit_on_progress_end:
                await self.onProgressError(message)
                self.host.quitFromSignal(C.EXIT_ERROR)

    async def progressUpdate(self):
        """This method is continualy called to update the progress bar

        @return (bool): False to stop being called
        """
        data = await self.host.bridge.progressGet(self.progress_id, self.profile)
        if data:
            try:
                size = data['size']
            except KeyError:
                self.disp(_("file size is not known, we can't show a progress bar"), 1,
                          error=True)
                return False
            if self.host.pbar is None:
                #first answer, we must construct the bar

                # if the instance has a pbar_template attribute, it is used has model,
                # else default one is used
                # template is a list of part, where part can be either a str to show directly
                # or a list where first argument is a name of a progressbar widget, and others
                # are used as widget arguments
                try:
                    template = self.pbar_template
                except AttributeError:
                    template = [
                        _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
                        ["FileTransferSpeed"], " ", ["ETA"]
                    ]

                widgets = []
                for part in template:
                    if isinstance(part, str):
                        widgets.append(part)
                    else:
                        widget = getattr(progressbar, part.pop(0))
                        widgets.append(widget(*part))

                self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
                self.host.pbar.start()

            self.host.pbar.update(int(data['position']))

        elif self.host.pbar is not None:
            return False

        await self.onProgressUpdate(data)

        return True

    async def onProgressStarted(self, metadata):
        """Called when progress has just started

        can be overidden by a command
        @param metadata(dict): metadata as sent by bridge.progressStarted
        """
        self.disp(_("Operation started"), 2)

    async def onProgressUpdate(self, metadata):
        """Method called on each progress updata

        can be overidden by a command to handle progress metadata
        @para metadata(dict): metadata as returned by bridge.progressGet
        """
        pass

    async def onProgressFinished(self, metadata):
        """Called when progress has just finished

        can be overidden by a command
        @param metadata(dict): metadata as sent by bridge.progressFinished
        """
        self.disp(_("Operation successfully finished"), 2)

    async def onProgressError(self, e):
        """Called when a progress failed

        @param error_msg(unicode): error message as sent by bridge.progressError
        """
        self.disp(_(f"Error while doing operation: {e}"), error=True)

    def disp(self, msg, verbosity=0, error=False, no_lf=False):
        return self.host.disp(msg, verbosity, error, no_lf)

    def output(self, data):
        try:
            output_type = self._output_type
        except AttributeError:
            raise exceptions.InternalError(
                _('trying to use output when use_output has not been set'))
        return self.host.output(output_type, self.args.output, self.extra_outputs, data)

    def getPubsubExtra(self, extra=None):
        """Helper method to compute extra data from pubsub arguments

        @param extra(None, dict): base extra dict, or None to generate a new one
        @return (dict): dict which can be used directly in the bridge for pubsub
        """
        if extra is None:
            extra = {}
        else:
            intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
            if intersection:
                raise exceptions.ConflictError(
                    "given extra dict has conflicting keys with pubsub keys "
                    "{intersection}".format(intersection=intersection))

        # RSM

        for attribute in ('max', 'after', 'before', 'index'):
            key = 'rsm_' + attribute
            if key in extra:
                raise exceptions.ConflictError(
                    "This key already exists in extra: u{key}".format(key=key))
            value = getattr(self.args, key, None)
            if value is not None:
                extra[key] = str(value)

        # MAM

        if hasattr(self.args, 'mam_filters'):
            for key, value in self.args.mam_filters:
                key = 'filter_' + key
                if key in extra:
                    raise exceptions.ConflictError(
                        "This key already exists in extra: u{key}".format(key=key))
                extra[key] = value

        # Order-By

        try:
            order_by = self.args.order_by
        except AttributeError:
            pass
        else:
            if order_by is not None:
                extra[C.KEY_ORDER_BY] = self.args.order_by
        return extra

    def add_parser_options(self):
        try:
            subcommands = self.subcommands
        except AttributeError:
            # We don't have subcommands, the class need to implements add_parser_options
            raise NotImplementedError

        # now we add subcommands to ourself
        for cls in subcommands:
            cls(self)

    async def run(self):
        """this method is called when a command is actually run

        It set stuff like progression callbacks and profile connection
        You should not overide this method: you should call self.start instead
        """
        # we keep a reference to run command, it may be useful e.g. for outputs
        self.host.command = self

        try:
            show_progress = self.args.progress
        except AttributeError:
            # the command doesn't use progress bar
            pass
        else:
            if show_progress:
                self.host.watch_progress = True
            # we need to register the following signal even if we don't display the
            # progress bar
            self.host.bridge.register_signal(
                "progressStarted", self.progressStartedHandler)
            self.host.bridge.register_signal(
                "progressFinished", self.progressFinishedHandler)
            self.host.bridge.register_signal(
                "progressError", self.progressErrorHandler)

        if self.need_connect is not None:
            await self.host.connect_profile()
        await self.start()

    async def start(self):
        """This is the starting point of the command, this method must be overriden

        at this point, profile are connected if needed
        """
        raise NotImplementedError


class CommandAnswering(CommandBase):
    """Specialised commands which answer to specific actions

    to manage action_types answer,
    """
    action_callbacks = {} # XXX: set managed action types in a dict here:
                          # key is the action_type, value is the callable
                          # which will manage the answer. profile filtering is
                          # already managed when callback is called

    def __init__(self, *args, **kwargs):
        super(CommandAnswering, self).__init__(*args, **kwargs)

    async def onActionNew(self, action_data, action_id, security_limit, profile):
        if profile != self.profile:
            return
        try:
            action_type = action_data['meta_type']
        except KeyError:
            try:
                xml_ui = action_data["xmlui"]
            except KeyError:
                pass
            else:
                self.onXMLUI(xml_ui)
        else:
            try:
                callback = self.action_callbacks[action_type]
            except KeyError:
                pass
            else:
                await callback(action_data, action_id, security_limit, profile)

    def onXMLUI(self, xml_ui):
        """Display a dialog received from the backend.

        @param xml_ui (unicode): dialog XML representation
        """
        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
        #        should be available in the future
        # TODO: XMLUI module
        ui = ET.fromstring(xml_ui.encode('utf-8'))
        dialog = ui.find("dialog")
        if dialog is not None:
            self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")

    async def start_answering(self):
        """Auto reply to confirmation requests"""
        self.host.bridge.register_signal("actionNew", self.onActionNew)
        actions = await self.host.bridge.actionsGet(self.profile)
        for action_data, action_id, security_limit in actions:
            await self.onActionNew(action_data, action_id, security_limit, self.profile)