"""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}", )