Connect to multiple IRC servers concurrently from a single config file. Plugins are loaded once and shared; per-server state is isolated via separate SQLite databases and per-bot runtime state (bot._pstate). - Add build_server_configs() for [servers.*] config layout - Bot.__init__ gains name parameter, _pstate dict for plugin isolation - cli.py runs multiple bots via asyncio.gather - 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern - Backward compatible: legacy [server] config works unchanged Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
6.3 KiB
Python
205 lines
6.3 KiB
Python
"""Webhook listener: receive HTTP POST requests and relay messages to IRC."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from derp.plugin import command, event
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_MAX_BODY = 65536 # 64 KB
|
|
|
|
|
|
def _ps(bot):
|
|
"""Per-bot plugin runtime state."""
|
|
return bot._pstate.setdefault("webhook", {
|
|
"server": None,
|
|
"request_count": 0,
|
|
"started": 0.0,
|
|
})
|
|
|
|
|
|
def _verify_signature(secret: str, body: bytes, signature: str) -> bool:
|
|
"""Verify HMAC-SHA256 signature from X-Signature header."""
|
|
if not secret:
|
|
return True # no secret configured = open access
|
|
if not signature.startswith("sha256="):
|
|
return False
|
|
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(expected, signature[7:])
|
|
|
|
|
|
def _http_response(status: int, reason: str, body: str = "") -> bytes:
|
|
"""Build a minimal HTTP/1.1 response."""
|
|
body_bytes = body.encode("utf-8") if body else b""
|
|
lines = [
|
|
f"HTTP/1.1 {status} {reason}",
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
f"Content-Length: {len(body_bytes)}",
|
|
"Connection: close",
|
|
"",
|
|
"",
|
|
]
|
|
return "\r\n".join(lines).encode("utf-8") + body_bytes
|
|
|
|
|
|
async def _handle_request(reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
bot, secret: str) -> None:
|
|
"""Parse one HTTP request and dispatch to IRC."""
|
|
ps = _ps(bot)
|
|
|
|
try:
|
|
# Read request line
|
|
request_line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
|
if not request_line:
|
|
return
|
|
parts = request_line.decode("utf-8", errors="replace").strip().split()
|
|
if len(parts) < 2:
|
|
writer.write(_http_response(400, "Bad Request", "malformed request"))
|
|
return
|
|
method, _path = parts[0], parts[1]
|
|
|
|
# Read headers
|
|
headers: dict[str, str] = {}
|
|
while True:
|
|
line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
|
if not line or line == b"\r\n" or line == b"\n":
|
|
break
|
|
decoded = line.decode("utf-8", errors="replace").strip()
|
|
if ":" in decoded:
|
|
key, val = decoded.split(":", 1)
|
|
headers[key.strip().lower()] = val.strip()
|
|
|
|
# Method check
|
|
if method != "POST":
|
|
writer.write(_http_response(405, "Method Not Allowed", "POST only"))
|
|
return
|
|
|
|
# Read body
|
|
content_length = int(headers.get("content-length", "0"))
|
|
if content_length > _MAX_BODY:
|
|
writer.write(_http_response(413, "Payload Too Large",
|
|
f"max {_MAX_BODY} bytes"))
|
|
return
|
|
body = await asyncio.wait_for(reader.readexactly(content_length),
|
|
timeout=10.0)
|
|
|
|
# Verify HMAC signature
|
|
signature = headers.get("x-signature", "")
|
|
if not _verify_signature(secret, body, signature):
|
|
writer.write(_http_response(401, "Unauthorized", "bad signature"))
|
|
return
|
|
|
|
# Parse JSON
|
|
try:
|
|
data = json.loads(body)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
writer.write(_http_response(400, "Bad Request", "invalid JSON"))
|
|
return
|
|
|
|
# Validate fields
|
|
channel = data.get("channel", "")
|
|
text = data.get("text", "")
|
|
is_action = data.get("action", False)
|
|
|
|
if not isinstance(channel, str) or not channel.startswith(("#", "&")):
|
|
writer.write(_http_response(400, "Bad Request", "invalid channel"))
|
|
return
|
|
if not isinstance(text, str) or not text.strip():
|
|
writer.write(_http_response(400, "Bad Request", "empty text"))
|
|
return
|
|
|
|
# Send to IRC
|
|
text = text.strip()
|
|
if is_action:
|
|
await bot.action(channel, text)
|
|
else:
|
|
await bot.send(channel, text)
|
|
|
|
ps["request_count"] += 1
|
|
writer.write(_http_response(200, "OK", "sent"))
|
|
log.info("webhook: relayed to %s (%d bytes)", channel, len(text))
|
|
|
|
except (asyncio.TimeoutError, asyncio.IncompleteReadError, ConnectionError):
|
|
log.debug("webhook: client disconnected")
|
|
except Exception:
|
|
log.exception("webhook: error handling request")
|
|
try:
|
|
writer.write(_http_response(500, "Internal Server Error"))
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@event("001")
|
|
async def on_connect(bot, message):
|
|
"""Start the webhook HTTP server on connect (if enabled)."""
|
|
ps = _ps(bot)
|
|
|
|
if ps["server"] is not None:
|
|
return # already running
|
|
|
|
cfg = bot.config.get("webhook", {})
|
|
if not cfg.get("enabled"):
|
|
return
|
|
|
|
host = cfg.get("host", "127.0.0.1")
|
|
port = cfg.get("port", 8080)
|
|
secret = cfg.get("secret", "")
|
|
|
|
async def handler(reader, writer):
|
|
await _handle_request(reader, writer, bot, secret)
|
|
|
|
try:
|
|
ps["server"] = await asyncio.start_server(handler, host, port)
|
|
ps["started"] = time.monotonic()
|
|
ps["request_count"] = 0
|
|
log.info("webhook: listening on %s:%d", host, port)
|
|
except OSError as exc:
|
|
log.error("webhook: failed to bind %s:%d: %s", host, port, exc)
|
|
|
|
|
|
@command("webhook", help="Show webhook listener status", admin=True)
|
|
async def cmd_webhook(bot, message):
|
|
"""Display webhook server status."""
|
|
ps = _ps(bot)
|
|
|
|
if ps["server"] is None:
|
|
await bot.reply(message, "Webhook: not running")
|
|
return
|
|
|
|
socks = ps["server"].sockets
|
|
if socks:
|
|
addr = socks[0].getsockname()
|
|
address = f"{addr[0]}:{addr[1]}"
|
|
else:
|
|
address = "unknown"
|
|
|
|
elapsed = int(time.monotonic() - ps["started"])
|
|
hours, rem = divmod(elapsed, 3600)
|
|
minutes, secs = divmod(rem, 60)
|
|
parts = []
|
|
if hours:
|
|
parts.append(f"{hours}h")
|
|
if minutes:
|
|
parts.append(f"{minutes}m")
|
|
parts.append(f"{secs}s")
|
|
uptime = " ".join(parts)
|
|
|
|
await bot.reply(
|
|
message,
|
|
f"Webhook: {address} | {ps['request_count']} requests | up {uptime}",
|
|
)
|