Capture kat-coder + qwen3-coder-30b outputs (5 prompts each, API-run, NOT graded yet)
Via tools/grade_run.py against LM Studio (load -> all prompts -> unload -> verify).
kat-coder: lfu/tts/webhook/automation/rust (lfu has a real SyntaxError: 'self._ bump_freq')
qwen3-coder-30b: lfu/tts/webhook/automation/rust (all 4 py parse clean; terse ~2k tok outputs)
Grading deferred to a later session (capture-only mode).
Script hardening:
- --resident / --unload-all commands (read loaded_instances, POST unload, verify)
- MEMORY GUARD: aborts if a different model is resident (never stack 30GB models)
- TTFT self-discovery dump (LM Studio returns empty stats non-streaming;
fix next session: switch to /api/v1/chat stream:true -> chat.end result.stats)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Twitch EventSub → Mock Discord Webhook Bridge
|
||||
==============================================
|
||||
Receives signed Twitch-style webhook events on POST /webhook,
|
||||
verifies HMAC signatures, deduplicates by event_id, rate-limits
|
||||
outbound Discord forwards, and retries on 429 backoff.
|
||||
|
||||
Run: python webhook_bridge.py [--secret <key>] [--port <port>]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import secrets
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", secrets.token_hex(16))
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
|
||||
RATE_LIMIT = 5 # Discord forwards per second
|
||||
IDEMPOTENCY_TTL = 300 # seconds
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("bridge")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock Discord Webhook Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DiscordMock:
|
||||
"""Simulates a downstream Discord webhook endpoint.
|
||||
|
||||
Succeeds ~95 % of the time. Occasionally returns HTTP 429 with a
|
||||
``Retry-After`` header to exercise the backoff path.
|
||||
"""
|
||||
|
||||
def __init__(self, fail_rate: float = 0.05) -> None:
|
||||
self.fail_rate = fail_rate
|
||||
self.forward_count: int = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def send(self, payload: dict[str, Any]) -> tuple[int, dict[str, str]]:
|
||||
"""Return (status_code, headers) simulating Discord's response."""
|
||||
async with self._lock:
|
||||
self.forward_count += 1
|
||||
|
||||
import random
|
||||
|
||||
if random.random() < self.fail_rate:
|
||||
retry_after = random.choice([1, 2, 3])
|
||||
log.warning("Discord returned 429 (Retry-After=%ds)", retry_after)
|
||||
return 429, {"Retry-After": str(retry_after)}
|
||||
|
||||
log.info("Discord forward #%d succeeded", self.forward_count)
|
||||
return 200, {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotency Store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IdempotencyStore:
|
||||
"""In-memory deduplication store keyed by ``event_id``.
|
||||
|
||||
Entries older than ``IDEMPOTENCY_TTL`` seconds are evicted on access.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: int = IDEMPOTENCY_TTL) -> None:
|
||||
self._ttl = ttl
|
||||
self._store: dict[str, float] = {} # event_id -> timestamp
|
||||
|
||||
def is_duplicate(self, event_id: str) -> bool:
|
||||
"""Return True if *event_id* was seen within the TTL window."""
|
||||
now = time.monotonic()
|
||||
# Evict stale entries
|
||||
self._store = {k: v for k, v in self._store.items() if now - v < self._ttl}
|
||||
if event_id in self._store:
|
||||
return True
|
||||
self._store[event_id] = now
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-Bucket Rate Limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TokenBucket:
|
||||
"""Non-blocking token-bucket rate limiter.
|
||||
|
||||
Capacity and refill rate are both *rate* tokens/second.
|
||||
``try_acquire()`` returns True immediately if a token is available,
|
||||
otherwise False. The caller should ``await self.wait()`` to block
|
||||
until a token is available when the bucket is empty.
|
||||
"""
|
||||
|
||||
def __init__(self, rate: float) -> None:
|
||||
self._rate = rate
|
||||
self._tokens = float(rate)
|
||||
self._last = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _refill(self) -> None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last
|
||||
self._tokens = min(float(self._rate), self._tokens + elapsed * self._rate)
|
||||
self._last = now
|
||||
|
||||
async def try_acquire(self) -> bool:
|
||||
"""Try to consume a token without blocking. Returns True on success."""
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
async def wait(self) -> None:
|
||||
"""Block until a token becomes available, then consume it."""
|
||||
while True:
|
||||
if await self.try_acquire():
|
||||
return
|
||||
# Sleep just enough for one token to refill
|
||||
await asyncio.sleep(1.0 / self._rate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook Bridge Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class EventContext:
|
||||
"""Holds the parsed event and its raw body for signing."""
|
||||
|
||||
event_id: str
|
||||
event_type: str
|
||||
data: Any
|
||||
raw_body: bytes
|
||||
|
||||
|
||||
class WebhookBridge:
|
||||
"""Core bridge: verify → deduplicate → rate-limit → forward."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secret: str,
|
||||
discord: DiscordMock,
|
||||
store: IdempotencyStore | None = None,
|
||||
limiter: TokenBucket | None = None,
|
||||
) -> None:
|
||||
self.secret = secret
|
||||
self.discord = discord
|
||||
self.store = store or IdempotencyStore()
|
||||
self.limiter = limiter or TokenBucket(RATE_LIMIT)
|
||||
# Track successful forwards for test assertions
|
||||
self.successful_forwards: list[dict] = []
|
||||
|
||||
# -- HMAC verification --------------------------------------------------
|
||||
|
||||
def verify_signature(self, body: bytes, signature: str) -> bool:
|
||||
"""Constant-time HMAC-SHA256 verification."""
|
||||
expected = hmac.new(
|
||||
self.secret.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
# -- Event parsing ------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def parse_event(body: bytes) -> EventContext | None:
|
||||
"""Parse and validate the JSON payload. Returns None on failure."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
log.error("Malformed JSON body: %s", exc)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
log.error("JSON body is not an object")
|
||||
return None
|
||||
|
||||
event_id = data.get("event_id")
|
||||
event_type = data.get("type")
|
||||
if not event_id or not isinstance(event_id, str):
|
||||
log.error("Missing or invalid 'event_id'")
|
||||
return None
|
||||
if not event_type or not isinstance(event_type, str):
|
||||
log.error("Missing or invalid 'type'")
|
||||
return None
|
||||
|
||||
return EventContext(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
data=data.get("data", {}),
|
||||
raw_body=body,
|
||||
)
|
||||
|
||||
# -- Forward pipeline ---------------------------------------------------
|
||||
|
||||
async def forward(self, ctx: EventContext) -> int:
|
||||
"""Run the full pipeline. Returns HTTP status code to send back."""
|
||||
|
||||
# 1. Idempotency check
|
||||
if self.store.is_duplicate(ctx.event_id):
|
||||
log.info("Duplicate event_id=%s — skipping", ctx.event_id)
|
||||
return 200
|
||||
|
||||
# 2. Rate-limit: acquire a token (may block briefly)
|
||||
await self.limiter.wait()
|
||||
|
||||
# 3. Build Discord payload
|
||||
discord_payload = {
|
||||
"content": (
|
||||
f"📨 Twitch Event [{ctx.event_type}]\n"
|
||||
f"ID: {ctx.event_id}\n"
|
||||
f"Data: {json.dumps(ctx.data, default=str)}"
|
||||
),
|
||||
}
|
||||
|
||||
# 4. Send to Discord with single-shot 429 backoff retry
|
||||
status, headers = await self.discord.send(discord_payload)
|
||||
|
||||
if status == 429:
|
||||
retry_after = int(headers.get("Retry-After", "1"))
|
||||
log.warning(
|
||||
"Discord 429 on event_id=%s — backing off %ds",
|
||||
ctx.event_id,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
# Retry once
|
||||
status, _ = await self.discord.send(discord_payload)
|
||||
|
||||
if status == 200:
|
||||
self.successful_forwards.append(discord_payload)
|
||||
log.info("Forwarded event_id=%s ✓", ctx.event_id)
|
||||
else:
|
||||
log.error("Discord returned %d for event_id=%s", status, ctx.event_id)
|
||||
|
||||
return 200 if status == 200 else 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async HTTP Server (pure stdlib, no third-party deps)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AsyncHandler:
|
||||
"""Minimal async HTTP request handler using asyncio streams."""
|
||||
|
||||
def __init__(self, bridge: WebhookBridge) -> None:
|
||||
self.bridge = bridge
|
||||
|
||||
async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
# Read request line
|
||||
request_line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
||||
if not request_line:
|
||||
return
|
||||
parts = request_line.decode().strip().split()
|
||||
if len(parts) < 2:
|
||||
self._send(writer, 400, "Bad 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 line in (b"\r\n", b"\n", b""):
|
||||
break
|
||||
decoded = line.decode().strip()
|
||||
if ":" in decoded:
|
||||
key, val = decoded.split(":", 1)
|
||||
headers[key.strip().lower()] = val.strip()
|
||||
|
||||
# Read body
|
||||
body = b""
|
||||
content_length = int(headers.get("content-length", "0"))
|
||||
if content_length > 0:
|
||||
body = await asyncio.wait_for(reader.readexactly(content_length), timeout=10.0)
|
||||
|
||||
if path == "/webhook" and method == "POST":
|
||||
await self._handle_webhook(reader, writer, body, headers)
|
||||
else:
|
||||
self._send(writer, 404, "Not Found")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
self._send(writer, 408, "Request Timeout")
|
||||
except Exception as exc:
|
||||
log.exception("Unhandled error in request handler")
|
||||
self._send(writer, 500, "Internal Server Error")
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _handle_webhook(
|
||||
self, reader, writer, body: bytes, headers: dict[str, str]
|
||||
) -> None:
|
||||
# Signature verification
|
||||
signature = headers.get("x-signature")
|
||||
if not signature:
|
||||
log.warning("Missing X-Signature header")
|
||||
self._send(writer, 401, "Unauthorized")
|
||||
return
|
||||
|
||||
if not self.bridge.verify_signature(body, signature):
|
||||
log.warning("HMAC signature mismatch")
|
||||
self._send(writer, 401, "Unauthorized")
|
||||
return
|
||||
|
||||
# Parse event
|
||||
ctx = self.bridge.parse_event(body)
|
||||
if ctx is None:
|
||||
self._send(writer, 400, "Bad Request — invalid payload")
|
||||
return
|
||||
|
||||
log.info("Received event_id=%s type=%s", ctx.event_id, ctx.event_type)
|
||||
|
||||
# Forward (async pipeline with rate limiting & retry)
|
||||
status = await self.bridge.forward(ctx)
|
||||
self._send(writer, status, "OK" if status == 200 else "Error")
|
||||
|
||||
@staticmethod
|
||||
def _send(writer: asyncio.StreamWriter, status: int, reason: str) -> None:
|
||||
body = f"{status} {reason}\n".encode()
|
||||
response = (
|
||||
b"HTTP/1.1 " + f"{status} {reason}".encode() + b"\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||
b"Connection: close\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
writer.write(response + body)
|
||||
|
||||
|
||||
async def run_server(bridge: WebhookBridge, host: str = "127.0.0.1", port: int = PORT) -> None:
|
||||
"""Start the async HTTP server."""
|
||||
server = await asyncio.start_server(
|
||||
lambda r, w: _AsyncHandler(bridge).handle(r, w), host, port
|
||||
)
|
||||
addr = server.sockets[0].getsockname()
|
||||
log.info("Bridge listening on http://%s:%d", addr[0], addr[1])
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threaded HTTP Server (for test compatibility with urllib/http.client)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class _BridgeHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Synchronous wrapper around the async bridge for use with http.server."""
|
||||
|
||||
bridge: WebhookBridge = None # type: ignore[assignment]
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/webhook":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Not Found")
|
||||
return
|
||||
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
headers_dict = {k.lower(): v for k, v in self.headers.items()}
|
||||
|
||||
# Signature verification
|
||||
signature = headers_dict.get("x-signature")
|
||||
if not signature:
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Unauthorized")
|
||||
return
|
||||
|
||||
if not self.bridge.verify_signature(body, signature):
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Unauthorized")
|
||||
return
|
||||
|
||||
ctx = self.bridge.parse_event(body)
|
||||
if ctx is None:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Bad Request")
|
||||
return
|
||||
|
||||
# Run async forward pipeline in a thread-safe way
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
task = loop.run_until_complete(self.bridge.forward(ctx))
|
||||
self.send_response(task)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK" if task == 200 else b"Error")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: ARG002
|
||||
log.info(fmt, *args)
|
||||
|
||||
|
||||
def start_threaded_server(bridge: WebhookBridge, port: int = PORT) -> _ThreadedServer:
|
||||
"""Start a threaded HTTP server in a background thread. Returns the server."""
|
||||
_BridgeHandler.bridge = bridge
|
||||
srv = _ThreadedServer(("127.0.0.1", port), _BridgeHandler)
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
log.info("Threaded test server on http://%s:%d", "127.0.0.1", port)
|
||||
return srv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_payload(event_id: str, event_type: str = "chat", data: dict | None = None) -> bytes:
|
||||
"""Create a signed webhook payload."""
|
||||
body_dict = {"event_id": event_id, "type": event_type, "data": data or {}}
|
||||
body = json.dumps(body_dict).encode()
|
||||
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return body, sig
|
||||
|
||||
|
||||
def post_webhook(url: str, body: bytes, signature: str) -> tuple[int, str]:
|
||||
"""Send a POST request and return (status_code, body_text)."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": signature,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read().decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_tests(port: int = 9099) -> int:
|
||||
"""Run all tests. Returns number of failures."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"http://127.0.0.1:{port}/webhook"
|
||||
failures = 0
|
||||
|
||||
def assert_eq(actual, expected, msg: str) -> None:
|
||||
nonlocal failures
|
||||
if actual != expected:
|
||||
print(f" ✗ FAIL: {msg} — expected {expected!r}, got {actual!r}")
|
||||
failures += 1
|
||||
else:
|
||||
print(f" ✓ PASS: {msg}")
|
||||
|
||||
def assert_true(condition: bool, msg: str) -> None:
|
||||
nonlocal failures
|
||||
if not condition:
|
||||
print(f" ✗ FAIL: {msg}")
|
||||
failures += 1
|
||||
else:
|
||||
print(f" ✓ PASS: {msg}")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Setup fresh bridge for tests
|
||||
# -----------------------------------------------------------------------
|
||||
discord = DiscordMock(fail_rate=0.0) # deterministic: no random failures
|
||||
bridge = WebhookBridge(secret=SECRET, discord=discord)
|
||||
server = start_threaded_server(bridge, port)
|
||||
time.sleep(0.2) # let server start
|
||||
|
||||
try:
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (a): Valid signed request → forwarded, returns 200 ===")
|
||||
# -------------------------------------------------------------------
|
||||
before = discord.forward_count
|
||||
body, sig = make_payload("evt-a1", "chat", {"message": "hello"})
|
||||
status, text = post_webhook(url, body, sig)
|
||||
assert_eq(status, 200, "HTTP status")
|
||||
assert_true(discord.forward_count == before + 1, "Discord forward count increased by 1")
|
||||
assert_true(len(bridge.successful_forwards) == 1, "Bridge recorded 1 successful forward")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (b): Tampered signature → 401, nothing forwarded ===")
|
||||
# -------------------------------------------------------------------
|
||||
before = discord.forward_count
|
||||
bad_body = body + b"tampered"
|
||||
bad_sig = hmac.new(SECRET.encode(), bad_body, hashlib.sha256).hexdigest()
|
||||
# Actually tamper: use wrong signature for correct body
|
||||
status, text = post_webhook(url, body, "deadbeef" * 8)
|
||||
assert_eq(status, 401, "HTTP status")
|
||||
assert_true(discord.forward_count == before, "No additional Discord forwards")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (c): Replay same event_id → skipped, count unchanged ===")
|
||||
# -------------------------------------------------------------------
|
||||
before = discord.forward_count
|
||||
status, text = post_webhook(url, body, sig)
|
||||
assert_eq(status, 200, "HTTP status (still 200 for replay)")
|
||||
assert_true(discord.forward_count == before, "Discord forward count did NOT increase")
|
||||
assert_true(len(bridge.successful_forwards) == 1, "Bridge still has only 1 forward")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (d): Burst >5 events/sec → rate limiter delays excess ===")
|
||||
# -------------------------------------------------------------------
|
||||
# We send 10 events rapidly. The limiter allows 5/sec, so the last
|
||||
# 5 should be delayed. We measure wall-clock time to verify.
|
||||
burst_ids = [f"evt-burst-{i}" for i in range(10)]
|
||||
payloads_sigs = [make_payload(eid, "chat", {"n": i}) for i, eid in enumerate(burst_ids)]
|
||||
|
||||
start_time = time.monotonic()
|
||||
results: list[tuple[int, float]] = [] # (status, timestamp)
|
||||
for i, (b, s) in enumerate(payloads_sigs):
|
||||
st, _ = post_webhook(url, b, s)
|
||||
results.append((st, time.monotonic() - start_time))
|
||||
|
||||
elapsed = time.monotonic() - start_time
|
||||
assert_true(all(r[0] == 200 for r in results), "All burst requests returned 200")
|
||||
|
||||
# With a 5/sec limiter and 10 events, total time should be > 1s
|
||||
# (first 5 immediate, next 5 wait ~1s for tokens)
|
||||
assert_true(
|
||||
elapsed >= 0.8,
|
||||
f"Burst took {elapsed:.2f}s (expected ≥0.8s due to rate limiting)",
|
||||
)
|
||||
|
||||
# At most 5 forwards should have succeeded (the rest may have hit 429
|
||||
# in the mock, but with fail_rate=0.0 they should all succeed eventually)
|
||||
# Actually with the token bucket + retry, all 10 should forward but
|
||||
# spread over time. Let's just verify the timing constraint above.
|
||||
print(f" Burst completed in {elapsed:.2f}s with {discord.forward_count} forwards")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (e): Missing X-Signature header → 401 ===")
|
||||
# -------------------------------------------------------------------
|
||||
body_only = json.dumps({"event_id": "no-sig", "type": "chat"}).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=body_only, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
assert_true(False, "Expected 401 for missing signature")
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert_eq(exc.code, 401, "Missing header → 401")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
print("\n=== Test (f): Malformed JSON → 400 ===")
|
||||
# -------------------------------------------------------------------
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=b"{not valid json",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": hmac.new(SECRET.encode(), b"{not valid json", hashlib.sha256).hexdigest(),
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
assert_true(False, "Expected 400 for malformed JSON")
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert_eq(exc.code, 400, "Malformed JSON → 400")
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
if failures:
|
||||
print(f"FAILED: {failures} test(s) failed")
|
||||
else:
|
||||
print("ALL TESTS PASSED ✓")
|
||||
return failures
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Twitch EventSub → Discord webhook bridge")
|
||||
parser.add_argument("--secret", default=SECRET, help="HMAC shared secret")
|
||||
parser.add_argument("--port", type=int, default=PORT, help="HTTP listen port")
|
||||
parser.add_argument("--test", action="store_true", help="Run tests and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
global SECRET, PORT
|
||||
SECRET = args.secret
|
||||
PORT = args.port
|
||||
|
||||
if args.test:
|
||||
sys.exit(run_tests(PORT))
|
||||
|
||||
discord = DiscordMock()
|
||||
bridge = WebhookBridge(secret=SECRET, discord=discord)
|
||||
|
||||
print(f"Starting bridge on http://127.0.0.1:{PORT}")
|
||||
print(f"Secret: {SECRET}")
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
asyncio.run(run_server(bridge, "127.0.0.1", PORT))
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user