agentirc 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentirc/__init__.py +9 -0
- agentirc/__main__.py +97 -0
- agentirc/api.py +369 -0
- agentirc/bot.py +437 -0
- agentirc/config.py +114 -0
- agentirc/history.py +202 -0
- agentirc/models.py +156 -0
- agentirc/tools.py +81 -0
- agentirc-1.0.0.dist-info/METADATA +115 -0
- agentirc-1.0.0.dist-info/RECORD +20 -0
- agentirc-1.0.0.dist-info/WHEEL +4 -0
- agentirc-1.0.0.dist-info/entry_points.txt +2 -0
- agentirc-1.0.0.dist-info/licenses/LICENSE +21 -0
- ircbot/__init__.py +15 -0
- ircbot/__main__.py +41 -0
- ircbot/bot.py +279 -0
- ircbot/commands.py +52 -0
- ircbot/config.py +64 -0
- ircbot/connection.py +133 -0
- ircbot/protocol.py +109 -0
ircbot/bot.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Core IRC bot with event dispatch and command registry.
|
|
2
|
+
|
|
3
|
+
This is the central piece: it ties the connection, protocol parser,
|
|
4
|
+
and command system together. Subclass it or register commands on an
|
|
5
|
+
instance -- both patterns work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import textwrap
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable, Awaitable
|
|
15
|
+
|
|
16
|
+
from . import protocol
|
|
17
|
+
from .config import BotConfig
|
|
18
|
+
from .connection import IRCConnection
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# -- Command handler types --
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class Command:
|
|
27
|
+
"""A registered bot command."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
handler: Callable[[IRCBot, protocol.IRCMessage, str], Awaitable[None]]
|
|
31
|
+
help: str
|
|
32
|
+
aliases: list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# The handler signature: async def handler(bot, message, args_str) -> None
|
|
36
|
+
|
|
37
|
+
class IRCBot:
|
|
38
|
+
"""Async IRC bot with a command registry and overridable event hooks.
|
|
39
|
+
|
|
40
|
+
Usage::
|
|
41
|
+
|
|
42
|
+
bot = IRCBot(config)
|
|
43
|
+
bot.command("ping", help="Check if the bot is alive")(ping_handler)
|
|
44
|
+
await bot.run()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: BotConfig) -> None:
|
|
48
|
+
self.config = config
|
|
49
|
+
self.nick = config.nick # mutable -- may change on collision
|
|
50
|
+
self.conn = IRCConnection(
|
|
51
|
+
host=config.host,
|
|
52
|
+
port=config.port,
|
|
53
|
+
use_tls=config.use_tls,
|
|
54
|
+
)
|
|
55
|
+
self._commands: dict[str, Command] = {}
|
|
56
|
+
self._alias_map: dict[str, str] = {} # alias -> canonical name
|
|
57
|
+
|
|
58
|
+
# -- Command registration --
|
|
59
|
+
|
|
60
|
+
def command(
|
|
61
|
+
self,
|
|
62
|
+
name: str,
|
|
63
|
+
*,
|
|
64
|
+
help: str = "",
|
|
65
|
+
aliases: list[str] | None = None,
|
|
66
|
+
) -> Callable:
|
|
67
|
+
"""Decorator to register a command.
|
|
68
|
+
|
|
69
|
+
The decorated function must have the signature::
|
|
70
|
+
|
|
71
|
+
async def handler(bot: IRCBot, msg: IRCMessage, args: str) -> None
|
|
72
|
+
|
|
73
|
+
``args`` is the text after the command name, stripped.
|
|
74
|
+
|
|
75
|
+
Example::
|
|
76
|
+
|
|
77
|
+
@bot.command("greet", help="Say hello", aliases=["hi"])
|
|
78
|
+
async def greet(bot, msg, args):
|
|
79
|
+
await bot.reply(msg, f"Hello, {msg.nick}!")
|
|
80
|
+
"""
|
|
81
|
+
aliases = aliases or []
|
|
82
|
+
|
|
83
|
+
def decorator(fn: Callable) -> Callable:
|
|
84
|
+
cmd = Command(name=name, handler=fn, help=help, aliases=aliases)
|
|
85
|
+
self._commands[name] = cmd
|
|
86
|
+
for alias in aliases:
|
|
87
|
+
self._alias_map[alias] = name
|
|
88
|
+
return fn
|
|
89
|
+
|
|
90
|
+
return decorator
|
|
91
|
+
|
|
92
|
+
def get_command(self, name: str) -> Command | None:
|
|
93
|
+
"""Look up a command by name or alias."""
|
|
94
|
+
if name in self._commands:
|
|
95
|
+
return self._commands[name]
|
|
96
|
+
canonical = self._alias_map.get(name)
|
|
97
|
+
if canonical:
|
|
98
|
+
return self._commands.get(canonical)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def commands(self) -> dict[str, Command]:
|
|
103
|
+
"""All registered commands (canonical names only)."""
|
|
104
|
+
return dict(self._commands)
|
|
105
|
+
|
|
106
|
+
# -- Convenience send helpers --
|
|
107
|
+
|
|
108
|
+
async def send(self, line: str) -> None:
|
|
109
|
+
"""Send a raw IRC line."""
|
|
110
|
+
await self.conn.send(line)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _chop(text: str, width: int = 420) -> list[str]:
|
|
114
|
+
"""Wrap text into lines under width, preserving word boundaries."""
|
|
115
|
+
result: list[str] = []
|
|
116
|
+
for line in text.splitlines():
|
|
117
|
+
if len(line) > width:
|
|
118
|
+
result.extend(textwrap.wrap(
|
|
119
|
+
line,
|
|
120
|
+
width=width,
|
|
121
|
+
drop_whitespace=False,
|
|
122
|
+
replace_whitespace=False,
|
|
123
|
+
fix_sentence_endings=True,
|
|
124
|
+
break_long_words=False,
|
|
125
|
+
))
|
|
126
|
+
else:
|
|
127
|
+
result.append(line)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
async def privmsg(self, target: str, text: str) -> None:
|
|
131
|
+
"""Send a PRIVMSG, chopping long lines at word boundaries."""
|
|
132
|
+
lines = self._chop(text)
|
|
133
|
+
for i, line in enumerate(lines):
|
|
134
|
+
await self.send(f"PRIVMSG {target} :{line}")
|
|
135
|
+
if i < len(lines) - 1:
|
|
136
|
+
await asyncio.sleep(1)
|
|
137
|
+
|
|
138
|
+
async def notice(self, target: str, text: str) -> None:
|
|
139
|
+
"""Send a NOTICE, chopping long lines at word boundaries."""
|
|
140
|
+
lines = self._chop(text)
|
|
141
|
+
for i, line in enumerate(lines):
|
|
142
|
+
await self.send(f"NOTICE {target} :{line}")
|
|
143
|
+
if i < len(lines) - 1:
|
|
144
|
+
await asyncio.sleep(1)
|
|
145
|
+
|
|
146
|
+
async def reply(self, msg: protocol.IRCMessage, text: str) -> None:
|
|
147
|
+
"""Reply in the appropriate context (channel or DM)."""
|
|
148
|
+
await self.privmsg(msg.reply_target, text)
|
|
149
|
+
|
|
150
|
+
async def join(self, channel: str) -> None:
|
|
151
|
+
await self.send(f"JOIN {channel}")
|
|
152
|
+
|
|
153
|
+
async def part(self, channel: str, reason: str = "Leaving") -> None:
|
|
154
|
+
await self.send(f"PART {channel} :{reason}")
|
|
155
|
+
|
|
156
|
+
async def quit(self, reason: str = "Bye") -> None:
|
|
157
|
+
await self.send(f"QUIT :{reason}")
|
|
158
|
+
|
|
159
|
+
# -- Connection lifecycle --
|
|
160
|
+
|
|
161
|
+
async def run(self) -> None:
|
|
162
|
+
"""Start the bot (blocks until cancelled)."""
|
|
163
|
+
log.info("Starting bot as %s", self.config.nick)
|
|
164
|
+
await self.conn.run_forever(
|
|
165
|
+
on_connect=self._on_connect,
|
|
166
|
+
on_line=self._on_line,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def _on_connect(self) -> None:
|
|
170
|
+
"""Send registration commands after TCP connect."""
|
|
171
|
+
if self.config.password:
|
|
172
|
+
await self.send(f"PASS {self.config.password}")
|
|
173
|
+
|
|
174
|
+
self.nick = self.config.nick # reset nick on reconnect
|
|
175
|
+
await self.send(f"NICK {self.nick}")
|
|
176
|
+
await self.send(f"USER {self.config.user} 0 * :{self.config.realname}")
|
|
177
|
+
|
|
178
|
+
# -- IRC line dispatch --
|
|
179
|
+
|
|
180
|
+
async def _on_line(self, raw: str) -> None:
|
|
181
|
+
"""Parse and dispatch a single IRC line."""
|
|
182
|
+
msg = protocol.parse(raw)
|
|
183
|
+
await self._dispatch(msg)
|
|
184
|
+
|
|
185
|
+
async def _dispatch(self, msg: protocol.IRCMessage) -> None:
|
|
186
|
+
"""Route a parsed message to the appropriate handler."""
|
|
187
|
+
|
|
188
|
+
if msg.command == "PING":
|
|
189
|
+
await self.send(f"PONG :{msg.text}")
|
|
190
|
+
|
|
191
|
+
elif msg.command == "001": # RPL_WELCOME
|
|
192
|
+
await self.on_welcome(msg)
|
|
193
|
+
|
|
194
|
+
elif msg.command == "PRIVMSG":
|
|
195
|
+
await self.on_privmsg(msg)
|
|
196
|
+
|
|
197
|
+
elif msg.command == "JOIN":
|
|
198
|
+
await self.on_join(msg)
|
|
199
|
+
|
|
200
|
+
elif msg.command == "PART":
|
|
201
|
+
await self.on_part(msg)
|
|
202
|
+
|
|
203
|
+
elif msg.command == "KICK":
|
|
204
|
+
await self.on_kick(msg)
|
|
205
|
+
|
|
206
|
+
elif msg.command in ("433", "432"):
|
|
207
|
+
# Nick in use or erroneous -- try with underscore suffix
|
|
208
|
+
self.nick += "_"
|
|
209
|
+
await self.send(f"NICK {self.nick}")
|
|
210
|
+
log.warning("Nick collision, trying %s", self.nick)
|
|
211
|
+
|
|
212
|
+
# Let subclasses handle arbitrary commands
|
|
213
|
+
handler = getattr(self, f"on_raw_{msg.command.lower()}", None)
|
|
214
|
+
if handler is not None:
|
|
215
|
+
await handler(msg)
|
|
216
|
+
|
|
217
|
+
# -- Event hooks (override in subclasses) --
|
|
218
|
+
|
|
219
|
+
async def on_welcome(self, msg: protocol.IRCMessage) -> None:
|
|
220
|
+
"""Called after successful registration (numeric 001)."""
|
|
221
|
+
log.info("Registered as %s", self.nick)
|
|
222
|
+
for channel in self.config.channels:
|
|
223
|
+
await self.join(channel)
|
|
224
|
+
|
|
225
|
+
async def on_privmsg(self, msg: protocol.IRCMessage) -> None:
|
|
226
|
+
"""Called on every PRIVMSG. Dispatches commands automatically."""
|
|
227
|
+
await self._try_command(msg)
|
|
228
|
+
|
|
229
|
+
async def on_join(self, msg: protocol.IRCMessage) -> None:
|
|
230
|
+
"""Called when someone (including the bot) joins a channel."""
|
|
231
|
+
log.debug("%s joined %s", msg.nick, msg.target)
|
|
232
|
+
|
|
233
|
+
async def on_part(self, msg: protocol.IRCMessage) -> None:
|
|
234
|
+
"""Called when someone parts a channel."""
|
|
235
|
+
reason = msg.text if len(msg.params) > 1 else ""
|
|
236
|
+
log.debug("%s left %s (%s)", msg.nick, msg.target, reason)
|
|
237
|
+
|
|
238
|
+
async def on_kick(self, msg: protocol.IRCMessage) -> None:
|
|
239
|
+
"""Called when someone is kicked. Auto-rejoins if it is the bot."""
|
|
240
|
+
kicked = msg.params[1] if len(msg.params) > 1 else ""
|
|
241
|
+
reason = msg.text if len(msg.params) > 2 else ""
|
|
242
|
+
log.debug("%s kicked from %s (%s)", kicked, msg.target, reason)
|
|
243
|
+
if kicked == self.nick:
|
|
244
|
+
await asyncio.sleep(5)
|
|
245
|
+
await self.join(msg.target)
|
|
246
|
+
|
|
247
|
+
# -- Command dispatch --
|
|
248
|
+
|
|
249
|
+
async def _try_command(self, msg: protocol.IRCMessage) -> None:
|
|
250
|
+
"""Check if a PRIVMSG is a bot command and dispatch it."""
|
|
251
|
+
text = msg.text.strip()
|
|
252
|
+
prefix = self.config.command_prefix
|
|
253
|
+
|
|
254
|
+
if not text.startswith(prefix):
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Split "!ping some args" -> ("ping", "some args")
|
|
258
|
+
without_prefix = text[len(prefix):]
|
|
259
|
+
parts = without_prefix.split(None, 1)
|
|
260
|
+
if not parts:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
cmd_name = parts[0].lower()
|
|
264
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
265
|
+
|
|
266
|
+
cmd = self.get_command(cmd_name)
|
|
267
|
+
if cmd is None:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if args:
|
|
271
|
+
log.info("command [%s] <%s> %s %s", msg.reply_target, msg.nick, cmd.name, args)
|
|
272
|
+
else:
|
|
273
|
+
log.info("command [%s] <%s> %s", msg.reply_target, msg.nick, cmd.name)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
await cmd.handler(self, msg, args)
|
|
277
|
+
except Exception:
|
|
278
|
+
log.exception("Error in command '%s'", cmd_name)
|
|
279
|
+
await self.reply(msg, f"Error running command '{cmd_name}'.")
|
ircbot/commands.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Built-in bot commands.
|
|
2
|
+
|
|
3
|
+
Each command is a plain async function with the signature:
|
|
4
|
+
async def handler(bot: IRCBot, msg: IRCMessage, args: str) -> None
|
|
5
|
+
|
|
6
|
+
Call register_builtins(bot) to wire them up.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .bot import IRCBot
|
|
16
|
+
from .protocol import IRCMessage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def cmd_ping(bot: IRCBot, msg: IRCMessage, _args: str) -> None:
|
|
20
|
+
"""Check if the bot is alive."""
|
|
21
|
+
await bot.reply(msg, f"{msg.nick}: pong!")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def cmd_time(bot: IRCBot, msg: IRCMessage, _args: str) -> None:
|
|
25
|
+
"""Show the current UTC time."""
|
|
26
|
+
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
|
27
|
+
await bot.reply(msg, f"UTC: {now}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def cmd_help(bot: IRCBot, msg: IRCMessage, args: str) -> None:
|
|
31
|
+
"""List available commands or show help for a specific command."""
|
|
32
|
+
prefix = bot.config.command_prefix
|
|
33
|
+
|
|
34
|
+
if args:
|
|
35
|
+
cmd = bot.get_command(args.lower())
|
|
36
|
+
if cmd:
|
|
37
|
+
alias_str = f" (aliases: {', '.join(cmd.aliases)})" if cmd.aliases else ""
|
|
38
|
+
await bot.reply(msg, f"{prefix}{cmd.name}{alias_str} -- {cmd.help or 'No description.'}")
|
|
39
|
+
else:
|
|
40
|
+
await bot.reply(msg, f"Unknown command: {args}")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
names = sorted(bot.commands.keys())
|
|
44
|
+
await bot.reply(msg, f"Commands: {', '.join(prefix + n for n in names)}")
|
|
45
|
+
await bot.reply(msg, f"Use {prefix}help <command> for details.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register_builtins(bot: IRCBot) -> None:
|
|
49
|
+
"""Register all built-in commands on a bot instance."""
|
|
50
|
+
bot.command("ping", help="Check if the bot is alive")(cmd_ping)
|
|
51
|
+
bot.command("time", help="Show current UTC time")(cmd_time)
|
|
52
|
+
bot.command("help", help="List commands or get help for one", aliases=["h"])(cmd_help)
|
ircbot/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Configuration loading from environment variables and .env files.
|
|
2
|
+
|
|
3
|
+
Provides a typed dataclass instead of a raw dict.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_env(path: str = ".env") -> None:
|
|
14
|
+
"""Load key=value pairs from a .env file into os.environ.
|
|
15
|
+
|
|
16
|
+
Uses setdefault so real environment variables always win.
|
|
17
|
+
Skips blank lines and comments (#).
|
|
18
|
+
Handles quoted values (single or double quotes).
|
|
19
|
+
"""
|
|
20
|
+
p = Path(path)
|
|
21
|
+
if not p.exists():
|
|
22
|
+
return
|
|
23
|
+
for line in p.read_text().splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
26
|
+
continue
|
|
27
|
+
key, _, value = line.partition("=")
|
|
28
|
+
key = key.strip()
|
|
29
|
+
value = value.strip()
|
|
30
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
31
|
+
value = value[1:-1]
|
|
32
|
+
os.environ.setdefault(key, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class BotConfig:
|
|
37
|
+
"""Typed, immutable bot configuration."""
|
|
38
|
+
|
|
39
|
+
host: str
|
|
40
|
+
port: int
|
|
41
|
+
nick: str
|
|
42
|
+
user: str
|
|
43
|
+
realname: str
|
|
44
|
+
channels: list[str] = field(default_factory=list)
|
|
45
|
+
password: str = ""
|
|
46
|
+
use_tls: bool = False
|
|
47
|
+
command_prefix: str = "!"
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_env(cls) -> BotConfig:
|
|
51
|
+
"""Build config from environment variables (IRC_HOST, IRC_PORT, etc.)."""
|
|
52
|
+
nick = os.environ["IRC_NICK"]
|
|
53
|
+
channels_raw = os.environ.get("IRC_CHANNELS", "")
|
|
54
|
+
return cls(
|
|
55
|
+
host=os.environ["IRC_HOST"],
|
|
56
|
+
port=int(os.environ.get("IRC_PORT", "6667")),
|
|
57
|
+
nick=nick,
|
|
58
|
+
user=os.environ.get("IRC_USER", nick),
|
|
59
|
+
realname=os.environ.get("IRC_REALNAME", "IRC Bot"),
|
|
60
|
+
channels=[c.strip() for c in channels_raw.split(",") if c.strip()],
|
|
61
|
+
password=os.environ.get("IRC_PASSWORD", ""),
|
|
62
|
+
use_tls=os.environ.get("IRC_USE_TLS", "false").lower() in ("1", "true", "yes"),
|
|
63
|
+
command_prefix=os.environ.get("IRC_CMD_PREFIX", "!"),
|
|
64
|
+
)
|
ircbot/connection.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Async IRC connection with automatic reconnect and exponential backoff.
|
|
2
|
+
|
|
3
|
+
Owns the TCP socket lifecycle. Knows nothing about IRC semantics beyond
|
|
4
|
+
sending/receiving raw lines.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import ssl
|
|
12
|
+
from collections.abc import AsyncGenerator
|
|
13
|
+
from typing import Callable, Awaitable
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Backoff parameters
|
|
18
|
+
_BACKOFF_BASE = 5
|
|
19
|
+
_BACKOFF_MAX = 300
|
|
20
|
+
_BACKOFF_MULTIPLIER = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IRCConnection:
|
|
24
|
+
"""Manages an async TCP connection to an IRC server."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
host: str,
|
|
29
|
+
port: int,
|
|
30
|
+
use_tls: bool = False,
|
|
31
|
+
encoding: str = "utf-8",
|
|
32
|
+
) -> None:
|
|
33
|
+
self.host = host
|
|
34
|
+
self.port = port
|
|
35
|
+
self.use_tls = use_tls
|
|
36
|
+
self.encoding = encoding
|
|
37
|
+
|
|
38
|
+
self._reader: asyncio.StreamReader | None = None
|
|
39
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
40
|
+
self._connected = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def connected(self) -> bool:
|
|
44
|
+
return self._connected
|
|
45
|
+
|
|
46
|
+
# -- connect / disconnect --
|
|
47
|
+
|
|
48
|
+
async def connect(self) -> None:
|
|
49
|
+
"""Open the TCP connection."""
|
|
50
|
+
ssl_ctx: ssl.SSLContext | None = None
|
|
51
|
+
if self.use_tls:
|
|
52
|
+
ssl_ctx = ssl.create_default_context()
|
|
53
|
+
|
|
54
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
55
|
+
self.host, self.port, ssl=ssl_ctx,
|
|
56
|
+
)
|
|
57
|
+
self._connected = True
|
|
58
|
+
log.info("Connected to %s:%d (tls=%s)", self.host, self.port, self.use_tls)
|
|
59
|
+
|
|
60
|
+
async def disconnect(self) -> None:
|
|
61
|
+
"""Close the TCP connection gracefully."""
|
|
62
|
+
self._connected = False
|
|
63
|
+
if self._writer is not None:
|
|
64
|
+
try:
|
|
65
|
+
self._writer.close()
|
|
66
|
+
await self._writer.wait_closed()
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
self._writer = None
|
|
70
|
+
self._reader = None
|
|
71
|
+
log.info("Disconnected")
|
|
72
|
+
|
|
73
|
+
# -- send / receive --
|
|
74
|
+
|
|
75
|
+
async def send(self, line: str) -> None:
|
|
76
|
+
"""Send a single IRC line (appends CRLF)."""
|
|
77
|
+
if self._writer is None:
|
|
78
|
+
log.warning("send() called while disconnected: %s", line)
|
|
79
|
+
return
|
|
80
|
+
data = (line + "\r\n").encode(self.encoding)
|
|
81
|
+
self._writer.write(data)
|
|
82
|
+
await self._writer.drain()
|
|
83
|
+
log.debug(">> %s", line)
|
|
84
|
+
|
|
85
|
+
async def read_lines(self) -> AsyncGenerator[str, None]:
|
|
86
|
+
"""Yield decoded IRC lines until the connection drops."""
|
|
87
|
+
if self._reader is None:
|
|
88
|
+
raise RuntimeError("read_lines() called before connect()")
|
|
89
|
+
while self._connected:
|
|
90
|
+
try:
|
|
91
|
+
raw = await self._reader.readline()
|
|
92
|
+
except (ConnectionResetError, asyncio.IncompleteReadError, OSError):
|
|
93
|
+
break
|
|
94
|
+
if not raw:
|
|
95
|
+
break
|
|
96
|
+
line = raw.decode(self.encoding, errors="replace").rstrip("\r\n")
|
|
97
|
+
log.debug("<< %s", line)
|
|
98
|
+
yield line
|
|
99
|
+
self._connected = False
|
|
100
|
+
|
|
101
|
+
# -- reconnect loop --
|
|
102
|
+
|
|
103
|
+
async def run_forever(
|
|
104
|
+
self,
|
|
105
|
+
on_connect: Callable[[], Awaitable[None]],
|
|
106
|
+
on_line: Callable[[str], Awaitable[None]],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Connect, read lines, and reconnect on failure with exponential backoff.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
on_connect: Called after each successful TCP connect (send NICK/USER here).
|
|
112
|
+
on_line: Called for every raw IRC line received.
|
|
113
|
+
"""
|
|
114
|
+
backoff = _BACKOFF_BASE
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
await self.connect()
|
|
119
|
+
backoff = _BACKOFF_BASE # reset on successful connect
|
|
120
|
+
await on_connect()
|
|
121
|
+
|
|
122
|
+
async for line in self.read_lines():
|
|
123
|
+
await on_line(line)
|
|
124
|
+
|
|
125
|
+
except (ConnectionResetError, OSError) as exc:
|
|
126
|
+
log.warning("Connection error: %s", exc)
|
|
127
|
+
|
|
128
|
+
finally:
|
|
129
|
+
await self.disconnect()
|
|
130
|
+
|
|
131
|
+
log.info("Reconnecting in %ds...", backoff)
|
|
132
|
+
await asyncio.sleep(backoff)
|
|
133
|
+
backoff = min(backoff * _BACKOFF_MULTIPLIER, _BACKOFF_MAX)
|
ircbot/protocol.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""IRC protocol message parsing.
|
|
2
|
+
|
|
3
|
+
Stateless functions that turn raw IRC lines into structured data.
|
|
4
|
+
Follows RFC 2812 message format: [:prefix] command params... [:trailing]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class IRCMessage:
|
|
14
|
+
"""A parsed IRC protocol message."""
|
|
15
|
+
|
|
16
|
+
raw: str
|
|
17
|
+
prefix: str
|
|
18
|
+
nick: str
|
|
19
|
+
user: str
|
|
20
|
+
host: str
|
|
21
|
+
command: str
|
|
22
|
+
params: list[str]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def target(self) -> str:
|
|
26
|
+
"""First param -- typically a channel or nick."""
|
|
27
|
+
return self.params[0] if self.params else ""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def text(self) -> str:
|
|
31
|
+
"""Trailing param (the human-readable body of PRIVMSG, NOTICE, etc.)."""
|
|
32
|
+
return self.params[-1] if self.params else ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_channel(self) -> bool:
|
|
36
|
+
"""Whether the target is a channel (starts with # or &)."""
|
|
37
|
+
return bool(self.target) and self.target[0] in "#&!+"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def reply_target(self) -> str:
|
|
41
|
+
"""Where to send a reply: the channel if public, the sender's nick if private."""
|
|
42
|
+
return self.target if self.is_channel else self.nick
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_prefix(prefix: str) -> tuple[str, str, str]:
|
|
46
|
+
"""Split a prefix into (nick, user, host).
|
|
47
|
+
|
|
48
|
+
Handles both 'nick!user@host' and plain server names.
|
|
49
|
+
"""
|
|
50
|
+
nick = prefix
|
|
51
|
+
user = ""
|
|
52
|
+
host = ""
|
|
53
|
+
|
|
54
|
+
if "!" in prefix:
|
|
55
|
+
nick, _, rest = prefix.partition("!")
|
|
56
|
+
user, _, host = rest.partition("@")
|
|
57
|
+
elif "@" in prefix:
|
|
58
|
+
nick, _, host = prefix.partition("@")
|
|
59
|
+
|
|
60
|
+
return nick, user, host
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse(line: str) -> IRCMessage:
|
|
64
|
+
"""Parse a raw IRC line into an IRCMessage.
|
|
65
|
+
|
|
66
|
+
Handles the full RFC 2812 format:
|
|
67
|
+
[:prefix SPACE] command [params] [:trailing]
|
|
68
|
+
"""
|
|
69
|
+
raw = line
|
|
70
|
+
prefix = ""
|
|
71
|
+
remaining = line
|
|
72
|
+
|
|
73
|
+
# Extract prefix
|
|
74
|
+
if remaining.startswith(":"):
|
|
75
|
+
prefix, _, remaining = remaining[1:].partition(" ")
|
|
76
|
+
|
|
77
|
+
# Extract command
|
|
78
|
+
if " " in remaining:
|
|
79
|
+
command, _, remaining = remaining.partition(" ")
|
|
80
|
+
else:
|
|
81
|
+
command = remaining
|
|
82
|
+
remaining = ""
|
|
83
|
+
|
|
84
|
+
command = command.upper()
|
|
85
|
+
|
|
86
|
+
# Extract params
|
|
87
|
+
params: list[str] = []
|
|
88
|
+
while remaining:
|
|
89
|
+
if remaining.startswith(":"):
|
|
90
|
+
params.append(remaining[1:])
|
|
91
|
+
break
|
|
92
|
+
if " " in remaining:
|
|
93
|
+
param, _, remaining = remaining.partition(" ")
|
|
94
|
+
params.append(param)
|
|
95
|
+
else:
|
|
96
|
+
params.append(remaining)
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
nick, user, host = parse_prefix(prefix)
|
|
100
|
+
|
|
101
|
+
return IRCMessage(
|
|
102
|
+
raw=raw,
|
|
103
|
+
prefix=prefix,
|
|
104
|
+
nick=nick,
|
|
105
|
+
user=user,
|
|
106
|
+
host=host,
|
|
107
|
+
command=command,
|
|
108
|
+
params=params,
|
|
109
|
+
)
|