Files
derp/docs/USAGE.md
user dd4c6b95b7 feat: rework !similar to build and play discovery playlists
Default !similar now discovers similar artists/tracks, resolves each
against YouTube in parallel via ThreadPoolExecutor, fades out current
playback, and starts the new playlist. Old display behavior moves to
!similar list subcommand.

New helpers: _search_queries() normalizes Last.fm/MB results into search
strings, _resolve_playlist() resolves queries to _Track objects in
parallel. Falls back to display mode when music plugin not loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:56:51 +01:00

1954 lines
68 KiB
Markdown

# Usage Guide
## Running
```bash
# From project directory
derp
# With options
derp --config /path/to/derp.toml --verbose
```
## CLI Flags
| Flag | Description |
|------|-------------|
| `-c, --config PATH` | Config file path |
| `-v, --verbose` | Debug logging |
| `--cprofile [PATH]` | Enable cProfile, dump to PATH [derp.prof] |
| `--tracemalloc [N]` | Enable tracemalloc, capture N frames deep [10] |
| `-V, --version` | Print version |
| `-h, --help` | Show help |
## Configuration
All settings in `config/derp.toml`.
### Single-Server (Legacy)
```toml
[server]
host = "irc.libera.chat" # IRC server hostname
port = 6697 # Port (6697 = TLS, 6667 = plain)
tls = true # Enable TLS encryption
proxy = false # Route through SOCKS5 proxy (default: false)
nick = "derp" # Bot nickname
user = "derp" # Username (ident)
realname = "derp IRC bot" # Real name field
password = "" # Server password (optional)
sasl_user = "" # SASL PLAIN username (optional)
sasl_pass = "" # SASL PLAIN password (optional)
ircv3_caps = [ # IRCv3 capabilities to request
"multi-prefix",
"away-notify",
"server-time",
"cap-notify",
"account-notify",
]
[bot]
prefix = "!" # Command prefix character
channels = ["#test"] # Channels to join on connect
plugins_dir = "plugins" # Plugin directory path
rate_limit = 2.0 # Max messages per second (default: 2.0)
rate_burst = 5 # Burst capacity (default: 5)
paste_threshold = 4 # Max lines before overflow to FlaskPaste (default: 4)
admins = [] # Hostmask patterns (fnmatch), IRCOPs auto-detected
timezone = "UTC" # Timezone for calendar reminders (IANA tz name)
operators = [] # Hostmask patterns for "oper" tier (fnmatch)
trusted = [] # Hostmask patterns for "trusted" tier (fnmatch)
[logging]
level = "info" # Logging level: debug, info, warning, error
format = "text" # Log format: "text" (default) or "json"
[webhook]
enabled = false # Enable HTTP webhook listener
host = "127.0.0.1" # Bind address
port = 8080 # Bind port
secret = "" # HMAC-SHA256 shared secret (empty = no auth)
```
### Multi-Server
Connect to multiple IRC servers from a single config. Plugins are loaded
once and shared; state is isolated per server (`data/state-<name>.db`).
```toml
[bot]
prefix = "!" # Shared defaults for all servers
plugins_dir = "plugins"
admins = ["*!~root@*.ops.net"]
[servers.libera]
host = "irc.libera.chat"
port = 6697
tls = true
nick = "derp"
channels = ["#test", "#ops"]
[servers.oftc]
host = "irc.oftc.net"
port = 6697
tls = true
nick = "derpbot"
channels = ["#derp"]
admins = ["*!~admin@oftc.host"] # Override shared admins
[logging]
level = "info"
format = "json"
[webhook]
enabled = true
port = 8080
secret = "shared-secret"
```
Each `[servers.<name>]` block may contain both server-level keys (host,
port, tls, nick, etc.) and bot-level overrides (prefix, channels, admins,
operators, trusted, rate_limit, rate_burst, paste_threshold). Unset keys
inherit from the shared `[bot]` and `[server]` defaults.
The server name (e.g. `libera`, `oftc`) is used for:
- Log prefixes and `!version` output
- State DB path (`data/state-libera.db`)
- Plugin runtime state isolation
Existing single-server configs (`[server]` section) continue to work
unchanged. The server name is derived from the hostname automatically.
## Built-in Commands
| Command | Description |
|---------|-------------|
| `!ping` | Bot responds with "pong" |
| `!help` | List all commands + paste full reference |
| `!help <cmd>` | Show help + paste detailed docstring |
| `!help <plugin>` | Show plugin description + paste command details |
| `!version` | Show bot version |
| `!uptime` | Show how long the bot has been running |
| `!echo <text>` | Echo back text (example plugin) |
| `!cert <domain> [...]` | Lookup CT logs for up to 5 domains |
| `!whoami` | Show your hostmask and admin status |
| `!load <plugin>` | Hot-load a plugin (admin) |
| `!reload <plugin>` | Reload a plugin (admin) |
| `!unload <plugin>` | Unload a plugin (admin) |
| `!admins` | Show admin patterns and detected opers (admin) |
| `!plugins` | List loaded plugins with handler counts |
| `!state <action> <plugin> [key]` | Inspect plugin state store (admin) |
| `!kick <nick> [reason]` | Kick user from channel (admin) |
| `!ban <mask>` | Ban a hostmask in channel (admin) |
| `!unban <mask>` | Remove a ban from channel (admin) |
| `!topic [text]` | Set or query channel topic (admin) |
| `!mode <mode> [args]` | Set channel mode (admin) |
| `!dns <target> [type]` | DNS lookup (A, AAAA, MX, NS, TXT, CNAME, PTR, SOA) |
| `!tdns <target> [type] [@server]` | TCP DNS lookup via SOCKS5 proxy |
| `!encode <fmt> <text>` | Encode text (b64, hex, url, rot13) |
| `!decode <fmt> <text>` | Decode text (b64, hex, url, rot13) |
| `!hash [algo] <text>` | Generate hash digests (md5, sha1, sha256, sha512) |
| `!hashid <hash>` | Identify hash type by format |
| `!defang <ioc>` | Defang URLs/IPs/domains for safe sharing |
| `!refang <text>` | Restore defanged IOCs |
| `!revshell <type> <ip> <port>` | Generate reverse shell one-liner |
| `!cidr <network>` | Subnet info (range, hosts, mask) |
| `!cidr contains <net> <ip>` | Check if IP belongs to network |
| `!whois <domain\|ip>` | WHOIS lookup via raw TCP (port 43) |
| `!portcheck <host> [ports]` | Async TCP port scan (max 20 ports) |
| `!httpcheck <url>` | HTTP status, redirects, response time |
| `!tlscheck <host> [port]` | TLS version, cipher, cert details |
| `!blacklist <ip>` | Check IP against 10 DNSBLs |
| `!rand <mode> [args]` | Random: password, hex, uuid, bytes, int, coin, dice |
| `!timer <duration> [label]` | Set countdown timer with notification |
| `!timer list` | Show active timers |
| `!timer cancel <label>` | Cancel a running timer |
| `!remind <duration> <text>` | One-shot reminder after duration |
| `!remind every <duration> <text>` | Repeating reminder at interval |
| `!remind at <YYYY-MM-DD> [HH:MM] <text>` | Calendar reminder at specific date/time |
| `!remind yearly <MM-DD> [HH:MM] <text>` | Yearly recurring reminder |
| `!remind list` | Show all active reminders |
| `!remind cancel <id>` | Cancel a reminder by ID |
| `!geoip <ip>` | GeoIP lookup (city, country, coords, timezone) |
| `!asn <ip>` | ASN lookup (AS number, organization) |
| `!tor <ip\|update>` | Check IP against Tor exit nodes |
| `!iprep <ip\|update>` | Check IP against Firehol/ET blocklists |
| `!cve <id\|search>` | CVE lookup from local NVD mirror |
| `!opslog <add\|list\|search\|del\|clear>` | Timestamped operational log |
| `!note <set\|get\|del\|list\|clear>` | Per-channel key-value notes |
| `!subdomain <domain> [brute]` | Subdomain enumeration (crt.sh + DNS) |
| `!headers <url>` | HTTP header fingerprinting |
| `!exploitdb <search\|id\|cve\|update>` | Search local Exploit-DB mirror |
| `!payload <type> [variant]` | Web vuln payload templates |
| `!dork <category\|list> [target]` | Google dork query builder |
| `!wayback <url> [YYYYMMDD]` | Wayback Machine snapshot lookup |
| `!username <user>` | Check username across ~25 services |
| `!username <user> <service>` | Check single service |
| `!username list` | Show available services by category |
| `!alert <add\|del\|list\|check\|info\|history>` | Keyword alert subscriptions across platforms |
| `!searx <query>` | Search SearXNG and show top results |
| `!ask <question>` | Single-shot LLM question via OpenRouter |
| `!chat <msg\|clear\|model\|models>` | Conversational LLM chat with history |
| `!jwt <token>` | Decode JWT header, claims, and flag issues |
| `!mac <address\|random\|update>` | MAC OUI vendor lookup / random MAC |
| `!abuse <ip> [ip2 ...]` | AbuseIPDB reputation check |
| `!abuse <ip> report <cats> <comment>` | Report IP to AbuseIPDB (admin) |
| `!vt <hash\|ip\|domain\|url>` | VirusTotal lookup |
| `!emailcheck <email> [email2 ...]` | SMTP email verification (admin) |
| `!internetdb <ip>` | Shodan InternetDB host recon (ports, CVEs, CPEs) |
| `!canary <gen\|list\|info\|del>` | Canary token generator/tracker |
| `!tcping <host> [port] [count]` | TCP connect latency probe via SOCKS5 |
| `!archive <url>` | Save URL to Wayback Machine |
| `!resolve <host> [host2 ...] [type]` | Bulk DNS resolution via TCP/SOCKS5 |
| `!shorten <url>` | Shorten a URL via FlaskPaste |
| `!paste <text>` | Create a paste via FlaskPaste |
| `!pastemoni <add\|del\|list\|check>` | Paste site keyword monitoring |
| `!cron <add\|del\|list>` | Scheduled command execution (admin) |
| `!webhook` | Show webhook listener status (admin) |
### Detailed Help (FlaskPaste)
`!help` pastes detailed reference output to FlaskPaste and appends the
URL. The paste uses a 3-level indentation hierarchy:
```
[plugin-name]
Plugin description.
!command -- short help
Full docstring with usage, subcommands,
and examples.
!other -- another command
Its docstring here.
```
- `!help` (no args) -- pastes the full reference grouped by plugin
- `!help <cmd>` -- pastes the command's docstring (command at column 0)
- `!help <plugin>` -- pastes all commands under the plugin header
If FlaskPaste is not loaded or the paste fails, the short IRC reply
still works -- no regression.
### Command Shorthand
Commands can be abbreviated to any unambiguous prefix:
```
!h -> !help (unique match)
!pi -> !ping (unique match)
!p -> error: ambiguous (ping, plugins)
```
Exact matches always take priority over prefix matches.
### `!cert` -- Certificate Transparency Lookup
Query [crt.sh](https://crt.sh) CT logs to enumerate SSL certificates for
domains. Reports totals (expired/valid) and flags domains still serving
expired certs.
```
!cert example.com
!cert example.com badsite.com another.org
```
Output format:
```
example.com -- 127 certs (23 expired, 104 valid)
badsite.com -- 45 certs (8 expired, 37 valid) | live cert EXPIRED
broken.test -- error: timeout
```
- Max 5 domains per invocation
- crt.sh can be slow; the bot confirms receipt before querying
- Live cert check runs only when expired CT entries exist
## Per-Channel Plugin Control
Restrict which plugins are active in specific channels. Channels without
a `[channels."<name>"]` section run all plugins. Channels with a `plugins`
list only run those plugins. The `core` plugin is always active (exempt
from filtering). Private messages are always unrestricted.
```toml
[channels."#public"]
plugins = ["core", "dns", "cidr", "encode"]
[channels."#ops"]
plugins = ["core", "revshell", "payload", "exploitdb", "opslog"]
# #unrestricted -- no section, runs everything
```
When a command is denied by channel config, it is silently ignored (no
error message). Event handlers from denied plugins are also skipped.
## Structured Logging (JSON)
Set `format = "json"` in `[logging]` to emit one JSON object per log line
(JSONL), suitable for log aggregation tools.
```toml
[logging]
level = "info"
format = "json"
```
Each line contains:
| Field | Description |
|-------|-------------|
| `ts` | Timestamp (`YYYY-MM-DDTHH:MM:SS`) |
| `level` | Log level (`debug`, `info`, `warning`, `error`) |
| `logger` | Logger name (`derp.bot`, `derp.plugin`, etc.) |
| `msg` | Log message text |
| `exc` | Exception traceback (only present on errors) |
Default format is `"text"` (human-readable, same as before).
## Permission Tiers (ACL)
The bot uses a 4-tier permission model. Each command has a required tier;
users must meet or exceed it.
```
user < trusted < oper < admin
```
| Tier | Granted by |
|------|------------|
| `user` | Everyone (default) |
| `trusted` | `[bot] trusted` hostmask patterns |
| `oper` | `[bot] operators` hostmask patterns |
| `admin` | `[bot] admins` hostmask patterns or IRC operator status |
```toml
[bot]
admins = ["*!~root@*.ops.net"]
operators = ["*!~staff@trusted.host"]
trusted = ["*!~user@known.host"]
```
All lists are empty by default -- only IRC operators get admin access
unless patterns are configured. Patterns use fnmatch-style matching.
### Oper Detection
IRC operators are detected via the `*` flag in `WHO` replies. Detection
happens at two points:
- **On connect** -- `WHO #channel` for each configured channel
- **On user JOIN** -- debounced `WHO #channel` (2-second window)
The debounce prevents flooding the server during netsplit recovery: many
rapid JOINs produce a single `WHO` per channel. Note that the first
command after joining may not yet have oper status -- the debounced WHO
fires after a 2-second delay. Users who `QUIT` are removed from the oper
set automatically.
| Command | Description |
|---------|-------------|
| `!whoami` | Show your hostmask and permission tier |
| `!admins` | Show configured tiers and detected opers (admin) |
Admin-restricted commands: `!load`, `!reload`, `!unload`, `!admins`, `!state`,
`!kick`, `!ban`, `!unban`, `!topic`, `!mode`, `!webhook`.
### Writing Tiered Commands
```python
# admin=True still works (maps to tier="admin")
@command("dangerous", help="Admin-only action", admin=True)
async def cmd_dangerous(bot, message):
...
# Explicit tier for finer control
@command("moderate", help="Trusted-only action", tier="trusted")
async def cmd_moderate(bot, message):
...
```
## IRCv3 Capability Negotiation
The bot negotiates IRCv3 capabilities using `CAP LS 302` during registration.
This enables richer features on servers that support them.
### Default Capabilities
```toml
[server]
ircv3_caps = ["multi-prefix", "away-notify", "server-time",
"cap-notify", "account-notify"]
```
| Capability | Purpose |
|------------|---------|
| `multi-prefix` | Better IRCOP/voice detection |
| `away-notify` | Receive AWAY status changes |
| `server-time` | Accurate message timestamps |
| `cap-notify` | Dynamic capability updates |
| `account-notify` | Account login/logout notices |
| `sasl` | Auto-added when SASL credentials configured |
The bot only requests caps the server advertises. SASL is automatically
included when `sasl_user` and `sasl_pass` are configured.
### Message Tags
IRCv3 message tags (`@key=value;...`) are parsed automatically and available
on the message object as `message.tags` (a `dict[str, str]`). Values are
unescaped per the IRCv3 spec.
## Channel Management
Channel management commands require admin privileges and must be used in a
channel (not DM).
```
!kick <nick> [reason] Kick a user from the channel
!ban <mask> Set +b on a hostmask (e.g. *!*@bad.host)
!unban <mask> Remove +b from a hostmask
!topic [text] Set topic (empty = query current topic)
!mode <mode> [args] Set raw MODE on the channel
```
The bot must have channel operator status for these commands to take effect.
### Invite Auto-Join
When an admin or IRC operator sends an `INVITE`, the bot automatically joins
the target channel and persists it for auto-rejoin on reconnect. Non-admin
invites are silently ignored. If the bot is kicked from a persisted channel,
it is removed from the auto-rejoin list. Channels already in the config
`[bot] channels` are skipped (they rejoin via the normal path).
## Plugin State Persistence
Plugins can persist key-value data across restarts via `bot.state`:
```python
bot.state.set("myplugin", "last_run", "2026-02-15")
value = bot.state.get("myplugin", "last_run")
bot.state.delete("myplugin", "last_run")
keys = bot.state.keys("myplugin")
bot.state.clear("myplugin")
```
Data is stored in `data/state-<name>.db` (SQLite, one per server). Each
plugin gets its own namespace so keys never collide.
### Inspection Commands (admin)
```
!state list <plugin> List keys for a plugin
!state get <plugin> <key> Get a value
!state del <plugin> <key> Delete a key
!state clear <plugin> Clear all state for a plugin
```
## Local Databases (Wave 3)
Several plugins rely on local data files in the `data/` directory. Use the
update script or in-bot commands to populate them.
### Data Update Script
```bash
./scripts/update-data.sh # Update all feeds
MAXMIND_LICENSE_KEY=xxx ./scripts/update-data.sh # Include GeoLite2
```
The script is cron-friendly (exit 0/1, quiet unless `NO_COLOR` is unset).
### In-Bot Updates
```
!tor update # Download Tor exit node list
!iprep update # Download Firehol/ET blocklist feeds
!cve update # Download NVD CVE feed (slow, paginated)
```
### Data Directory Layout
```
data/
GeoLite2-City.mmdb # MaxMind GeoIP (requires license key)
GeoLite2-ASN.mmdb # MaxMind ASN (requires license key)
tor-exit-nodes.txt # Tor exit node IPs
iprep/ # Firehol/ET blocklist feeds
firehol_level1.netset
firehol_level2.netset
et_compromised.ipset
...
nvd/ # NVD CVE JSON files
nvd_0000.json
...
```
GeoLite2 databases require a free MaxMind license key. Set
`MAXMIND_LICENSE_KEY` when running the update script.
## Plugin Management
Plugins can be loaded, unloaded, and reloaded at runtime without
restarting the bot.
```
!load crtsh # Hot-load a new plugin from plugins/
!reload crtsh # Reload a changed plugin
!unload crtsh # Remove a plugin and all its handlers
!plugins # List loaded plugins with handler counts
```
The `core` plugin cannot be unloaded (prevents losing `!load`/`!reload`),
but it can be reloaded.
## Webhook Listener
Receive HTTP POST requests from external services (CI, monitoring, GitHub,
etc.) and relay messages to IRC channels.
### Configuration
```toml
[webhook]
enabled = true
host = "127.0.0.1"
port = 8080
secret = "your-shared-secret"
```
### HTTP API
Single endpoint: `POST /`
**Request body** (JSON):
```json
{"channel": "#ops", "text": "Deploy v2.3.1 complete"}
```
Optional `"action": true` sends as a `/me` action.
**Authentication**: HMAC-SHA256 via `X-Signature: sha256=<hex>` header.
If `secret` is empty, no authentication is required.
**Response codes**:
| Status | When |
|--------|------|
| 200 OK | Message sent |
| 400 Bad Request | Invalid JSON, missing/invalid channel, empty text |
| 401 Unauthorized | Bad or missing HMAC signature |
| 405 Method Not Allowed | Non-POST request |
| 413 Payload Too Large | Body > 64 KB |
### Usage Example
```bash
SECRET="your-shared-secret"
BODY='{"channel":"#ops","text":"Deploy v2.3.1 complete"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://127.0.0.1:8080/ \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIG" \
-d "$BODY"
```
### Status Command
```
!webhook Show listener address, request count, uptime (admin)
```
The server starts on IRC connect (event 001) and runs for the lifetime of
the bot. If the server is already running (e.g. after reconnect), it is
not restarted.
## Writing Plugins
Create a `.py` file in the `plugins/` directory:
```python
from derp.plugin import command, event
@command("hello", help="Greet the user")
async def cmd_hello(bot, message):
"""Handler receives bot instance and parsed Message."""
await bot.reply(message, f"Hello, {message.nick}!")
@event("JOIN")
async def on_join(bot, message):
"""Event handlers fire on IRC events (JOIN, PART, QUIT, etc.)."""
if message.nick != bot.nick:
await bot.send(message.target, f"Welcome, {message.nick}")
```
### Plugin API
The `bot` object provides:
| Method | Description |
|--------|-------------|
| `bot.send(target, text)` | Send message to channel or nick |
| `bot.reply(msg, text)` | Reply to source (channel or PM) |
| `bot.action(target, text)` | Send `/me` action |
| `bot.join(channel)` | Join a channel |
| `bot.part(channel [, reason])` | Leave a channel |
| `bot.quit([reason])` | Disconnect from server |
| `bot.kick(channel, nick [, reason])` | Kick a user |
| `bot.mode(target, mode_str, *args)` | Set a mode |
| `bot.set_topic(channel, topic)` | Set channel topic |
| `bot.state` | Plugin state store (get/set/delete/keys/clear) |
The `message` object provides:
| Attribute | Description |
|-----------|-------------|
| `message.nick` | Sender's nickname |
| `message.prefix` | Full `nick!user@host` prefix |
| `message.command` | IRC command (PRIVMSG, JOIN, etc.) |
| `message.target` | First param (channel or nick) |
| `message.text` | Trailing text content |
| `message.is_channel` | Whether target is a channel |
| `message.params` | All message parameters |
| `message.tags` | IRCv3 message tags (dict) |
## Message Truncation
Messages are automatically split at UTF-8 safe boundaries to comply with
the IRC 512-byte line limit (RFC 2812). The overhead of `PRIVMSG <target> :`
and `\r\n` is accounted for, so plugins can send arbitrarily long text
without worrying about protocol limits.
### `!remind` -- Reminders
Set one-shot, repeating, or calendar-based reminders. Duration-based reminders
are in-memory and lost on restart. Calendar reminders (`at`, `yearly`) are
persisted via `bot.state` and restored on reconnect.
```
!remind 5m check the oven One-shot after 5 minutes
!remind 2d12h renew cert One-shot after 2 days 12 hours
!remind every 1h drink water Repeat every hour
!remind at 2027-06-15 deploy release Fire at specific date (noon default)
!remind at 2027-06-15 14:30 deploy Fire at specific date + time
!remind yearly 02-14 valentines Every year on Feb 14 at noon
!remind yearly 12-25 09:00 merry xmas Every year on Dec 25 at 09:00
!remind list Show all active reminders
!remind cancel abc123 Cancel by ID
```
- Default time when omitted: **12:00** (noon) in configured timezone
- Timezone: `bot.timezone` in config, default `UTC`
- Leap day (02-29): fires on Feb 28 in non-leap years
- Past dates for `at` are rejected
- Calendar reminders survive restarts; duration-based do not
## Reconnect Backoff
On connection loss, the bot reconnects with exponential backoff and jitter:
- Initial delay: 5 seconds
- Growth: doubles each attempt (5s, 10s, 20s, 40s, ...)
- Cap: 300 seconds (5 minutes)
- Jitter: +/- 25% to avoid thundering herd
- Resets to 5s after a successful connection
### `!dork` -- Google Dork Query Builder
Generate Google dork queries for a target domain. Template-based, no HTTP
requests -- just outputs the query string for manual use.
```
!dork list List all dork categories
!dork admin example.com Admin/login panel dorks
!dork files example.com Exposed document dorks
```
Categories: admin, backup, cloud, config, creds, dirs, errors, exposed,
files, login.
### `!wayback` -- Wayback Machine Lookup
Check the Wayback Machine for archived snapshots of a URL.
```
!wayback example.com Check latest snapshot
!wayback example.com/page 20240101 Check snapshot near a date
```
Auto-prepends `https://` if no scheme is provided. Uses the Wayback Machine
availability API.
### `!username` -- Username Enumeration
Check username availability across ~25 services using HTTP probes and
public JSON APIs. Supports GitHub, GitLab, Reddit, Docker Hub, Keybase,
Dev.to, Twitch, Steam, and more.
```
!username list List services by category
!username john Full scan (~25 services)
!username john github Check single service
```
Output format:
```
Full scan:
Checking "john" across 25 services...
john -- 8 found, 14 not found, 3 errors
Found: GitHub, GitLab, Reddit, Twitch, Steam, PyPI, Docker Hub, Medium
Single service:
GitHub: john -> found | https://github.com/john
GitHub: john -> not found
List:
Dev: GitHub, GitLab, Codeberg, ...
Social: Reddit, Twitter/X, ...
Media: Twitch, Spotify, ...
Other: Steam, Pastebin, ...
```
- Username must match `[a-zA-Z0-9._-]{1,39}`
- Full scan sends acknowledgment before probing
- 8 parallel workers, 20s overall timeout
- Three check methods: HTTP status, JSON API, body search
### `!rss` -- RSS/Atom Feed Subscriptions
Subscribe RSS or Atom feeds to IRC channels with automatic polling and
new-item announcements.
```
!rss add <url> [name] Subscribe a feed to this channel (admin)
!rss del <name> Unsubscribe a feed (admin)
!rss list List feeds in this channel
!rss check <name> Force-poll a feed now
```
- `add` and `del` require admin privileges
- All subcommands must be used in a channel (not PM)
- If `name` is omitted on `add`, it is derived from the URL hostname
- Names must be lowercase alphanumeric + hyphens, 1-20 characters
- Maximum 20 feeds per channel
Polling and announcements:
- Feeds are polled every 10 minutes by default
- On `add`, existing items are recorded without announcing (prevents flood)
- New items are announced as `[name] title | YYYY-MM-DD -- link`
- Published date is included when available (RSS `pubDate`, Atom `published`)
- Maximum 5 items announced per poll; excess shown as `... and N more`
- Titles are truncated to 80 characters
- Supports HTTP conditional requests (`ETag`, `If-Modified-Since`)
- 5 consecutive errors doubles the poll interval (max 1 hour)
- Feed subscriptions persist across bot restarts via `bot.state`
### `!yt` -- YouTube Channel Subscriptions
Follow YouTube channels and announce new videos in IRC channels. Accepts any
YouTube URL (video, channel, handle, shorts, embed) and resolves it to the
channel's Atom feed.
```
!yt follow <url> [name] Follow a YouTube channel (admin)
!yt unfollow <name> Unfollow a channel (admin)
!yt list List followed channels
!yt check <name> Force-poll a channel now
```
- `follow` and `unfollow` require admin privileges
- All subcommands must be used in a channel (not PM)
- If `name` is omitted on `follow`, it is derived from the channel title
- Names must be lowercase alphanumeric + hyphens, 1-20 characters
- Maximum 20 channels per IRC channel
URL resolution:
- `/channel/UCXXX` URLs extract the channel ID directly
- All other YouTube URLs (handles, videos, shorts) fetch the page to resolve
- Constructs the YouTube Atom feed URL from the resolved channel ID
Polling and announcements:
- Channels are polled every 10 minutes by default
- On `follow`, existing videos are recorded without announcing (prevents flood)
- New videos are announced as `[name] Video Title | 18:25 1.5Mv 45klk 2026-01-15 -- URL`
- Metadata suffix includes duration, views, likes, and published date when available
- Duration fetched via InnerTube API per new video (only at announcement time)
- Maximum 5 videos announced per poll; excess shown as `... and N more`
- Titles are truncated to 80 characters
- Supports HTTP conditional requests (`ETag`, `If-Modified-Since`)
- 5 consecutive errors doubles the poll interval (max 1 hour)
- Subscriptions persist across bot restarts via `bot.state`
### `!twitch` -- Twitch Livestream Notifications
Follow Twitch streamers and get notified when they go live. Uses Twitch's
public GQL endpoint (no API credentials required).
```
!twitch follow <username> [name] Follow a streamer (admin)
!twitch unfollow <name> Unfollow a streamer (admin)
!twitch list List followed streamers
!twitch check <name> Check status now
```
- `follow` and `unfollow` require admin privileges
- All subcommands must be used in a channel (not PM)
- If `name` is omitted on `follow`, it defaults to the Twitch login (lowercase)
- Names must be lowercase alphanumeric + hyphens, 1-20 characters
- Twitch usernames must match `[a-zA-Z0-9_]{1,25}`
- Maximum 20 streamers per IRC channel
Polling and announcements:
- Streamers are polled every 2 minutes by default
- On `follow`, the current stream state is recorded without announcing
- Announcements fire on state transitions: offline to live, or new stream ID
- Format: `[name] is live: Stream Title (Game) | 50k viewers -- https://twitch.tv/login`
- Game is omitted if not set; viewer count shown when available
- Titles are truncated to 80 characters
- 5 consecutive errors doubles the poll interval (max 1 hour)
- Subscriptions persist across bot restarts via `bot.state`
- `list` shows live/error status with viewer count: `name (live, 50k)`
- `check` forces an immediate poll and reports current status
### `!searx` -- SearXNG Web Search
Search the local SearXNG instance and display top results.
```
!searx <query...> Search SearXNG and show top results
```
- Open to all users, channel only (no PM)
- Query is everything after `!searx`
- Shows top 3 results as `Title -- URL`
- Titles truncated to 80 characters
- Query limited to 200 characters
Output format:
```
Title One -- https://example.com/page1
Title Two -- https://example.com/page2
Title Three -- https://example.com/page3
```
### `!ask` / `!chat` -- LLM Chat (OpenRouter)
Chat with large language models via [OpenRouter](https://openrouter.ai/)'s
API. `!ask` is stateless (single question), `!chat` maintains per-user
conversation history.
```
!ask <question> Single-shot question (no history)
!chat <message> Chat with conversation history
!chat clear Clear your history
!chat model Show current model
!chat model <name> Switch model
!chat models List suggested free models
```
Output format:
```
<alice> !ask what is DNS
<derp> DNS (Domain Name System) translates domain names to IP addresses...
<alice> !chat explain TCP
<derp> TCP is a connection-oriented transport protocol...
<alice> !chat how does the handshake work
<derp> The TCP three-way handshake: SYN, SYN-ACK, ACK...
```
- Open to all users, works in channels and PMs
- Per-user cooldown: 5 seconds between requests
- Conversation history capped at 20 messages per user (ephemeral, not
persisted across restarts)
- Responses truncated to 400 characters; multi-line replies use paste overflow
- Default model: `openrouter/auto` (auto-routes to best available free model)
- Reasoning models (DeepSeek R1) are handled transparently -- falls back to
the `reasoning` field when `content` is empty
- Rate limit errors (HTTP 429) produce a clear user-facing message
Configuration:
```toml
[openrouter]
api_key = "" # or set OPENROUTER_API_KEY env var
model = "openrouter/auto" # default model
system_prompt = "You are a helpful IRC bot assistant. Keep responses concise and under 200 words."
```
API key: set `OPENROUTER_API_KEY` env var (preferred) or `api_key` under
`[openrouter]` in config. The env var takes precedence.
### `!alert` -- Keyword Alert Subscriptions
Search keywords across 27 platforms and announce new results. Unlike
`!rss`/`!yt`/`!twitch` which follow specific channels/feeds, `!alert` searches
keywords across all supported platforms simultaneously.
```
!alert add <name> <keyword...> Add keyword alert (admin)
!alert del <name> Remove alert (admin)
!alert list List alerts
!alert check <name> Force-poll now
!alert info <id> Show full details for a result
!alert history <name> [n] Show recent results (default 5, max 20)
```
- `add` and `del` require admin privileges
- All subcommands must be used in a channel (not PM)
- Name is required as the first argument after `add`; everything after is the keyword
- Names must be lowercase alphanumeric + hyphens, 1-20 characters
- Keywords: 1-100 characters, free-form text
- Maximum 20 alerts per IRC channel
Platforms searched:
- **YouTube** (`yt`) -- InnerTube search API (no auth required)
- **Twitch** (`tw`) -- Public GQL endpoint: live streams and VODs (no auth required)
- **SearXNG** (`sx`) -- Local SearXNG instance, searches general/news/videos/social media categories filtered to last 24h (no auth required)
- **Reddit** (`rd`) -- JSON search API, sorted by new, past week (no auth required)
- **Mastodon** (`ft`) -- Public hashtag timeline across 4 instances (no auth required)
- **DuckDuckGo** (`dg`) -- HTML lite search endpoint via SOCKS5 proxy (no auth required)
- **Google News** (`gn`) -- Public RSS feed via SOCKS5 proxy (no auth required)
- **Kick** (`kk`) -- Public search API: channels and livestreams (no auth required)
- **Dailymotion** (`dm`) -- Public video API, sorted by recent (no auth required)
- **PeerTube** (`pt`) -- Federated video search across 4 instances (no auth required)
- **Bluesky** (`bs`) -- Public post search API via SOCKS5 proxy (no auth required)
- **Lemmy** (`ly`) -- Federated post search across 4 instances (no auth required)
- **Odysee** (`od`) -- LBRY JSON-RPC claim search: video, audio, documents (no auth required)
- **Archive.org** (`ia`) -- Internet Archive advanced search, sorted by date (no auth required)
- **Hacker News** (`hn`) -- Algolia search API, sorted by date (no auth required)
- **GitHub** (`gh`) -- Repository search API, sorted by recently updated (no auth required)
- **Wikipedia** (`wp`) -- MediaWiki search API, English Wikipedia (no auth required)
- **Stack Exchange** (`se`) -- Stack Overflow search API, sorted by activity (no auth required)
- **GitLab** (`gl`) -- Public project search API, sorted by last activity (no auth required)
- **npm** (`nm`) -- npm registry search API (no auth required)
- **PyPI** (`pp`) -- Recent package updates RSS feed, keyword-filtered (no auth required)
- **Docker Hub** (`dh`) -- Public repository search API (no auth required)
- **arXiv** (`ax`) -- Atom search API for academic papers (no auth required)
- **Lobsters** (`lb`) -- Community link aggregator search (no auth required)
- **DEV.to** (`dv`) -- Forem articles API, tag-based search (no auth required)
- **Medium** (`md`) -- Tag-based RSS feed (no auth required)
- **Hugging Face** (`hf`) -- Model search API, sorted by downloads (no auth required)
Backend metadata (shown as `| extra` suffix on titles):
| Tag | Metrics | Example |
|-----|---------|---------|
| `tw` | viewers / views | `500 viewers`, `1k views` |
| `rd` | score, comments | `+127 42c` |
| `ft` | reblogs, favourites | `3rb 12fav` |
| `bs` | likes, reposts | `5lk 2rp` |
| `ly` | score, comments | `+15 3c` |
| `kk` | viewers | `500 viewers` |
| `dm` | views | `1.2k views` |
| `pt` | views, likes | `120v 5lk` |
| `hn` | points, comments | `127pt 42c` |
| `gh` | stars, forks | `42* 5fk` |
| `gl` | stars, forks | `42* 5fk` |
| `se` | score, answers, views | `+5 3a 1.2kv` |
| `dh` | stars, pulls | `42* 1.2M pulls` |
| `hf` | downloads, likes | `500dl 12lk` |
| `dv` | reactions, comments | `+15 3c` |
Polling and announcements:
- Alerts are polled every 5 minutes by default
- On `add`, the bot replies immediately; existing results are seeded in the
background to avoid flooding
- New results announced as two lines:
- ACTION: `* derp [name/<tag>/<id>] date - URL`
- PRIVMSG: `title | extra` (title with compact engagement metrics when available)
- Tags: `yt`, `tw`, `sx`, `rd`, `ft`, `dg`, `gn`, `kk`, `dm`, `pt`, `bs`, `ly`,
`od`, `ia`, `hn`, `gh`, `wp`, `se`, `gl`, `nm`, `pp`, `dh`, `ax`, `lb`, `dv`,
`md`, `hf` -- `<id>` is a short deterministic ID for use with `!alert info`
- Each platform maintains its own seen list (capped at 200 per platform)
- Per-backend error tracking with exponential backoff (5+ errors skips
that backend with increasing cooldown; other backends unaffected)
- Multi-instance backends (PeerTube, Mastodon, Lemmy, SearXNG) fetch
concurrently for faster polling
- Subscriptions persist across bot restarts via `bot.state`
- Matched results are stored in `data/alert_history.db` (SQLite)
- `list` shows per-backend error counts next to each alert
- `check` forces an immediate poll across all platforms
- `history` queries stored results (titles truncated), most recent first
### `!jwt` -- JWT Decoder
Decode JSON Web Token header and payload, flag common issues.
```
!jwt eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.sig
```
Output format:
```
Header: alg=RS256 typ=JWT | sig=43 bytes
sub=user123
WARN: expired (2026-03-01 12:00 UTC)
```
Issues detected:
- `alg=none` (unsigned token)
- Expired tokens (`exp` in the past)
- Not-yet-valid tokens (`nbf` in the future)
No external dependencies -- pure base64/JSON decoding.
### `!mac` -- MAC Address Lookup
OUI vendor lookup from IEEE database, random MAC generation.
```
!mac AA:BB:CC:DD:EE:FF Vendor lookup
!mac AABB.CCDD.EEFF Cisco-style format also accepted
!mac random Generate random locally-administered MAC
!mac update Download IEEE OUI database
```
Output format:
```
AA:BB:CC:DD:EE:FF -- Cisco Systems, Inc (OUI: AA:BB:CC)
Random MAC: 02:4A:F7:3C:91:E2 (locally administered)
```
- Accepts any common MAC format (colon, dash, dot, no separator)
- Random MACs have the locally-administered bit set and multicast bit cleared
- OUI database stored at `data/oui.txt`, also downloadable via `scripts/update-data.sh`
### `!abuse` -- AbuseIPDB
Check IP reputation or report abuse via the AbuseIPDB API.
```
!abuse 8.8.8.8 Check single IP
!abuse 8.8.8.8 1.1.1.1 Check multiple (max 5)
!abuse 8.8.8.8 report 14,22 Brute force Report IP (admin)
```
Output format:
```
8.8.8.8 -- Abuse: 0% (0 reports) | ISP: Google LLC | Usage: Data Center | Country: US
```
- API key: set `ABUSEIPDB_API_KEY` env var or `api_key` under `[abuseipdb]` in config
- Private/loopback IPs are rejected
- Reporting requires admin privileges
- Categories are comma-separated numbers per AbuseIPDB docs
### `!vt` -- VirusTotal
Query VirusTotal API v3 for file hashes, IPs, domains, or URLs.
```
!vt 44d88612fea8a8f36de82e12... File hash (MD5/SHA1/SHA256)
!vt 8.8.8.8 IP address
!vt example.com Domain
!vt https://example.com/page URL
```
Output format:
```
44d88612fea8a8... -- 62/72 detected | trojan, malware | first seen: 2024-01-15
8.8.8.8 -- 0/94 | AS15169 GOOGLE | Country: US | Reputation: 0
example.com -- 0/94 | Registrar: Example Inc | Reputation: 0
```
- API key: set `VIRUSTOTAL_API_KEY` env var or `api_key` under `[virustotal]` in config
- Auto-detects input type from format (hash length, URL scheme, IP, domain)
- Rate limited to 4 requests per minute (VT free tier)
- URL IDs are base64url-encoded per VT API spec
### `!emailcheck` -- SMTP Email Verification (admin)
Verify email deliverability via MX resolution and raw SMTP RCPT TO conversation
through the SOCKS5 proxy.
```
!emailcheck user@example.com Single check
!emailcheck user@example.com user2@test.org Batch (max 5)
```
Output format:
```
user@example.com -- SMTP 250 OK (mx: mail.example.com)
bad@example.com -- SMTP 550 User unknown (mx: mail.example.com)
```
- Admin only (prevents enumeration abuse)
- Resolves MX records via Tor DNS, falls back to A record
- Raw SMTP via SOCKS5 proxy: EHLO, MAIL FROM:<>, RCPT TO, QUIT
- 15-second timeout per connection
- Max 5 emails per invocation
### `!shorten` -- Shorten URL
Shorten a URL via FlaskPaste's URL shortener.
```
!shorten https://very-long-url.example.com/path/to/resource
```
Output format:
```
https://paste.mymx.me/s/AbCdEfGh
```
- URL must start with `http://` or `https://`
- mTLS client cert skips PoW; falls back to PoW challenge if no cert
- Also used internally by `!alert` to shorten announcement URLs
### `!paste` -- Create Paste
Create a text paste via FlaskPaste.
```
!paste some text or data to paste
```
Output format:
```
https://paste.mymx.me/AbCdEfGh
```
- Pastes arbitrary text content
- mTLS client cert skips PoW; falls back to PoW challenge if no cert
### `!pastemoni` -- Paste Site Keyword Monitor
Monitor public paste sites for keywords (data leaks, credential dumps, brand
mentions). Polls Pastebin's archive and GitHub's public Gists API on a
schedule, checks new pastes for keyword matches, and announces hits to the
subscribed IRC channel.
```
!pastemoni add <name> <keyword> Add monitor (admin)
!pastemoni del <name> Remove monitor (admin)
!pastemoni list List monitors
!pastemoni check <name> Force-poll now
```
- `add` and `del` require admin privileges
- All subcommands must be used in a channel (not PM)
- Names must be lowercase alphanumeric + hyphens, 1-20 characters
- Maximum 20 monitors per channel
Backends:
- **Pastebin** (`pb`) -- Scrapes `pastebin.com/archive` for recent pastes,
fetches raw content, case-insensitive keyword match against title + content
- **GitHub Gists** (`gh`) -- Queries `api.github.com/gists/public`, matches
keyword against description and filenames
Polling and announcements:
- Monitors are polled every 5 minutes by default
- On `add`, existing items are seeded in the background (no flood)
- New matches announced as `[tag] Title -- snippet -- URL`
- Maximum 5 items announced per backend per poll; excess shown as `... and N more`
- Titles truncated to 60 characters, snippets to 80 characters
- 5 consecutive all-backend failures doubles the poll interval (max 1 hour)
- Subscriptions persist across bot restarts via `bot.state`
- `list` shows keyword and per-backend error counts
- `check` forces an immediate poll across all backends
### `!internetdb` -- Shodan InternetDB
Look up host information from Shodan's free InternetDB API. Returns open ports,
reverse hostnames, CPE software fingerprints, tags, and known CVEs. No API key
required.
```
!internetdb 8.8.8.8
```
Output format:
```
8.8.8.8 -- dns.google | Ports: 53, 443 | CPEs: cpe:/a:isc:bind | Tags: cloud
```
- Single IP per query (IPv4 or IPv6)
- Private/loopback addresses are rejected
- Hostnames truncated to first 5; CVEs truncated to first 10 (with `+N more`)
- CPEs truncated to first 8
- All requests routed through SOCKS5 proxy
- Returns "no data available" for IPs not in the InternetDB index
### `!canary` -- Canary Token Generator
Generate realistic-looking credentials for planting as canary tokens (tripwires
for detecting unauthorized access). Tokens are persisted per-channel.
```
!canary gen db-cred Generate default token (40-char hex)
!canary gen aws staging-key AWS-style keypair
!canary gen basic svc-login Username:password pair
!canary list List canaries in channel
!canary info db-cred Show full token details
!canary del db-cred Delete a canary (admin)
```
Token types:
| Type | Format | Example |
|------|--------|---------|
| `token` | 40-char hex (API key / SHA1) | `a3f8b2c1d4e5...` |
| `aws` | AKIA access key + base64 secret | `AKIA7X9M2PVL5N...` |
| `basic` | user:pass pair | `svcadmin:xK9mP2vL5nR8wQ3z` |
- `gen` and `del` require admin privileges
- All subcommands must be used in a channel (not PM)
- Labels: 1-32 chars, alphanumeric + hyphens + underscores
- Maximum 50 canaries per channel
- Persisted via `bot.state` (survives restarts)
### `!tcping` -- TCP Connect Latency Probe
Measure TCP connect latency to a host:port through the SOCKS5 proxy. Sequential
probes with min/avg/max summary.
```
!tcping example.com Port 443, 3 probes
!tcping example.com 22 Port 22, 3 probes
!tcping example.com 80 5 Port 80, 5 probes
```
Output format:
```
tcping example.com:443 -- 3 probes 1: 45ms 2: 43ms 3: 47ms min/avg/max: 43/45/47 ms
```
- Default port: 443, default count: 3
- Max count: 10, timeout: 10s per probe
- Private/reserved addresses rejected
- Routed through SOCKS5 proxy
### `!archive` -- Wayback Machine Save
Save a URL to the Wayback Machine via the Save Page Now API.
```
!archive https://example.com/page
```
Output format:
```
Archiving https://example.com/page...
Archived: https://web.archive.org/web/20260220.../https://example.com/page
```
- URL must start with `http://` or `https://`
- Timeout: 30s (archiving can be slow)
- Handles 429 rate limit, 523 origin unreachable
- Sends acknowledgment before archiving
- Routed through SOCKS5 proxy
### `!resolve` -- Bulk DNS Resolution
Resolve multiple hosts via TCP DNS through the SOCKS5 proxy. Concurrent
resolution with compact output.
```
!resolve example.com github.com A records (default)
!resolve example.com AAAA Specific record type
!resolve 1.2.3.4 8.8.8.8 Auto PTR for IPs
```
Output format:
```
example.com -> 93.184.216.34
github.com -> 140.82.121.3
badhost.invalid -> NXDOMAIN
```
- Max 10 hosts per invocation
- Default type: A (auto-detect IP -> PTR)
- DNS server: 1.1.1.1 (Cloudflare)
- Concurrent via `asyncio.gather()`
- Valid types: A, NS, CNAME, SOA, PTR, MX, TXT, AAAA
### `!cron` -- Scheduled Command Execution
Schedule bot commands to repeat on a timer. Admins only.
```
!cron add <interval> <#channel> <command...> Schedule a command
!cron del <id> Remove a job
!cron list List jobs in channel
```
Examples:
```
!cron add 1h #ops !rss check news Poll RSS feed every hour
!cron add 2d #alerts !tor update Update Tor list every 2 days
!cron del abc123 Remove job by ID
!cron list Show jobs in current channel
```
Output format:
```
Cron #a1b2c3: !rss check news every 1h in #ops
#a1b2c3 every 1h: !rss check news
```
- `add` and `del` require admin privileges
- `add` and `list` must be used in a channel (not PM)
- Interval formats: `5m`, `1h30m`, `2d`, `90s`, or raw seconds
- Minimum interval: 1 minute
- Maximum interval: 7 days
- Maximum 20 jobs per channel
- Jobs persist across bot restarts via `bot.state`
- Dispatched commands run with the original creator's identity
- The scheduled command goes through normal command routing and permissions
### FlaskPaste Configuration
```toml
[flaskpaste]
url = "https://paste.mymx.me" # or set FLASKPASTE_URL env var
```
Auth: place client cert/key at `secrets/flaskpaste/derp.crt` and `derp.key`
for mTLS (bypasses PoW). Without them, PoW challenges are solved per request.
### URL Title Preview (urltitle)
Automatic URL title preview for channel messages. When a user posts a URL,
the bot fetches the page title and description and displays a one-line
preview. No commands -- event-driven only.
```
<alice> check out https://example.com/article
<derp> ↳ Article Title -- Description of the article...
```
Behavior:
- Automatically previews HTTP(S) URLs posted in channel messages
- Skips private messages, bot's own messages, and command messages (`!prefix`)
- URLs prefixed with `!` are suppressed: `!https://example.com` produces no preview
- HEAD-then-GET fetch strategy (checks Content-Type before downloading body)
- Skips non-HTML content types (images, PDFs, JSON, etc.)
- Skips binary file extensions (`.png`, `.jpg`, `.pdf`, `.zip`, etc.)
- Skips FlaskPaste URLs and configured ignore hosts
- Dedup: same URL only previewed once per cooldown window (5 min default)
- Max 3 URLs previewed per message (configurable)
- Title from `og:title` takes priority over `<title>` tag
- Description from `og:description` takes priority over `<meta name="description">`
- Title truncated at 200 chars, description at 150 chars
Output format:
```
↳ Page Title -- Description truncated to 150 chars...
↳ Page Title
```
Configuration (optional):
```toml
[urltitle]
cooldown = 300 # seconds before same URL previewed again
timeout = 10 # HTTP fetch timeout
max_urls = 3 # max URLs to preview per message
ignore_hosts = [] # additional hostnames to skip
```
## Teams Integration
Connect derp to Microsoft Teams via outgoing webhooks. The bot runs an HTTP
server that receives messages from Teams and replies inline. No Microsoft SDK
required -- raw asyncio HTTP, same pattern as the webhook plugin.
### How It Works
1. **Outgoing webhook** (Teams -> bot): Teams POSTs an Activity JSON to the
bot's HTTP endpoint when a user @mentions the bot. The bot dispatches the
command through the shared plugin registry and returns the reply as the
HTTP response body.
2. **Incoming webhook** (bot -> Teams, optional): For proactive messages
(alerts, subscriptions), the bot POSTs to a Teams incoming webhook URL.
### Configuration
```toml
[teams]
enabled = true
proxy = true # Route outbound HTTP through SOCKS5
bot_name = "derp" # outgoing webhook display name
bind = "127.0.0.1" # HTTP listen address
port = 8081 # HTTP listen port
webhook_secret = "" # HMAC-SHA256 secret from Teams
incoming_webhook_url = "" # for proactive messages (optional)
admins = [] # AAD object IDs (UUID format)
operators = [] # AAD object IDs
trusted = [] # AAD object IDs
```
### Teams Setup
1. **Create an outgoing webhook** in a Teams channel:
- Channel settings -> Connectors -> Outgoing Webhook
- Set the callback URL to your bot's endpoint (e.g.
`https://derp.example.com/api/messages`)
- Copy the HMAC secret and set `webhook_secret` in config
2. **Expose the bot** via Cloudflare Tunnel or reverse proxy:
```bash
cloudflared tunnel --url http://127.0.0.1:8081
```
3. **Configure permissions** using AAD object IDs from the Activity JSON.
The AAD object ID is sent in `from.aadObjectId` on every message. Use
`!whoami` to discover your ID.
### Permission Tiers
Same 4-tier model as IRC, but matches exact AAD object IDs instead of
fnmatch hostmask patterns:
```toml
[teams]
admins = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"]
operators = ["yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]
trusted = ["zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"]
```
### Plugin Compatibility
~90% of plugins work on Teams without modification -- any plugin that uses
only `bot.send()`, `bot.reply()`, `bot.state`, `message.text`, `.nick`,
and `.target`.
| Feature | IRC | Teams |
|---------|-----|-------|
| `bot.reply()` | Sends PRIVMSG | Appends to HTTP response |
| `bot.send()` | Sends PRIVMSG | POSTs to incoming webhook |
| `bot.action()` | CTCP ACTION | Italic text via incoming webhook |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact AAD object IDs |
| Passive monitoring | All channel messages | @mention only |
### HMAC Verification
Teams outgoing webhooks sign requests with HMAC-SHA256. The secret is
base64-encoded when you create the webhook. The `Authorization` header
format is `HMAC <base64(hmac-sha256(b64decode(secret), body))>`.
If `webhook_secret` is empty, no authentication is performed (useful for
development but not recommended for production).
### Endpoint
Single endpoint: `POST /api/messages`
The bot returns a JSON response:
```json
{"type": "message", "text": "reply text here"}
```
Multiple reply lines are joined with `\n`.
## Telegram Integration
Connect derp to Telegram via long-polling (`getUpdates`). All outbound HTTP
is routed through the SOCKS5 proxy. No public endpoint required, no Telegram
SDK dependency.
### How It Works
The bot calls `getUpdates` in a loop with a long-poll timeout (default 30s).
When a message arrives with the configured prefix, it is dispatched through
the shared plugin registry. Replies are sent immediately via `sendMessage`.
### Configuration
```toml
[telegram]
enabled = true
proxy = true # Route HTTP through SOCKS5
bot_token = "123456:ABC-DEF..." # from @BotFather
poll_timeout = 30 # long-poll timeout in seconds
admins = [123456789] # Telegram user IDs (numeric)
operators = [] # Telegram user IDs
trusted = [] # Telegram user IDs
```
### Telegram Setup
1. **Create a bot** via [@BotFather](https://t.me/BotFather):
- `/newbot` and follow the prompts
- Copy the bot token and set `bot_token` in config
2. **Add the bot** to a group or send it a DM
3. **Configure permissions** using Telegram user IDs. Use `!whoami` to
discover your numeric user ID.
### Permission Tiers
Same 4-tier model as IRC, but matches exact Telegram user IDs (numeric
strings) instead of fnmatch hostmask patterns:
```toml
[telegram]
admins = [123456789]
operators = [987654321]
trusted = [111222333]
```
### Plugin Compatibility
Same compatibility as Teams -- ~90% of plugins work without modification.
| Feature | IRC | Telegram |
|---------|-----|----------|
| `bot.reply()` | Sends PRIVMSG | `sendMessage` API call |
| `bot.send()` | Sends PRIVMSG | `sendMessage` API call |
| `bot.action()` | CTCP ACTION | Italic Markdown text |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact user IDs |
| Message limit | 512 bytes (IRC) | 4096 chars (Telegram) |
### Group Commands
In groups, Telegram appends `@botusername` to commands. The bot strips
this automatically: `!help@mybot` becomes `!help`.
### Transport
All HTTP traffic (API calls, long-polling) routes through the SOCKS5
proxy at `127.0.0.1:1080` via `derp.http.urlopen` when `proxy = true`
(default). Set `proxy = false` to connect directly.
## Mumble Integration
Connect derp to a Mumble server with text chat and voice playback.
Uses [pymumble](https://github.com/azlux/pymumble) for the Mumble
protocol (connection, SSL, voice encoding). Text commands are bridged
from pymumble's thread callbacks to asyncio for plugin dispatch.
### How It Works
pymumble handles the Mumble protocol: TLS connection, ping keepalives,
channel/user tracking, and Opus voice encoding. The bot registers
callbacks for text messages and connection events, then bridges them
to asyncio via `run_coroutine_threadsafe()`. Voice playback feeds raw
PCM to `sound_output.add_sound()` -- pymumble handles Opus encoding,
packetization, and timing.
### Configuration
```toml
[mumble]
enabled = true
proxy = false # SOCKS5 proxy (pymumble connects directly)
host = "mumble.example.com" # Mumble server hostname
port = 64738 # Default Mumble port
username = "derp" # Bot username
password = "" # Server password (optional)
admins = ["admin_user"] # Mumble usernames
operators = [] # Mumble usernames
trusted = [] # Mumble usernames
```
### Mumble Setup
1. **Ensure a Mumble server** (Murmur/Mumble-server) is running
2. **Configure the bot** with the server hostname, port, and credentials
3. **Configure permissions** using Mumble registered usernames. Use
`!whoami` to discover your username as the bot sees it.
### Permission Tiers
Same 4-tier model as IRC, but matches exact Mumble usernames instead of
fnmatch hostmask patterns:
```toml
[mumble]
admins = ["admin_user"]
operators = ["oper_user"]
trusted = ["trusted_user"]
```
### Plugin Compatibility
Same compatibility as Teams/Telegram -- ~90% of plugins work without
modification.
| Feature | IRC | Mumble |
|---------|-----|--------|
| `bot.reply()` | Sends PRIVMSG | TextMessage to channel |
| `bot.send()` | Sends PRIVMSG | TextMessage to channel |
| `bot.action()` | CTCP ACTION | Italic HTML text (`<i>...</i>`) |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact usernames |
### Text Encoding
Mumble uses HTML for text messages. On receive, the bot strips tags and
unescapes entities. On send, text is HTML-escaped. Action messages use
`<i>` tags for italic formatting.
### Music Playback
Stream audio from YouTube, SoundCloud, and other yt-dlp-supported sites
into the Mumble voice channel. Audio is decoded to PCM via a
`yt-dlp | ffmpeg` subprocess pipeline; pymumble handles Opus encoding
and voice transmission.
**System dependencies** (container image includes these):
- `yt-dlp` -- audio stream extraction
- `ffmpeg` -- decode to 48kHz mono s16le PCM
- `libopus` -- Opus codec (used by pymumble/opuslib)
```
!play <url|playlist> Play audio or add to queue (playlists expanded)
!play <query> Search YouTube, play a random result
!stop Stop playback, clear queue (fade-out)
!skip Skip current track (fade-out)
!prev Go back to the previous track (fade-out)
!seek <offset> Seek to position (1:30, 90, +30, -30)
!resume Resume last stopped/skipped track from saved position
!queue Show queue (with durations + totals)
!queue <url> Add to queue (alias for !play)
!np Now playing
!volume [0-100] Get/set volume (persisted across restarts)
!keep Keep current track's audio file (with metadata)
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
!testtone Play 3-second 440Hz test tone
!playlist save <name> Save current + queued tracks as named playlist
!playlist load <name> Append saved playlist to queue, start if idle
!playlist list Show saved playlists with track counts
!playlist del <name> Delete a saved playlist
```
- Queue holds up to 50 tracks
- Non-URL input is treated as a YouTube search; 10 results are fetched
and one is picked randomly
- Playlists are expanded into individual tracks; excess tracks are
truncated at the queue limit
- `!skip`, `!stop`, `!prev`, and `!seek` fade out smoothly (~0.8s) before
switching tracks; volume changes ramp smoothly over ~1s (no abrupt jumps)
- Default volume: 50%; persisted via `bot.state` across restarts
- Titles resolved via `yt-dlp --flat-playlist` before playback
- Audio is downloaded before playback (`data/music/`); files are deleted
after playback unless `!keep` is used. Falls back to streaming on
download failure.
- Audio pipeline: `ffmpeg` subprocess for local files, `yt-dlp | ffmpeg`
for streaming fallback, PCM fed to pymumble
- Commands are Mumble-only; `!play` on other adapters replies with an error,
other music commands silently no-op
- Playback runs as an asyncio background task; the bot remains responsive
to text commands during streaming
- `!prev` returns to the last-played track; up to 10 tracks are kept in a
per-session history stack (populated on skip and natural track completion)
- `!resume` continues from where playback was interrupted (`!stop`/`!skip`);
position is persisted via `bot.state` and survives bot restarts
### Auto-Resume on Reconnect
If the bot disconnects while music is playing (network hiccup, server
restart), it saves the current track and position. On reconnect, it
automatically resumes playback -- but only after the channel is silent
(using the same silence threshold as voice ducking, default 15s).
- Resume state is saved on both explicit stop/skip and on stream errors
(disconnect)
- Works across container restarts (cold boot) and network reconnections
- The bot waits up to 60s for silence; if the channel stays active, it
aborts and the saved state remains for manual `!resume`
- Chat messages announce resume intentions and abort reasons
- The reconnect watcher starts via the `on_connected` plugin lifecycle hook
### Seeking
Fast-forward or rewind within the currently playing track.
```
!seek 1:30 Seek to 1 minute 30 seconds
!seek 90 Seek to 90 seconds
!seek +30 Jump forward 30 seconds
!seek -30 Jump backward 30 seconds
!seek +1:00 Jump forward 1 minute
```
- Absolute offsets (`1:30`, `90`) seek to that position from the start
- Relative offsets (`+30`, `-1:00`) jump from the current position
- Negative seeks are clamped to the start of the track
- Seeking restarts the audio pipeline at the new position
### Disconnect-Resilient Streaming
During brief network disconnects (~5-15s), the audio stream stays alive.
The ffmpeg pipeline keeps running; PCM frames are read at real-time pace
but dropped while pymumble reconnects. Once the connection re-establishes
and the codec is negotiated, audio feeding resumes automatically. The
listener hears a brief silence instead of a 30+ second restart with URL
re-resolution.
- The `_is_audio_ready()` guard checks: mumble connected, sound_output
exists, Opus encoder initialized
- Frames are counted even during disconnect, so position tracking remains
accurate
- State transitions (connected/disconnected) are logged for diagnostics
### Voice Ducking
When other users speak in the Mumble channel, the music volume automatically
ducks (lowers) to a configurable floor. After a configurable silence period,
volume gradually restores to the user-set level in small steps.
```
!duck Show ducking status and settings
!duck on Enable voice ducking
!duck off Disable voice ducking
!duck floor <0-100> Set floor volume % (default: 2)
!duck silence <sec> Set silence timeout in seconds (default: 15)
!duck restore <sec> Set restore ramp duration in seconds (default: 30)
```
Behavior:
- Enabled by default; voice is detected via pymumble's sound callback
- When someone speaks, volume drops immediately to the floor value
- After `silence` seconds of no voice, volume restores via a single
smooth linear ramp over `restore` seconds (default 30s)
- The per-frame volume ramp in `stream_audio` further smooths the
transition, eliminating audible steps
- Ducking resets when playback stops, skips, or the queue empties
Configuration (optional):
```toml
[music]
duck_enabled = true # Enable voice ducking (default: true)
duck_floor = 1 # Floor volume % during ducking (default: 1)
duck_silence = 15 # Seconds of silence before restoring (default: 15)
duck_restore = 30 # Seconds for smooth volume restore (default: 30)
```
### Download-First Playback
Audio is downloaded to `data/music/` before playback begins. This
eliminates CDN hiccups mid-stream and enables instant seeking. Files
are identified by a hash of the URL so the same URL reuses the same
file (natural dedup).
- If download fails, playback falls back to streaming (`yt-dlp | ffmpeg`)
- After a track finishes, the local file is automatically deleted
- Use `!keep` during playback to preserve the file; metadata (title, artist,
duration) is fetched via yt-dlp and stored in `bot.state`
- Use `!kept` to list preserved files with metadata (title, artist, duration,
file size)
- Use `!kept rm <id>` to remove a single kept track (file + metadata)
- Use `!kept clear` to delete all preserved files and their metadata
- Use `!kept repair` to re-download any kept tracks whose local files are
missing (e.g. after a cleanup or volume mount issue)
- On cancel/error, files are not deleted (needed for `!resume`)
### Music Discovery
Find similar music and genre tags for artists. Uses Last.fm when an API
key is configured; falls back to MusicBrainz automatically (no key
required).
```
!similar Discover + play similar to current track
!similar <artist> Discover + play similar to named artist
!similar list Show similar (display only)
!similar list <artist> Show similar for named artist
!tags Genre tags for currently playing artist
!tags <artist> Genre tags for named artist
```
- Default `!similar` builds a discovery playlist: finds similar artists/tracks,
resolves each against YouTube in parallel, fades out current playback, and
starts the new playlist
- `!similar list` shows results without playing (old default behavior)
- When an API key is set, Last.fm is tried first for richer results
- When no API key is set (or Last.fm returns empty), MusicBrainz is
used as a fallback (artist search -> tags -> similar recordings)
- Without the music plugin loaded, `!similar` falls back to display mode
- MusicBrainz rate limit: 1 request/second (handled automatically)
Configuration (optional):
```toml
[lastfm]
api_key = "" # Last.fm API key (or set LASTFM_API_KEY env var)
```
### Autoplay Discovery
During autoplay, the bot periodically discovers new tracks instead of
only playing from the kept library. Every Nth autoplay pick (configurable
via `discover_ratio`), it queries Last.fm or MusicBrainz for a track
similar to the last-played one. Discovered tracks are searched on YouTube
and queued automatically.
Configuration (optional):
```toml
[music]
autoplay = true # Enable autoplay (default: true)
autoplay_cooldown = 30 # Seconds between autoplay tracks (default: 30)
discover = true # Enable discovery during autoplay (default: true)
discover_ratio = 3 # Discover every Nth pick (default: 3)
```
### Extra Mumble Bots
Run additional bot identities on the same Mumble server. Each extra bot
inherits the main `[mumble]` connection settings and overrides only what
differs (username, certificates, greeting). Extra bots share the plugin
registry but get their own state DB and do **not** run the voice trigger
by default (prevents double-processing).
```toml
[[mumble.extra]]
username = "merlin"
certfile = "secrets/mumble/merlin.crt"
keyfile = "secrets/mumble/merlin.key"
greet = "The sorcerer has arrived."
```
- `username`, `certfile`, `keyfile` -- identity overrides
- `greet` -- TTS message spoken on first connect (optional)
- All other `[mumble]` keys (host, port, password, admins, etc.) are inherited
- Voice trigger is disabled unless the extra entry includes a `voice` key
### Voice STT/TTS
Transcribe voice from Mumble users via Whisper STT and speak text aloud
via Piper TTS. Requires local Whisper and Piper services.
```
!listen [on|off] Toggle voice-to-text transcription (admin)
!listen Show current listen status
!say <text> Speak text aloud via TTS (max 500 chars)
```
STT behavior:
- When enabled, the bot buffers incoming voice PCM per user
- After a configurable silence gap (default 1.5s), the buffer is
transcribed via Whisper and posted as an action message
- Utterances shorter than 0.5s are discarded (noise filter)
- Utterances are capped at 30s to bound memory and latency
- Transcription results are posted as: `* derp heard Alice say: hello`
- The listener survives reconnects when `!listen` is on
TTS behavior:
- `!say` fetches WAV from Piper and plays it via `stream_audio()`
- Piper outputs 22050Hz WAV; ffmpeg resamples to 48kHz automatically
- TTS shares the audio output with music playback
- Text is limited to 500 characters
- Set `greet` in `[mumble]` or `[[mumble.extra]]` for automatic TTS on first connect
### Always-On Trigger Mode
Set a trigger word to enable always-on voice listening. The bot
continuously transcribes voice and watches for the trigger word. When
detected, the text after the trigger is spoken back via TTS. No
`!listen` command needed.
```toml
[voice]
trigger = "claude"
```
Behavior:
- Listener starts automatically on connect (no `!listen on` required)
- All speech is transcribed and checked for the trigger word
- Trigger match is case-insensitive: "Claude", "CLAUDE", "claude" all work
- On match, the trigger word is stripped and the remainder is sent to TTS
- Non-triggered speech is silently discarded (unless `!listen` is also on)
- When both trigger and `!listen` are active, triggered speech goes to
TTS and all other speech is posted as the usual "heard X say: ..."
- `!listen` status shows trigger configuration when set
Configuration (optional):
```toml
[voice]
whisper_url = "http://192.168.129.9:8080/inference"
piper_url = "http://192.168.129.9:5100/"
silence_gap = 1.5
trigger = ""
```
Piper TTS accepts POST with JSON body `{"text": "..."}` and returns
22050 Hz 16-bit PCM mono WAV. Default voice: `en_US-lessac-medium`.
Available voices:
| Region | Voices |
|--------|--------|
| en_US | lessac-medium/high, amy-medium, ryan-medium/high, joe-medium, john-medium, kristin-medium, danny-low, ljspeech-high |
| en_GB | alba-medium, cori-medium/high, jenny_dioco-medium, northern_english_male-medium, southern_english_female-low |
| fr_FR | siwis-low/medium, gilles-low, tom-medium, mls-medium, mls_1840-low, upmc-medium |
To switch the active voice, set `piper_tts_voice` (e.g.
`fr_FR-siwis-medium`) and redeploy the TTS service.
### Mumble Server Admin (admin)
Manage Mumble users and channels via chat commands. All subcommands
require admin tier. Mumble-only (no-op on other adapters).
```
!mu kick <user> [reason] Kick user from server
!mu ban <user> [reason] Ban user from server
!mu mute <user> Server-mute user
!mu unmute <user> Remove server-mute
!mu deafen <user> Server-deafen user
!mu undeafen <user> Remove server-deafen
!mu move <user> <channel> Move user to channel
!mu users List connected users
!mu channels List server channels
!mu mkchan <name> [parent] Create channel (under parent or root)
!mu rmchan <name> Remove empty channel
!mu rename <old> <new> Rename channel
!mu desc <channel> <text> Set channel description
```