Completed
Push — main ( 41617a...281afb )
by Jochen
01:36
created

weitersager.irc.create_bot()   A

Complexity

Conditions 2

Size

Total Lines 14
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 4.3145

Importance

Changes 0
Metric Value
cc 2
eloc 11
nop 2
dl 0
loc 14
ccs 1
cts 6
cp 0.1666
crap 4.3145
rs 9.85
c 0
b 0
f 0
1
"""
2
weitersager.irc
3
~~~~~~~~~~~~~~~
4
5
Internet Relay Chat
6
7
:Copyright: 2007-2020 Jochen Kupperschmidt
8
:License: MIT, see LICENSE for details.
9
"""
10
11 1
from dataclasses import dataclass
12 1
from typing import List, Optional
13
14 1
from irc.bot import ServerSpec, SingleServerIRCBot
15 1
from jaraco.stream.buffer import LenientDecodingLineBuffer
16
17 1
from .signals import channel_joined, shutdown_requested
18 1
from .util import log, start_thread
19
20
21 1
@dataclass(frozen=True)
22
class Server:
23
    """An IRC server."""
24 1
    host: str
25 1
    port: int
26 1
    password: Optional[str] = None
27 1
    rate_limit: Optional[float] = None
28
29
30 1
@dataclass(frozen=True)
31
class Channel:
32
    """An IRC channel with optional password."""
33 1
    name: str
34 1
    password: Optional[str] = None
35
36
37 1
@dataclass(frozen=True)
38
class Config:
39 1
    server: Optional[Server]
40 1
    nickname: str
41 1
    realname: str
42 1
    channels: List[Channel]
43
44
45 1
class Bot(SingleServerIRCBot):
46
    """An IRC bot to forward messages to IRC channels."""
47
48 1
    def __init__(
49
        self,
50
        server,
51
        nickname,
52
        realname,
53
        channels,
54
        *,
55
        shutdown_predicate=None,
56
    ):
57
        log('Connecting to IRC server {0.host}:{0.port:d} ...', server)
58
59
        server_spec = ServerSpec(server.host, server.port, server.password)
60
        SingleServerIRCBot.__init__(self, [server_spec], nickname, realname)
61
62
        if server.rate_limit is not None:
63
            self.connection.set_rate_limit(server.rate_limit)
64
65
        # Avoid `UnicodeDecodeError` on non-UTF-8 messages.
66
        self.connection.buffer_class = LenientDecodingLineBuffer
67
68
        # Note: `self.channels` already exists in super class.
69
        self.channels_to_join = channels
70
71
        self.shutdown_predicate = shutdown_predicate
72
73 1
    def start(self):
74
        """Connect to the server, in a separate thread."""
75
        start_thread(super().start, self.__class__.__name__)
76
77 1
    def get_version(self):
78
        return 'Weitersager'
79
80 1
    def on_welcome(self, conn, event):
81
        """Join channels after connect."""
82
        log('Connected to {}:{:d}.', *conn.socket.getpeername())
83
84
        channel_names = sorted(c.name for c in self.channels_to_join)
85
        log('Channels to join: {}', ', '.join(channel_names))
86
87
        for channel in self.channels_to_join:
88
            log('Joining channel {} ...', channel.name)
89
            conn.join(channel.name, channel.password or '')
90
91 1
    def on_nicknameinuse(self, conn, event):
92
        """Choose another nickname if conflicting."""
93
        self._nickname += '_'
94
        conn.nick(self._nickname)
95
96 1
    def on_join(self, conn, event):
97
        """Successfully joined channel."""
98
        joined_nick = event.source.nick
99
        channel_name = event.target
100
101
        if joined_nick == self._nickname:
102
            log('Joined IRC channel: {}', channel_name)
103
            channel_joined.send(channel_name=channel_name)
104
105 1
    def on_badchannelkey(self, conn, event):
106
        """Channel could not be joined due to wrong password."""
107
        channel = event.arguments[0]
108
        log('Cannot join channel {} (bad key).', channel)
109
110 1
    def on_privmsg(self, conn, event):
111
        """React on private messages."""
112
        nickmask = event.source
113
        text = event.arguments[0]
114
        if self.shutdown_predicate and self.shutdown_predicate(nickmask, text):
115
            self.shutdown(nickmask)
116
117 1
    def shutdown(self, nickmask):
118
        """Shut the bot down."""
119
        log('Shutdown requested by {}.', nickmask)
120
        shutdown_requested.send()
121
        self.die('Shutting down.')  # Joins IRC bot thread.
122
123 1
    def say(self, sender, *, channel_name=None, text=None):
124
        """Say message on channel."""
125
        self.connection.privmsg(channel_name, text)
126
127
128 1
class DummyBot:
129
130 1
    def __init__(self, server, nickname, realname, channels, **options):
131
        self.channels = channels
132
133 1
    def start(self):
134
        # Fake channel joins.
135
        for channel in self.channels:
136
            channel_joined.send(channel_name=channel.name)
137
138 1
    def say(self, sender, *, channel_name=None, text=None):
139
        log('{}> {}', channel_name, text)
140
141
142 1
def create_bot(config, **options):
143
    """Create and return an IRC bot according to the configuration."""
144
    if config.server:
145
        bot_class = Bot
146
    else:
147
        log('No IRC server specified; will write to STDOUT instead.')
148
        bot_class = DummyBot
149
150
    return bot_class(
151
        config.server,
152
        config.nickname,
153
        config.realname,
154
        config.channels,
155
        **options,
156
    )
157
158
159 1
def default_shutdown_predicate(nickmask, text):
160
    """Determine if this is a valid shutdown request."""
161
    return text == 'shutdown!'
162