rostertab.py 40.4 KB
Newer Older
1 2 3 4 5 6 7
"""
The RosterInfoTab is the tab showing roster info, the list of contacts,
half of it is dedicated to showing the information buffer, and a small
rectangle shows the current contact info.

This module also includes functions to match users in the roster.
"""
8
import logging
mathieui's avatar
mathieui committed
9
import base64
10 11 12
import curses
import difflib
import os
mathieui's avatar
mathieui committed
13
import ssl
14
from functools import partial
15
from os import getenv, path
16
from pathlib import Path
17
from typing import Dict, Callable
18

19 20
from poezio import common
from poezio import windows
21
from poezio.common import safeJID, shell_split
22 23 24 25 26
from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.decorators import refresh_wrapper
from poezio.roster import RosterGroup, roster
from poezio.theming import get_theme, dump_tuple
27
from poezio.decorators import command_args_parser, deny_anonymous
28
from poezio.core.structs import Command, Completion
mathieui's avatar
mathieui committed
29
from poezio.tabs import Tab
30
from poezio.ui.types import InfoMessage
mathieui's avatar
mathieui committed
31

32 33
log = logging.getLogger(__name__)

mathieui's avatar
mathieui committed
34

35 36
class RosterInfoTab(Tab):
    """
37
    A tab, split in two, containing the roster and infos
38
    """
39 40
    plugin_commands = {}  # type: Dict[str, Command]
    plugin_keys = {}  # type: Dict[str, Callable]
mathieui's avatar
mathieui committed
41

42 43
    def __init__(self, core):
        Tab.__init__(self, core)
44 45 46 47 48 49
        self.name = "Roster"
        self.v_separator = windows.VerticalSeparator()
        self.information_win = windows.TextWin()
        self.core.information_buffer.add_window(self.information_win)
        self.roster_win = windows.RosterWin()
        self.contact_info_win = windows.ContactInfoWin()
50
        self.avatar_win = windows.ImageWin()
mathieui's avatar
mathieui committed
51 52
        self.default_help_message = windows.HelpText(
            "Enter commands with “/”. “o”: toggle offline show")
53 54 55 56
        self.input = self.default_help_message
        self.state = 'normal'
        self.key_func['^I'] = self.completion
        self.key_func["/"] = self.on_slash
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
        # disable most of the roster features when in anonymous mode
        if not self.core.xmpp.anon:
            self.key_func[' '] = self.on_space
            self.key_func["KEY_UP"] = self.move_cursor_up
            self.key_func["KEY_DOWN"] = self.move_cursor_down
            self.key_func["M-u"] = self.move_cursor_to_next_contact
            self.key_func["M-y"] = self.move_cursor_to_prev_contact
            self.key_func["M-U"] = self.move_cursor_to_next_group
            self.key_func["M-Y"] = self.move_cursor_to_prev_group
            self.key_func["M-[1;5B"] = self.move_cursor_to_next_group
            self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group
            self.key_func["l"] = self.command_last_activity
            self.key_func["o"] = self.toggle_offline_show
            self.key_func["v"] = self.get_contact_version
            self.key_func["i"] = self.show_contact_info
            self.key_func["s"] = self.start_search
            self.key_func["S"] = self.start_search_slow
            self.key_func["n"] = self.change_contact_name
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
        self.register_command(
            'name',
            self.command_name,
            usage='<jid> [name]',
            shortdesc='Set the given JID\'s name.',
            completion=self.completion_name)
        self.register_command(
            'groupadd',
            self.command_groupadd,
            usage='[<jid> <group>]|<group>',
            desc='Add the given JID or selected line to the given group.',
            shortdesc='Add a user to a group',
            completion=self.completion_groupadd)
        self.register_command(
            'groupmove',
            self.command_groupmove,
            usage='<jid> <old group> <new group>',
            desc='Move the given JID from the old group to the new group.',
            shortdesc='Move a user to another group.',
            completion=self.completion_groupmove)
        self.register_command(
            'groupremove',
            self.command_groupremove,
            usage='<jid> <group>',
            desc='Remove the given JID from the given group.',
            shortdesc='Remove a user from a group.',
            completion=self.completion_groupremove)
        self.register_command(
            'export',
            self.command_export,
            usage='[/path/to/file]',
            desc='Export your contacts into /path/to/file if '
            'specified, or $HOME/poezio_contacts if not.',
            shortdesc='Export your roster to a file.',
            completion=partial(self.completion_file, 1))
        self.register_command(
            'import',
            self.command_import,
            usage='[/path/to/file]',
            desc='Import your contacts from /path/to/file if '
            'specified, or $HOME/poezio_contacts if not.',
            shortdesc='Import your roster from a file.',
            completion=partial(self.completion_file, 1))
        self.register_command(
            'password',
            self.command_password,
            usage='<password>',
            shortdesc='Change your password')
mathieui's avatar
mathieui committed
123 124 125 126 127 128 129 130 131 132 133 134 135 136
        self.register_command(
            'disconnect',
            self.command_disconnect,
            desc='Disconnect from the remote server.',
            shortdesc='Disconnect from the server.')
        self.register_command(
            'clear', self.command_clear, shortdesc='Clear the info buffer.')
        self.register_command(
            'last_activity',
            self.command_last_activity,
            usage='<jid>',
            desc='Informs you of the last activity of a JID.',
            shortdesc='Get the activity of someone.',
            completion=self.core.completion.last_activity)
137 138 139 140 141 142

        self.resize()
        self.update_commands()
        self.update_keys()

    def check_blocking(self, features):
143
        if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon:
mathieui's avatar
mathieui committed
144 145 146 147 148 149 150 151
            self.register_command(
                'list_blocks',
                self.command_list_blocks,
                shortdesc='Show the blocked contacts.')
            self.core.xmpp.del_event_handler('session_start',
                                             self.check_blocking)
            self.core.xmpp.add_event_handler('blocked_message',
                                             self.on_blocked_message)
152

mathieui's avatar
mathieui committed
153
    def check_saslexternal(self, features):
154
        if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon:
mathieui's avatar
mathieui committed
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
            self.register_command(
                'certs',
                self.command_certs,
                desc='List the fingerprints of certificates'
                ' which can connect to your account.',
                shortdesc='List allowed client certs.')
            self.register_command(
                'cert_add',
                self.command_cert_add,
                desc='Add a client certificate to the authorized ones. '
                'It must have an unique name and be contained in '
                'a PEM file. [management] is a boolean indicating'
                ' if a client connected using this certificate can'
                ' manage the certificates itself.',
                shortdesc='Add a client certificate.',
                usage='<name> <certificate path> [management]',
                completion=self.completion_cert_add)
            self.register_command(
                'cert_disable',
                self.command_cert_disable,
                desc='Remove a certificate from the list '
                'of allowed ones. Clients currently '
                'using this certificate will not be '
                'forcefully disconnected.',
                shortdesc='Disable a certificate',
                usage='<name>')
            self.register_command(
                'cert_revoke',
                self.command_cert_revoke,
                desc='Remove a certificate from the list '
                'of allowed ones. Clients currently '
                'using this certificate will be '
                'forcefully disconnected.',
                shortdesc='Revoke a certificate',
                usage='<name>')
            self.register_command(
                'cert_fetch',
                self.command_cert_fetch,
                desc='Retrieve a certificate with its '
                'name. It will be stored in <path>.',
                shortdesc='Fetch a certificate',
                usage='<name> <path>',
                completion=self.completion_cert_fetch)
mathieui's avatar
mathieui committed
198

199 200 201 202
    @property
    def selected_row(self):
        return self.roster_win.get_selected_row()

mathieui's avatar
mathieui committed
203 204 205 206 207
    @command_args_parser.ignored
    def command_certs(self):
        """
        /certs
        """
mathieui's avatar
mathieui committed
208

mathieui's avatar
mathieui committed
209 210
        def cb(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
211 212
                self.core.information(
                    'Unable to retrieve the certificate list.', 'Error')
mathieui's avatar
mathieui committed
213 214 215 216 217 218 219
                return
            certs = []
            for item in iq['sasl_certs']['items']:
                users = '\n'.join(item['users'])
                certs.append((item['name'], users))

            if not certs:
220 221
                return self.core.information('No certificates found', 'Info')
            msg = 'Certificates:\n'
mathieui's avatar
mathieui committed
222 223 224
            msg += '\n'.join(
                (('  %s%s' % (item[0] + (': ' if item[1] else ''), item[1]))
                 for item in certs))
mathieui's avatar
mathieui committed
225 226 227 228 229 230 231 232 233 234
            self.core.information(msg, 'Info')

        self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3)

    @command_args_parser.quoted(2, 1)
    def command_cert_add(self, args):
        """
        /cert_add <name> <certfile> [cert-management]
        """
        if not args or len(args) < 2:
235
            return self.core.command.help('cert_add')
mathieui's avatar
mathieui committed
236

mathieui's avatar
mathieui committed
237 238
        def cb(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
239 240
                self.core.information('Unable to add the certificate.',
                                      'Error')
mathieui's avatar
mathieui committed
241
            else:
242
                self.core.information('Certificate added.', 'Info')
mathieui's avatar
mathieui committed
243 244 245 246 247 248

        name = args[0]

        try:
            with open(args[1]) as fd:
                crt = fd.read()
mathieui's avatar
mathieui committed
249 250
            crt = crt.replace(ssl.PEM_FOOTER, '').replace(
                ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '')
mathieui's avatar
mathieui committed
251
        except Exception as e:
mathieui's avatar
mathieui committed
252 253
            self.core.information('Unable to read the certificate: %s' % e,
                                  'Error')
mathieui's avatar
mathieui committed
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
            return

        if len(args) > 2:
            management = args[2]
            if management:
                management = management.lower()
                if management not in ('false', '0'):
                    management = True
                else:
                    management = False
            else:
                management = False
        else:
            management = True

mathieui's avatar
mathieui committed
269 270
        self.core.xmpp.plugin['xep_0257'].add_cert(
            name, crt, callback=cb, allow_management=management)
mathieui's avatar
mathieui committed
271

272 273 274 275 276 277 278 279 280 281 282
    def completion_cert_add(self, the_input):
        """
        completion for /cert_add <name> <path> [management]
        """
        n = the_input.get_argument_position()
        log.debug('%s %s %s', the_input.text, n, the_input.pos)
        if n == 1:
            return
        elif n == 2:
            return self.completion_file(2, the_input)
        elif n == 3:
283
            return Completion(the_input.new_completion, ['true', 'false'], n)
284

mathieui's avatar
mathieui committed
285 286 287 288 289 290
    @command_args_parser.quoted(1)
    def command_cert_disable(self, args):
        """
        /cert_disable <name>
        """
        if not args:
291
            return self.core.command.help('cert_disable')
mathieui's avatar
mathieui committed
292

mathieui's avatar
mathieui committed
293 294
        def cb(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
295 296
                self.core.information('Unable to disable the certificate.',
                                      'Error')
mathieui's avatar
mathieui committed
297
            else:
298
                self.core.information('Certificate disabled.', 'Info')
mathieui's avatar
mathieui committed
299 300 301 302 303 304 305 306 307 308 309

        name = args[0]

        self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb)

    @command_args_parser.quoted(1)
    def command_cert_revoke(self, args):
        """
        /cert_revoke <name>
        """
        if not args:
310
            return self.core.command.help('cert_revoke')
mathieui's avatar
mathieui committed
311

mathieui's avatar
mathieui committed
312 313
        def cb(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
314 315
                self.core.information('Unable to revoke the certificate.',
                                      'Error')
mathieui's avatar
mathieui committed
316
            else:
317
                self.core.information('Certificate revoked.', 'Info')
mathieui's avatar
mathieui committed
318 319 320 321 322 323 324 325 326 327 328

        name = args[0]

        self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb)

    @command_args_parser.quoted(2)
    def command_cert_fetch(self, args):
        """
        /cert_fetch <name> <path>
        """
        if not args or len(args) < 2:
329
            return self.core.command.help('cert_fetch')
mathieui's avatar
mathieui committed
330

mathieui's avatar
mathieui committed
331 332
        def cb(iq):
            if iq['type'] == 'error':
333 334
                self.core.information('Unable to fetch the certificate.',
                                      'Error')
mathieui's avatar
mathieui committed
335 336 337 338 339 340 341 342 343
                return

            cert = None
            for item in iq['sasl_certs']['items']:
                if item['name'] == name:
                    cert = base64.b64decode(item['x509cert'])
                    break

            if not cert:
344
                return self.core.information('Certificate not found.', 'Info')
mathieui's avatar
mathieui committed
345 346 347 348 349

            cert = ssl.DER_cert_to_PEM_cert(cert)
            with open(path, 'w') as fd:
                fd.write(cert)

350
            self.core.information('File stored at %s' % path, 'Info')
mathieui's avatar
mathieui committed
351 352 353 354 355 356

        name = args[0]
        path = args[1]

        self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb)

357 358 359 360 361 362 363 364 365 366 367
    def completion_cert_fetch(self, the_input):
        """
        completion for /cert_fetch <name> <path>
        """
        n = the_input.get_argument_position()
        log.debug('%s %s %s', the_input.text, n, the_input.pos)
        if n == 1:
            return
        elif n == 2:
            return self.completion_file(2, the_input)

368 369 370 371 372 373
    def on_blocked_message(self, message):
        """
        When we try to send a message to a blocked contact
        """
        tab = self.core.get_conversation_by_jid(message['from'], False)
        if not tab:
mathieui's avatar
mathieui committed
374 375
            log.debug('Received message from nonexistent tab: %s',
                      message['from'])
376
        message = 'Cannot send message to %(jid)s: contact blocked' % {
mathieui's avatar
mathieui committed
377 378 379
            'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'jid': message['from'],
        }
380
        tab.add_message(InfoMessage(message), typ=0)
381

382 383
    @command_args_parser.ignored
    def command_list_blocks(self):
384 385 386
        """
        /list_blocks
        """
mathieui's avatar
mathieui committed
387

388 389
        def callback(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
390 391
                return self.core.information(
                    'Could not retrieve the blocklist.', 'Error')
392 393 394 395 396 397 398 399 400
            s = 'List of blocked JIDs:\n'
            items = (str(item) for item in iq['blocklist']['items'])
            jids = '\n'.join(items)
            if jids:
                s += jids
            else:
                s = 'No blocked JIDs.'
            self.core.information(s, 'Info')

401
        self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback)
402

403 404
    @command_args_parser.ignored
    def command_disconnect(self):
405 406 407 408 409
        """
        /disconnect
        """
        self.core.disconnect()

410 411
    @command_args_parser.quoted(0, 1)
    def command_last_activity(self, args):
412 413 414 415
        """
        /activity [jid]
        """
        item = self.roster_win.selected_row
416 417
        if args:
            jid = args[0]
418 419 420
        elif isinstance(item, Contact):
            jid = item.bare_jid
        elif isinstance(item, Resource):
421
            jid = item.jid
422 423 424
        else:
            self.core.information('No JID selected.', 'Error')
            return
425
        self.core.command.last_activity(jid)
426 427

    def resize(self):
428
        self.need_resize = False
429 430 431 432 433 434 435 436 437 438 439
        if self.size.tab_degrade_x:
            display_info = False
            roster_width = self.width
        else:
            display_info = True
            roster_width = self.width // 2
            if self.size.tab_degrade_y:
                display_contact_win = False
                contact_win_h = 0
            else:
                display_contact_win = True
440
                contact_win_h = 8
441 442 443 444 445 446 447
        if self.size.tab_degrade_y:
            tab_win_height = 0
        else:
            tab_win_height = Tab.tab_win_height()

        info_width = self.width - roster_width - 1
        if display_info:
mathieui's avatar
mathieui committed
448 449 450 451 452
            self.v_separator.resize(self.height - 1 - tab_win_height, 1, 0,
                                    roster_width)
            self.information_win.resize(
                self.height - 1 - tab_win_height - contact_win_h, info_width,
                0, roster_width + 1, self.core.information_buffer)
453
            if display_contact_win:
454 455
                y = self.height - tab_win_height - contact_win_h - 1
                avatar_width = contact_win_h * 2
456
                self.contact_info_win.resize(contact_win_h,
mathieui's avatar
mathieui committed
457 458 459 460
                                             info_width - avatar_width, y,
                                             roster_width + 1)
                self.avatar_win.resize(contact_win_h, avatar_width, y,
                                       self.width - avatar_width)
461 462
        self.roster_win.resize(self.height - 1 - Tab.tab_win_height(),
                               roster_width, 0, 0)
mathieui's avatar
mathieui committed
463 464
        self.input.resize(1, self.width, self.height - 1, 0)
        self.default_help_message.resize(1, self.width, self.height - 1, 0)
465 466 467 468 469 470 471

    def completion(self):
        # Check if we are entering a command (with the '/' key)
        if isinstance(self.input, windows.Input) and\
                not self.input.help_message:
            self.complete_commands(self.input)

472
    def completion_file(self, complete_number, the_input):
473
        """
474 475 476
        Generic quoted completion for files/paths
        (use functools.partial to use directly as a completion
        for a command)
477 478
        """
        text = the_input.get_text()
479
        args = shell_split(text)
480 481
        n = the_input.get_argument_position()
        if n == complete_number:
mathieui's avatar
mathieui committed
482
            if args[n - 1] == '' or len(args) < n + 1:
483
                home = os.getenv('HOME', default='/')
mathieui's avatar
mathieui committed
484 485
                return Completion(
                    the_input.new_completion, [home, '/tmp'], n, quotify=True)
486 487
            path_ = Path(args[n])
            if path_.is_dir():
488 489 490
                dir_ = path_
                base = ''
            else:
491 492
                dir_ = path_.parent
                base = path_.name
493
            try:
494
                names = list(dir_.iterdir())
495
            except OSError:
496
                names = []
mathieui's avatar
mathieui committed
497 498 499
            names_filtered = [
                name for name in names if str(name).startswith(base)
            ]
500 501 502 503
            if names_filtered:
                names = names_filtered
            if not names:
                names = [path_]
504 505
            end_list = []
            for name in names:
506 507 508
                if not str(name).startswith('.'):
                    value = dir_ / name
                    end_list.append(str(value))
509

mathieui's avatar
mathieui committed
510 511
            return Completion(
                the_input.new_completion, end_list, n, quotify=True)
512

513 514
    @command_args_parser.ignored
    def command_clear(self):
515 516 517 518 519
        """
        /clear
        """
        self.core.information_buffer.messages = []
        self.information_win.rebuild_everything(self.core.information_buffer)
mathieui's avatar
mathieui committed
520 521
        self.core.information_win.rebuild_everything(
            self.core.information_buffer)
522 523
        self.refresh()

524
    @deny_anonymous
525 526
    @command_args_parser.quoted(1)
    def command_password(self, args):
527 528 529
        """
        /password <password>
        """
mathieui's avatar
mathieui committed
530

531 532 533
        def callback(iq):
            if iq['type'] == 'result':
                self.core.information('Password updated', 'Account')
534
                if config.get('password'):
535
                    config.silent_set('password', args[0])
536
            else:
mathieui's avatar
mathieui committed
537 538 539 540 541
                self.core.information('Unable to change the password',
                                      'Account')

        self.core.xmpp.plugin['xep_0077'].change_password(
            args[0], callback=callback)
542 543


544
    @deny_anonymous
louiz’'s avatar
louiz’ committed
545
    @command_args_parser.quoted(1, 1)
546
    def command_name(self, args):
547 548 549
        """
        Set a name for the specified JID in your roster
        """
mathieui's avatar
mathieui committed
550

551 552 553 554
        def callback(iq):
            if not iq:
                self.core.information('The name could not be set.', 'Error')
                log.debug('Error in /name:\n%s', iq)
mathieui's avatar
mathieui committed
555

louiz’'s avatar
louiz’ committed
556
        if args is None:
557
            return self.core.command.help('name')
558 559 560 561 562
        jid = safeJID(args[0]).bare
        name = args[1] if len(args) == 2 else ''

        contact = roster[jid]
        if contact is None:
563
            self.core.information('No such JID in roster', 'Error')
564 565 566 567 568 569
            return

        groups = set(contact.groups)
        if 'none' in groups:
            groups.remove('none')
        subscription = contact.subscription
mathieui's avatar
mathieui committed
570 571 572 573 574 575
        self.core.xmpp.update_roster(
            jid,
            name=name,
            groups=groups,
            subscription=subscription,
            callback=callback)
576

577
    @deny_anonymous
578
    @command_args_parser.quoted(1, 1)
579 580 581 582
    def command_groupadd(self, args):
        """
        Add the specified JID to the specified group
        """
583
        if args is None:
584
            return self.core.command.help('groupadd')
585 586 587 588 589 590 591 592 593 594 595 596
        if len(args) == 1:
            group = args[0]
            item = self.roster_win.selected_row
            if isinstance(item, Contact):
                jid = item.bare_jid
            elif isinstance(item, Resource):
                jid = item.jid
            else:
                return self.core.command.help('groupadd')
        else:
            jid = safeJID(args[0]).bare
            group = args[1]
597 598 599

        contact = roster[jid]
        if contact is None:
600
            self.core.information('No such JID in roster', 'Error')
601 602 603 604
            return

        new_groups = set(contact.groups)
        if group in new_groups:
605
            self.core.information('JID already in group', 'Error')
606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624
            return

        roster.modified()
        new_groups.add(group)
        try:
            new_groups.remove('none')
        except KeyError:
            pass

        name = contact.name
        subscription = contact.subscription

        def callback(iq):
            if iq:
                roster.update_contact_groups(jid)
            else:
                self.core.information('The group could not be set.', 'Error')
                log.debug('Error in groupadd:\n%s', iq)

mathieui's avatar
mathieui committed
625 626 627 628 629 630
        self.core.xmpp.update_roster(
            jid,
            name=name,
            groups=new_groups,
            subscription=subscription,
            callback=callback)
631

632
    @deny_anonymous
633 634
    @command_args_parser.quoted(3)
    def command_groupmove(self, args):
635 636 637
        """
        Remove the specified JID from the first specified group and add it to the second one
        """
638
        if args is None:
639
            return self.core.command.help('groupmove')
640 641 642 643 644 645
        jid = safeJID(args[0]).bare
        group_from = args[1]
        group_to = args[2]

        contact = roster[jid]
        if not contact:
646
            self.core.information('No such JID in roster', 'Error')
647 648 649 650 651 652 653
            return

        new_groups = set(contact.groups)
        if 'none' in new_groups:
            new_groups.remove('none')

        if group_to == 'none' or group_from == 'none':
654
            self.core.information('"none" is not a group.', 'Error')
655 656 657
            return

        if group_from not in new_groups:
658
            self.core.information('JID not in first group', 'Error')
659 660 661
            return

        if group_to in new_groups:
662
            self.core.information('JID already in second group', 'Error')
663 664 665
            return

        if group_to == group_from:
666
            self.core.information('The groups are the same.', 'Error')
667 668 669 670 671 672 673 674 675 676 677 678 679 680 681
            return

        roster.modified()
        new_groups.add(group_to)
        if 'none' in new_groups:
            new_groups.remove('none')

        new_groups.remove(group_from)
        name = contact.name
        subscription = contact.subscription

        def callback(iq):
            if iq:
                roster.update_contact_groups(contact)
            else:
682
                self.core.information('The group could not be set', 'Error')
683 684
                log.debug('Error in groupmove:\n%s', iq)

mathieui's avatar
mathieui committed
685 686 687 688 689 690
        self.core.xmpp.update_roster(
            jid,
            name=name,
            groups=new_groups,
            subscription=subscription,
            callback=callback)
691

692
    @deny_anonymous
693
    @command_args_parser.quoted(2)
694 695 696 697
    def command_groupremove(self, args):
        """
        Remove the specified JID from the specified group
        """
698
        if args is None:
699
            return self.core.command.help('groupremove')
700

701 702 703 704 705
        jid = safeJID(args[0]).bare
        group = args[1]

        contact = roster[jid]
        if contact is None:
706
            self.core.information('No such JID in roster', 'Error')
707 708 709 710 711 712 713 714
            return

        new_groups = set(contact.groups)
        try:
            new_groups.remove('none')
        except KeyError:
            pass
        if group not in new_groups:
715
            self.core.information('JID not in group', 'Error')
716 717 718 719 720 721 722 723 724 725 726 727 728 729 730
            return

        roster.modified()

        new_groups.remove(group)
        name = contact.name
        subscription = contact.subscription

        def callback(iq):
            if iq:
                roster.update_contact_groups(jid)
            else:
                self.core.information('The group could not be set')
                log.debug('Error in groupremove:\n%s', iq)

mathieui's avatar
mathieui committed
731 732 733 734 735 736
        self.core.xmpp.update_roster(
            jid,
            name=name,
            groups=new_groups,
            subscription=subscription,
            callback=callback)
737

738
    @deny_anonymous
739 740
    @command_args_parser.quoted(0, 1)
    def command_import(self, args):
741 742 743
        """
        Import the contacts
        """
744
        if args:
745 746 747 748 749 750 751
            if args[0].startswith('/'):
                filepath = args[0]
            else:
                filepath = path.join(getenv('HOME'), args[0])
        else:
            filepath = path.join(getenv('HOME'), 'poezio_contacts')
        if not path.isfile(filepath):
mathieui's avatar
mathieui committed
752 753
            self.core.information('The file %s does not exist' % filepath,
                                  'Error')
754 755 756 757 758 759 760 761 762 763
            return
        try:
            handle = open(filepath, 'r', encoding='utf-8')
            lines = handle.readlines()
            handle.close()
        except IOError:
            self.core.information('Could not open %s' % filepath, 'Error')
            log.error('Unable to correct a message', exc_info=True)
            return
        for jid in lines:
mathieui's avatar
mathieui committed
764
            self.core.command.command_add(jid.lstrip('\n'))
765 766
        self.core.information('Contacts imported from %s' % filepath, 'Info')

767
    @deny_anonymous
768 769
    @command_args_parser.quoted(0, 1)
    def command_export(self, args):
770 771 772
        """
        Export the contacts
        """
773
        if args:
774 775 776 777 778 779 780 781 782 783 784 785 786 787 788
            if args[0].startswith('/'):
                filepath = args[0]
            else:
                filepath = path.join(getenv('HOME'), args[0])
        else:
            filepath = path.join(getenv('HOME'), 'poezio_contacts')
        if path.isfile(filepath):
            self.core.information('The file already exists', 'Error')
            return
        elif not path.isdir(path.dirname(filepath)):
            self.core.information('Parent directory not found', 'Error')
            return
        if roster.export(filepath):
            self.core.information('Contacts exported to %s' % filepath, 'Info')
        else:
mathieui's avatar
mathieui committed
789 790
            self.core.information('Failed to export contacts to %s' % filepath,
                                  'Info')
791 792 793 794 795 796

    def completion_remove(self, the_input):
        """
        Completion for /remove
        """
        jids = [jid for jid in roster.jids()]
797
        return Completion(the_input.auto_completion, jids, '', quotify=False)
798 799 800 801 802 803

    def completion_name(self, the_input):
        """Completion for /name"""
        n = the_input.get_argument_position()
        if n == 1:
            jids = [jid for jid in roster.jids()]
804
            return Completion(the_input.new_completion, jids, n, quotify=True)
805 806 807 808 809 810
        return False

    def completion_groupadd(self, the_input):
        n = the_input.get_argument_position()
        if n == 1:
            jids = sorted(jid for jid in roster.jids())
mathieui's avatar
mathieui committed
811 812
            return Completion(
                the_input.new_completion, jids, n, '', quotify=True)
813
        elif n == 2:
mathieui's avatar
mathieui committed
814 815 816 817
            groups = sorted(
                group for group in roster.groups if group != 'none')
            return Completion(
                the_input.new_completion, groups, n, '', quotify=True)
818 819 820
        return False

    def completion_groupmove(self, the_input):
821
        args = shell_split(the_input.text)
822 823 824
        n = the_input.get_argument_position()
        if n == 1:
            jids = sorted(jid for jid in roster.jids())
mathieui's avatar
mathieui committed
825 826
            return Completion(
                the_input.new_completion, jids, n, '', quotify=True)
827 828 829 830 831 832 833
        elif n == 2:
            contact = roster[args[1]]
            if not contact:
                return False
            groups = list(contact.groups)
            if 'none' in groups:
                groups.remove('none')
mathieui's avatar
mathieui committed
834 835
            return Completion(
                the_input.new_completion, groups, n, '', quotify=True)
836 837
        elif n == 3:
            groups = sorted(group for group in roster.groups)
mathieui's avatar
mathieui committed
838 839
            return Completion(
                the_input.new_completion, groups, n, '', quotify=True)
840 841 842
        return False

    def completion_groupremove(self, the_input):
843
        args = shell_split(the_input.text)
844 845 846
        n = the_input.get_argument_position()
        if n == 1:
            jids = sorted(jid for jid in roster.jids())
mathieui's avatar
mathieui committed
847 848
            return Completion(
                the_input.new_completion, jids, n, '', quotify=True)
849 850 851 852 853 854 855 856 857
        elif n == 2:
            contact = roster[args[1]]
            if contact is None:
                return False
            groups = sorted(contact.groups)
            try:
                groups.remove('none')
            except ValueError:
                pass
mathieui's avatar
mathieui committed
858 859
            return Completion(
                the_input.new_completion, groups, n, '', quotify=True)
860 861 862 863 864
        return False

    def refresh(self):
        if self.need_resize:
            self.resize()
865
        log.debug('  TAB   Refresh: %s', self.__class__.__name__)
866 867 868 869

        display_info = not self.size.tab_degrade_x
        display_contact_win = not self.size.tab_degrade_y

870
        self.roster_win.refresh(roster)
871 872 873 874
        if display_info:
            self.v_separator.refresh()
            self.information_win.refresh()
            if display_contact_win:
875 876 877 878 879 880
                row = self.roster_win.get_selected_row()
                self.contact_info_win.refresh(row)
                if isinstance(row, Contact):
                    self.avatar_win.refresh(row.avatar)
                else:
                    self.avatar_win.refresh(None)
881 882 883 884 885 886 887
        self.refresh_tab_win()
        self.input.refresh()

    def on_input(self, key, raw):
        if key == '^M':
            selected_row = self.roster_win.get_selected_row()
        res = self.input.do_command(key, raw=raw)
888 889
        if res:
            return not isinstance(self.input, windows.Input)
890 891 892 893 894 895 896 897 898 899 900 901
        if key == '^M':
            self.core.on_roster_enter_key(selected_row)
            return selected_row
        elif not raw and key in self.key_func:
            return self.key_func[key]()

    @refresh_wrapper.conditional
    def toggle_offline_show(self):
        """
        Show or hide offline contacts
        """
        option = 'roster_show_offline'
902
        value = config.get(option)
mathieui's avatar
mathieui committed
903
        success = config.silent_set(option, str(not value))
904 905
        roster.modified()
        if not success:
mathieui's avatar
mathieui committed
906 907
            self.core.information('Unable to write in the config file',
                                  'Error')
908 909 910 911 912 913 914
        return True

    def on_slash(self):
        """
        '/' is pressed, we enter "input mode"
        """
        curses.curs_set(1)
mathieui's avatar
mathieui committed
915 916 917 918
        self.input = windows.CommandInput("", self.reset_help_message,
                                          self.execute_slash_command)
        self.input.resize(1, self.width, self.height - 1, 0)
        self.input.do_command("/")  # we add the slash
919 920 921

    def reset_help_message(self, _=None):
        self.input = self.default_help_message
922
        if self.core.tabs.current_tab is self:
923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945
            curses.curs_set(0)
            self.input.refresh()
            self.core.doupdate()
        return True

    def execute_slash_command(self, txt):
        if txt.startswith('/'):
            self.input.key_enter()
            self.execute_command(txt)
        return self.reset_help_message()

    def on_lose_focus(self):
        self.state = 'normal'

    def on_gain_focus(self):
        self.state = 'current'
        if isinstance(self.input, windows.HelpText):
            curses.curs_set(0)
        else:
            curses.curs_set(1)

    @refresh_wrapper.conditional
    def move_cursor_down(self):
mathieui's avatar
mathieui committed
946 947
        if isinstance(self.input,
                      windows.Input) and not self.input.history_disabled:
948 949 950 951 952
            return
        return self.roster_win.move_cursor_down()

    @refresh_wrapper.conditional
    def move_cursor_up(self):
mathieui's avatar
mathieui committed
953 954
        if isinstance(self.input,
                      windows.Input) and not self.input.history_disabled:
955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022
            return
        return self.roster_win.move_cursor_up()

    def move_cursor_to_prev_contact(self):
        self.roster_win.move_cursor_up()
        while not isinstance(self.roster_win.get_selected_row(), Contact):
            if not self.roster_win.move_cursor_up():
                break
        self.roster_win.refresh(roster)

    def move_cursor_to_next_contact(self):
        self.roster_win.move_cursor_down()
        while not isinstance(self.roster_win.get_selected_row(), Contact):
            if not self.roster_win.move_cursor_down():
                break
        self.roster_win.refresh(roster)

    def move_cursor_to_prev_group(self):
        self.roster_win.move_cursor_up()
        while not isinstance(self.roster_win.get_selected_row(), RosterGroup):
            if not self.roster_win.move_cursor_up():
                break
        self.roster_win.refresh(roster)

    def move_cursor_to_next_group(self):
        self.roster_win.move_cursor_down()
        while not isinstance(self.roster_win.get_selected_row(), RosterGroup):
            if not self.roster_win.move_cursor_down():
                break
        self.roster_win.refresh(roster)

    def on_scroll_down(self):
        return self.roster_win.move_cursor_down(self.height // 2)

    def on_scroll_up(self):
        return self.roster_win.move_cursor_up(self.height // 2)

    @refresh_wrapper.conditional
    def on_space(self):
        if isinstance(self.input, windows.Input):
            return
        selected_row = self.roster_win.get_selected_row()
        if isinstance(selected_row, RosterGroup):
            selected_row.toggle_folded()
            roster.modified()
            return True
        elif isinstance(selected_row, Contact):
            group = "none"
            found_group = False
            pos = self.roster_win.pos
            while not found_group and pos >= 0:
                row = self.roster_win.roster_cache[pos]
                pos -= 1
                if isinstance(row, RosterGroup):
                    found_group = True
                    group = row.name
            selected_row.toggle_folded(group)
            roster.modified()
            return True
        return False

    def get_contact_version(self):
        """
        Show the versions of the resource(s) currently selected
        """
        selected_row = self.roster_win.get_selected_row()
        if isinstance(selected_row, Contact):
            for resource in selected_row.resources:
1023
                self.core.command.version(str(resource.jid))
1024
        elif isinstance(selected_row, Resource):
1025
            self.core.command.version(str(selected_row.jid))
1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038
        else:
            self.core.information('Nothing to get versions from', 'Info')

    def show_contact_info(self):
        """
        Show the contact info (resource number, status, presence, etc)
        when 'i' is pressed.
        """
        selected_row = self.roster_win.get_selected_row()
        if isinstance(selected_row, Contact):
            cont = selected_row
            res = selected_row.get_highest_priority_resource()
            acc = []
mathieui's avatar
mathieui committed
1039 1040
            acc.append('Contact: %s (%s)' % (cont.bare_jid, res.presence
                                             if res else 'unavailable'))
1041
            if res:
mathieui's avatar
mathieui committed
1042 1043 1044
                acc.append(
                    '%s connected resource%s' % (len(cont), ''
                                                 if len(cont) == 1 else 's'))
1045 1046 1047 1048 1049 1050 1051 1052
                acc.append('Current status: %s' % res.status)
            if cont.tune:
                acc.append('Tune: %s' % common.format_tune_string(cont.tune))
            if cont.mood:
                acc.append('Mood: %s' % cont.mood)
            if cont.activity:
                acc.append('Activity: %s' % cont.activity)
            if cont.gaming:
mathieui's avatar
mathieui committed
1053 1054
                acc.append(
                    'Game: %s' % (common.format_gaming_string(cont.gaming)))
1055 1056 1057 1058
            msg = '\n'.join(acc)
        elif isinstance(selected_row, Resource):
            res = selected_row
            msg = 'Resource: %s (%s)\nCurrent status: %s\nPriority: %s' % (
mathieui's avatar
mathieui committed
1059
                res.jid, res.presence, res.status, res.priority)
1060 1061 1062
        elif isinstance(selected_row, RosterGroup):
            rg = selected_row
            msg = 'Group: %s [%s/%s] contacts online' % (
mathieui's avatar
mathieui committed
1063 1064 1065 1066
                rg.name,
                rg.get_nb_connected_contacts(),
                len(rg),
            )
1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094
        else:
            msg = None
        if msg:
            self.core.information(msg, 'Info')

    def change_contact_name(self):
        """
        Auto-fill a /name command when 'n' is pressed
        """
        selected_row = self.roster_win.get_selected_row()
        if isinstance(selected_row, Contact):
            jid = selected_row.bare_jid
        elif isinstance(selected_row, Resource):
            jid = safeJID(selected_row.jid).bare
        else:
            return
        self.on_slash()
        self.input.text = '/name "%s" ' % jid
        self.input.key_end()
        self.input.refresh()

    @refresh_wrapper.always
    def start_search(self):
        """
        Start the search. The input should appear with a short instruction
        in it.
        """
        curses.curs_set(1)
mathieui's avatar
mathieui committed
1095 1096 1097 1098
        self.input = windows.CommandInput("[Search]", self.on_search_terminate,
                                          self.on_search_terminate,
                                          self.set_roster_filter)
        self.input.resize(1, self.width, self.height - 1, 0)
1099 1100 1101 1102 1103 1104 1105 1106
        self.input.disable_history()
        roster.modified()
        self.refresh()
        return True

    @refresh_wrapper.always
    def start_search_slow(self):
        curses.curs_set(1)
mathieui's avatar
mathieui committed
1107 1108 1109 1110
        self.input = windows.CommandInput("[Search]", self.on_search_terminate,
                                          self.on_search_terminate,
                                          self.set_roster_filter_slow)
        self.input.resize(1, self.width, self.height - 1, 0)
1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128
        self.input.disable_history()
        return True

    def set_roster_filter_slow(self, txt):
        roster.contact_filter = (jid_and_name_match_slow, txt)
        roster.modified()
        self.refresh()
        return False

    def set_roster_filter(self, txt):
        roster.contact_filter = (jid_and_name_match, txt)
        roster.modified()
        self.refresh()
        return False

    @refresh_wrapper.always
    def on_search_terminate(self, txt):
        curses.curs_set(0)
1129
        roster.contact_filter = roster.DEFAULT_FILTER
1130 1131 1132 1133 1134 1135 1136
        self.reset_help_message()
        roster.modified()
        return True

    def on_close(self):
        return

mathieui's avatar
mathieui committed
1137

1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148
def diffmatch(search, string):
    """
    Use difflib and a loop to check if search_pattern can
    be 'almost' found INSIDE a string.
    'almost' being defined by difflib
    """
    if len(search) > len(string):
        return False
    l = len(search)
    ratio = 0.7
    for i in range(len(string) - l + 1):
mathieui's avatar
mathieui committed
1149 1150
        if difflib.SequenceMatcher(None, search,
                                   string[i:i + l]).ratio() >= ratio:
1151 1152 1153
            return True
    return False

mathieui's avatar
mathieui committed
1154

1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167
def jid_and_name_match(contact, txt):
    """
    Match jid with text precisely
    """
    if not txt:
        return True
    txt = txt.lower()
    if txt in safeJID(contact.bare_jid).bare.lower():
        return True
    if txt in contact.name.lower():
        return True
    return False

mathieui's avatar
mathieui committed
1168

1169 1170 1171 1172 1173 1174
def jid_and_name_match_slow(contact, txt):
    """
    A function used to know if a contact in the roster should
    be shown in the roster
    """
    if not txt:
mathieui's avatar
mathieui committed
1175
        return True  # Everything matches when search is empty
1176 1177 1178 1179 1180 1181
    user = safeJID(contact.bare_jid).bare
    if diffmatch(txt, user):
        return True
    if contact.name and diffmatch(txt, contact.name):
        return True
    return False