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