ForcePilot/backend/package/yuxi/channel/extensions/github/webhook.py
Kris 702fcd4c33 feat(github): 新增GitHub渠道插件完整实现
新增了完整的GitHub集成插件,包含认证、会话管理、Webhook处理、消息收发、反应支持等核心功能,支持Issues、Discussions、Pull Request的交互与通知。
2026-05-21 10:48:42 +08:00

122 lines
5.0 KiB
Python

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