muctab.py 81.5 KB
Newer Older
1 2 3 4 5 6 7 8 9
"""
Module for the MucTab

A MucTab is a tab for multi-user chats as defined in XEP-0045.

It keeps track of many things such as part/joins, maintains an
user list, and updates private tabs when necessary.
"""

10
import asyncio
11
import bisect
12
import curses
13
import logging
Madhur Garg's avatar
Madhur Garg committed
14
import asyncio
15 16
import os
import random
17
import re
18
import functools
19
from copy import copy
mathieui's avatar
mathieui committed
20
from datetime import datetime
21
from typing import Dict, Callable, List, Optional, Tuple, Union, Set
22

23
from slixmpp import InvalidJID, JID, Presence
24
from slixmpp.exceptions import IqError, IqTimeout
25
from poezio.tabs import ChatTab, Tab, SHOW_NAME
26

27 28
from poezio import common
from poezio import fixes
29
from poezio import mam
30 31 32 33 34 35
from poezio import multiuserchat as muc
from poezio import timed_events
from poezio import windows
from poezio import xhtml
from poezio.common import safeJID
from poezio.config import config
36
from poezio.core.structs import Command
37 38 39 40 41
from poezio.decorators import refresh_wrapper, command_args_parser
from poezio.logger import logger
from poezio.roster import roster
from poezio.theming import get_theme, dump_tuple
from poezio.user import User
42
from poezio.core.structs import Completion, Status
43 44 45 46 47 48 49 50
from poezio.ui.types import (
    BaseMessage,
    InfoMessage,
    Message,
    MucOwnJoinMessage,
    MucOwnLeaveMessage,
    StatusMessage,
)
51

52 53
log = logging.getLogger(__name__)

54
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'
55
STATUS_XPATH = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER)
56

57 58
COMPARE_USERS_LAST_TALKED = lambda x: x.last_talked

59 60 61 62

class MucTab(ChatTab):
    """
    The tab containing a multi-user-chat room.
Maxime Buquet's avatar
Maxime Buquet committed
63
    It contains a userlist, an input, a topic, an information and a chat zone
64 65
    """
    message_type = 'groupchat'
66 67
    plugin_commands = {}  # type: Dict[str, Command]
    plugin_keys = {}  # type: Dict[str, Callable]
68
    additional_information = {}  # type: Dict[str, Callable[[str], str]]
69
    lagged = False
70

71
    def __init__(self, core, jid, nick, password=None):
72
        ChatTab.__init__(self, core, jid)
73 74 75
        self.joined = False
        self._state = 'disconnected'
        # our nick in the MUC
76
        self.own_nick = nick
77
        # self User object
78
        self.own_user = None  # type: Optional[User]
79
        self.password = password
80
        # buffered presences
81
        self.presence_buffer = []
82
        # userlist
83
        self.users = []  # type: List[User]
84
        # private conversations
85
        self.privates = []  # type: List[Tab]
86 87
        # Used to check if we are still receiving muc history
        self.last_message_was_history = None  # type: Optional[bool]
88
        self.topic = ''
89
        self.topic_from = ''
90 91 92
        # Self ping event, so we can cancel it when we leave the room
        self.self_ping_event = None
        # UI stuff
93 94 95 96 97 98 99
        self.topic_win = windows.Topic()
        self.text_win = windows.TextWin()
        self._text_buffer.add_window(self.text_win)
        self.v_separator = windows.VerticalSeparator()
        self.user_win = windows.UserList()
        self.info_header = windows.MucInfoWin()
        self.input = windows.MessageInput()
100
        # List of ignored users
101
        self.ignores = []  # type: List[User]
102
        # keys
103 104
        self.register_keys()
        self.update_keys()
105
        # commands
106
        self.register_commands()
107
        self.update_commands()
108
        self.resize()
109 110 111

    @property
    def general_jid(self):
112
        return self.jid
113

114
    def check_send_chat_state(self) -> bool:
mathieui's avatar
mathieui committed
115 116 117
        "If we should send a chat state"
        return self.joined

118
    @property
119
    def last_connection(self) -> Optional[datetime]:
120 121 122 123 124
        last_message = self._text_buffer.last_message
        if last_message:
            return last_message.time
        return None

125
    @staticmethod
126
    @refresh_wrapper.always
127 128 129 130 131 132 133
    def add_information_element(plugin_name: str, callback: Callable[[str], str]) -> None:
        """
        Lets a plugin add its own information to the MucInfoWin
        """
        MucTab.additional_information[plugin_name] = callback

    @staticmethod
134
    @refresh_wrapper.always
135 136 137 138 139 140
    def remove_information_element(plugin_name: str) -> None:
        """
        Lets a plugin add its own information to the MucInfoWin
        """
        del MucTab.additional_information[plugin_name]

141 142
    def cancel_config(self, form):
        """
Kim Alvefur's avatar
Kim Alvefur committed
143
        The user do not want to send their config, send an iq cancel
144
        """
145
        muc.cancel_config(self.core.xmpp, self.jid.bare)
146 147 148 149
        self.core.close_tab()

    def send_config(self, form):
        """
Kim Alvefur's avatar
Kim Alvefur committed
150
        The user sends their config to the server
151
        """
152
        muc.configure_room(self.core.xmpp, self.jid.bare, form)
153 154
        self.core.close_tab()

155 156 157 158 159 160 161 162 163
    def join(self):
        """
        Join the room
        """
        status = self.core.get_status()
        if self.last_connection:
            delta = datetime.now() - self.last_connection
            seconds = delta.seconds + delta.days * 24 * 3600
        else:
164
            seconds = self._text_buffer.find_last_message()
mathieui's avatar
mathieui committed
165 166
        muc.join_groupchat(
            self.core,
167
            self.jid.bare,
mathieui's avatar
mathieui committed
168 169 170 171 172
            self.own_nick,
            self.password,
            status=status.message,
            show=status.show,
            seconds=seconds)
mathieui's avatar
mathieui committed
173
        mam.schedule_tab_open(self)
174

175
    def leave_room(self, message: str):
mathieui's avatar
mathieui committed
176
        if self.joined:
177 178 179 180
            theme = get_theme()
            info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
            char_quit = theme.CHAR_QUIT
            spec_col = dump_tuple(theme.COLOR_QUIT_CHAR)
181

182 183
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
184
                color = dump_tuple(theme.COLOR_OWN_NICK)
185
            else:
186
                color = "3"
187

mathieui's avatar
mathieui committed
188
            if message:
189 190
                msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
                       'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
191
                       ' left the room'
192
                       ' (\x19o%(reason)s\x19%(info_col)s})') % {
mathieui's avatar
mathieui committed
193 194 195 196
                           'info_col': info_col,
                           'reason': message,
                           'spec': char_quit,
                           'color': color,
197 198 199
                           'color_spec': spec_col,
                           'nick': self.own_nick,
                       }
200
            else:
201 202
                msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
                       'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
203
                       ' left the room') % {
204
                           'info_col': info_col,
mathieui's avatar
mathieui committed
205 206
                           'spec': char_quit,
                           'color': color,
207 208 209
                           'color_spec': spec_col,
                           'nick': self.own_nick,
                       }
210
            self.add_message(MucOwnLeaveMessage(msg), typ=2)
211
            self.disconnect()
212
            muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
mathieui's avatar
mathieui committed
213
                                message)
214
            self.core.disable_private_tabs(self.jid.bare, reason=msg)
215
        else:
216
            muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
mathieui's avatar
mathieui committed
217
                                message)
mathieui's avatar
mathieui committed
218

219 220 221 222
    def change_affiliation(self,
                           nick_or_jid: Union[str, JID],
                           affiliation: str,
                           reason=''):
223 224 225
        """
        Change the affiliation of a nick or JID
        """
mathieui's avatar
mathieui committed
226

227 228
        def callback(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
229 230 231 232
                self.core.information(
                    "Could not set affiliation '%s' for '%s'." %
                    (affiliation, nick_or_jid), "Warning")

233 234 235 236 237
        if not self.joined:
            return

        valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner')
        if affiliation not in valid_affiliations:
mathieui's avatar
mathieui committed
238 239 240
            return self.core.information(
                'The affiliation must be one of ' +
                ', '.join(valid_affiliations), 'Error')
241
        if nick_or_jid in [user.nick for user in self.users]:
mathieui's avatar
mathieui committed
242
            muc.set_user_affiliation(
mathieui's avatar
mathieui committed
243
                self.core.xmpp,
244
                self.jid.bare,
mathieui's avatar
mathieui committed
245 246 247 248
                affiliation,
                nick=nick_or_jid,
                callback=callback,
                reason=reason)
249
        else:
mathieui's avatar
mathieui committed
250
            muc.set_user_affiliation(
mathieui's avatar
mathieui committed
251
                self.core.xmpp,
252
                self.jid.bare,
mathieui's avatar
mathieui committed
253 254 255 256
                affiliation,
                jid=safeJID(nick_or_jid),
                callback=callback,
                reason=reason)
257

258
    def change_role(self, nick: str, role: str, reason=''):
259 260 261
        """
        Change the role of a nick
        """
mathieui's avatar
mathieui committed
262

263 264
        def callback(iq):
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
265 266 267
                self.core.information(
                    "Could not set role '%s' for '%s'." % (role, nick),
                    "Warning")
mathieui's avatar
mathieui committed
268

269 270 271
        valid_roles = ('none', 'visitor', 'participant', 'moderator')

        if not self.joined or role not in valid_roles:
mathieui's avatar
mathieui committed
272 273
            return self.core.information(
                'The role must be one of ' + ', '.join(valid_roles), 'Error')
274

275 276 277 278
        try:
            target_jid = copy(self.jid)
            target_jid.resource = nick
        except InvalidJID:
279
            return self.core.information('Invalid nick', 'Info')
280

mathieui's avatar
mathieui committed
281
        muc.set_user_role(
282
            self.core.xmpp, self.jid.bare, nick, reason, role, callback=callback)
283

mathieui's avatar
mathieui committed
284
    @refresh_wrapper.conditional
285
    def print_info(self, nick: str) -> bool:
mathieui's avatar
mathieui committed
286 287 288 289 290 291 292 293 294
        """Print information about a user"""
        user = self.get_user_by_name(nick)
        if not user:
            return False

        theme = get_theme()
        inf = '\x19' + dump_tuple(theme.COLOR_INFORMATION_TEXT) + '}'
        if user.jid:
            user_jid = '%s (\x19%s}%s\x19o%s)' % (
mathieui's avatar
mathieui committed
295
                inf, dump_tuple(theme.COLOR_MUC_JID), user.jid, inf)
mathieui's avatar
mathieui committed
296 297 298 299 300 301
        else:
            user_jid = ''
        info = ('\x19%(user_col)s}%(nick)s\x19o%(jid)s%(info)s: show: '
                '\x19%(show_col)s}%(show)s\x19o%(info)s, affiliation: '
                '\x19%(role_col)s}%(affiliation)s\x19o%(info)s, role: '
                '\x19%(role_col)s}%(role)s\x19o%(status)s') % {
mathieui's avatar
mathieui committed
302 303 304 305 306 307 308 309 310 311 312
                    'user_col': dump_tuple(user.color),
                    'nick': nick,
                    'jid': user_jid,
                    'info': inf,
                    'show_col': dump_tuple(theme.color_show(user.show)),
                    'show': user.show or 'Available',
                    'role_col': dump_tuple(theme.color_role(user.role)),
                    'affiliation': user.affiliation or 'None',
                    'role': user.role or 'None',
                    'status': '\n%s' % user.status if user.status else ''
                }
313
        self.add_message(InfoMessage(info), typ=0)
mathieui's avatar
mathieui committed
314 315
        return True

316
    def change_topic(self, topic: str):
mathieui's avatar
mathieui committed
317
        """Change the current topic"""
318
        muc.change_subject(self.core.xmpp, self.jid.bare, topic)
mathieui's avatar
mathieui committed
319 320 321 322 323 324

    @refresh_wrapper.always
    def show_topic(self):
        """
        Print the current topic
        """
325 326 327
        theme = get_theme()
        info_text = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        norm_text = dump_tuple(theme.COLOR_NORMAL_TEXT)
mathieui's avatar
mathieui committed
328 329 330 331
        if self.topic_from:
            user = self.get_user_by_name(self.topic_from)
            if user:
                user_text = dump_tuple(user.color)
mathieui's avatar
mathieui committed
332 333
                user_string = '\x19%s}(set by \x19%s}%s\x19%s})' % (
                    info_text, user_text, user.nick, info_text)
mathieui's avatar
mathieui committed
334 335 336 337 338
            else:
                user_string = self.topic_from
        else:
            user_string = ''

339 340 341 342 343 344 345
        self.add_message(
            InfoMessage(
                "The subject of the room is: \x19%s}%s %s" %
                (norm_text, self.topic, user_string),
            ),
            typ=0,
        )
mathieui's avatar
mathieui committed
346

mathieui's avatar
mathieui committed
347 348 349
    @refresh_wrapper.always
    def recolor(self, random_colors=False):
        """Recolor the current MUC users"""
mathieui's avatar
mathieui committed
350
        deterministic = config.get_by_tabname('deterministic_nick_colors',
351
                                              self.jid.bare)
mathieui's avatar
mathieui committed
352 353 354 355 356 357 358 359 360 361 362
        if deterministic:
            for user in self.users:
                if user is self.own_user:
                    continue
                color = self.search_for_color(user.nick)
                if color != '':
                    continue
                user.set_deterministic_color()
            return
        # Sort the user list by last talked, to avoid color conflicts
        # on active participants
363
        sorted_users = sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True)
mathieui's avatar
mathieui committed
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
        full_sorted_users = sorted_users[:]
        # search our own user, to remove it from the list
        # Also remove users whose color is fixed
        for user in full_sorted_users:
            color = self.search_for_color(user.nick)
            if user is self.own_user:
                sorted_users.remove(user)
            elif color != '':
                sorted_users.remove(user)
                user.change_color(color, deterministic)
        colors = list(get_theme().LIST_COLOR_NICKNAMES)
        if random_colors:
            random.shuffle(colors)
        for i, user in enumerate(sorted_users):
            user.color = colors[i % len(colors)]
        self.text_win.rebuild_everything(self._text_buffer)

381
    @refresh_wrapper.conditional
382
    def set_nick_color(self, nick: str, color: str) -> bool:
383 384 385 386 387 388 389 390
        "Set a custom color for a nick, permanently"
        user = self.get_user_by_name(nick)
        if color not in xhtml.colors and color not in ('unset', 'random'):
            return False
        if nick == self.own_nick:
            return False
        if color == 'unset':
            if config.remove_and_save(nick, 'muc_colors'):
mathieui's avatar
mathieui committed
391 392
                self.core.information('Color for nick %s unset' % (nick),
                                      'Info')
393 394 395 396 397 398
        else:
            if color == 'random':
                color = random.choice(list(xhtml.colors))
            if user:
                user.change_color(color)
            config.set_and_save(nick, color, 'muc_colors')
mathieui's avatar
mathieui committed
399
            nick_color_aliases = config.get_by_tabname('nick_color_aliases',
400
                                                       self.jid.bare)
401 402 403 404 405 406 407 408 409 410 411
            if nick_color_aliases:
                # if any user in the room has a nick which is an alias of the
                # nick, update its color
                for tab in self.core.get_tabs(MucTab):
                    for u in tab.users:
                        nick_alias = re.sub('^_*', '', u.nick)
                        nick_alias = re.sub('_*$', '', nick_alias)
                        if nick_alias == nick:
                            u.change_color(color)
        self.text_win.rebuild_everything(self._text_buffer)
        return True
mathieui's avatar
mathieui committed
412

mathieui's avatar
mathieui committed
413 414 415 416 417 418
    def on_input(self, key, raw):
        if not raw and key in self.key_func:
            self.key_func[key]()
            return False
        self.input.do_command(key, raw=raw)
        empty_after = self.input.get_text() == ''
mathieui's avatar
mathieui committed
419 420 421
        empty_after = empty_after or (
            self.input.get_text().startswith('/')
            and not self.input.get_text().startswith('//'))
mathieui's avatar
mathieui committed
422 423
        self.send_composing_chat_state(empty_after)
        return False
424

425
    def get_nick(self) -> str:
426
        if config.get('show_muc_jid'):
427 428
            return self.jid.bare
        bookmark = self.core.bookmarks[self.jid.bare]
429 430 431
        if bookmark is not None and bookmark.name:
            return bookmark.name
        # TODO: send the disco#info identity name here, if it exists.
432
        return self.jid.user
433

mathieui's avatar
mathieui committed
434 435
    def get_text_window(self):
        return self.text_win
436

mathieui's avatar
mathieui committed
437 438 439 440
    def on_lose_focus(self):
        if self.joined:
            if self.input.text:
                self.state = 'nonempty'
441 442
            elif self.lagged:
                self.state = 'disconnected'
mathieui's avatar
mathieui committed
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
            else:
                self.state = 'normal'
        else:
            self.state = 'disconnected'
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
        if config.get_by_tabname('send_chat_states', self.general_jid):
            self.send_chat_state('inactive')
        self.check_scrolled()

    def on_gain_focus(self):
        self.state = 'current'
        if (self.text_win.built_lines and self.text_win.built_lines[-1] is None
                and not config.get('show_useless_separator')):
            self.text_win.remove_line_separator()
        curses.curs_set(1)
mathieui's avatar
mathieui committed
459 460
        if self.joined and config.get_by_tabname(
                'send_chat_states',
mathieui's avatar
mathieui committed
461 462 463 464
                self.general_jid) and not self.input.get_text():
            self.send_chat_state('active')

    def handle_presence(self, presence):
465
        """
mathieui's avatar
mathieui committed
466
        Handle MUC presence
467
        """
468
        self.reset_lag()
mathieui's avatar
mathieui committed
469 470 471
        status_codes = set()
        for status_code in presence.xml.findall(STATUS_XPATH):
            status_codes.add(status_code.attrib['code'])
mathieui's avatar
mathieui committed
472
        if presence['type'] == 'error':
473
            self.core.room_error(presence, self.jid.bare)
mathieui's avatar
mathieui committed
474
        elif not self.joined:
475
            own = '110' in status_codes
476 477
            if own or len(self.presence_buffer) >= 10:
                self.process_presence_buffer(presence, own)
mathieui's avatar
mathieui committed
478 479 480 481 482 483 484 485
            else:
                self.presence_buffer.append(presence)
                return
        else:
            try:
                self.handle_presence_joined(presence, status_codes)
            except PresenceError:
                self.core.room_error(presence, presence['from'].bare)
486
        if self.core.tabs.current_tab is self:
mathieui's avatar
mathieui committed
487 488
            self.text_win.refresh()
            self.user_win.refresh_if_changed(self.users)
489 490 491
            self.info_header.refresh(
                self, self.text_win, user=self.own_user,
                information=MucTab.additional_information)
mathieui's avatar
mathieui committed
492 493
            self.input.refresh()
            self.core.doupdate()
494

495
    def process_presence_buffer(self, last_presence, own):
496
        """
mathieui's avatar
mathieui committed
497
        Batch-process all the initial presences
498
        """
mathieui's avatar
mathieui committed
499
        deterministic = config.get_by_tabname('deterministic_nick_colors',
500
                                              self.jid.bare)
mathieui's avatar
mathieui committed
501

mathieui's avatar
mathieui committed
502 503 504 505 506
        for stanza in self.presence_buffer:
            try:
                self.handle_presence_unjoined(stanza, deterministic)
            except PresenceError:
                self.core.room_error(stanza, stanza['from'].bare)
507 508
        self.presence_buffer = []
        self.handle_presence_unjoined(last_presence, deterministic, own)
mathieui's avatar
mathieui committed
509 510 511
        self.users.sort()
        # Enable the self ping event, to regularly check if we
        # are still in the room.
512 513
        if own:
            self.enable_self_ping_event()
514
        if self.core.tabs.current_tab is not self:
mathieui's avatar
mathieui committed
515
            self.refresh_tab_win()
516
            self.core.tabs.current_tab.refresh_input()
mathieui's avatar
mathieui committed
517
            self.core.doupdate()
518

519
    def handle_presence_unjoined(self, presence: Presence, deterministic, own=False) -> None:
mathieui's avatar
mathieui committed
520 521 522
        """
        Presence received while we are not in the room (before code=110)
        """
523 524 525
        # If presence is coming from MUC barejid, ignore.
        if not presence['from'].resource:
            return None
526 527
        dissected_presence = dissect_presence(presence)
        from_nick, _, affiliation, show, status, role, jid, typ = dissected_presence
528 529
        if typ == 'unavailable':
            return
mathieui's avatar
mathieui committed
530
        user_color = self.search_for_color(from_nick)
mathieui's avatar
mathieui committed
531 532
        new_user = User(from_nick, affiliation, show, status, role, jid,
                        deterministic, user_color)
mathieui's avatar
mathieui committed
533 534 535 536 537 538 539
        self.users.append(new_user)
        self.core.events.trigger('muc_join', presence, self)
        if own:
            status_codes = set()
            for status_code in presence.xml.findall(STATUS_XPATH):
                status_codes.add(status_code.attrib['code'])
            self.own_join(from_nick, new_user, status_codes)
540

541
    def own_join(self, from_nick: str, new_user: User, status_codes: Set[str]):
542
        """
mathieui's avatar
mathieui committed
543
        Handle the last presence we received, entering the room
544
        """
mathieui's avatar
mathieui committed
545 546 547
        self.own_nick = from_nick
        self.own_user = new_user
        self.joined = True
548 549
        if self.jid.bare in self.core.initial_joins:
            self.core.initial_joins.remove(self.jid.bare)
mathieui's avatar
mathieui committed
550
            self._state = 'normal'
551
        elif self != self.core.tabs.current_tab:
mathieui's avatar
mathieui committed
552
            self._state = 'joined'
553
        if (self.core.tabs.current_tab is self
mathieui's avatar
mathieui committed
554 555
                and self.core.status.show not in ('xa', 'away')):
            self.send_chat_state('active')
556 557
        theme = get_theme()
        new_user.color = theme.COLOR_OWN_NICK
558

mathieui's avatar
mathieui committed
559 560 561 562
        if config.get_by_tabname('display_user_color_in_join_part',
                                 self.general_jid):
            color = dump_tuple(new_user.color)
        else:
563
            color = "3"
564

565 566 567
        info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        warn_col = dump_tuple(theme.COLOR_WARNING_TEXT)
        spec_col = dump_tuple(theme.COLOR_JOIN_CHAR)
mathieui's avatar
mathieui committed
568 569 570 571
        enable_message = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You '
                          '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined'
                          ' the room') % {
                              'nick': from_nick,
572
                              'spec': theme.CHAR_JOIN,
mathieui's avatar
mathieui committed
573 574 575 576
                              'color_spec': spec_col,
                              'nick_col': color,
                              'info_col': info_col,
                          }
577
        self.add_message(MucOwnJoinMessage(enable_message), typ=2)
578
        self.core.enable_private_tabs(self.jid.bare, enable_message)
mathieui's avatar
mathieui committed
579 580
        if '201' in status_codes:
            self.add_message(
581 582 583
                InfoMessage('Info: The room has been created'),
                typ=0
            )
mathieui's avatar
mathieui committed
584 585
        if '170' in status_codes:
            self.add_message(
586 587 588 589 590 591 592
                InfoMessage(
                    '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
                    ' This room is publicly logged' % {
                        'info_col': info_col,
                        'warn_col': warn_col
                    },
                ),
mathieui's avatar
mathieui committed
593 594 595
                typ=0)
        if '100' in status_codes:
            self.add_message(
596 597 598 599 600 601 602
                InfoMessage(
                    '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
                    ' This room is not anonymous.' % {
                        'info_col': info_col,
                        'warn_col': warn_col
                    },
                ),
mathieui's avatar
mathieui committed
603
                typ=0)
604

605
    def handle_presence_joined(self, presence: Presence, status_codes) -> None:
606
        """
mathieui's avatar
mathieui committed
607
        Handle new presences when we are already in the room
608
        """
609 610 611
        # If presence is coming from MUC barejid, ignore.
        if not presence['from'].resource:
            return None
612 613
        dissected_presence = dissect_presence(presence)
        from_nick, from_room, affiliation, show, status, role, jid, typ = dissected_presence
mathieui's avatar
mathieui committed
614 615 616 617
        change_nick = '303' in status_codes
        kick = '307' in status_codes and typ == 'unavailable'
        ban = '301' in status_codes and typ == 'unavailable'
        shutdown = '332' in status_codes and typ == 'unavailable'
618
        server_initiated = '333' in status_codes and typ == 'unavailable'
mathieui's avatar
mathieui committed
619 620 621 622 623 624
        non_member = '322' in status_codes and typ == 'unavailable'
        user = self.get_user_by_name(from_nick)
        # New user
        if not user and typ != "unavailable":
            user_color = self.search_for_color(from_nick)
            self.core.events.trigger('muc_join', presence, self)
mathieui's avatar
mathieui committed
625 626
            self.on_user_join(from_nick, affiliation, show, status, role, jid,
                              user_color)
mathieui's avatar
mathieui committed
627
        elif user is None:
628
            log.error('BUG: User %s in %s is None', from_nick, self.jid.bare)
mathieui's avatar
mathieui committed
629 630 631 632 633 634
            return
        elif change_nick:
            self.core.events.trigger('muc_nickchange', presence, self)
            self.on_user_nick_change(presence, user, from_nick, from_room)
        elif ban:
            self.core.events.trigger('muc_ban', presence, self)
mathieui's avatar
mathieui committed
635 636
            self.core.on_user_left_private_conversation(
                from_room, user, status)
mathieui's avatar
mathieui committed
637
            self.on_user_banned(presence, user, from_nick)
638
        elif kick and not server_initiated:
mathieui's avatar
mathieui committed
639
            self.core.events.trigger('muc_kick', presence, self)
mathieui's avatar
mathieui committed
640 641
            self.core.on_user_left_private_conversation(
                from_room, user, status)
mathieui's avatar
mathieui committed
642 643 644 645 646 647 648 649 650
            self.on_user_kicked(presence, user, from_nick)
        elif shutdown:
            self.core.events.trigger('muc_shutdown', presence, self)
            self.on_muc_shutdown()
        elif non_member:
            self.core.events.trigger('muc_shutdown', presence, self)
            self.on_non_member_kicked()
        # user quit
        elif typ == 'unavailable':
mathieui's avatar
mathieui committed
651
            self.on_user_leave_groupchat(user, jid, status, from_nick,
652
                                         from_room, server_initiated)
mathieui's avatar
mathieui committed
653
        # status change
654
        else:
mathieui's avatar
mathieui committed
655 656
            self.on_user_change_status(user, from_nick, from_room, affiliation,
                                       role, show, status)
657

mathieui's avatar
mathieui committed
658 659 660
    def on_non_member_kicked(self):
        """We have been kicked because the MUC is members-only"""
        self.add_message(
661
            MucOwnLeaveMessage(
662 663 664
                'You have been kicked because you '
                'are not a member and the room is now members-only.'
            ),
mathieui's avatar
mathieui committed
665 666 667 668 669 670
            typ=2)
        self.disconnect()

    def on_muc_shutdown(self):
        """We have been kicked because the MUC service is shutting down"""
        self.add_message(
671
            MucOwnLeaveMessage(
672 673 674
                'You have been kicked because the'
                ' MUC service is shutting down.'
            ),
mathieui's avatar
mathieui committed
675 676 677
            typ=2)
        self.disconnect()

mathieui's avatar
mathieui committed
678 679
    def on_user_join(self, from_nick, affiliation, show, status, role, jid,
                     color):
680
        """
mathieui's avatar
mathieui committed
681
        When a new user joins the groupchat
682
        """
mathieui's avatar
mathieui committed
683
        deterministic = config.get_by_tabname('deterministic_nick_colors',
684
                                              self.jid.bare)
mathieui's avatar
mathieui committed
685 686
        user = User(from_nick, affiliation, show, status, role, jid,
                    deterministic, color)
mathieui's avatar
mathieui committed
687 688 689 690 691 692 693 694 695
        bisect.insort_left(self.users, user)
        hide_exit_join = config.get_by_tabname('hide_exit_join',
                                               self.general_jid)
        if hide_exit_join != 0:
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
                color = dump_tuple(user.color)
            else:
                color = 3
696 697 698 699
            theme = get_theme()
            info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
            spec_col = dump_tuple(theme.COLOR_JOIN_CHAR)
            char_join = theme.CHAR_JOIN
mathieui's avatar
mathieui committed
700 701 702
            if not jid.full:
                msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s'
                       '\x19%(info_col)s} joined the room') % {
mathieui's avatar
mathieui committed
703 704 705 706 707 708
                           'nick': from_nick,
                           'spec': char_join,
                           'color': color,
                           'info_col': info_col,
                           'color_spec': spec_col,
                       }
mathieui's avatar
mathieui committed
709 710 711 712
            else:
                msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s'
                       '\x19%(info_col)s} (\x19%(jid_color)s}%(jid)s\x19'
                       '%(info_col)s}) joined the room') % {
mathieui's avatar
mathieui committed
713 714 715 716 717
                           'spec': char_join,
                           'nick': from_nick,
                           'color': color,
                           'jid': jid.full,
                           'info_col': info_col,
718
                           'jid_color': dump_tuple(theme.COLOR_MUC_JID),
mathieui's avatar
mathieui committed
719 720
                           'color_spec': spec_col,
                       }
721
            self.add_message(InfoMessage(msg), typ=2)
722
        self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick)
mathieui's avatar
mathieui committed
723 724

    def on_user_nick_change(self, presence, user, from_nick, from_room):
mathieui's avatar
mathieui committed
725 726
        new_nick = presence.xml.find(
            '{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)).attrib['nick']
727
        old_color = user.color
mathieui's avatar
mathieui committed
728 729 730 731
        if user.nick == self.own_nick:
            self.own_nick = new_nick
            # also change our nick in all private discussions of this room
            self.core.handler.on_muc_own_nickchange(self)
732
            user.change_nick(new_nick)
733
        else:
734
            user.change_nick(new_nick)
mathieui's avatar
mathieui committed
735
            deterministic = config.get_by_tabname('deterministic_nick_colors',
736
                                                  self.jid.bare)
737 738
            color = config.get_by_tabname(new_nick, 'muc_colors') or None
            if color or deterministic:
mathieui's avatar
mathieui committed
739 740 741
                user.change_color(color, deterministic)
        self.users.remove(user)
        bisect.insort_left(self.users, user)
742

mathieui's avatar
mathieui committed
743 744 745
        if config.get_by_tabname('display_user_color_in_join_part',
                                 self.general_jid):
            color = dump_tuple(user.color)
746
            old_color = dump_tuple(old_color)
747
        else:
748
            old_color = color = 3
mathieui's avatar
mathieui committed
749
        info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
mathieui's avatar
mathieui committed
750
        self.add_message(
751 752 753 754 755 756 757 758 759 760
            InfoMessage(
                '\x19%(old_color)s}%(old)s\x19%(info_col)s} is'
                ' now known as \x19%(color)s}%(new)s' % {
                    'old': from_nick,
                    'new': new_nick,
                    'color': color,
                    'old_color': old_color,
                    'info_col': info_col
                },
            ),
mathieui's avatar
mathieui committed
761
            typ=2)
mathieui's avatar
mathieui committed
762
        # rename the private tabs if needed
763
        self.core.rename_private_tabs(self.jid.bare, from_nick, user)
764

mathieui's avatar
mathieui committed
765
    def on_user_banned(self, presence, user, from_nick):
766
        """
mathieui's avatar
mathieui committed
767
        When someone is banned from a muc
768
        """
769
        cls = InfoMessage
mathieui's avatar
mathieui committed
770 771 772 773 774 775 776 777 778
        self.users.remove(user)
        by = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
                               (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
        reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
                                   (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
        if by:
            by = by.get('jid') or by.get('nick') or None
        else:
            by = None
779

780 781 782
        theme = get_theme()
        info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        char_kick = theme.CHAR_KICK
783

mathieui's avatar
mathieui committed
784
        if from_nick == self.own_nick:  # we are banned
785
            cls = MucOwnLeaveMessage
mathieui's avatar
mathieui committed
786 787 788
            if by:
                kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
                            ' have been banned by \x194}%(by)s') % {
mathieui's avatar
mathieui committed
789 790 791 792
                                'spec': char_kick,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
793 794 795
            else:
                kick_msg = ('\x191}%(spec)s \x193}You\x19'
                            '%(info_col)s} have been banned.') % {
mathieui's avatar
mathieui committed
796 797 798
                                'spec': char_kick,
                                'info_col': info_col
                            }
799
            self.core.disable_private_tabs(self.jid.bare, reason=kick_msg)
mathieui's avatar
mathieui committed
800 801
            self.disconnect()
            self.refresh_tab_win()
802
            self.core.tabs.current_tab.refresh_input()
mathieui's avatar
mathieui committed
803 804 805 806 807
            if config.get_by_tabname('autorejoin', self.general_jid):
                delay = config.get_by_tabname('autorejoin_delay',
                                              self.general_jid)
                delay = common.parse_str_to_secs(delay)
                if delay <= 0:
808
                    muc.join_groupchat(self.core, self.jid.bare, self.own_nick)
mathieui's avatar
mathieui committed
809
                else:
mathieui's avatar
mathieui committed
810 811
                    self.core.add_timed_event(
                        timed_events.DelayedEvent(delay, muc.join_groupchat,
812
                                                  self.core, self.jid.bare,
mathieui's avatar
mathieui committed
813
                                                  self.own_nick))
814

mathieui's avatar
mathieui committed
815 816 817 818 819 820 821 822 823 824 825
        else:
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
                color = dump_tuple(user.color)
            else:
                color = 3

            if by:
                kick_msg = ('\x191}%(spec)s \x19%(color)s}'
                            '%(nick)s\x19%(info_col)s} '
                            'has been banned by \x194}%(by)s') % {
mathieui's avatar
mathieui committed
826 827 828 829 830 831
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
832 833 834
            else:
                kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
                            '\x19%(info_col)s} has been banned') % {
mathieui's avatar
mathieui committed
835 836 837 838 839
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
840 841 842
        if reason is not None and reason.text:
            kick_msg += ('\x19%(info_col)s} Reason: \x196}'
                         '%(reason)s\x19%(info_col)s}') % {
mathieui's avatar
mathieui committed
843 844 845
                             'reason': reason.text,
                             'info_col': info_col
                         }
846
        self.add_message(cls(kick_msg), typ=2)
mathieui's avatar
mathieui committed
847 848

    def on_user_kicked(self, presence, user, from_nick):
849
        """
mathieui's avatar
mathieui committed
850
        When someone is kicked from a muc
851
        """
852
        cls = InfoMessage
mathieui's avatar
mathieui committed
853 854 855 856 857 858
        self.users.remove(user)
        actor_elem = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
                                       (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
        reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
                                   (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
        by = None
859 860 861
        theme = get_theme()
        info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        char_kick = theme.CHAR_KICK
mathieui's avatar
mathieui committed
862 863
        if actor_elem is not None:
            by = actor_elem.get('nick') or actor_elem.get('jid')
mathieui's avatar
mathieui committed
864
        if from_nick == self.own_nick:  # we are kicked
865
            cls = MucOwnLeaveMessage
mathieui's avatar
mathieui committed
866 867 868 869
            if by:
                kick_msg = ('\x191}%(spec)s \x193}You\x19'
                            '%(info_col)s} have been kicked'
                            ' by \x193}%(by)s') % {
mathieui's avatar
mathieui committed
870 871 872 873
                                'spec': char_kick,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
874 875 876
            else:
                kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
                            ' have been kicked.') % {
mathieui's avatar
mathieui committed
877 878 879
                                'spec': char_kick,
                                'info_col': info_col
                            }
880
            self.core.disable_private_tabs(self.jid.bare, reason=kick_msg)
mathieui's avatar
mathieui committed
881 882
            self.disconnect()
            self.refresh_tab_win()
883
            self.core.tabs.current_tab.refresh_input()
mathieui's avatar
mathieui committed
884 885 886 887 888 889
            # try to auto-rejoin
            if config.get_by_tabname('autorejoin', self.general_jid):
                delay = config.get_by_tabname('autorejoin_delay',
                                              self.general_jid)
                delay = common.parse_str_to_secs(delay)
                if delay <= 0:
890
                    muc.join_groupchat(self.core, self.jid.bare, self.own_nick)
mathieui's avatar
mathieui committed
891
                else:
mathieui's avatar
mathieui committed
892 893
                    self.core.add_timed_event(
                        timed_events.DelayedEvent(delay, muc.join_groupchat,
894
                                                  self.core, self.jid.bare,
mathieui's avatar
mathieui committed
895
                                                  self.own_nick))
mathieui's avatar
mathieui committed
896 897 898 899 900 901 902 903 904 905
        else:
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
                color = dump_tuple(user.color)
            else:
                color = 3
            if by:
                kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
                            '\x19%(info_col)s} has been kicked by '
                            '\x193}%(by)s') % {
mathieui's avatar
mathieui committed
906 907 908 909 910 911
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
912 913 914
            else:
                kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
                            '\x19%(info_col)s} has been kicked') % {
mathieui's avatar
mathieui committed
915 916 917 918 919
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
920 921 922
        if reason is not None and reason.text:
            kick_msg += ('\x19%(info_col)s} Reason: \x196}'
                         '%(reason)s') % {
mathieui's avatar
mathieui committed
923 924 925
                             'reason': reason.text,
                             'info_col': info_col
                         }
926
        self.add_message(cls(kick_msg), typ=2)
mathieui's avatar
mathieui committed
927

mathieui's avatar
mathieui committed
928
    def on_user_leave_groupchat(self,
929 930 931 932 933
                                user: User,
                                jid: JID,
                                status: str,
                                from_nick: str,
                                from_room: JID,
Maxime Buquet's avatar