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