commit 841a152436aa53cf138c8875a06bbeabae89a510 Author: megaproxy Date: Thu Jun 4 17:13:56 2026 +0100 Initial quotebot: stdlib IRC quotes bot for Libera #r.trees diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f43a003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Secrets — never commit +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +secrets/ +credentials/ +.aws/ +.ssh/ + +# Dependencies / build artifacts +node_modules/ +__pycache__/ +*.py[cod] +.venv/ +venv/ +env/ +dist/ +build/ +target/ +*.egg-info/ + +# Editor / OS noise +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo + +# Logs / caches +*.log +.cache/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +coverage/ +.coverage +.nyc_output/ + +# quotebot local state +quotes.db +quotes.db-journal +config.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c7da365 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Project: quotebot + +A simple quotes IRC bot for Libera.Chat (`irc.libera.chat`), default channel +`#r.trees`. Standard-library Python 3 only — no third-party dependencies. +Quotes are stored in a local SQLite DB and managed with in-channel `!` commands. + +## Working agreement + +- This is a git repo with `origin` on Forgejo at `https://git.rdx4.com/megaproxy/quotebot.git` (private). HTTPS auth uses the token in `~/.git-credentials` — pushes are non-interactive. +- Commit after each logical change with a one-line imperative message; `git push` after each commit (or at minimum before ending the session). +- Read `memory.md` at session start. Update it before ending the session. +- Never commit secrets — see `.gitignore` and the rules in `~/claude/CLAUDE.md`. + +## Project-specific notes + +- **Runtime:** Python 3 stdlib only. Run with `python3 bot.py`. No venv, no pip. +- **Config:** environment variables (see `config.example.env`). Defaults target + Libera + `#r.trees`. Real secrets (`IRC_NICKSERV_PASS`) stay in `config.env`, + which is gitignored — never commit it. +- **Data:** `quotes.db` (SQLite) is created on first run and gitignored. +- **No tests yet.** Syntax check with `python3 -m py_compile bot.py`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..117cbdb --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# quotebot + +A simple quotes IRC bot for [Libera.Chat](https://libera.chat). Standard-library +Python only — no pip install, no virtualenv. Connects over TLS, joins a channel, +and serves quotes from a local SQLite database via `!` commands. + +## Run it + +```bash +python3 bot.py +``` + +That connects to `irc.libera.chat:6697` as `treesquotes` and joins `#r.trees`. + +To override anything, copy the example config and source it first: + +```bash +cp config.example.env config.env # config.env is gitignored +# edit config.env as needed +set -a; source config.env; set +a +python3 bot.py +``` + +## Commands (in-channel) + +| Command | Does | +|--------------------|-------------------------------------------| +| `!quote` | Random quote | +| `!quote ` | Quote by number | +| `!addquote ` | Store a new quote, returns its id | +| `!delquote ` | Delete a quote | +| `!search ` | First quote containing a substring | +| `!quotecount` | How many quotes are stored | +| `!help` | List commands | + +## Notes + +- Quotes live in `quotes.db` (SQLite, created on first run, gitignored). +- The bot auto-reconnects with exponential backoff and answers `PING`. +- If the nick is taken it appends `_` and retries. +- A NickServ password, if set via `IRC_NICKSERV_PASS`, is sent on connect — + keep it in the environment, never in the repo. +- Outbound messages are spaced by `QUOTEBOT_SEND_DELAY` seconds to avoid + tripping Libera's flood limits. + +## Registering the nick (recommended) + +Libera lets unregistered bots connect, but channels can be set to block +unregistered users. To register `treesquotes`: + +1. Connect once, then `/msg NickServ REGISTER `. +2. Confirm via the emailed code. +3. Put the password in `config.env` as `IRC_NICKSERV_PASS` and restart. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..20c3dd8 --- /dev/null +++ b/bot.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""quotebot — a simple quotes IRC bot for Libera.Chat. + +Standard library only. Connects over TLS, joins a channel, and serves +quotes stored in a local SQLite database. Quotes are added and recalled +with in-channel `!` commands. + +Configuration is read from environment variables (see config.example.env); +sensible defaults connect to Libera and join #r.trees. +""" + +from __future__ import annotations + +import logging +import os +import socket +import sqlite3 +import ssl +import sys +import time + +# -------------------------------------------------------------------------- +# Configuration (env-overridable; defaults target Libera + #r.trees) +# -------------------------------------------------------------------------- + +SERVER = os.environ.get("IRC_SERVER", "irc.libera.chat") +PORT = int(os.environ.get("IRC_PORT", "6697")) +USE_TLS = os.environ.get("IRC_TLS", "1") != "0" +NICK = os.environ.get("IRC_NICK", "treesquotes") +USERNAME = os.environ.get("IRC_USER", NICK) +REALNAME = os.environ.get("IRC_REALNAME", "quotebot") +CHANNEL = os.environ.get("IRC_CHANNEL", "#r.trees") +# Optional NickServ password — keep it in the environment, never in git. +NICKSERV_PASS = os.environ.get("IRC_NICKSERV_PASS", "") +DB_PATH = os.environ.get("QUOTEBOT_DB", os.path.join(os.path.dirname(__file__), "quotes.db")) +CMD_PREFIX = os.environ.get("QUOTEBOT_PREFIX", "!") + +# Seconds between outbound messages, to stay clear of flood limits. +SEND_DELAY = float(os.environ.get("QUOTEBOT_SEND_DELAY", "1.0")) +# Max characters of a quote echoed back (IRC line length is ~512 with overhead). +MAX_QUOTE_LEN = 400 + +logging.basicConfig( + level=os.environ.get("QUOTEBOT_LOGLEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s", +) +log = logging.getLogger("quotebot") + + +# -------------------------------------------------------------------------- +# Quote storage +# -------------------------------------------------------------------------- + + +class QuoteStore: + """Thin SQLite wrapper for the quotes table.""" + + def __init__(self, path: str): + self.db = sqlite3.connect(path) + self.db.execute( + """ + CREATE TABLE IF NOT EXISTS quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + added_by TEXT NOT NULL, + added_at INTEGER NOT NULL + ) + """ + ) + self.db.commit() + + def add(self, text: str, added_by: str) -> int: + cur = self.db.execute( + "INSERT INTO quotes (text, added_by, added_at) VALUES (?, ?, ?)", + (text, added_by, int(time.time())), + ) + self.db.commit() + return cur.lastrowid + + def get(self, quote_id: int): + row = self.db.execute( + "SELECT id, text FROM quotes WHERE id = ?", (quote_id,) + ).fetchone() + return row + + def random(self): + return self.db.execute( + "SELECT id, text FROM quotes ORDER BY RANDOM() LIMIT 1" + ).fetchone() + + def search(self, term: str): + return self.db.execute( + "SELECT id, text FROM quotes WHERE text LIKE ? ORDER BY id LIMIT 1", + (f"%{term}%",), + ).fetchone() + + def delete(self, quote_id: int) -> bool: + cur = self.db.execute("DELETE FROM quotes WHERE id = ?", (quote_id,)) + self.db.commit() + return cur.rowcount > 0 + + def count(self) -> int: + return self.db.execute("SELECT COUNT(*) FROM quotes").fetchone()[0] + + +# -------------------------------------------------------------------------- +# IRC client +# -------------------------------------------------------------------------- + + +class IRCBot: + def __init__(self, store: QuoteStore): + self.store = store + self.sock: socket.socket | None = None + self._recv_buf = b"" + + # --- low-level I/O ---------------------------------------------------- + + def connect(self): + log.info("Connecting to %s:%s (tls=%s)", SERVER, PORT, USE_TLS) + raw = socket.create_connection((SERVER, PORT), timeout=300) + if USE_TLS: + ctx = ssl.create_default_context() + self.sock = ctx.wrap_socket(raw, server_hostname=SERVER) + else: + self.sock = raw + + self._send_raw(f"NICK {NICK}") + self._send_raw(f"USER {USERNAME} 0 * :{REALNAME}") + + def _send_raw(self, line: str): + """Send a single protocol line (no trailing CRLF needed).""" + log.debug(">> %s", line) + self.sock.sendall((line + "\r\n").encode("utf-8", "replace")) + + def send_privmsg(self, target: str, text: str): + # Strip newlines so a quote can't inject extra IRC commands. + text = text.replace("\r", " ").replace("\n", " ") + if len(text) > MAX_QUOTE_LEN: + text = text[: MAX_QUOTE_LEN - 1] + "…" + self._send_raw(f"PRIVMSG {target} :{text}") + time.sleep(SEND_DELAY) + + def _read_lines(self): + """Yield complete protocol lines as they arrive.""" + while True: + data = self.sock.recv(4096) + if not data: + raise ConnectionError("server closed the connection") + self._recv_buf += data + while b"\r\n" in self._recv_buf: + line, self._recv_buf = self._recv_buf.split(b"\r\n", 1) + yield line.decode("utf-8", "replace") + + # --- main loop -------------------------------------------------------- + + def run_forever(self): + backoff = 5 + while True: + try: + self.connect() + self._event_loop() + except (OSError, ConnectionError) as exc: + log.warning("Disconnected: %s", exc) + finally: + if self.sock: + try: + self.sock.close() + except OSError: + pass + self.sock = None + log.info("Reconnecting in %ss", backoff) + time.sleep(backoff) + backoff = min(backoff * 2, 300) + + def _event_loop(self): + registered = False + for line in self._read_lines(): + log.debug("<< %s", line) + prefix, command, params = parse_message(line) + + if command == "PING": + self._send_raw(f"PONG :{params[-1] if params else ''}") + continue + + # 376 = end of MOTD, 422 = no MOTD; either means we can join. + if command in ("376", "422") and not registered: + registered = True + if NICKSERV_PASS: + self.send_privmsg("NickServ", f"IDENTIFY {NICKSERV_PASS}") + log.info("Joining %s", CHANNEL) + self._send_raw(f"JOIN {CHANNEL}") + continue + + # Nick already in use — try a variant so we still connect. + if command == "433": + global NICK + NICK = NICK + "_" + log.warning("Nick in use, retrying as %s", NICK) + self._send_raw(f"NICK {NICK}") + continue + + if command == "PRIVMSG": + self._handle_privmsg(prefix, params) + + # --- command handling ------------------------------------------------- + + def _handle_privmsg(self, prefix: str, params: list[str]): + if len(params) < 2: + return + target, message = params[0], params[1] + sender = prefix.split("!", 1)[0] if prefix else "" + + # Reply to the channel for channel messages, or to the user for PMs. + reply_to = target if target.startswith(("#", "&")) else sender + + if not message.startswith(CMD_PREFIX): + return + parts = message[len(CMD_PREFIX):].strip().split(" ", 1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + handler = COMMANDS.get(cmd) + if handler: + handler(self, reply_to, sender, arg) + + +# -------------------------------------------------------------------------- +# Commands +# -------------------------------------------------------------------------- + + +def _fmt(row) -> str: + return f"[#{row[0]}] {row[1]}" + + +def cmd_quote(bot: IRCBot, reply_to: str, sender: str, arg: str): + """!quote [id] — random quote, or a specific one by id.""" + if arg: + if not arg.isdigit(): + bot.send_privmsg(reply_to, "Usage: !quote [id]") + return + row = bot.store.get(int(arg)) + if not row: + bot.send_privmsg(reply_to, f"No quote #{arg}.") + return + else: + row = bot.store.random() + if not row: + bot.send_privmsg(reply_to, "No quotes yet — add one with !addquote ") + return + bot.send_privmsg(reply_to, _fmt(row)) + + +def cmd_addquote(bot: IRCBot, reply_to: str, sender: str, arg: str): + """!addquote — store a new quote.""" + if not arg: + bot.send_privmsg(reply_to, "Usage: !addquote ") + return + qid = bot.store.add(arg, sender) + bot.send_privmsg(reply_to, f"Added quote #{qid}.") + + +def cmd_delquote(bot: IRCBot, reply_to: str, sender: str, arg: str): + """!delquote — remove a quote.""" + if not arg.isdigit(): + bot.send_privmsg(reply_to, "Usage: !delquote ") + return + ok = bot.store.delete(int(arg)) + bot.send_privmsg(reply_to, f"Deleted quote #{arg}." if ok else f"No quote #{arg}.") + + +def cmd_search(bot: IRCBot, reply_to: str, sender: str, arg: str): + """!search — first quote matching a substring.""" + if not arg: + bot.send_privmsg(reply_to, "Usage: !search ") + return + row = bot.store.search(arg) + bot.send_privmsg(reply_to, _fmt(row) if row else f"No quote matching '{arg}'.") + + +def cmd_count(bot: IRCBot, reply_to: str, sender: str, arg: str): + """!quotecount — how many quotes are stored.""" + bot.send_privmsg(reply_to, f"{bot.store.count()} quotes stored.") + + +def cmd_help(bot: IRCBot, reply_to: str, sender: str, arg: str): + bot.send_privmsg( + reply_to, + "Commands: !quote [id], !addquote , !delquote , " + "!search , !quotecount, !help", + ) + + +COMMANDS = { + "quote": cmd_quote, + "addquote": cmd_addquote, + "delquote": cmd_delquote, + "search": cmd_search, + "quotecount": cmd_count, + "count": cmd_count, + "help": cmd_help, +} + + +# -------------------------------------------------------------------------- +# IRC message parsing +# -------------------------------------------------------------------------- + + +def parse_message(line: str): + """Split a raw IRC line into (prefix, command, params).""" + prefix = "" + if line.startswith(":"): + prefix, _, line = line[1:].partition(" ") + # The trailing parameter (after " :") may contain spaces. + if " :" in line: + head, _, trailing = line.partition(" :") + params = head.split() + params.append(trailing) + else: + params = line.split() + command = params.pop(0) if params else "" + return prefix, command, params + + +# -------------------------------------------------------------------------- +# Entry point +# -------------------------------------------------------------------------- + + +def main(): + store = QuoteStore(DB_PATH) + log.info("Quote DB: %s (%d quotes)", DB_PATH, store.count()) + bot = IRCBot(store) + try: + bot.run_forever() + except KeyboardInterrupt: + log.info("Shutting down.") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/config.example.env b/config.example.env new file mode 100644 index 0000000..42b8048 --- /dev/null +++ b/config.example.env @@ -0,0 +1,27 @@ +# quotebot configuration — copy to a local file and `source` it before running. +# All values are optional; defaults connect to Libera and join #r.trees. +# +# cp config.example.env config.env # config.env is gitignored +# set -a; source config.env; set +a +# python3 bot.py + +# --- Network --- +IRC_SERVER=irc.libera.chat +IRC_PORT=6697 +IRC_TLS=1 # set to 0 for a plaintext connection (port 6667) + +# --- Identity --- +IRC_NICK=treesquotes +IRC_USER=treesquotes +IRC_REALNAME=quotebot +IRC_CHANNEL=#r.trees + +# Optional: register the nick with NickServ on connect. +# Leave empty/unset if the nick is unregistered. NEVER commit a real password. +IRC_NICKSERV_PASS= + +# --- Bot behaviour --- +QUOTEBOT_DB=quotes.db +QUOTEBOT_PREFIX=! +QUOTEBOT_SEND_DELAY=1.0 # seconds between outbound lines (flood protection) +QUOTEBOT_LOGLEVEL=INFO # DEBUG to see raw IRC traffic diff --git a/memory.md b/memory.md new file mode 100644 index 0000000..630d897 --- /dev/null +++ b/memory.md @@ -0,0 +1,38 @@ +# memory — quotebot + +Durable memory for this project. Read at session start, update before session end. Date format: `YYYY-MM-DD`. + +## Decisions & rationale + +- **Python stdlib only, raw-socket IRC.** User asked for a *simple* bot; no + third-party libs means it runs anywhere with `python3 bot.py`, no venv/pip. + TLS via stdlib `ssl` on Libera's port 6697. +- **SQLite for quotes** (`quotes.db`), managed entirely with in-channel `!` + commands (`!addquote`/`!quote`/`!delquote`/`!search`/`!quotecount`). Chosen + over a JSON file for safe concurrent-ish access and easy querying. +- **Config via env vars** with Libera + `#r.trees` defaults, so the common case + needs zero config. Secrets (NickServ pass) live in gitignored `config.env`. +- **Resilience:** auto-reconnect with exponential backoff, PING/PONG, nick-in-use + fallback (append `_`). Outbound send delay to dodge flood limits. + +## Open questions / TODOs + +- [ ] Live-test against Libera (join `#r.trees`, exercise commands). Not yet run. +- [ ] Decide whether `!delquote` should be restricted to ops/admins (currently + anyone can delete). Fine for a trusted channel; revisit if abused. +- [ ] Consider registering the `treesquotes` nick with NickServ if the channel + blocks unregistered users. + +## Session log + +### 2026-06-04 +- Created project scaffold from template. +- Wrote `bot.py`: stdlib IRC client (TLS, PING/PONG, reconnect, nick fallback), + `QuoteStore` SQLite wrapper, and `!` command handlers. +- Added `README.md`, `config.example.env`; gitignored `quotes.db`/`config.env`. +- Syntax-checked with `py_compile` (Python 3.12.3). Not yet live-tested on IRC. + +## External references + +- Libera.Chat: — server `irc.libera.chat:6697` (TLS). +- NickServ registration docs: