from __future__ import annotations import asyncio import hmac import logging from collections import OrderedDict from yuxi.channel.extensions.github.monitor import GitHubMonitor from yuxi.channel.extensions.github.types import GitHubAppAccount, GitHubWebhookEvent logger = logging.getLogger(__name__) MAX_DEDUPE_SIZE = 10_000 class GitHubWebhookHandler: def __init__(self, monitor: GitHubMonitor | None = None): self._monitor = monitor or GitHubMonitor() self._account: GitHubAppAccount | None = None self._delivery_cache: OrderedDict[str, None] = OrderedDict() def register_account(self, account: GitHubAppAccount) -> None: self._account = account def verify_signature(self, payload_body: bytes, signature_header: str | None) -> bool: if not signature_header or not self._account: return False expected = "sha256=" + hmac.new( self._account.webhook_secret.encode("utf-8"), payload_body, "sha256", ).hexdigest() return hmac.compare_digest(expected, signature_header) def is_duplicate(self, delivery_id: str) -> bool: if delivery_id in self._delivery_cache: return True self._delivery_cache[delivery_id] = None if len(self._delivery_cache) > MAX_DEDUPE_SIZE: self._delivery_cache.popitem(last=False) return False def is_self_message(self, payload: dict) -> bool: if not self._account or not self._account.bot_login: return False sender_login = payload.get("sender", {}).get("login", "") return sender_login == self._account.bot_login async def handle_webhook(self, payload: dict) -> dict: import json body = payload.get("_raw_body") signature = payload.get("_headers", {}).get("x-hub-signature-256", "") event_type = payload.get("_headers", {}).get("x-github-event", "") delivery_id = payload.get("_headers", {}).get("x-github-delivery", "") if not body: return {"status": "ignored", "reason": "empty body"} if isinstance(body, bytes): if not self.verify_signature(body, signature): logger.warning("GitHub webhook: signature verification failed") return {"status": "unauthorized"} else: return {"status": "ignored", "reason": "invalid body type"} if delivery_id and self.is_duplicate(delivery_id): return {"status": "duplicate"} webhook_payload = payload if isinstance(webhook_payload, dict) and "_raw_body" in webhook_payload: webhook_payload = json.loads(body) if self.is_self_message(webhook_payload): return {"status": "self_message"} event = GitHubWebhookEvent.from_payload(webhook_payload, event_type, delivery_id) if not self._should_process(event): return {"status": "ignored", "reason": f"event type: {event.event_type}, action: {event.action}"} return await self._process_event(event) def _should_process(self, event: GitHubWebhookEvent) -> bool: if event.event_type == "issue_comment" and event.action == "created": return bool(event.comment and event.comment.body.strip()) if event.event_type == "issues": if event.action == "opened": return bool(event.issue and event.issue.body.strip()) if event.action in ("closed", "reopened"): return bool(event.issue) if event.action == "labeled" and event.raw_payload.get("label"): return bool(event.issue) if event.event_type == "discussion_comment" and event.action == "created": return bool(event.comment and event.comment.body.strip()) if event.event_type == "discussion" and event.action == "created": return bool(event.discussion and event.discussion.body.strip()) if event.event_type == "pull_request_review" and event.action == "submitted": return bool(event.review and (event.review.body or "").strip()) if event.event_type == "pull_request" and event.action in ("opened", "closed", "reopened"): return True if event.event_type == "pull_request_review_comment" and event.action == "created": return bool(event.comment and event.comment.body.strip()) return False async def _process_event(self, event: GitHubWebhookEvent) -> dict: try: unified_msg = self._monitor.event_to_unified_message(event) if unified_msg is None: return {"status": "filtered"} from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=120.0) return {"status": "processed"} except Exception: logger.exception("Failed to process GitHub webhook event") return {"status": "error"}