Files
derp/plugins/webhook.py
user c483beb555 feat: add webhook listener for push events to channels
HTTP POST endpoint for external services (CI, monitoring, GitHub).
HMAC-SHA256 auth, JSON body, single POST endpoint at /.

- asyncio.start_server with raw HTTP parsing (zero deps)
- Body validation: channel prefix, non-empty text, 64KB cap
- !webhook admin command shows address, request count, uptime
- Module-level server guard prevents duplicates on reconnect
- 22 test cases in test_webhook.py
2026-02-21 17:59:14 +01:00

197 lines
6.1 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
_server: asyncio.Server | None = None
_request_count: int = 0
_started: float = 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."""
global _request_count
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)
_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)."""
global _server, _started, _request_count
if _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:
_server = await asyncio.start_server(handler, host, port)
_started = time.monotonic()
_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."""
if _server is None:
await bot.reply(message, "Webhook: not running")
return
socks = _server.sockets
if socks:
addr = socks[0].getsockname()
address = f"{addr[0]}:{addr[1]}"
else:
address = "unknown"
elapsed = int(time.monotonic() - _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} | {_request_count} requests | up {uptime}",
)