Revert "feat: add --llm mode for LLM-friendly stdout filtering"
This reverts commit ea6f07914e.
This commit is contained in:
21
TASKS.md
21
TASKS.md
@@ -1,25 +1,6 @@
|
|||||||
# derp - Tasks
|
# derp - Tasks
|
||||||
|
|
||||||
## Current Sprint -- v1.2.9 LLM Mode (2026-02-19)
|
## Current Sprint -- v1.2.7 Subscription Plugin Enrichment (2026-02-19)
|
||||||
|
|
||||||
| Pri | Status | Task |
|
|
||||||
|-----|--------|------|
|
|
||||||
| P0 | [x] | `--llm` CLI flag: route logging to `info.log`, stdout for addressed messages |
|
|
||||||
| P0 | [x] | `_is_addressed()` method: DMs + nick-prefixed |
|
|
||||||
| P1 | [x] | Stdout routing: PRIVMSG in/out, PING, 001, disconnect, reconnect |
|
|
||||||
| P2 | [x] | Documentation update (USAGE.md CLI flags + LLM mode section) |
|
|
||||||
|
|
||||||
## Previous Sprint -- v1.2.8 ASN Backend Replacement (2026-02-19)
|
|
||||||
|
|
||||||
| Pri | Status | Task |
|
|
||||||
|-----|--------|------|
|
|
||||||
| P0 | [x] | Replace MaxMind ASN with iptoasn.com TSV backend (no license key) |
|
|
||||||
| P0 | [x] | Bisect-based lookup in `plugins/asn.py` (pure stdlib) |
|
|
||||||
| P1 | [x] | `update_asn()` in `scripts/update-data.sh` (SOCKS5 download) |
|
|
||||||
| P2 | [x] | Tests: load, lookup, command handler (30 cases, 906 total) |
|
|
||||||
| P2 | [x] | Documentation update (USAGE.md data directory layout) |
|
|
||||||
|
|
||||||
## Previous Sprint -- v1.2.7 Subscription Plugin Enrichment (2026-02-19)
|
|
||||||
|
|
||||||
| Pri | Status | Task |
|
| Pri | Status | Task |
|
||||||
|-----|--------|------|
|
|-----|--------|------|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ derp --config /path/to/derp.toml --verbose
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `-c, --config PATH` | Config file path |
|
| `-c, --config PATH` | Config file path |
|
||||||
| `-v, --verbose` | Debug logging |
|
| `-v, --verbose` | Debug logging |
|
||||||
| `--llm` | LLM mode: addressed messages to stdout, rest to info.log |
|
|
||||||
| `--cprofile [PATH]` | Enable cProfile, dump to PATH [derp.prof] |
|
| `--cprofile [PATH]` | Enable cProfile, dump to PATH [derp.prof] |
|
||||||
| `--tracemalloc [N]` | Enable tracemalloc, capture N frames deep [10] |
|
| `--tracemalloc [N]` | Enable tracemalloc, capture N frames deep [10] |
|
||||||
| `-V, --version` | Print version |
|
| `-V, --version` | Print version |
|
||||||
@@ -52,7 +51,6 @@ plugins_dir = "plugins" # Plugin directory path
|
|||||||
rate_limit = 2.0 # Max messages per second (default: 2.0)
|
rate_limit = 2.0 # Max messages per second (default: 2.0)
|
||||||
rate_burst = 5 # Burst capacity (default: 5)
|
rate_burst = 5 # Burst capacity (default: 5)
|
||||||
paste_threshold = 4 # Max lines before overflow to FlaskPaste (default: 4)
|
paste_threshold = 4 # Max lines before overflow to FlaskPaste (default: 4)
|
||||||
owner = [] # Owner hostmask patterns (fnmatch), grants admin + LLM access
|
|
||||||
admins = [] # Hostmask patterns (fnmatch), IRCOPs auto-detected
|
admins = [] # Hostmask patterns (fnmatch), IRCOPs auto-detected
|
||||||
timezone = "UTC" # Timezone for calendar reminders (IANA tz name)
|
timezone = "UTC" # Timezone for calendar reminders (IANA tz name)
|
||||||
|
|
||||||
@@ -218,20 +216,14 @@ Default format is `"text"` (human-readable, same as before).
|
|||||||
|
|
||||||
## Admin System
|
## Admin System
|
||||||
|
|
||||||
Commands marked as `admin` require elevated permissions. There are two
|
Commands marked as `admin` require elevated permissions. Admin access is
|
||||||
privilege levels:
|
granted via:
|
||||||
|
|
||||||
| Level | Source | Grants |
|
1. **IRC operator status** -- detected automatically via `WHO`
|
||||||
|-------|--------|--------|
|
2. **Hostmask patterns** -- configured in `[bot] admins`, fnmatch-style
|
||||||
| **Owner** | `[bot] owner` hostmask patterns | Admin + LLM mode access |
|
|
||||||
| **Admin** | `[bot] admins` patterns, IRC operators | Admin commands only |
|
|
||||||
|
|
||||||
Owner is a superset of admin -- owners automatically have admin privileges.
|
|
||||||
Only owners can interact with the bot via `--llm` mode.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[bot]
|
[bot]
|
||||||
owner = ["me!~user@my.host"]
|
|
||||||
admins = [
|
admins = [
|
||||||
"*!~user@trusted.host",
|
"*!~user@trusted.host",
|
||||||
"ops!*@*.ops.net",
|
"ops!*@*.ops.net",
|
||||||
@@ -376,7 +368,7 @@ The script is cron-friendly (exit 0/1, quiet unless `NO_COLOR` is unset).
|
|||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
GeoLite2-City.mmdb # MaxMind GeoIP (requires license key)
|
GeoLite2-City.mmdb # MaxMind GeoIP (requires license key)
|
||||||
ip2asn-v4.tsv # iptoasn.com ASN database (no key required)
|
GeoLite2-ASN.mmdb # MaxMind ASN (requires license key)
|
||||||
tor-exit-nodes.txt # Tor exit node IPs
|
tor-exit-nodes.txt # Tor exit node IPs
|
||||||
iprep/ # Firehol/ET blocklist feeds
|
iprep/ # Firehol/ET blocklist feeds
|
||||||
firehol_level1.netset
|
firehol_level1.netset
|
||||||
@@ -388,8 +380,7 @@ data/
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
The ASN database is downloaded from iptoasn.com (no account required).
|
GeoLite2 databases require a free MaxMind license key. Set
|
||||||
GeoLite2-City requires a free MaxMind license key -- set
|
|
||||||
`MAXMIND_LICENSE_KEY` when running the update script.
|
`MAXMIND_LICENSE_KEY` when running the update script.
|
||||||
|
|
||||||
## Plugin Management
|
## Plugin Management
|
||||||
@@ -497,39 +488,6 @@ On connection loss, the bot reconnects with exponential backoff and jitter:
|
|||||||
- Jitter: +/- 25% to avoid thundering herd
|
- Jitter: +/- 25% to avoid thundering herd
|
||||||
- Resets to 5s after a successful connection
|
- Resets to 5s after a successful connection
|
||||||
|
|
||||||
## LLM Mode
|
|
||||||
|
|
||||||
Run with `--llm` to split output for LLM consumption. All internal logging
|
|
||||||
(connection status, plugin loads, unaddressed chatter) goes to `info.log`.
|
|
||||||
Stdout receives only messages addressed to the bot and the bot's own replies.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
derp --llm
|
|
||||||
derp --llm --config config/derp.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
### What goes to stdout
|
|
||||||
|
|
||||||
- **DMs**: private messages from an owner
|
|
||||||
- **Nick-prefixed**: channel messages from an owner starting with `<botnick>:` or `<botnick>,`
|
|
||||||
- **Bot replies**: all messages sent by the bot (PRIVMSG and ACTION)
|
|
||||||
- **Status lines**: connection, ping, disconnect, reconnect events
|
|
||||||
|
|
||||||
### Output format
|
|
||||||
|
|
||||||
```
|
|
||||||
19:09 --- connected as derp
|
|
||||||
19:09 --- ping
|
|
||||||
19:09 #test <alice> derp: what is 1.1.1.1?
|
|
||||||
19:09 #test <derp> 1.1.1.1: AS13335 CLOUDFLARENET (US)
|
|
||||||
19:09 <bob> hey derp, check this out
|
|
||||||
19:09 <derp> I'm just a bot
|
|
||||||
19:09 #test * derp [rss/hackernews] New article -- URL
|
|
||||||
19:15 --- disconnected: Connection reset
|
|
||||||
19:15 --- reconnecting in 5s
|
|
||||||
19:15 --- connected as derp
|
|
||||||
```
|
|
||||||
|
|
||||||
### `!dork` -- Google Dork Query Builder
|
### `!dork` -- Google Dork Query Builder
|
||||||
|
|
||||||
Generate Google dork queries for a target domain. Template-based, no HTTP
|
Generate Google dork queries for a target domain. Template-based, no HTTP
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import base64
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -78,10 +77,9 @@ class _TokenBucket:
|
|||||||
class Bot:
|
class Bot:
|
||||||
"""IRC bot: ties connection, config, and plugins together."""
|
"""IRC bot: ties connection, config, and plugins together."""
|
||||||
|
|
||||||
def __init__(self, config: dict, registry: PluginRegistry, *, llm: bool = False) -> None:
|
def __init__(self, config: dict, registry: PluginRegistry) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
self._llm = llm
|
|
||||||
self.conn = IRCConnection(
|
self.conn = IRCConnection(
|
||||||
host=config["server"]["host"],
|
host=config["server"]["host"],
|
||||||
port=config["server"]["port"],
|
port=config["server"]["port"],
|
||||||
@@ -94,7 +92,6 @@ class Bot:
|
|||||||
self._started: float = time.monotonic()
|
self._started: float = time.monotonic()
|
||||||
self._tasks: set[asyncio.Task] = set()
|
self._tasks: set[asyncio.Task] = set()
|
||||||
self._reconnect_delay: float = 5.0
|
self._reconnect_delay: float = 5.0
|
||||||
self._owner: list[str] = config.get("bot", {}).get("owner", [])
|
|
||||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
self._opers: set[str] = set() # hostmasks of known IRC operators
|
||||||
self._caps: set[str] = set() # negotiated IRCv3 caps
|
self._caps: set[str] = set() # negotiated IRCv3 caps
|
||||||
@@ -120,18 +117,10 @@ class Bot:
|
|||||||
self._reconnect_delay = 5.0 # reset on clean run
|
self._reconnect_delay = 5.0 # reset on clean run
|
||||||
except (OSError, ConnectionError) as exc:
|
except (OSError, ConnectionError) as exc:
|
||||||
log.error("connection lost: %s", exc)
|
log.error("connection lost: %s", exc)
|
||||||
if self._llm:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
sys.stdout.write(f"{ts} --- disconnected: {exc}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
if self._running:
|
if self._running:
|
||||||
jitter = self._reconnect_delay * 0.25 * (2 * random.random() - 1)
|
jitter = self._reconnect_delay * 0.25 * (2 * random.random() - 1)
|
||||||
delay = self._reconnect_delay + jitter
|
delay = self._reconnect_delay + jitter
|
||||||
log.info("reconnecting in %.0fs...", delay)
|
log.info("reconnecting in %.0fs...", delay)
|
||||||
if self._llm:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
sys.stdout.write(f"{ts} --- reconnecting in {delay:.0f}s\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
self._reconnect_delay = min(self._reconnect_delay * 2, 300.0)
|
self._reconnect_delay = min(self._reconnect_delay * 2, 300.0)
|
||||||
|
|
||||||
@@ -270,19 +259,11 @@ class Bot:
|
|||||||
# Protocol-level PING/PONG
|
# Protocol-level PING/PONG
|
||||||
if msg.command == "PING":
|
if msg.command == "PING":
|
||||||
await self.conn.send(format_msg("PONG", msg.params[0] if msg.params else ""))
|
await self.conn.send(format_msg("PONG", msg.params[0] if msg.params else ""))
|
||||||
if self._llm:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
sys.stdout.write(f"{ts} --- ping\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# RPL_WELCOME (001) — join channels and WHO for oper detection
|
# RPL_WELCOME (001) — join channels and WHO for oper detection
|
||||||
if msg.command == "001":
|
if msg.command == "001":
|
||||||
self.nick = msg.params[0] if msg.params else self.nick
|
self.nick = msg.params[0] if msg.params else self.nick
|
||||||
if self._llm:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
sys.stdout.write(f"{ts} --- connected as {self.nick}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
for channel in self.config["bot"]["channels"]:
|
for channel in self.config["bot"]["channels"]:
|
||||||
await self.join(channel)
|
await self.join(channel)
|
||||||
await self.conn.send(format_msg("WHO", channel))
|
await self.conn.send(format_msg("WHO", channel))
|
||||||
@@ -319,16 +300,6 @@ class Bot:
|
|||||||
self._spawn(self._handle_ctcp(msg), name="ctcp")
|
self._spawn(self._handle_ctcp(msg), name="ctcp")
|
||||||
return
|
return
|
||||||
|
|
||||||
# LLM mode: route PRIVMSG to stdout or info.log
|
|
||||||
if self._llm and msg.command == "PRIVMSG" and msg.text:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
if self._is_addressed(msg) and self._is_owner(msg):
|
|
||||||
prefix = f"{msg.target} " if msg.is_channel else ""
|
|
||||||
sys.stdout.write(f"{ts} {prefix}<{msg.nick}> {msg.text}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
else:
|
|
||||||
log.info("%s <%s> %s", msg.target, msg.nick, msg.text)
|
|
||||||
|
|
||||||
# Dispatch to event handlers (fire-and-forget)
|
# Dispatch to event handlers (fire-and-forget)
|
||||||
channel = msg.target if msg.is_channel else None
|
channel = msg.target if msg.is_channel else None
|
||||||
event_type = msg.command
|
event_type = msg.command
|
||||||
@@ -388,25 +359,14 @@ class Bot:
|
|||||||
return True
|
return True
|
||||||
return plugin_name in allowed
|
return plugin_name in allowed
|
||||||
|
|
||||||
def _is_owner(self, msg: Message) -> bool:
|
|
||||||
"""Check if the sender matches a configured owner hostmask pattern."""
|
|
||||||
if not msg.prefix:
|
|
||||||
return False
|
|
||||||
for pattern in self._owner:
|
|
||||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_admin(self, msg: Message) -> bool:
|
def _is_admin(self, msg: Message) -> bool:
|
||||||
"""Check if the message sender is a bot admin.
|
"""Check if the message sender is a bot admin.
|
||||||
|
|
||||||
Returns True if the sender is an owner, a known IRC operator,
|
Returns True if the sender is a known IRC operator or matches
|
||||||
or matches a configured admin hostmask pattern (fnmatch-style).
|
a configured hostmask pattern (fnmatch-style).
|
||||||
"""
|
"""
|
||||||
if not msg.prefix:
|
if not msg.prefix:
|
||||||
return False
|
return False
|
||||||
if self._is_owner(msg):
|
|
||||||
return True
|
|
||||||
if msg.prefix in self._opers:
|
if msg.prefix in self._opers:
|
||||||
return True
|
return True
|
||||||
for pattern in self._admins:
|
for pattern in self._admins:
|
||||||
@@ -414,18 +374,6 @@ class Bot:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _is_addressed(self, msg: Message) -> bool:
|
|
||||||
"""Check if a message is addressed to the bot (DM or nick-prefixed)."""
|
|
||||||
if not msg.is_channel:
|
|
||||||
return True
|
|
||||||
text = (msg.text or "").lstrip()
|
|
||||||
nick_lower = self.nick.lower()
|
|
||||||
if text.lower().startswith(nick_lower):
|
|
||||||
rest = text[len(nick_lower):]
|
|
||||||
if rest and rest[0] in ":, ":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _dispatch_command(self, msg: Message) -> None:
|
def _dispatch_command(self, msg: Message) -> None:
|
||||||
"""Check if a PRIVMSG is a bot command and spawn it."""
|
"""Check if a PRIVMSG is a bot command and spawn it."""
|
||||||
text = msg.text
|
text = msg.text
|
||||||
@@ -505,16 +453,6 @@ class Bot:
|
|||||||
for chunk in _split_utf8(line, max_text):
|
for chunk in _split_utf8(line, max_text):
|
||||||
await self._bucket.acquire()
|
await self._bucket.acquire()
|
||||||
await self.conn.send(format_msg("PRIVMSG", target, chunk))
|
await self.conn.send(format_msg("PRIVMSG", target, chunk))
|
||||||
if self._llm:
|
|
||||||
ts = time.strftime("%H:%M")
|
|
||||||
display = chunk
|
|
||||||
if display.startswith("\x01ACTION ") and display.endswith("\x01"):
|
|
||||||
display = display[8:-1]
|
|
||||||
out = f"{ts} {target} * {self.nick} {display}"
|
|
||||||
else:
|
|
||||||
out = f"{ts} {target} <{self.nick}> {display}"
|
|
||||||
sys.stdout.write(out + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
async def reply(self, msg: Message, text: str) -> None:
|
async def reply(self, msg: Message, text: str) -> None:
|
||||||
"""Reply to the source of a message (channel or PM)."""
|
"""Reply to the source of a message (channel or PM)."""
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="enable debug logging",
|
help="enable debug logging",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
|
||||||
"--llm",
|
|
||||||
action="store_true",
|
|
||||||
help="LLM mode: addressed messages to stdout, rest to info.log",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--cprofile",
|
"--cprofile",
|
||||||
metavar="PATH",
|
metavar="PATH",
|
||||||
@@ -116,14 +111,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
level = logging.DEBUG if args.verbose else logging.INFO
|
level = logging.DEBUG if args.verbose else logging.INFO
|
||||||
log_fmt = config.get("logging", {}).get("format", "text")
|
log_fmt = config.get("logging", {}).get("format", "text")
|
||||||
if args.llm:
|
if log_fmt == "json":
|
||||||
handler = logging.FileHandler("info.log")
|
|
||||||
if log_fmt == "json":
|
|
||||||
handler.setFormatter(JsonFormatter())
|
|
||||||
else:
|
|
||||||
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE))
|
|
||||||
logging.basicConfig(handlers=[handler], level=level)
|
|
||||||
elif log_fmt == "json":
|
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(JsonFormatter())
|
handler.setFormatter(JsonFormatter())
|
||||||
logging.basicConfig(handlers=[handler], level=level)
|
logging.basicConfig(handlers=[handler], level=level)
|
||||||
@@ -134,7 +122,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
log.info("derp %s starting", __version__)
|
log.info("derp %s starting", __version__)
|
||||||
|
|
||||||
registry = PluginRegistry()
|
registry = PluginRegistry()
|
||||||
bot = Bot(config, registry, llm=args.llm)
|
bot = Bot(config, registry)
|
||||||
bot.load_plugins()
|
bot.load_plugins()
|
||||||
|
|
||||||
if args.tracemalloc:
|
if args.tracemalloc:
|
||||||
|
|||||||
Reference in New Issue
Block a user