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

238 lines
8.2 KiB
Python

from __future__ import annotations
import logging
from yuxi.channel.extensions.github.api import GitHubApiClient
from yuxi.channel.extensions.github.auth import GitHubAuth
from yuxi.channel.extensions.github.format import chunk_gfm_markdown, sanitize_gfm_text
from yuxi.channel.extensions.github.types import OutboundResult
logger = logging.getLogger(__name__)
class GitHubOutboundAdapter:
delivery_mode = "direct"
chunker_mode = "markdown"
text_chunk_limit: int = 65000
extract_markdown_images = False
def __init__(self):
self._clients: dict[int, GitHubApiClient] = {}
async def _get_client(self, app_id: int, private_key: str, installation_id: int) -> GitHubApiClient:
key = installation_id
if key not in self._clients:
auth = GitHubAuth(app_id, private_key)
self._clients[key] = GitHubApiClient(auth, installation_id)
return self._clients[key]
async def _resolve_account(self, account_id: str | None) -> dict | None:
from yuxi.channel.extensions.github.config import GitHubConfigAdapter
adapter = GitHubConfigAdapter()
aid = account_id or "default"
return await adapter.resolve_account(aid)
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account or not account.get("app_id"):
logger.error("GitHub send_text: account not configured")
return
safe = sanitize_gfm_text(content)
if not safe:
return
chunks = chunk_gfm_markdown(safe, self.text_chunk_limit)
client = await self._get_client(
account["app_id"], account["private_key"], account["installation_id"],
)
session_info = self._parse_target_id(target_id)
if not session_info:
logger.error("GitHub send_text: invalid target_id: %s", target_id)
return
for i, chunk in enumerate(chunks):
body = chunk
if len(chunks) > 1:
if i < len(chunks) - 1:
body += "\n\n---\n*(续)*"
else:
body += "\n\n---\n*(续完)*"
if session_info["kind"] == "discussion":
await client.add_discussion_comment(
discussion_node_id=session_info["node_id"],
body=body,
reply_to_id=reply_to_id,
)
else:
await client.create_issue_comment(
owner=session_info["owner"],
repo=session_info["repo"],
issue_number=session_info["number"],
body=body,
)
async def send_reaction(
self, target_id: str, message_id: str, emoji: str, account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
return OutboundResult(success=False, error="account not found")
client = await self._get_client(
account["app_id"], account["private_key"], account["installation_id"],
)
session_info = self._parse_target_id(target_id)
if not session_info:
return OutboundResult(success=False, error="invalid target_id")
from yuxi.channel.extensions.github.reactions import normalize_reaction_content
content = normalize_reaction_content(emoji)
result = await client.create_reaction(
owner=session_info["owner"],
repo=session_info["repo"],
issue_number=session_info["number"],
content=content,
)
return OutboundResult(
msg_id=result.get("id"),
success=result.get("success", False),
error=result.get("error"),
)
def _parse_target_id(self, target_id: str) -> dict | None:
if not target_id.startswith("github:"):
return None
parts = target_id.split(":")
if len(parts) < 5:
return None
repo_full = parts[1]
kind = parts[2]
number = int(parts[3])
owner, _, repo = repo_full.partition("/")
if not repo:
return None
node_id = parts[4] if len(parts) > 5 else None
return {"owner": owner, "repo": repo, "kind": kind, "number": number, "node_id": node_id}
async def edit_text(
self,
target_id: str,
message_id: str,
content: str,
*,
account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
return OutboundResult(success=False, error="account not found")
client = await self._get_client(
account["app_id"], account["private_key"], account["installation_id"],
)
session_info = self._parse_target_id(target_id)
if not session_info:
return OutboundResult(success=False, error="invalid target_id")
safe = sanitize_gfm_text(content)
if not safe:
return OutboundResult(success=False, error="empty content after sanitize")
if session_info["kind"] == "discussion":
result = await client.update_discussion_comment(
comment_node_id=message_id,
body=safe,
)
else:
comment_id = self._extract_comment_id(message_id)
if not comment_id:
return OutboundResult(success=False, error=f"cannot extract comment ID from: {message_id}")
result = await client.update_issue_comment(
owner=session_info["owner"],
repo=session_info["repo"],
comment_id=comment_id,
body=safe,
)
return OutboundResult(
msg_id=result.get("id"),
success=result.get("success", False),
)
async def unsend_text(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
return OutboundResult(success=False, error="account not found")
client = await self._get_client(
account["app_id"], account["private_key"], account["installation_id"],
)
session_info = self._parse_target_id(target_id)
if not session_info:
return OutboundResult(success=False, error="invalid target_id")
if session_info["kind"] == "discussion":
await client.delete_discussion_comment(comment_node_id=message_id)
else:
comment_id = self._extract_comment_id(message_id)
if not comment_id:
return OutboundResult(success=False, error=f"cannot extract comment ID from: {message_id}")
await client.delete_issue_comment(
owner=session_info["owner"],
repo=session_info["repo"],
comment_id=comment_id,
)
return OutboundResult(success=True)
@staticmethod
def _extract_comment_id(message_id: str) -> int | None:
if message_id.startswith("gh:comment:"):
try:
return int(message_id.split(":")[2])
except (ValueError, IndexError):
pass
try:
return int(message_id)
except ValueError:
pass
return None
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return chunk_gfm_markdown(text, limit)
def sanitize_text(self, text: str, payload: object) -> str:
return sanitize_gfm_text(text)
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def resolve_target(
self, to: str | None = None, *,
config: dict | None = None, allow_from: list[str] | None = None,
account_id: str | None = None, mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "target required"
return True, to