muctab.py 84.4 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 22 23 24 25 26 27 28 29 30 31 32 33
from typing import (
    cast,
    Any,
    Dict,
    Callable,
    List,
    Optional,
    Tuple,
    Union,
    Set,
    Pattern,
    TYPE_CHECKING,
)
34

35
from slixmpp import InvalidJID, JID, Presence, Iq
36
from slixmpp.exceptions import IqError, IqTimeout
37
from poezio.tabs import ChatTab, Tab, SHOW_NAME
38

39 40
from poezio import common
from poezio import fixes
41
from poezio import mam
42 43 44 45
from poezio import multiuserchat as muc
from poezio import timed_events
from poezio import windows
from poezio import xhtml
46
from poezio.common import safeJID, to_utc
47
from poezio.config import config
48
from poezio.core.structs import Command
49 50 51 52 53
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
54
from poezio.core.structs import Completion, Status
55 56 57 58 59 60 61 62
from poezio.ui.types import (
    BaseMessage,
    InfoMessage,
    Message,
    MucOwnJoinMessage,
    MucOwnLeaveMessage,
    StatusMessage,
)
63

64 65 66 67
if TYPE_CHECKING:
    from poezio.core.core import Core
    from slixmpp.plugins.xep_0004 import Form

68 69
log = logging.getLogger(__name__)

70
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'
71
STATUS_XPATH = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER)
72

73 74
COMPARE_USERS_LAST_TALKED = lambda x: x.last_talked

75 76 77 78

class MucTab(ChatTab):
    """
    The tab containing a multi-user-chat room.
Maxime Buquet's avatar
Maxime Buquet committed
79
    It contains a userlist, an input, a topic, an information and a chat zone
80 81
    """
    message_type = 'groupchat'
82
    plugin_commands = {}  # type: Dict[str, Command]
83
    plugin_keys = {}  # type: Dict[str, Callable[..., Any]]
84
    additional_information = {}  # type: Dict[str, Callable[[str], str]]
85
    lagged = False
86

87
    def __init__(self, core: 'Core', jid: JID, nick: str, password: Optional[str] = None) -> None:
88
        ChatTab.__init__(self, core, jid)
89 90 91
        self.joined = False
        self._state = 'disconnected'
        # our nick in the MUC
92
        self.own_nick = nick
93
        # self User object
94
        self.own_user = None  # type: Optional[User]
95
        self.password = password
96
        # buffered presences
97
        self.presence_buffer = []  # type: List[Presence]
98
        # userlist
99
        self.users = []  # type: List[User]
100
        # private conversations
101
        self.privates = []  # type: List[Tab]
102 103
        # Used to check if we are still receiving muc history
        self.last_message_was_history = None  # type: Optional[bool]
104
        self.topic = ''
105
        self.topic_from = ''
106
        # Self ping event, so we can cancel it when we leave the room
107
        self.self_ping_event = None  # type: Optional[timed_events.DelayedEvent]
108
        # UI stuff
109 110 111 112
        self.topic_win = windows.Topic()
        self.v_separator = windows.VerticalSeparator()
        self.user_win = windows.UserList()
        self.info_header = windows.MucInfoWin()
113
        self.input = windows.MessageInput()  # type: windows.MessageInput
114
        # List of ignored users
115
        self.ignores = []  # type: List[User]
116
        # keys
117 118
        self.register_keys()
        self.update_keys()
119
        # commands
120
        self.register_commands()
121
        self.update_commands()
122
        self.resize()
123 124

    @property
125
    def general_jid(self) -> JID:
126
        return self.jid
127

128
    def check_send_chat_state(self) -> bool:
mathieui's avatar
mathieui committed
129 130 131
        "If we should send a chat state"
        return self.joined

132
    @property
133
    def last_connection(self) -> Optional[datetime]:
134 135 136 137 138
        last_message = self._text_buffer.last_message
        if last_message:
            return last_message.time
        return None

139
    @staticmethod
140
    @refresh_wrapper.always
141 142 143 144 145 146 147
    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
148
    @refresh_wrapper.always
149 150 151 152 153 154
    def remove_information_element(plugin_name: str) -> None:
        """
        Lets a plugin add its own information to the MucInfoWin
        """
        del MucTab.additional_information[plugin_name]

155
    def cancel_config(self, form: 'Form') -> None:
156
        """
Kim Alvefur's avatar
Kim Alvefur committed
157
        The user do not want to send their config, send an iq cancel
158
        """
159
        muc.cancel_config(self.core.xmpp, self.jid.bare)
160 161
        self.core.close_tab()

162
    def send_config(self, form: 'Form') -> None:
163
        """
Kim Alvefur's avatar
Kim Alvefur committed
164
        The user sends their config to the server
165
        """
166
        muc.configure_room(self.core.xmpp, self.jid.bare, form)
167 168
        self.core.close_tab()

169
    def join(self) -> None:
170 171 172 173 174
        """
        Join the room
        """
        status = self.core.get_status()
        if self.last_connection:
175
            delta = to_utc(datetime.now()) - to_utc(self.last_connection)
176 177
            seconds = delta.seconds + delta.days * 24 * 3600
        else:
178 179 180 181
            last_message = self._text_buffer.find_last_message()
            seconds = None
            if last_message is not None:
                seconds = (datetime.now() - last_message.time).seconds
mathieui's avatar
mathieui committed
182 183
        muc.join_groupchat(
            self.core,
184
            self.jid.bare,
mathieui's avatar
mathieui committed
185
            self.own_nick,
186
            self.password or '',
mathieui's avatar
mathieui committed
187 188 189
            status=status.message,
            show=status.show,
            seconds=seconds)
190

191
    def leave_room(self, message: str) -> None:
mathieui's avatar
mathieui committed
192
        if self.joined:
193 194 195 196
            theme = get_theme()
            info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
            char_quit = theme.CHAR_QUIT
            spec_col = dump_tuple(theme.COLOR_QUIT_CHAR)
197

198 199
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
200
                color = dump_tuple(theme.COLOR_OWN_NICK)
201
            else:
202
                color = "3"
203

mathieui's avatar
mathieui committed
204
            if message:
205 206
                msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
                       'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
207
                       ' left the room'
208
                       ' (\x19o%(reason)s\x19%(info_col)s})') % {
mathieui's avatar
mathieui committed
209 210 211 212
                           'info_col': info_col,
                           'reason': message,
                           'spec': char_quit,
                           'color': color,
213 214 215
                           'color_spec': spec_col,
                           'nick': self.own_nick,
                       }
216
            else:
217 218
                msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
                       'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
219
                       ' left the room') % {
220
                           'info_col': info_col,
mathieui's avatar
mathieui committed
221 222
                           'spec': char_quit,
                           'color': color,
223 224 225
                           'color_spec': spec_col,
                           'nick': self.own_nick,
                       }
226
            self.add_message(MucOwnLeaveMessage(msg), typ=2)
227
            self.disconnect()
228
            muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
mathieui's avatar
mathieui committed
229
                                message)
230
            self.core.disable_private_tabs(self.jid.bare, reason=msg)
231
        else:
232
            muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
mathieui's avatar
mathieui committed
233
                                message)
mathieui's avatar
mathieui committed
234

235 236 237 238 239 240
    def change_affiliation(
        self,
        nick_or_jid: Union[str, JID],
        affiliation: str,
        reason: str = ''
    ) -> None:
241 242 243
        """
        Change the affiliation of a nick or JID
        """
mathieui's avatar
mathieui committed
244

245
        def callback(iq: Iq) -> None:
246
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
247 248 249 250
                self.core.information(
                    "Could not set affiliation '%s' for '%s'." %
                    (affiliation, nick_or_jid), "Warning")

251 252 253 254 255
        if not self.joined:
            return

        valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner')
        if affiliation not in valid_affiliations:
256
            self.core.information(
mathieui's avatar
mathieui committed
257 258
                'The affiliation must be one of ' +
                ', '.join(valid_affiliations), 'Error')
259
            return
260
        if nick_or_jid in [user.nick for user in self.users]:
mathieui's avatar
mathieui committed
261
            muc.set_user_affiliation(
mathieui's avatar
mathieui committed
262
                self.core.xmpp,
263
                self.jid.bare,
mathieui's avatar
mathieui committed
264 265 266 267
                affiliation,
                nick=nick_or_jid,
                callback=callback,
                reason=reason)
268
        else:
mathieui's avatar
mathieui committed
269
            muc.set_user_affiliation(
mathieui's avatar
mathieui committed
270
                self.core.xmpp,
271
                self.jid.bare,
mathieui's avatar
mathieui committed
272 273 274 275
                affiliation,
                jid=safeJID(nick_or_jid),
                callback=callback,
                reason=reason)
276

277
    def change_role(self, nick: str, role: str, reason: str = '') -> None:
278 279 280
        """
        Change the role of a nick
        """
mathieui's avatar
mathieui committed
281

282
        def callback(iq: Iq) -> None:
283
            if iq['type'] == 'error':
mathieui's avatar
mathieui committed
284 285 286
                self.core.information(
                    "Could not set role '%s' for '%s'." % (role, nick),
                    "Warning")
mathieui's avatar
mathieui committed
287

288 289 290
        valid_roles = ('none', 'visitor', 'participant', 'moderator')

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

295 296 297 298
        try:
            target_jid = copy(self.jid)
            target_jid.resource = nick
        except InvalidJID:
299 300
            self.core.information('Invalid nick', 'Info')
            return
301

mathieui's avatar
mathieui committed
302
        muc.set_user_role(
303
            self.core.xmpp, self.jid.bare, nick, reason, role, callback=callback)
304

mathieui's avatar
mathieui committed
305
    @refresh_wrapper.conditional
306
    def print_info(self, nick: str) -> bool:
mathieui's avatar
mathieui committed
307 308 309 310 311 312 313 314 315
        """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
316
                inf, dump_tuple(theme.COLOR_MUC_JID), user.jid, inf)
mathieui's avatar
mathieui committed
317 318 319 320 321 322
        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
323 324 325 326 327 328 329 330 331 332 333
                    '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 ''
                }
334
        self.add_message(InfoMessage(info), typ=0)
mathieui's avatar
mathieui committed
335 336
        return True

337
    def change_topic(self, topic: str) -> None:
mathieui's avatar
mathieui committed
338
        """Change the current topic"""
339
        muc.change_subject(self.core.xmpp, self.jid.bare, topic)
mathieui's avatar
mathieui committed
340 341

    @refresh_wrapper.always
342
    def show_topic(self) -> None:
mathieui's avatar
mathieui committed
343 344 345
        """
        Print the current topic
        """
346 347 348
        theme = get_theme()
        info_text = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        norm_text = dump_tuple(theme.COLOR_NORMAL_TEXT)
mathieui's avatar
mathieui committed
349 350 351 352
        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
353 354
                user_string = '\x19%s}(set by \x19%s}%s\x19%s})' % (
                    info_text, user_text, user.nick, info_text)
mathieui's avatar
mathieui committed
355 356 357 358 359
            else:
                user_string = self.topic_from
        else:
            user_string = ''

360 361 362 363 364 365 366
        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
367

mathieui's avatar
mathieui committed
368
    @refresh_wrapper.always
369
    def recolor(self, random_colors: bool = False) -> None:
mathieui's avatar
mathieui committed
370
        """Recolor the current MUC users"""
mathieui's avatar
mathieui committed
371
        deterministic = config.get_by_tabname('deterministic_nick_colors',
372
                                              self.jid.bare)
mathieui's avatar
mathieui committed
373 374 375 376 377 378 379 380 381 382 383
        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
384
        sorted_users = sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True)
mathieui's avatar
mathieui committed
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401
        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)

402
    @refresh_wrapper.conditional
403
    def set_nick_color(self, nick: str, color: str) -> bool:
404 405 406 407 408 409 410 411
        "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
412 413
                self.core.information('Color for nick %s unset' % (nick),
                                      'Info')
414 415 416 417 418 419
        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
420
            nick_color_aliases = config.get_by_tabname('nick_color_aliases',
421
                                                       self.jid.bare)
422 423 424 425 426 427 428 429 430 431 432
            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
433

434
    def on_input(self, key: str, raw: bool) -> bool:
mathieui's avatar
mathieui committed
435 436 437 438 439
        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
440 441 442
        empty_after = empty_after or (
            self.input.get_text().startswith('/')
            and not self.input.get_text().startswith('//'))
mathieui's avatar
mathieui committed
443 444
        self.send_composing_chat_state(empty_after)
        return False
445

446
    def get_nick(self) -> str:
447
        if config.get('show_muc_jid'):
448
            return cast(str, self.jid.bare)
449
        bookmark = self.core.bookmarks[self.jid.bare]
450 451 452
        if bookmark is not None and bookmark.name:
            return bookmark.name
        # TODO: send the disco#info identity name here, if it exists.
453
        return self.jid.user
454

455
    def get_text_window(self) -> windows.TextWin:
mathieui's avatar
mathieui committed
456
        return self.text_win
457

458
    def on_lose_focus(self) -> None:
mathieui's avatar
mathieui committed
459 460 461
        if self.joined:
            if self.input.text:
                self.state = 'nonempty'
462 463
            elif self.lagged:
                self.state = 'disconnected'
mathieui's avatar
mathieui committed
464 465 466 467 468 469 470 471 472 473
            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()

474
    def on_gain_focus(self) -> None:
mathieui's avatar
mathieui committed
475 476 477 478 479
        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
480 481
        if self.joined and config.get_by_tabname(
                'send_chat_states',
mathieui's avatar
mathieui committed
482 483 484
                self.general_jid) and not self.input.get_text():
            self.send_chat_state('active')

485 486
    def handle_presence(self, presence: Presence) -> None:
        """Handle MUC presence"""
487
        self.reset_lag()
mathieui's avatar
mathieui committed
488 489 490
        status_codes = set()
        for status_code in presence.xml.findall(STATUS_XPATH):
            status_codes.add(status_code.attrib['code'])
mathieui's avatar
mathieui committed
491
        if presence['type'] == 'error':
492
            self.core.room_error(presence, self.jid.bare)
mathieui's avatar
mathieui committed
493
        elif not self.joined:
494
            own = '110' in status_codes
495 496
            if own or len(self.presence_buffer) >= 10:
                self.process_presence_buffer(presence, own)
mathieui's avatar
mathieui committed
497 498 499 500 501 502 503 504
            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)
505
        if self.core.tabs.current_tab is self:
mathieui's avatar
mathieui committed
506 507
            self.text_win.refresh()
            self.user_win.refresh_if_changed(self.users)
508 509 510
            self.info_header.refresh(
                self, self.text_win, user=self.own_user,
                information=MucTab.additional_information)
mathieui's avatar
mathieui committed
511 512
            self.input.refresh()
            self.core.doupdate()
513

514
    def process_presence_buffer(self, last_presence: Presence, own: bool) -> None:
515
        """
mathieui's avatar
mathieui committed
516
        Batch-process all the initial presences
517
        """
mathieui's avatar
mathieui committed
518
        deterministic = config.get_by_tabname('deterministic_nick_colors',
519
                                              self.jid.bare)
mathieui's avatar
mathieui committed
520

mathieui's avatar
mathieui committed
521 522 523 524 525
        for stanza in self.presence_buffer:
            try:
                self.handle_presence_unjoined(stanza, deterministic)
            except PresenceError:
                self.core.room_error(stanza, stanza['from'].bare)
526 527
        self.presence_buffer = []
        self.handle_presence_unjoined(last_presence, deterministic, own)
mathieui's avatar
mathieui committed
528 529 530
        self.users.sort()
        # Enable the self ping event, to regularly check if we
        # are still in the room.
531 532
        if own:
            self.enable_self_ping_event()
533
        if self.core.tabs.current_tab is not self:
mathieui's avatar
mathieui committed
534
            self.refresh_tab_win()
535
            self.core.tabs.current_tab.refresh_input()
mathieui's avatar
mathieui committed
536
            self.core.doupdate()
537

538
    def handle_presence_unjoined(self, presence: Presence, deterministic: bool, own: bool = False) -> None:
mathieui's avatar
mathieui committed
539 540 541
        """
        Presence received while we are not in the room (before code=110)
        """
542 543 544
        # If presence is coming from MUC barejid, ignore.
        if not presence['from'].resource:
            return None
545 546
        dissected_presence = dissect_presence(presence)
        from_nick, _, affiliation, show, status, role, jid, typ = dissected_presence
547 548
        if typ == 'unavailable':
            return
mathieui's avatar
mathieui committed
549
        user_color = self.search_for_color(from_nick)
mathieui's avatar
mathieui committed
550 551
        new_user = User(from_nick, affiliation, show, status, role, jid,
                        deterministic, user_color)
mathieui's avatar
mathieui committed
552 553 554 555 556 557 558
        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)
559

560
    def own_join(self, from_nick: str, new_user: User, status_codes: Set[str]) -> None:
561
        """
mathieui's avatar
mathieui committed
562
        Handle the last presence we received, entering the room
563
        """
mathieui's avatar
mathieui committed
564 565 566
        self.own_nick = from_nick
        self.own_user = new_user
        self.joined = True
567 568
        if self.jid.bare in self.core.initial_joins:
            self.core.initial_joins.remove(self.jid.bare)
mathieui's avatar
mathieui committed
569
            self._state = 'normal'
570
        elif self != self.core.tabs.current_tab:
mathieui's avatar
mathieui committed
571
            self._state = 'joined'
572
        if (self.core.tabs.current_tab is self
mathieui's avatar
mathieui committed
573 574
                and self.core.status.show not in ('xa', 'away')):
            self.send_chat_state('active')
575 576
        theme = get_theme()
        new_user.color = theme.COLOR_OWN_NICK
577

mathieui's avatar
mathieui committed
578 579 580 581
        if config.get_by_tabname('display_user_color_in_join_part',
                                 self.general_jid):
            color = dump_tuple(new_user.color)
        else:
582
            color = "3"
583

584 585 586
        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
587 588 589 590
        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,
591
                              'spec': theme.CHAR_JOIN,
mathieui's avatar
mathieui committed
592 593 594 595
                              'color_spec': spec_col,
                              'nick_col': color,
                              'info_col': info_col,
                          }
596
        self.add_message(MucOwnJoinMessage(enable_message), typ=2)
597
        self.core.enable_private_tabs(self.jid.bare, enable_message)
mathieui's avatar
mathieui committed
598 599
        if '201' in status_codes:
            self.add_message(
600 601 602
                InfoMessage('Info: The room has been created'),
                typ=0
            )
mathieui's avatar
mathieui committed
603 604
        if '170' in status_codes:
            self.add_message(
605 606 607 608 609 610 611
                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
612 613 614
                typ=0)
        if '100' in status_codes:
            self.add_message(
615 616 617 618 619 620 621
                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
622
                typ=0)
623
        mam.schedule_tab_open(self)
624

625
    def handle_presence_joined(self, presence: Presence, status_codes: Set[str]) -> None:
626
        """
mathieui's avatar
mathieui committed
627
        Handle new presences when we are already in the room
628
        """
629 630 631
        # If presence is coming from MUC barejid, ignore.
        if not presence['from'].resource:
            return None
632 633
        dissected_presence = dissect_presence(presence)
        from_nick, from_room, affiliation, show, status, role, jid, typ = dissected_presence
mathieui's avatar
mathieui committed
634 635 636 637
        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'
638
        server_initiated = '333' in status_codes and typ == 'unavailable'
mathieui's avatar
mathieui committed
639 640 641 642 643 644
        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
645 646
            self.on_user_join(from_nick, affiliation, show, status, role, jid,
                              user_color)
mathieui's avatar
mathieui committed
647
        elif user is None:
648
            log.error('BUG: User %s in %s is None', from_nick, self.jid.bare)
mathieui's avatar
mathieui committed
649 650 651
            return
        elif change_nick:
            self.core.events.trigger('muc_nickchange', presence, self)
652
            self.on_user_nick_change(presence, user, from_nick)
mathieui's avatar
mathieui committed
653 654
        elif ban:
            self.core.events.trigger('muc_ban', presence, self)
mathieui's avatar
mathieui committed
655 656
            self.core.on_user_left_private_conversation(
                from_room, user, status)
mathieui's avatar
mathieui committed
657
            self.on_user_banned(presence, user, from_nick)
658
        elif kick and not server_initiated:
mathieui's avatar
mathieui committed
659
            self.core.events.trigger('muc_kick', presence, self)
mathieui's avatar
mathieui committed
660 661
            self.core.on_user_left_private_conversation(
                from_room, user, status)
mathieui's avatar
mathieui committed
662 663 664 665 666 667 668 669 670
            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
671
            self.on_user_leave_groupchat(user, jid, status, from_nick,
672
                                         from_room, server_initiated)
mathieui's avatar
mathieui committed
673
        # status change
674
        else:
mathieui's avatar
mathieui committed
675 676
            self.on_user_change_status(user, from_nick, from_room, affiliation,
                                       role, show, status)
677

678
    def on_non_member_kicked(self) -> None:
mathieui's avatar
mathieui committed
679 680
        """We have been kicked because the MUC is members-only"""
        self.add_message(
681
            MucOwnLeaveMessage(
682 683 684
                'You have been kicked because you '
                'are not a member and the room is now members-only.'
            ),
mathieui's avatar
mathieui committed
685 686 687
            typ=2)
        self.disconnect()

688
    def on_muc_shutdown(self) -> None:
mathieui's avatar
mathieui committed
689 690
        """We have been kicked because the MUC service is shutting down"""
        self.add_message(
691
            MucOwnLeaveMessage(
692 693 694
                'You have been kicked because the'
                ' MUC service is shutting down.'
            ),
mathieui's avatar
mathieui committed
695 696 697
            typ=2)
        self.disconnect()

698 699
    def on_user_join(self, from_nick: str, affiliation: str, show: str, status: str, role: str, jid: JID,
                     color: str) -> None:
700
        """
mathieui's avatar
mathieui committed
701
        When a new user joins the groupchat
702
        """
mathieui's avatar
mathieui committed
703
        deterministic = config.get_by_tabname('deterministic_nick_colors',
704
                                              self.jid.bare)
mathieui's avatar
mathieui committed
705 706
        user = User(from_nick, affiliation, show, status, role, jid,
                    deterministic, color)
mathieui's avatar
mathieui committed
707 708 709 710 711 712 713 714
        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:
715
                color = "3"
716 717 718 719
            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
720 721 722
            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
723 724 725 726 727 728
                           'nick': from_nick,
                           'spec': char_join,
                           'color': color,
                           'info_col': info_col,
                           'color_spec': spec_col,
                       }
mathieui's avatar
mathieui committed
729 730 731 732
            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
733 734 735 736 737
                           'spec': char_join,
                           'nick': from_nick,
                           'color': color,
                           'jid': jid.full,
                           'info_col': info_col,
738
                           'jid_color': dump_tuple(theme.COLOR_MUC_JID),
mathieui's avatar
mathieui committed
739 740
                           'color_spec': spec_col,
                       }
741
            self.add_message(InfoMessage(msg), typ=2)
742
        self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick)
mathieui's avatar
mathieui committed
743

744
    def on_user_nick_change(self, presence: Presence, user: User, from_nick: str) -> None:
mathieui's avatar
mathieui committed
745 746
        new_nick = presence.xml.find(
            '{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)).attrib['nick']
747
        old_color = user.color
mathieui's avatar
mathieui committed
748 749 750 751
        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)
752
            user.change_nick(new_nick)
753
        else:
754
            user.change_nick(new_nick)
mathieui's avatar
mathieui committed
755
            deterministic = config.get_by_tabname('deterministic_nick_colors',
756
                                                  self.jid.bare)
757 758
            color = config.get_by_tabname(new_nick, 'muc_colors') or None
            if color or deterministic:
mathieui's avatar
mathieui committed
759 760 761
                user.change_color(color, deterministic)
        self.users.remove(user)
        bisect.insort_left(self.users, user)
762

mathieui's avatar
mathieui committed
763 764 765
        if config.get_by_tabname('display_user_color_in_join_part',
                                 self.general_jid):
            color = dump_tuple(user.color)
766
            old_color = dump_tuple(old_color)
767
        else:
768
            old_color = color = "3"
mathieui's avatar
mathieui committed
769
        info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
mathieui's avatar
mathieui committed
770
        self.add_message(
771 772 773 774 775 776 777 778 779 780
            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
781
            typ=2)
mathieui's avatar
mathieui committed
782
        # rename the private tabs if needed
783
        self.core.rename_private_tabs(self.jid.bare, from_nick, user)
784

785
    def on_user_banned(self, presence: Presence, user: User, from_nick: str) -> None:
786
        """
mathieui's avatar
mathieui committed
787
        When someone is banned from a muc
788
        """
789
        cls = InfoMessage
mathieui's avatar
mathieui committed
790 791 792 793 794 795 796 797 798
        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
799

800 801 802
        theme = get_theme()
        info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        char_kick = theme.CHAR_KICK
803

mathieui's avatar
mathieui committed
804
        if from_nick == self.own_nick:  # we are banned
805
            cls = MucOwnLeaveMessage
mathieui's avatar
mathieui committed
806 807 808
            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
809 810 811 812
                                'spec': char_kick,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
813 814 815
            else:
                kick_msg = ('\x191}%(spec)s \x193}You\x19'
                            '%(info_col)s} have been banned.') % {
mathieui's avatar
mathieui committed
816 817 818
                                'spec': char_kick,
                                'info_col': info_col
                            }
819
            self.core.disable_private_tabs(self.jid.bare, reason=kick_msg)
mathieui's avatar
mathieui committed
820 821
            self.disconnect()
            self.refresh_tab_win()
822
            self.core.tabs.current_tab.refresh_input()
mathieui's avatar
mathieui committed
823 824 825 826 827
            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:
828
                    muc.join_groupchat(self.core, self.jid.bare, self.own_nick)
mathieui's avatar
mathieui committed
829
                else:
mathieui's avatar
mathieui committed
830 831
                    self.core.add_timed_event(
                        timed_events.DelayedEvent(delay, muc.join_groupchat,
832
                                                  self.core, self.jid.bare,
mathieui's avatar
mathieui committed
833
                                                  self.own_nick))
834

mathieui's avatar
mathieui committed
835 836 837 838 839
        else:
            if config.get_by_tabname('display_user_color_in_join_part',
                                     self.general_jid):
                color = dump_tuple(user.color)
            else:
840
                color = "3"
mathieui's avatar
mathieui committed
841 842 843 844 845

            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
846 847 848 849 850 851
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'by': by,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
852 853 854
            else:
                kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
                            '\x19%(info_col)s} has been banned') % {
mathieui's avatar
mathieui committed
855 856 857 858 859
                                'spec': char_kick,
                                'nick': from_nick,
                                'color': color,
                                'info_col': info_col
                            }
mathieui's avatar
mathieui committed
860 861 862
        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
863 864 865
                             'reason': reason.text,
                             'info_col': info_col
                         }
866
        self.add_message(cls(kick_msg), typ=2)
mathieui's avatar
mathieui committed
867

868
    def on_user_kicked(self, presence: Presence, user: User, from_nick: str) -> None:
869
        """
mathieui's avatar
mathieui committed
870
        When someone is kicked from a muc
871
        """
872
        cls = InfoMessage
mathieui's avatar
mathieui committed
873 874 875 876 877 878
        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
879 880 881
        theme = get_theme()
        info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
        char_kick = theme.CHAR_KICK
mathieui's avatar
mathieui committed
882