Initial quotebot: stdlib IRC quotes bot for Libera #r.trees
This commit is contained in:
commit
841a152436
6 changed files with 530 additions and 0 deletions
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
|
|
@ -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`.
|
||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -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 <id>` | Quote by number |
|
||||
| `!addquote <text>` | Store a new quote, returns its id |
|
||||
| `!delquote <id>` | Delete a quote |
|
||||
| `!search <term>` | 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 <password> <email>`.
|
||||
2. Confirm via the emailed code.
|
||||
3. Put the password in `config.env` as `IRC_NICKSERV_PASS` and restart.
|
||||
344
bot.py
Normal file
344
bot.py
Normal file
|
|
@ -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 <text>")
|
||||
return
|
||||
bot.send_privmsg(reply_to, _fmt(row))
|
||||
|
||||
|
||||
def cmd_addquote(bot: IRCBot, reply_to: str, sender: str, arg: str):
|
||||
"""!addquote <text> — store a new quote."""
|
||||
if not arg:
|
||||
bot.send_privmsg(reply_to, "Usage: !addquote <text>")
|
||||
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 <id> — remove a quote."""
|
||||
if not arg.isdigit():
|
||||
bot.send_privmsg(reply_to, "Usage: !delquote <id>")
|
||||
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 <term> — first quote matching a substring."""
|
||||
if not arg:
|
||||
bot.send_privmsg(reply_to, "Usage: !search <term>")
|
||||
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 <text>, !delquote <id>, "
|
||||
"!search <term>, !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()
|
||||
27
config.example.env
Normal file
27
config.example.env
Normal file
|
|
@ -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
|
||||
38
memory.md
Normal file
38
memory.md
Normal file
|
|
@ -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: <https://libera.chat> — server `irc.libera.chat:6697` (TLS).
|
||||
- NickServ registration docs: <https://libera.chat/guides/registration>
|
||||
Loading…
Add table
Add a link
Reference in a new issue