新增了完整的GitHub集成插件,包含认证、会话管理、Webhook处理、消息收发、反应支持等核心功能,支持Issues、Discussions、Pull Request的交互与通知。
122 lines
5.0 KiB
Python
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"}
|