1from __future__ import absolute_import
2
3import logging
4import re
5import struct
6import subprocess
7import sys
8import threading
9
10from markdown import Markdown
11from markdown.extensions.extra import ExtraExtension
12
13from errbot.backends.base import (
14    ONLINE,
15    Message,
16    Person,
17    Room,
18    RoomError,
19    RoomNotJoinedError,
20    RoomOccupant,
21    Stream,
22)
23from errbot.core import ErrBot
24from errbot.rendering.ansiext import NSC, AnsiExtension, CharacterTable, enable_format
25from errbot.utils import rate_limited
26
27log = logging.getLogger(__name__)
28
29IRC_CHRS = CharacterTable(
30    fg_black=NSC("\x0301"),
31    fg_red=NSC("\x0304"),
32    fg_green=NSC("\x0303"),
33    fg_yellow=NSC("\x0308"),
34    fg_blue=NSC("\x0302"),
35    fg_magenta=NSC("\x0306"),
36    fg_cyan=NSC("\x0310"),
37    fg_white=NSC("\x0300"),
38    fg_default=NSC("\x03"),
39    bg_black=NSC("\x03,01"),
40    bg_red=NSC("\x03,04"),
41    bg_green=NSC("\x03,03"),
42    bg_yellow=NSC("\x03,08"),
43    bg_blue=NSC("\x03,02"),
44    bg_magenta=NSC("\x03,06"),
45    bg_cyan=NSC("\x03,10"),
46    bg_white=NSC("\x03,00"),
47    bg_default=NSC("\x03,"),
48    fx_reset=NSC("\x03"),
49    fx_bold=NSC("\x02"),
50    fx_italic=NSC("\x1D"),
51    fx_underline=NSC("\x1F"),
52    fx_not_italic=NSC("\x0F"),
53    fx_not_underline=NSC("\x0F"),
54    fx_normal=NSC("\x0F"),
55    fixed_width="",
56    end_fixed_width="",
57    inline_code="",
58    end_inline_code="",
59)
60
61IRC_NICK_REGEX = r"[a-zA-Z\[\]\\`_\^\{\|\}][a-zA-Z0-9\[\]\\`_\^\{\|\}-]+"
62
63try:
64    import irc.connection
65    from irc.bot import SingleServerIRCBot
66    from irc.client import NickMask, ServerNotConnectedError
67except ImportError:
68    log.fatal(
69        """You need the IRC support to use IRC, you can install it with:
70    pip install errbot[IRC]
71    """
72    )
73    sys.exit(-1)
74
75
76def irc_md():
77    """This makes a converter from markdown to mirc color format."""
78    md = Markdown(output_format="irc", extensions=[ExtraExtension(), AnsiExtension()])
79    md.stripTopLevelTags = False
80    return md
81
82
83class IRCPerson(Person):
84    def __init__(self, mask):
85        self._nickmask = NickMask(mask)
86        self._email = ""
87
88    @property
89    def nick(self):
90        return self._nickmask.nick
91
92    @property
93    def user(self):
94        return self._nickmask.user
95
96    @property
97    def host(self):
98        return self._nickmask.host
99
100    # generic compatibility
101    person = nick
102
103    @property
104    def client(self):
105        return self._nickmask.userhost
106
107    @property
108    def fullname(self):
109        # TODO: this should be possible to get
110        return None
111
112    @property
113    def email(self):
114        return self._email
115
116    @property
117    def aclattr(self):
118        return IRCBackend.aclpattern.format(
119            nick=self._nickmask.nick, user=self._nickmask.user, host=self._nickmask.host
120        )
121
122    def __unicode__(self):
123        return str(self._nickmask)
124
125    def __str__(self):
126        return self.__unicode__()
127
128    def __eq__(self, other):
129        if not isinstance(other, IRCPerson):
130            log.warning("Weird you are comparing an IRCPerson to a %s.", type(other))
131            return False
132        return self.person == other.person
133
134
135class IRCRoomOccupant(IRCPerson, RoomOccupant):
136    def __init__(self, mask, room):
137        super().__init__(mask)
138        self._room = room
139
140    @property
141    def room(self):
142        return self._room
143
144    def __unicode__(self):
145        return self._nickmask
146
147    def __str__(self):
148        return self.__unicode__()
149
150    def __repr__(self):
151        return f"<{self.__unicode__()} - {super().__repr__()}>"
152
153
154class IRCRoom(Room):
155    """
156    Represent the specifics of a IRC Room/Channel.
157
158    This lifecycle of this object is:
159     - Created in IRCConnection.on_join
160     - The joined status change in IRCConnection on_join/on_part
161     - Deleted/destroyed in IRCConnection.on_disconnect
162    """
163
164    def __init__(self, room, bot):
165        self._bot = bot
166        self.room = room
167        self.connection = self._bot.conn.connection
168        self._topic_lock = threading.Lock()
169        self._topic = None
170
171    def __unicode__(self):
172        return self.room
173
174    def __str__(self):
175        return self.__unicode__()
176
177    def __repr__(self):
178        return f"<{self.__unicode__()} - {super().__repr__()}>"
179
180    def cb_set_topic(self, current_topic):
181        """
182        Store the current topic for this room.
183
184        This method is called by the IRC backend when a `currenttopic`,
185        `topic` or `notopic` IRC event is received to store the topic set for this channel.
186
187        This function is not meant to be executed by regular plugins.
188        To get or set
189        """
190        with self._topic_lock:
191            self._topic = current_topic
192
193    def join(self, username=None, password=None):
194        """
195        Join the room.
196
197        If the room does not exist yet, this will automatically call
198        :meth:`create` on it first.
199        """
200        if username is not None:
201            log.debug(
202                "Ignored username parameter on join(), it is unsupported on this back-end."
203            )
204        if password is None:
205            password = ""  # nosec
206
207        self.connection.join(self.room, key=password)
208        self._bot.callback_room_joined(self, self._bot.bot_identifier)
209        log.info("Joined room %s.", self.room)
210
211    def leave(self, reason=None):
212        """
213        Leave the room.
214
215        :param reason:
216            An optional string explaining the reason for leaving the room
217        """
218        if reason is None:
219            reason = ""
220
221        self.connection.part(self.room, reason)
222        self._bot.callback_room_left(self, self._bot.bot_identifier)
223        log.info(
224            "Leaving room %s with reason %s.",
225            self.room,
226            reason if reason is not None else "",
227        )
228
229    def create(self):
230        """
231        Not supported on this back-end. Will join the room to ensure it exists, instead.
232        """
233        logging.warning(
234            "IRC back-end does not support explicit creation, joining room instead to ensure it exists."
235        )
236        self.join()
237
238    def destroy(self):
239        """
240        Not supported on IRC, will raise :class:`~errbot.backends.base.RoomError`.
241        """
242        raise RoomError("IRC back-end does not support destroying rooms.")
243
244    @property
245    def exists(self):
246        """
247        Boolean indicating whether this room already exists or not.
248
249        :getter:
250            Returns `True` if the room exists, `False` otherwise.
251        """
252        logging.warning(
253            "IRC back-end does not support determining if a room exists. "
254            "Returning the result of joined instead."
255        )
256        return self.joined
257
258    @property
259    def joined(self):
260        """
261        Boolean indicating whether this room has already been joined.
262
263        :getter:
264            Returns `True` if the room has been joined, `False` otherwise.
265        """
266        return self.room in self._bot.conn.channels.keys()
267
268    @property
269    def topic(self):
270        """
271        The room topic.
272
273        :getter:
274            Returns the topic (a string) if one is set, `None` if no
275            topic has been set at all.
276        """
277        if not self.joined:
278            raise RoomNotJoinedError("Must join the room to get the topic.")
279        with self._topic_lock:
280            return self._topic
281
282    @topic.setter
283    def topic(self, topic):
284        """
285        Set the room's topic.
286
287        :param topic:
288            The topic to set.
289        """
290        if not self.joined:
291            raise RoomNotJoinedError("Must join the room to set the topic.")
292        self.connection.topic(self.room, topic)
293
294    @property
295    def occupants(self):
296        """
297        The room's occupants.
298
299        :getter:
300            Returns a list of occupants.
301            :raises:
302            :class:`~MUCNotJoinedError` if the room has not yet been joined.
303        """
304        occupants = []
305        try:
306            for nick in self._bot.conn.channels[self.room].users():
307                occupants.append(IRCRoomOccupant(nick, room=self.room))
308        except KeyError:
309            raise RoomNotJoinedError("Must be in a room in order to see occupants.")
310        return occupants
311
312    def invite(self, *args):
313        """
314        Invite one or more people into the room.
315
316        :*args:
317            One or more nicks to invite into the room.
318        """
319        for nick in args:
320            self.connection.invite(nick, self.room)
321            log.info("Invited %s to %s.", nick, self.room)
322
323    def __eq__(self, other):
324        if not isinstance(other, IRCRoom):
325            log.warning(
326                "This is weird you are comparing an IRCRoom to a %s.", type(other)
327            )
328            return False
329        return self.room == other.room
330
331
332class IRCConnection(SingleServerIRCBot):
333    def __init__(
334        self,
335        bot,
336        nickname,
337        server,
338        port=6667,
339        ssl=False,
340        bind_address=None,
341        ipv6=False,
342        password=None,
343        username=None,
344        nickserv_password=None,
345        private_rate=1,
346        channel_rate=1,
347        reconnect_on_kick=5,
348        reconnect_on_disconnect=5,
349    ):
350        self.use_ssl = ssl
351        self.use_ipv6 = ipv6
352        self.bind_address = bind_address
353        self.bot = bot
354        # manually decorate functions
355        if private_rate:
356            self.send_private_message = rate_limited(private_rate)(
357                self.send_private_message
358            )
359
360        if channel_rate:
361            self.send_public_message = rate_limited(channel_rate)(
362                self.send_public_message
363            )
364        self._reconnect_on_kick = reconnect_on_kick
365        self._pending_transfers = {}
366        self._rooms_lock = threading.Lock()
367        self._rooms = {}
368        self._recently_joined_to = set()
369
370        self.nickserv_password = nickserv_password
371        if username is None:
372            username = nickname
373        self.transfers = {}
374        super().__init__(
375            [(server, port, password)],
376            nickname,
377            username,
378            reconnection_interval=reconnect_on_disconnect,
379        )
380
381    def connect(self, *args, **kwargs):
382        # Decode all input to UTF-8, but use a replacement character for
383        # unrecognized byte sequences
384        # (as described at https://pypi.python.org/pypi/irc)
385        self.connection.buffer_class.errors = "replace"
386
387        connection_factory_kwargs = {}
388        if self.use_ssl:
389            import ssl
390
391            connection_factory_kwargs["wrapper"] = ssl.wrap_socket
392        if self.bind_address is not None:
393            connection_factory_kwargs["bind_address"] = self.bind_address
394        if self.use_ipv6:
395            connection_factory_kwargs["ipv6"] = True
396
397        connection_factory = irc.connection.Factory(**connection_factory_kwargs)
398        self.connection.connect(*args, connect_factory=connection_factory, **kwargs)
399
400    def on_welcome(self, _, e):
401        log.info("IRC welcome %s", e)
402
403        # try to identify with NickServ if there is a NickServ password in the
404        # config
405        if self.nickserv_password:
406            msg = f"identify {self.nickserv_password}"
407            self.send_private_message("NickServ", msg)
408
409        # Must be done in a background thread, otherwise the join room
410        # from the ChatRoom plugin joining channels from CHATROOM_PRESENCE
411        # ends up blocking on connect.
412        t = threading.Thread(target=self.bot.connect_callback)
413        t.setDaemon(True)
414        t.start()
415
416    def _pubmsg(self, e, notice=False):
417        msg = Message(e.arguments[0], extras={"notice": notice})
418        room_name = e.target
419        if room_name[0] != "#" and room_name[0] != "$":
420            raise Exception(f"[{room_name}] is not a room")
421        room = IRCRoom(room_name, self.bot)
422        msg.frm = IRCRoomOccupant(e.source, room)
423        msg.to = room
424        msg.nick = msg.frm.nick  # FIXME find the real nick in the channel
425        self.bot.callback_message(msg)
426
427        possible_mentions = re.findall(IRC_NICK_REGEX, e.arguments[0])
428        room_users = self.channels[room_name].users()
429        mentions = filter(lambda x: x in room_users, possible_mentions)
430        if mentions:
431            mentions = [self.bot.build_identifier(mention) for mention in mentions]
432            self.bot.callback_mention(msg, mentions)
433
434    def _privmsg(self, e, notice=False):
435        msg = Message(e.arguments[0], extras={"notice": notice})
436        msg.frm = IRCPerson(e.source)
437        msg.to = IRCPerson(e.target)
438        self.bot.callback_message(msg)
439
440    def on_pubmsg(self, _, e):
441        self._pubmsg(e)
442
443    def on_privmsg(self, _, e):
444        self._privmsg(e)
445
446    def on_pubnotice(self, _, e):
447        self._pubmsg(e, True)
448
449    def on_privnotice(self, _, e):
450        self._privmsg(e, True)
451
452    def on_kick(self, _, e):
453        if not self._reconnect_on_kick:
454            log.info("RECONNECT_ON_KICK is 0 or None, won't try to reconnect")
455            return
456        log.info(
457            "Got kicked out of %s... reconnect in %d seconds... ",
458            e.target,
459            self._reconnect_on_kick,
460        )
461
462        def reconnect_channel(name):
463            log.info("Reconnecting to %s after having beeing kicked.", name)
464            self.bot.query_room(name).join()
465
466        t = threading.Timer(
467            self._reconnect_on_kick,
468            reconnect_channel,
469            [
470                e.target,
471            ],
472        )
473        t.daemon = True
474        t.start()
475
476    def send_private_message(self, to, line):
477        try:
478            self.connection.privmsg(to, line)
479        except ServerNotConnectedError:
480            pass  # the message will be lost
481
482    def send_public_message(self, to, line):
483        try:
484            self.connection.privmsg(to, line)
485        except ServerNotConnectedError:
486            pass  # the message will be lost
487
488    def on_disconnect(self, connection, event):
489        self._rooms = {}
490        self.bot.disconnect_callback()
491
492    def send_stream_request(
493        self, identifier, fsource, name=None, size=None, stream_type=None
494    ):
495        # Creates a new connection
496        dcc = self.dcc_listen("raw")
497        msg_parts = map(
498            str,
499            (
500                "SEND",
501                name,
502                irc.client.ip_quad_to_numstr(dcc.localaddress),
503                dcc.localport,
504                size,
505            ),
506        )
507        msg = subprocess.list2cmdline(msg_parts)
508        self.connection.ctcp("DCC", identifier.nick, msg)
509        stream = Stream(identifier, fsource, name, size, stream_type)
510        self.transfers[dcc] = stream
511
512        return stream
513
514    def on_dcc_connect(self, dcc, event):
515        stream = self.transfers.get(dcc, None)
516        if stream is None:
517            log.error("DCC connect on a none registered connection")
518            return
519        log.debug("Start transfer for %s.", stream.identifier)
520        stream.accept()
521        self.send_chunk(stream, dcc)
522
523    def on_dcc_disconnect(self, dcc, event):
524        self.transfers.pop(dcc)
525
526    def on_part(self, connection, event):
527        """
528        Handler of the part IRC Message/event.
529
530        The part message is sent to the client as a confirmation of a
531        /PART command sent by someone in the room/channel.
532        If the event.source contains the bot nickname then we need to fire
533        the :meth:`~errbot.backends.base.Backend.callback_room_left` event on the bot.
534
535        :param connection: Is an 'irc.client.ServerConnection' object
536
537        :param event: Is an 'irc.client.Event' object
538            The event.source contains the nickmask of the user that
539            leave the room
540            The event.target contains the channel name
541        """
542        leaving_nick = event.source.nick
543        leaving_room = event.target
544        if self.bot.bot_identifier.nick == leaving_nick:
545            with self._rooms_lock:
546                self.bot.callback_room_left(self._rooms[leaving_room])
547            log.info("Left room {}.", leaving_room)
548
549    def on_endofnames(self, connection, event):
550        """
551        Handler of the enfofnames IRC message/event.
552
553        The endofnames message is sent to the client when the server finish
554        to send the list of names of the room ocuppants.
555        This usually happens when you join to the room.
556        So in this case, we use this event to determine that our bot is
557        finally joined to the room.
558
559        :param connection: Is an 'irc.client.ServerConnection' object
560
561        :param event: Is an 'irc.client.Event' object
562            the event.arguments[0] contains the channel name
563        """
564        # The event.arguments[0] contains the channel name.
565        # We filter that to avoid a misfire of the event.
566        room_name = event.arguments[0]
567        with self._rooms_lock:
568            if room_name in self._recently_joined_to:
569                self._recently_joined_to.remove(room_name)
570                self.bot.callback_room_joined(self._rooms[room_name])
571
572    def on_join(self, connection, event):
573        """
574        Handler of the join IRC message/event.
575        Is in response of a /JOIN client message.
576
577        :param connection: Is an 'irc.client.ServerConnection' object
578
579        :param event: Is an 'irc.client.Event' object
580            the event.target contains the channel name
581        """
582        # We can't fire the room_joined event yet,
583        # because we don't have the occupants info.
584        # We need to wait to endofnames message.
585        room_name = event.target
586        with self._rooms_lock:
587            if room_name not in self._rooms:
588                self._rooms[room_name] = IRCRoom(room_name, self.bot)
589            self._recently_joined_to.add(room_name)
590
591    def on_currenttopic(self, connection, event):
592        """
593        When you Join a room with a topic set this event fires up to
594        with the topic information.
595        If the room that you join don't have a topic set, nothing happens.
596        Here is NOT the place to fire the :meth:`~errbot.backends.base.Backend.callback_room_topic` event for
597        that case exist on_topic.
598
599        :param connection: Is an 'irc.client.ServerConnection' object
600
601        :param event: Is an 'irc.client.Event' object
602            The event.arguments[0] contains the room name
603            The event.arguments[1] contains the topic of the room.
604        """
605        room_name, current_topic = event.arguments
606        with self._rooms_lock:
607            self._rooms[room_name].cb_set_topic(current_topic)
608
609    def on_topic(self, connection, event):
610        """
611        On response to the /TOPIC command if the room have a topic.
612        If the room don't have a topic the event fired is on_notopic
613        :param connection: Is an 'irc.client.ServerConnection' object
614
615        :param event: Is an 'irc.client.Event' object
616            The event.target contains the room name.
617            The event.arguments[0] contains the topic name
618        """
619        room_name = event.target
620        current_topic = event.arguments[0]
621        with self._rooms_lock:
622            self._rooms[room_name].cb_set_topic(current_topic)
623            self.bot.callback_room_topic(self._rooms[room_name])
624
625    def on_notopic(self, connection, event):
626        """
627        This event fires ip when there is no topic set on a room
628
629        :param connection: Is an 'irc.client.ServerConnection' object
630
631        :param event: Is an 'irc.client.Event' object
632            The event.arguments[0] contains the room name
633        """
634        room_name = event.arguments[0]
635        with self._rooms_lock:
636            self._rooms[room_name].cb_set_topic(None)
637            self.bot.callback_room_topic(self._rooms[room_name])
638
639    @staticmethod
640    def send_chunk(stream, dcc):
641        data = stream.read(4096)
642        dcc.send_bytes(data)
643        stream.ack_data(len(data))
644
645    def on_dccmsg(self, dcc, event):
646        stream = self.transfers.get(dcc, None)
647        if stream is None:
648            log.error("DCC connect on a none registered connection")
649            return
650        acked = struct.unpack("!I", event.arguments[0])[0]
651        if acked == stream.size:
652            log.info(
653                "File %s successfully transfered to %s", stream.name, stream.identifier
654            )
655            dcc.disconnect()
656            self.transfers.pop(dcc)
657        elif acked == stream.transfered:
658            log.debug(
659                "Chunk for file %s successfully transfered to %s (%d/%d).",
660                stream.name,
661                stream.identifier,
662                stream.transfered,
663                stream.size,
664            )
665            self.send_chunk(stream, dcc)
666        else:
667            log.debug(
668                "Partial chunk for file %s successfully transfered to %s (%d/%d), wait for more",
669                stream.name,
670                stream.identifier,
671                stream.transfered,
672                stream.size,
673            )
674
675    def away(self, message=""):
676        """
677        Extend the original implementation to support AWAY.
678        To set an away message, set message to something.
679        To cancel an away message, leave message at empty string.
680        """
681        self.connection.send_raw(" ".join(["AWAY", message]).strip())
682
683
684class IRCBackend(ErrBot):
685    aclpattern = "{nick}!{user}@{host}"
686
687    def __init__(self, config):
688        if hasattr(config, "IRC_ACL_PATTERN"):
689            IRCBackend.aclpattern = config.IRC_ACL_PATTERN
690
691        identity = config.BOT_IDENTITY
692        nickname = identity["nickname"]
693        server = identity["server"]
694        port = identity.get("port", 6667)
695        password = identity.get("password", None)
696        ssl = identity.get("ssl", False)
697        bind_address = identity.get("bind_address", None)
698        ipv6 = identity.get("ipv6", False)
699        username = identity.get("username", None)
700        nickserv_password = identity.get("nickserv_password", None)
701
702        compact = config.COMPACT_OUTPUT if hasattr(config, "COMPACT_OUTPUT") else True
703        enable_format("irc", IRC_CHRS, borders=not compact)
704
705        private_rate = getattr(config, "IRC_PRIVATE_RATE", 1)
706        channel_rate = getattr(config, "IRC_CHANNEL_RATE", 1)
707        reconnect_on_kick = getattr(config, "IRC_RECONNECT_ON_KICK", 5)
708        reconnect_on_disconnect = getattr(config, "IRC_RECONNECT_ON_DISCONNECT", 5)
709
710        self.bot_identifier = IRCPerson(nickname + "!" + nickname + "@" + server)
711        super().__init__(config)
712        self.conn = IRCConnection(
713            bot=self,
714            nickname=nickname,
715            server=server,
716            port=port,
717            ssl=ssl,
718            bind_address=bind_address,
719            ipv6=ipv6,
720            password=password,
721            username=username,
722            nickserv_password=nickserv_password,
723            private_rate=private_rate,
724            channel_rate=channel_rate,
725            reconnect_on_kick=reconnect_on_kick,
726            reconnect_on_disconnect=reconnect_on_disconnect,
727        )
728        self.md = irc_md()
729
730    def set_message_size_limit(self, limit=510, hard_limit=510):
731        """
732        IRC message size limit
733        """
734        super().set_message_size_limit(limit, hard_limit)
735
736    def send_message(self, msg):
737        super().send_message(msg)
738        if msg.is_direct:
739            msg_func = self.conn.send_private_message
740            msg_to = msg.to.person
741        else:
742            msg_func = self.conn.send_public_message
743            msg_to = msg.to.room
744
745        body = self.md.convert(msg.body)
746        for line in body.split("\n"):
747            msg_func(msg_to, line)
748
749    def change_presence(self, status: str = ONLINE, message: str = "") -> None:
750        if status == ONLINE:
751            self.conn.away()  # cancels the away message
752        else:
753            self.conn.away(f"[{status}] {message}")
754
755    def send_stream_request(
756        self, identifier, fsource, name=None, size=None, stream_type=None
757    ):
758        return self.conn.send_stream_request(
759            identifier, fsource, name, size, stream_type
760        )
761
762    def build_reply(self, msg, text=None, private=False, threaded=False):
763        response = self.build_message(text)
764        if msg.is_group:
765            if private:
766                response.frm = self.bot_identifier
767                response.to = IRCPerson(str(msg.frm))
768            else:
769                response.frm = IRCRoomOccupant(str(self.bot_identifier), msg.frm.room)
770                response.to = msg.frm.room
771        else:
772            response.frm = self.bot_identifier
773            response.to = msg.frm
774        return response
775
776    def serve_forever(self):
777        try:
778            self.conn.start()
779        except KeyboardInterrupt:
780            log.info("Interrupt received, shutting down")
781        finally:
782            self.conn.disconnect("Shutting down")
783            log.debug("Trigger disconnect callback")
784            self.disconnect_callback()
785            log.debug("Trigger shutdown")
786            self.shutdown()
787
788    def connect(self):
789        return self.conn
790
791    def build_message(self, text):
792        text = text.replace(
793            "", "*"
794        )  # there is a weird chr IRC is sending that we need to filter out
795        return super().build_message(text)
796
797    def build_identifier(self, txtrep):
798        log.debug("Build identifier from %s.", txtrep)
799        # A textual representation starting with # means that we are talking
800        # about an IRC channel -- IRCRoom in internal err-speak.
801        if txtrep.startswith("#"):
802            return IRCRoom(txtrep, self)
803
804        # Occupants are represented as 2 lines, one is the IRC mask and the second is the Room.
805        if "\n" in txtrep:
806            m, r = txtrep.split("\n")
807            return IRCRoomOccupant(m, IRCRoom(r, self))
808        return IRCPerson(txtrep)
809
810    def shutdown(self):
811        super().shutdown()
812
813    def query_room(self, room):
814        """
815        Query a room for information.
816
817        :param room:
818            The channel name to query for.
819        :returns:
820            An instance of :class:`~IRCMUCRoom`.
821        """
822        with self.conn._rooms_lock:
823            if room not in self.conn._rooms:
824                self.conn._rooms[room] = IRCRoom(room, self)
825            return self.conn._rooms[room]
826
827    @property
828    def mode(self):
829        return "irc"
830
831    def rooms(self):
832        """
833        Return a list of rooms the bot is currently in.
834
835        :returns:
836            A list of :class:`~IRCMUCRoom` instances.
837        """
838        with self.conn._rooms_lock:
839            return self.conn._rooms.values()
840
841    def prefix_groupchat_reply(self, message, identifier):
842        super().prefix_groupchat_reply(message, identifier)
843        message.body = f"{identifier.nick}: {message.body}"
844