Passed
Push — main ( da4e6c...a03b0b )
by Jochen
01:53
created

syslog2irc.irc.Bot._join_channels()   A

Complexity

Conditions 2

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 6
nop 2
dl 0
loc 8
rs 10
c 0
b 0
f 0
ccs 6
cts 6
cp 1
crap 2
1
"""
2
syslog2irc.irc
3
~~~~~~~~~~~~~~
4
5
Internet Relay Chat
6
7
:Copyright: 2007-2021 Jochen Kupperschmidt
8
:License: MIT, see LICENSE for details.
9
"""
10
11 1
from dataclasses import dataclass
12 1
import logging
13 1
from ssl import wrap_socket as ssl_wrap_socket
14 1
from typing import Any, List, Optional, Set, Union
15
16 1
from irc.bot import ServerSpec, SingleServerIRCBot
17 1
from irc.connection import Factory
18
19 1
from .signals import irc_channel_joined
20 1
from .util import start_thread
21
22
23 1
logger = logging.getLogger(__name__)
24
25
26 1
@dataclass(frozen=True)
27
class IrcServer:
28
    """An IRC server."""
29
30 1
    host: str
31 1
    port: int = 6667
32 1
    ssl: bool = False
33 1
    password: Optional[str] = None
34 1
    rate_limit: Optional[float] = None
35
36
37 1
@dataclass(frozen=True)
38
class IrcChannel:
39
    """An IRC channel with optional password."""
40
41 1
    name: str
42 1
    password: Optional[str] = None
43
44
45 1
@dataclass(frozen=True)
46
class IrcConfig:
47
    """An IRC bot configuration."""
48
49 1
    server: Optional[IrcServer]
50 1
    nickname: str
51 1
    realname: str
52 1
    commands: List[str]
53 1
    channels: Set[IrcChannel]
54
55
56 1
class Bot(SingleServerIRCBot):
57
    """An IRC bot to forward messages to IRC channels."""
58
59 1
    def __init__(
60
        self,
61
        server: IrcServer,
62
        nickname: str,
63
        realname: str,
64
        commands: List[str],
65
        channels: Set[IrcChannel],
66
    ) -> None:
67 1
        logger.info(
68
            'Connecting to IRC server %s:%d ...', server.host, server.port
69
        )
70
71 1
        server_spec = ServerSpec(server.host, server.port, server.password)
72 1
        factory = Factory(wrapper=ssl_wrap_socket) if server.ssl else Factory()
73 1
        SingleServerIRCBot.__init__(
74
            self, [server_spec], nickname, realname, connect_factory=factory
75
        )
76
77 1
        if server.rate_limit is not None:
78
            logger.info(
79
                'IRC send rate limit set to %.2f messages per second.',
80
                server.rate_limit,
81
            )
82
            self.connection.set_rate_limit(server.rate_limit)
83
        else:
84 1
            logger.info('No IRC send rate limit set.')
85
86 1
        self.commands = commands
87
88
        # Note: `self.channels` already exists in super class.
89 1
        self.channels_to_join = channels
90
91 1
    def start(self) -> None:
92
        """Connect to the server, in a separate thread."""
93
        start_thread(super().start, self.__class__.__name__)
94
95 1
    def get_version(self) -> str:
96
        """Return this on CTCP VERSION requests."""
97 1
        return 'syslog2IRC'
98
99 1
    def on_welcome(self, conn, event) -> None:
100
        """Join channels after connect."""
101 1
        logger.info(
102
            'Connected to IRC server %s:%d.', *conn.socket.getpeername()
103
        )
104
105 1
        self._send_custom_commands_after_welcome(conn)
106 1
        self._join_channels(conn)
107
108 1
    def _send_custom_commands_after_welcome(self, conn):
109
        """Send custom commands after having been welcomed by the server."""
110 1
        for command in self.commands:
111
            conn.send_raw(command)
112
113 1
    def _join_channels(self, conn):
114
        """Join the configured channels."""
115 1
        channels = _sort_channels_by_name(self.channels_to_join)
116 1
        logger.info('Channels to join: %s', ', '.join(c.name for c in channels))
117
118 1
        for channel in channels:
119 1
            logger.info('Joining channel %s ...', channel.name)
120 1
            conn.join(channel.name, channel.password or '')
121
122 1
    def on_nicknameinuse(self, conn, event) -> None:
123
        """Choose another nickname if conflicting."""
124
        self._nickname += '_'
125
        conn.nick(self._nickname)
126
127 1
    def on_join(self, conn, event) -> None:
128
        """Successfully joined channel."""
129 1
        joined_nick = event.source.nick
130 1
        channel_name = event.target
131
132 1
        if joined_nick == self._nickname:
133 1
            logger.info('Joined IRC channel: %s', channel_name)
134 1
            irc_channel_joined.send(channel_name=channel_name)
135
136 1
    def on_badchannelkey(self, conn, event) -> None:
137
        """Channel could not be joined due to wrong password."""
138
        channel_name = event.arguments[0]
139
        logger.info('Cannot join channel %s (bad key).', channel_name)
140
141 1
    def say(
142
        self,
143
        sender: Optional[Any],
144
        *,
145
        channel_name: Optional[str] = None,
146
        text: Optional[str] = None,
147
    ) -> None:
148
        """Say message on channel."""
149
        self.connection.privmsg(channel_name, text)
150
151
152 1
class DummyBot:
153
    """A fake bot that writes messages to STDOUT."""
154
155 1
    def __init__(self, channels: Set[IrcChannel]) -> None:
156 1
        self.channels = channels
157
158 1
    def start(self) -> None:
159
        # Fake channel joins.
160 1
        for channel in _sort_channels_by_name(self.channels):
161 1
            irc_channel_joined.send(channel_name=channel.name)
162
163 1
    def say(
164
        self,
165
        sender: Optional[Any],
166
        *,
167
        channel_name: Optional[str] = None,
168
        text: Optional[str] = None,
169
    ) -> None:
170
        logger.info('%s> %s', channel_name, text)
171
172 1
    def disconnect(self, msg: str) -> None:
173
        # Mimics `irc.bot.SingleServerIRCBot.disconnect`.
174 1
        logger.info('Shutting down bot ...')
175
176
177 1
def _sort_channels_by_name(channels: Set[IrcChannel]) -> List[IrcChannel]:
178 1
    return list(sorted(channels, key=lambda c: c.name))
179
180
181 1
def create_bot(config: IrcConfig) -> Union[Bot, DummyBot]:
182
    """Create and return an IRC bot according to the configuration."""
183 1
    if config.server is None:
184 1
        logger.info('No IRC server specified; will write to STDOUT instead.')
185 1
        return DummyBot(config.channels)
186
187 1
    return Bot(
188
        config.server,
189
        config.nickname,
190
        config.realname,
191
        config.commands,
192
        config.channels,
193
    )
194