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.
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
+ )