from __future__ import annotations import logging from yuxi.channel.capabilities import ChannelCapabilities from yuxi.channel.extensions.base import BaseChannelPlugin from yuxi.channel.extensions.yuanbao.accounts import ( ResolvedYuanbaoAccount, TokenCache, resolve_yuanbao_account, ) from yuxi.channel.extensions.yuanbao.gateway import YuanbaoGateway from yuxi.channel.extensions.yuanbao.security import check_dm_access, check_group_access logger = logging.getLogger("yuxi.channel.yuanbao") class YuanbaoPlugin(BaseChannelPlugin): id = "yuanbao" name = "Yuanbao (元宝)" order = 85 label = "Yuanbao" aliases = ["yuanbao", "yb", "tencent-yuanbao", "元宝"] def __init__(self): self._token_cache = TokenCache() self._accounts: dict[str, ResolvedYuanbaoAccount] = {} self._gateways: dict[str, YuanbaoGateway] = {} self._reply_to_modes: dict[str, str] = {} self._default_account_id: str | None = None self._seen_msg_keys: set[str] = set() self._max_seen = 10000 @property def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=["direct", "group"], message_types=["text", "image", "file", "audio", "video", "sticker"], reactions=False, typing_indicator=True, threads=False, edit=False, unsend=False, reply=True, media=True, native_commands=True, polls=False, streaming=True, streaming_mode="block", block_streaming=True, block_streaming_chunk_min_chars=2800, block_streaming_chunk_max_chars=3000, block_streaming_coalesce_min_chars=2800, block_streaming_coalesce_max_chars=3000, block_streaming_coalesce_idle_ms=1000, ) def list_account_ids(self, config: dict) -> list[str]: accounts = config.get("channels", {}).get("yuanbao", {}).get("accounts", {}) return list(accounts.keys()) def is_configured(self, account: dict) -> bool: app_key = account.get("appKey") app_secret = account.get("appSecret") token = account.get("token") return bool((app_key and app_secret) or token) def is_enabled(self, account: dict) -> bool: return account.get("enabled", True) def disabled_reason(self, account: dict) -> str: if not account.get("enabled", True): return "账户已被禁用" if not self.is_configured(account): return "缺少 AppKey/AppSecret 或 Token 配置" return "" def describe_account(self, account: dict) -> dict: return { "account_id": account.get("account_id", ""), "name": account.get("name", ""), "api_domain": account.get("apiDomain", "bot.yuanbao.tencent.com"), } async def resolve_account(self, account_id: str) -> dict: account = self._accounts.get(account_id) if account is None: return {} return { "account_id": account.account_id, "name": account.name, "api_domain": account.api_domain, "enabled": account.enabled, } async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None: raw = config.get("channels", {}).get("yuanbao", {}).get("accounts", {}).get(account_id, {}) whitelist = raw.get("debugWhitelist") return whitelist if whitelist else None async def start(self, ctx) -> object: account = await resolve_yuanbao_account(ctx.account_id, ctx.config) self._accounts[ctx.account_id] = account account_cfg = ( ctx.config.get("channels", {}) .get("yuanbao", {}) .get("accounts", {}) .get(ctx.account_id, {}) ) self._reply_to_modes[ctx.account_id] = account_cfg.get("replyToMode", "first") if account_cfg.get("defaultAccount", False): self._default_account_id = ctx.account_id elif self._default_account_id is None: self._default_account_id = ctx.account_id gateway = self._gateways.get(ctx.account_id) if gateway is None: gateway = YuanbaoGateway( account=account, token_cache=self._token_cache, queue=ctx.queue, cancel_event=ctx.cancel_event, logger=ctx.logger or logger, ) self._gateways[ctx.account_id] = gateway await gateway.start() return gateway async def stop(self, ctx) -> None: gateway = self._gateways.pop(ctx.account_id, None) if gateway: await gateway.stop() if self._default_account_id == ctx.account_id: self._default_account_id = None 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: if not content: return gateway = self._get_gateway(account_id) if gateway is None: logger.error("No gateway available for account %s", account_id) return await gateway.send_text(target_id, content, reply_to_id=reply_to_id) async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: gateway = self._get_gateway(account_id) if gateway is None: return await gateway.send_media(target_id, media_url, media_type, reply_to_id=reply_to_id) async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: gateway = self._get_gateway(None) if gateway is not None and gateway.is_connected: await gateway.send_typing(target_id) async def probe(self, account: dict) -> bool: try: acct = await resolve_yuanbao_account(account.get("account_id", "default"), account) await self._token_cache.get(acct) return True except Exception: return False async def check_ready(self, account_id: str) -> bool: gateway = self._gateways.get(account_id) return gateway is not None and gateway.is_connected def build_summary(self, snapshot: object) -> dict: from yuxi.channel.protocols import build_standard_summary return build_standard_summary(snapshot, self.id) def config_schema(self) -> dict: from yuxi.channel.extensions.yuanbao.config_schema import YUANBAO_CONFIG_SCHEMA return YUANBAO_CONFIG_SCHEMA def resolve_reply_to_mode( self, config: dict, account_id: str | None = None, chat_type: str | None = None, ) -> str: if account_id and account_id in self._reply_to_modes: return self._reply_to_modes[account_id] return "first" def resolve_reply_transport(self, msg: object, thread_id: str | None): from yuxi.channel.protocols import ReplyTransport transport = ReplyTransport() reply_to_id = getattr(msg, "reply_to_id", None) if reply_to_id: transport.reply_to_id = reply_to_id account_id = getattr(msg, "account_id", None) transport.mode = self.resolve_reply_to_mode({}, account_id) return transport def resolve_session(self, msg: object): from yuxi.channel.routing.models import PeerKind from yuxi.channel.protocols import SessionResolution if hasattr(msg, "sender") and hasattr(msg.sender, "kind"): if msg.sender.kind == PeerKind.DIRECT: return SessionResolution(kind="direct", conversation_id=msg.sender.id) gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown" return SessionResolution(kind="group", conversation_id=gid) async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: for account in self._accounts.values(): cfg = getattr(account, "raw", {}) whitelist = cfg.get("debugWhitelist", []) if channel_type == "direct": policy = cfg.get("dmPolicy", "open") if not check_dm_access(peer_id, policy, whitelist): return False else: if not check_group_access(peer_id, "open", whitelist): return False return True def resolve_dm_policy(self) -> dict: return {"mode": "open", "allow_from": []} def is_duplicate(self, key: str) -> bool: return key in self._seen_msg_keys def mark_seen(self, key: str) -> None: self._seen_msg_keys.add(key) if len(self._seen_msg_keys) > self._max_seen: to_remove = list(self._seen_msg_keys)[: len(self._seen_msg_keys) // 2] self._seen_msg_keys.difference_update(to_remove) def sanitize_text(self, text: str, payload: object | None = None) -> str: return text def build_context_note(self, context) -> str: return "" def build_system_prompt(self, context) -> str | None: return ( "你正在通过腾讯元宝与用户交互。元宝支持 Markdown 消息格式," "包括代码块、加粗、斜体、删除线、标题(H1-H3)、有序/无序列表、" "引用和超链接。消息会按 ~3000 字符自动分块发送。" "请使用简洁清晰的格式回复,仅对代码片段使用代码围栏。" ) @property def channel_format_instructions(self) -> str | None: return ( "Yuanbao supports Markdown messages with inline code, fenced code blocks, " "bold, italic, strikethrough, headers (H1-H3), ordered/unordered lists, " "blockquotes, and hyperlinks. Messages are chunked at ~3000 characters. " "Do not wrap entire responses in markdown code fences — " "use them only for actual code snippets." ) @property def markdown_hint_enabled(self) -> bool: return True def classify_error(self, error: BaseException) -> object: from yuxi.channel.errors import classify_error as _classify_error return _classify_error(error) def _get_gateway(self, account_id: str | None) -> YuanbaoGateway | None: if account_id: return self._gateways.get(account_id) if self._default_account_id: gw = self._gateways.get(self._default_account_id) if gw and gw.is_connected: return gw for gw in self._gateways.values(): if gw.is_connected: return gw return None