"""relay.py: turns a GitHub webhook into a Pikoman message, in Python. A small HTTP server: GitHub calls POST /github on each push, the relay checks GitHub's signature (an HMAC of the exact body with the secret you both know), and publishes "Ada pushed 3 commits to main" to a channel that your team's pages listen to. A request without a valid signature is refused and publishes nothing. Python 3.10+. pip install -r requirements.txt, then python relay.py (reads .env). Its key (dashboard, New API key): right publish, kind public, channels: exact name updates. """ import hashlib import hmac import json import signal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pikoman_bot import Rest, env PORT = int(env("PORT", "8080")) CHANNEL = env("CHANNEL", "updates") SECRET = env("GITHUB_WEBHOOK_SECRET").encode() if not SECRET: raise SystemExit("Set GITHUB_WEBHOOK_SECRET: the secret you also give GitHub") rest = Rest() # PIKOMAN_APP and PIKOMAN_API_KEY; PIKOMAN_HTTP only for another server def signed_by_github(body: bytes, header: str) -> bool: """GitHub's X-Hub-Signature-256: "sha256=" and the HMAC-SHA256 of the body, in hex.""" expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() # Compared in constant time, so the answer's timing gives nothing away. return hmac.compare_digest(expected.encode(), (header or "").encode()) def summary(push: dict) -> dict: """A push, in one line for the channel.""" branch = str(push.get("ref", "")).removeprefix("refs/heads/") n = len(push.get("commits") or []) head = ((push.get("head_commit") or {}).get("message") or "").split("\n")[0] who = (push.get("pusher") or {}).get("name", "Someone") repo = (push.get("repository") or {}).get("full_name") text = f"{who} pushed {n} commit{'' if n == 1 else 's'} to {branch} in {repo}" + (f": {head}" if head else "") return {"text": text, "url": push.get("compare")} class Relay(BaseHTTPRequestHandler): def answer(self, status: int, text: str): self.send_response(status) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(text.encode()) def do_POST(self): if self.path != "/github": return self.answer(404, "Not found") length = int(self.headers.get("Content-Length") or 0) if length > 1024 * 1024: return self.answer(413, "too large") body = self.rfile.read(length) # the bytes exactly as sent: the signature is over these if not signed_by_github(body, self.headers.get("X-Hub-Signature-256")): return self.answer(401, "Bad signature") event = self.headers.get("X-GitHub-Event") if event == "ping": return self.answer(200, "pong") # GitHub's test when the webhook is added if event != "push": return self.answer(202, f"Ignored: {event}") try: rest.publish(CHANNEL, "push", summary(json.loads(body))) self.answer(200, "Published") except Exception as e: # noqa: BLE001: GitHub shows the 502, and can redeliver print("publish:", e) self.answer(502, "Could not publish") def do_GET(self): self.answer(404, "Not found") def log_message(self, *args): pass # quiet: GitHub calls often def stop(*_): raise KeyboardInterrupt signal.signal(signal.SIGTERM, stop) # as from systemd or podman stop: the same as Ctrl+C server = ThreadingHTTPServer(("", PORT), Relay) print(f"Relay on port {PORT}: POST /github -> {CHANNEL}") try: server.serve_forever() except KeyboardInterrupt: print("Stopped.")