ForcePilot/backend/package/yuxi/channels/adapters/yuanbao/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1066 lines
43 KiB
Python

from __future__ import annotations
import asyncio
import os
import re
from collections import deque
from datetime import datetime
from typing import Any
import aiohttp
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelNotConnectedError,
DeliveryFailedError,
)
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MessageType,
MentionsInfo,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
from .chunking import chunk_text
from .dispatch import InteractiveDispatcher, DispatchAction, DispatchContext, CardAction, BotMenuAction
from .event_queue import EventQueue
from .format import format_outbound
from .monitor import YuanbaoMonitor
from .outbound_queue import OutboundQueue
from .probe import health_check_yuanbao
from .security import check_dm_policy, check_group_policy, check_mention_required
from .security_audit import SecurityAuditLogger
from .send import send_with_retry
from .send_cache import SendMessageCache
from .token import YuanbaoTokenManager
from .vision import download_and_analyze, VisionResult
from .yb_accounts import (
YuanbaoAccountManager,
load_accounts_from_config,
)
from .yb_commands import (
CommandResult,
NativeCommandContext,
handle_command,
parse_command,
sync_commands_menu,
)
@register_builtin_adapter
class YuanbaoAdapter(BaseChannelAdapter):
channel_id = "yuanbao"
channel_type = ChannelType.YUANBAO
ALLOWED_API_DOMAINS = {
"open-api.yuanbao.tencent.com",
"api.yuanbao.tencent.com",
}
text_chunk_limit = 3000
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "block", "lane", "reasoning"]
max_media_size_mb = 20
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
delivery_mode="direct",
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "block", "lane", "reasoning"],
text_chunk_limit=3000,
max_media_size_mb=20,
reply=True,
reactions=True,
media=True,
block_streaming=True,
lane_streaming=True,
reasoning_streaming=True,
native_commands=True,
)
meta = ChannelMeta(
id="yuanbao",
label="Yuanbao (元宝)",
selection_label="Yuanbao (元宝)",
blurb="腾讯元宝 AI 聊天机器人,支持私聊和群聊",
order=85,
docs_path="/channels/yuanbao",
aliases=["yb", "tencent-yuanbao", "元宝"],
markdown_capable=True,
quickstart_allow_from=["*"],
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._http_client: aiohttp.ClientSession | None = None
self._token_manager: YuanbaoTokenManager | None = None
self._circuit_breaker = CircuitBreaker(failure_threshold=5)
self._bot_info: dict[str, Any] | None = None
self._bot_message_ids: set[str] = set()
self._monitor: YuanbaoMonitor | None = None
self._token_refresh_task: asyncio.Task | None = None
self._outbound_queue: OutboundQueue | None = None
max_concurrency = int(config.get("maxConcurrency", 16)) if config else 16
self._api_semaphore = asyncio.Semaphore(max_concurrency)
self._account_manager: YuanbaoAccountManager | None = None
self._credential_source: str = "inline"
self._dispatcher = InteractiveDispatcher()
self._send_cache = SendMessageCache()
self._seen_message_ids: deque[str] = deque(maxlen=10000)
self._event_queue = EventQueue()
self._account_monitors: dict[str, YuanbaoMonitor] = {}
self._account_token_managers: dict[str, YuanbaoTokenManager] = {}
self._first_reply_db: dict[str, float] = {}
self._first_reply_db_max_size = 100
self._first_reply_ttl_seconds = 60.0
self._chat_account_map: dict[str, str] = {}
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[Yuanbao] Starting channel '{self.channel_id}'...")
try:
app_key = self._resolve_env_var(self.config.get("app_key", ""))
app_secret = self._resolve_env_var(self.config.get("app_secret", ""))
bot_app_id = self.config.get("bot_app_id", self.channel_id)
if app_key != self.config.get("app_key", ""):
self._credential_source = "env"
elif app_key:
self._credential_source = "config"
else:
self._credential_source = "inline"
if not app_key or not app_secret:
raise ChannelAuthenticationError("Missing app_key or app_secret")
self._account_manager = load_accounts_from_config(self.config)
self._token_manager = YuanbaoTokenManager(
app_key=app_key,
app_secret=app_secret,
bot_app_id=bot_app_id,
pre_signed_token=self.config.get("token"),
api_base=self.config.get("apiBase"),
)
token = await self._token_manager.get_token()
logger.debug("[Yuanbao] Access token obtained")
connector = aiohttp.TCPConnector()
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
if proxy_url:
logger.info(f"[Yuanbao] Using proxy: {proxy_url}")
self._http_client = aiohttp.ClientSession(
connector=connector,
trust_env=True,
)
else:
self._http_client = aiohttp.ClientSession(connector=connector)
self._outbound_queue = OutboundQueue(self.config, self._raw_send)
self._token_manager._http_client = self._http_client
self._bot_info = await self._probe_bot(token)
await self._event_queue.start(self._handle_ws_event)
self._start_ws_monitor()
await self._start_account_monitors()
self._token_refresh_task = asyncio.create_task(self._token_auto_refresh_loop())
asyncio.create_task(self._sync_commands_menu(token))
self._status = ChannelStatus.CONNECTED
logger.info(
f"[Yuanbao] Channel '{self.channel_id}' started "
f"(bot_app_id={self._bot_info.get('bot_app_id', bot_app_id)})"
)
except ChannelAuthenticationError:
self._status = ChannelStatus.ERROR
raise
except Exception as e:
self._status = ChannelStatus.ERROR
logger.error(f"[Yuanbao] Failed to start channel '{self.channel_id}': {e}")
raise
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[Yuanbao] Stopping channel '{self.channel_id}'...")
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = None
if self._monitor:
await self._monitor.stop()
self._monitor = None
for monitor in self._account_monitors.values():
await monitor.stop()
self._account_monitors.clear()
self._account_token_managers.clear()
if self._outbound_queue:
await self._outbound_queue.flush()
self._outbound_queue = None
await self._event_queue.stop()
if self._http_client:
await self._http_client.close()
self._http_client = None
self._token_manager = None
self._status = ChannelStatus.DISCONNECTED
logger.info(f"[Yuanbao] Channel '{self.channel_id}' stopped")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._outbound_queue:
return DeliveryResult(success=False, error="Client not initialized")
response = self._apply_send_policies(response)
if response is None:
return DeliveryResult(success=True)
overflow_policy = self.config.get("overflowPolicy", "split")
if overflow_policy == "split" and response.message_type == MessageType.TEXT:
return await self._send_split(response)
elif overflow_policy == "stop":
return await self._send_single(response)
else:
return await self._send_single(response)
def _apply_send_policies(self, response: ChannelResponse) -> ChannelResponse | None:
if not response.content and response.message_type == MessageType.TEXT:
fallback = self.config.get("fallbackReply", "暂时无法解答,你可以换个问题问问我哦")
if fallback:
response = response.model_copy(update={"content": fallback})
elif not response.attachments:
return None
reply_mode = self.config.get("replyToMode", "first")
if response.reply_to_message_id:
if response.reply_to_message_id in self._bot_message_ids:
response = response.model_copy(update={"reply_to_message_id": None})
elif reply_mode == "first" and not self._check_first_reply(response.reply_to_message_id):
response = response.model_copy(update={"reply_to_message_id": None})
elif reply_mode == "off":
response = response.model_copy(update={"reply_to_message_id": None})
max_size_mb = self.config.get("mediaMaxMb", self.max_media_size_mb)
valid_attachments = []
for att in response.attachments:
if att.size_bytes and att.size_bytes > max_size_mb * 1024 * 1024:
logger.warning(
f"[Yuanbao] Attachment '{att.filename}' ({att.size_bytes} bytes) "
f"exceeds mediaMaxMb={max_size_mb}, skipping"
)
continue
valid_attachments.append(att)
if valid_attachments != response.attachments:
response = response.model_copy(update={"attachments": valid_attachments})
return response
async def _send_single(self, response: ChannelResponse) -> DeliveryResult:
overflow_policy = self.config.get("overflowPolicy", "split")
limit = self.config.get("textChunkLimit", self.text_chunk_limit)
if overflow_policy == "stop" and response.message_type == MessageType.TEXT and len(response.content) > limit:
response = response.model_copy(update={"content": response.content[:limit]})
result = await self._outbound_queue.enqueue(response)
if result is not None:
return result
return DeliveryResult(success=True)
async def _send_split(self, response: ChannelResponse) -> DeliveryResult:
limit = self.config.get("textChunkLimit", self.text_chunk_limit)
chunks = chunk_text(response.content, limit)
reply_mode = self.config.get("replyToMode", "first")
last_error = None
for i, chunk in enumerate(chunks):
chunk_response = response.model_copy(update={"content": chunk})
if reply_mode == "off":
chunk_response = chunk_response.model_copy(update={"reply_to_message_id": None})
elif reply_mode == "first" and i > 0:
chunk_response = chunk_response.model_copy(update={"reply_to_message_id": None})
# reply_mode == "all": keep reply_to_message_id for every chunk
result = await self._outbound_queue.enqueue(chunk_response)
if result is not None and not result.success:
last_error = result.error
if last_error:
return DeliveryResult(success=False, error=last_error)
return DeliveryResult(success=True)
def _resolve_outbound_account(self, response: ChannelResponse) -> YuanbaoTokenManager | None:
metadata = response.metadata or {}
account_id = metadata.get("account_id", "")
if account_id and account_id in self._account_token_managers:
return self._account_token_managers[account_id]
chat_id = response.identity.channel_chat_id
group_id = metadata.get("group_open_id", "")
route_key = group_id or chat_id
if route_key and route_key in self._chat_account_map:
mapped_id = self._chat_account_map[route_key]
if mapped_id in self._account_token_managers:
return self._account_token_managers[mapped_id]
return self._token_manager
def bind_chat_to_account(self, chat_id: str, account_id: str) -> None:
self._chat_account_map[chat_id] = account_id
logger.info(f"[Yuanbao] Bound chat '{chat_id}' to account '{account_id}'")
def unbind_chat(self, chat_id: str) -> None:
self._chat_account_map.pop(chat_id, None)
def _check_first_reply(self, ref_msg_id: str) -> bool:
import time
now = time.time()
self._cleanup_first_reply_db(now)
if ref_msg_id in self._first_reply_db:
return False
if len(self._first_reply_db) >= self._first_reply_db_max_size:
oldest_key = min(self._first_reply_db, key=self._first_reply_db.get)
del self._first_reply_db[oldest_key]
self._first_reply_db[ref_msg_id] = now
return True
def _cleanup_first_reply_db(self, now: float) -> None:
expired = [k for k, ts in self._first_reply_db.items() if now - ts > self._first_reply_ttl_seconds]
for k in expired:
del self._first_reply_db[k]
async def _raw_send(self, response: ChannelResponse) -> DeliveryResult:
if not self._http_client:
return DeliveryResult(success=False, error="Client not initialized")
token_mgr = self._resolve_outbound_account(response)
if not token_mgr:
return DeliveryResult(success=False, error="No token manager available")
api_base = token_mgr.api_base
if not self._is_allowed_domain(api_base):
logger.error(f"[Yuanbao] SSRF check failed: domain not in allowlist - {api_base}")
return DeliveryResult(success=False, error=f"Domain not allowed: {api_base}")
if not response.identity.channel_chat_id:
return DeliveryResult(success=False, error="channel_chat_id is empty")
payload = self.format_outbound(response)
token = await token_mgr.get_token()
async def _do_send():
async with self._api_semaphore:
result = await send_with_retry(self._http_client, token, api_base, payload, self.config)
if not result.success and result.auth_expired:
await self._refresh_token_if_needed()
new_token = await token_mgr.get_token()
return await send_with_retry(self._http_client, new_token, api_base, payload, self.config)
return result
try:
result = await self._circuit_breaker.call(_do_send)
if result.success and result.message_id:
self._bot_message_ids.add(result.message_id)
self._send_cache.add(result.message_id, response.identity.channel_chat_id, response.content[:200])
return result
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
def normalize_inbound(self, raw: dict) -> ChannelMessage:
chat_type, chat_id = self._resolve_chat_context(raw)
raw_user_id = raw.get("open_id", "")
channel_user_id = f"yb:{raw_user_id}" if raw_user_id else ""
identity = ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=channel_user_id,
channel_chat_id=chat_id,
channel_message_id=raw.get("msg_id"),
)
msg_type_map = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"file": MessageType.FILE,
"sticker": MessageType.STICKER,
"markdown": MessageType.TEXT,
}
message_type = msg_type_map.get(raw.get("msg_type", "text"), MessageType.TEXT)
content = raw.get("content", "")
if content and content.startswith("/"):
message_type = MessageType.COMMAND
attachments = []
for att in raw.get("attachments", []):
attachments.append(
Attachment(
type=att.get("type", "file"),
url=att.get("url"),
filename=att.get("filename"),
size_bytes=att.get("size", 0),
mime_type=att.get("mime_type"),
)
)
extracted_urls = []
metadata = {
"yuanbao_chat_type": raw.get("chat_type", "direct"),
"reply_to_msg_id": raw.get("reply_to_msg_id"),
}
if raw.get("group_open_id"):
metadata["group_open_id"] = raw["group_open_id"]
if raw.get("channel_id"):
metadata["channel_id"] = raw["channel_id"]
if raw.get("private_from_group_code"):
metadata["private_from_group_code"] = raw["private_from_group_code"]
custom_elements = raw.get("custom_elements", [])
for elem in custom_elements:
elem_type = elem.get("type", "")
if elem_type == "link_card":
card_url = elem.get("url", "")
if card_url:
extracted_urls.append(card_url)
card_title = elem.get("title", "")
card_desc = elem.get("description", "")
if card_title:
if not content:
content = card_title
metadata["link_card"] = {
"url": card_url,
"title": card_title,
"description": card_desc,
}
mentions = None
raw_chat_type = raw.get("chat_type", "direct")
if raw_chat_type == "group":
bot_mentioned = False
if self._bot_info:
bot_name = self._bot_info.get("username", "")
if bot_name and f"@{bot_name}" in content:
bot_mentioned = True
mentions = MentionsInfo(
is_bot_mentioned=bot_mentioned,
raw_text=content,
)
timestamp = datetime.now()
if raw.get("timestamp"):
try:
ts = float(raw["timestamp"])
timestamp = datetime.fromtimestamp(ts)
except (ValueError, TypeError):
logger.debug(
f"[Yuanbao] Invalid timestamp '{raw.get('timestamp')}' "
f"in message {raw.get('msg_id')}, using current time"
)
event_type_map = {
"message": EventType.MESSAGE_RECEIVED,
"edited_message": EventType.MESSAGE_UPDATED,
"deleted_message": EventType.MESSAGE_DELETED,
}
event_type = event_type_map.get(raw.get("type", "message"), EventType.MESSAGE_RECEIVED)
return ChannelMessage(
identity=identity,
event_type=event_type,
message_type=message_type,
chat_type=chat_type,
content=content,
attachments=attachments,
extracted_urls=extracted_urls,
mentions=mentions,
metadata=metadata,
timestamp=timestamp,
)
def format_outbound(self, response: ChannelResponse) -> dict:
return format_outbound(response)
async def health_check(self) -> HealthStatus:
if not self._token_manager:
return HealthStatus(status="unhealthy", last_error="Token manager not initialized")
try:
token = await self._token_manager.get_token()
return await health_check_yuanbao(
self._token_manager.api_base,
token,
ws_connected=(self._monitor is not None and self._monitor.is_connected),
session=self._http_client,
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
def get_account_status(self) -> dict[str, dict]:
result = {}
if self._monitor:
result["_primary"] = {
"connected": self._monitor.is_connected,
"healthy": self._monitor.healthy,
"reconnect_count": self._monitor.reconnect_count,
}
for account_id, monitor in self._account_monitors.items():
result[account_id] = {
"connected": monitor.is_connected,
"healthy": monitor.healthy,
"reconnect_count": monitor.reconnect_count,
}
return result
async def download_media(self, file_id: str) -> bytes:
if not self._token_manager or not self._http_client:
raise ChannelNotConnectedError()
token = await self._token_manager.get_token()
headers = {"Authorization": f"Bearer {token}"}
async with self._http_client.get(
f"{self._token_manager.api_base}/api/v1/media/{file_id}",
headers=headers,
) as resp:
if resp.status != 200:
raise DeliveryFailedError(f"Media download failed: HTTP {resp.status}")
return await resp.read()
async def analyze_image_message(self, image_url: str, prompt: str | None = None) -> VisionResult | None:
if not self._token_manager or not self._http_client:
return None
token = await self._token_manager.get_token()
return await download_and_analyze(
image_url=image_url,
api_base=self._token_manager.api_base,
token=token,
http_client=self._http_client,
prompt=prompt,
)
async def _refresh_token_if_needed(self) -> bool:
if self._token_manager is None:
return False
if self._token_manager.is_expired():
await self._token_manager.refresh_token()
return True
return False
async def handle_native_command(self, msg: ChannelMessage) -> CommandResult:
command, args = parse_command(msg.content)
if command is None:
return CommandResult(command="", action="no_command")
ctx = NativeCommandContext(
user_id=msg.identity.channel_user_id,
chat_id=msg.identity.channel_chat_id,
chat_type=msg.chat_type.value if hasattr(msg.chat_type, "value") else msg.chat_type,
thread_key=None,
adapter_status=self._status.value,
ws_connected=(self._monitor is not None and self._monitor.is_connected),
)
return await handle_command(command, args, ctx)
async def _check_security(self, msg: ChannelMessage) -> bool:
chat_type = msg.chat_type
user_id = msg.identity.channel_user_id
chat_id = msg.identity.channel_chat_id
metadata = msg.metadata or {}
if chat_type == ChatType.DIRECT:
allowed = await check_dm_policy(user_id, self.config)
if not allowed:
SecurityAuditLogger.log_dm_policy_blocked(
user_id=user_id,
reason="dm_policy_denied",
policy=self.config.get("dm_policy", "open"),
)
return allowed
elif chat_type == ChatType.GROUP:
group_open_id = metadata.get("group_open_id", chat_id)
if not await check_group_policy(group_open_id, user_id, self.config):
SecurityAuditLogger.log_group_access_blocked(
group_id=group_open_id,
user_id=user_id,
reason="group_policy_denied",
)
return False
bot_names = []
if self._bot_info:
bot_names = [self._bot_info.get("username", "")]
allowed = await check_mention_required(group_open_id, msg, self.config, bot_names, self._bot_message_ids)
if not allowed:
SecurityAuditLogger.log_mention_required_blocked(
group_id=group_open_id,
user_id=user_id,
)
return allowed
return True
async def _handle_ws_event(self, event: dict) -> None:
try:
msg_id = event.get("msg_id", "")
if msg_id and msg_id in self._seen_message_ids:
return
if msg_id:
self._seen_message_ids.append(msg_id)
action = InteractiveDispatcher.classify_event(event)
ctx = DispatchContext(action=action, raw_event=event)
if action == DispatchAction.MESSAGE or action == DispatchAction.COMMAND:
channel_message = self.normalize_inbound(event)
ctx.channel_message = channel_message
if await self._check_security(channel_message):
await self._handle_message(channel_message)
elif action == DispatchAction.MEMBER_EVENT:
await self._handle_member_event(event)
elif action == DispatchAction.REACTION:
await self._handle_reaction_event(event)
elif action == DispatchAction.CARD_ACTION:
await self._handle_card_action(event)
elif action == DispatchAction.BOT_MENU:
await self._handle_bot_menu(event)
elif action == DispatchAction.READ_RECEIPT:
self._handle_read_receipt(event)
else:
await self._dispatcher.dispatch(ctx)
except Exception as e:
logger.error(f"[Yuanbao] Failed to handle event: {e}", exc_info=True)
async def _handle_member_event(self, event: dict) -> None:
event_type = event.get("type", "")
chat_type, chat_id = self._resolve_chat_context(event)
user_id = event.get("open_id", "")
channel_user_id = f"yb:{user_id}" if user_id else ""
logger.info(f"[Yuanbao] Member event: {event_type} user={channel_user_id} chat={chat_id}")
def _handle_read_receipt(self, event: dict) -> None:
chat_type, chat_id = self._resolve_chat_context(event)
user_id = event.get("open_id", "")
msg_id = event.get("msg_id", "")
logger.debug(f"[Yuanbao] Read receipt: user={user_id} chat={chat_id} msg_id={msg_id}")
async def _handle_card_action(self, event: dict) -> None:
card = CardAction.from_event(event)
logger.info(
f"[Yuanbao] Card action: type={card.action_type} "
f"button_id={card.button_id} value={card.value} "
f"user={card.user_id} chat={card.chat_id}"
)
ctx = DispatchContext(
action=DispatchAction.CARD_ACTION,
raw_event=event,
metadata={"card_action": card},
)
await self._dispatcher.dispatch(ctx)
async def _handle_bot_menu(self, event: dict) -> None:
menu = BotMenuAction.from_event(event)
logger.info(
f"[Yuanbao] Bot menu: menu={menu.menu_name} menu_id={menu.menu_id} user={menu.user_id} chat={menu.chat_id}"
)
ctx = DispatchContext(
action=DispatchAction.BOT_MENU,
raw_event=event,
metadata={"bot_menu": menu},
)
await self._dispatcher.dispatch(ctx)
async def _handle_reaction_event(self, event: dict) -> None:
event_type = event.get("type", "")
chat_type, chat_id = self._resolve_chat_context(event)
user_id = event.get("open_id", "")
channel_user_id = f"yb:{user_id}" if user_id else ""
emoji = event.get("emoji", "")
target_msg_id = event.get("msg_id", "")
logger.debug(
f"[Yuanbao] Reaction event: {event_type} emoji={emoji} user={channel_user_id} target={target_msg_id}"
)
mapped_event_type = EventType.REACTION_ADDED if event_type == "reaction_added" else EventType.REACTION_REMOVED
reaction_msg = ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=channel_user_id,
channel_chat_id=chat_id,
channel_message_id=target_msg_id,
),
event_type=mapped_event_type,
message_type=MessageType.TEXT,
chat_type=chat_type,
content=emoji,
metadata={
"reaction_event_type": event_type,
"reaction_target_msg_id": target_msg_id,
"group_open_id": event.get("group_open_id"),
"channel_id": event.get("channel_id"),
},
)
await self._handle_message(reaction_msg)
def _resolve_chat_context(self, raw_payload: dict) -> tuple[ChatType, str]:
if channel_id := raw_payload.get("channel_id"):
return ChatType.GUILD_CHANNEL, channel_id
elif group_open_id := raw_payload.get("group_open_id"):
return ChatType.GROUP, group_open_id
else:
return ChatType.DIRECT, raw_payload.get("open_id", "")
async def _probe_bot(self, token: str) -> dict[str, Any]:
api_base = self._token_manager.api_base if self._token_manager else "https://open-api.yuanbao.tencent.com"
headers = {"Authorization": f"Bearer {token}"}
async with self._http_client.get(
f"{api_base}/api/v1/bot/info",
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
data = await resp.json()
logger.info(f"[Yuanbao] Bot verified: bot_app_id={data.get('bot_app_id')}")
return data
elif resp.status in (401, 403):
raise ChannelAuthenticationError(f"Failed to probe bot: HTTP {resp.status}")
else:
logger.warning(f"[Yuanbao] Bot probe returned {resp.status}, using minimal bot info")
return {
"bot_app_id": self.config.get("bot_app_id", self.channel_id),
"username": self.config.get("bot_name", "Yuanbao_Bot"),
}
def _start_ws_monitor(self) -> None:
ws_url = self.config.get(
"ws_url",
"wss://open-api.yuanbao.tencent.com/ws/events",
)
ws_max_reconnect = self.config.get("ws_max_reconnect", 10)
ws_ping_interval = self.config.get("ws_ping_interval", 30)
ws_ping_timeout = self.config.get("ws_ping_timeout", 10)
ws_close_timeout = self.config.get("ws_close_timeout", 5)
ws_auth_timeout = self.config.get("ws_auth_timeout", 10)
async def token_provider():
return await self._token_manager.get_token()
async def on_event(event: dict):
await self._event_queue.enqueue(event)
self._monitor = YuanbaoMonitor(
ws_url=ws_url,
token_provider=token_provider,
on_event=on_event,
max_reconnect=ws_max_reconnect,
ping_interval=ws_ping_interval,
ping_timeout=ws_ping_timeout,
close_timeout=ws_close_timeout,
auth_timeout=ws_auth_timeout,
)
asyncio.create_task(self._monitor.start())
async def _start_account_monitors(self) -> None:
if not self._account_manager or self._account_manager.account_count <= 1:
return
for account in self._account_manager.enabled_accounts:
if account.account_id == self._account_manager.default_account_id:
continue
try:
api_base = account.api_domain or self.config.get("apiBase")
token_mgr = YuanbaoTokenManager(
app_key=account.app_key,
app_secret=account.app_secret,
bot_app_id=account.bot_app_id or account.account_id,
pre_signed_token=account.token,
api_base=api_base,
http_client=self._http_client,
)
self._account_token_managers[account.account_id] = token_mgr
await token_mgr.get_token()
monitor = self._create_monitor_for_account(account, token_mgr)
self._account_monitors[account.account_id] = monitor
asyncio.create_task(monitor.start())
logger.info(f"[Yuanbao] Started monitor for account '{account.account_id}'")
except Exception as e:
logger.error(f"[Yuanbao] Failed to start monitor for account '{account.account_id}': {e}")
def _create_monitor_for_account(self, account, token_mgr: YuanbaoTokenManager) -> YuanbaoMonitor:
ws_url = account.ws_url or self.config.get("ws_url", "wss://open-api.yuanbao.tencent.com/ws/events")
ws_max_reconnect = self.config.get("ws_max_reconnect", 10)
ws_ping_interval = self.config.get("ws_ping_interval", 30)
ws_ping_timeout = self.config.get("ws_ping_timeout", 10)
ws_close_timeout = self.config.get("ws_close_timeout", 5)
ws_auth_timeout = self.config.get("ws_auth_timeout", 10)
async def token_provider():
return await token_mgr.get_token()
async def on_event(event: dict):
await self._event_queue.enqueue(event)
return YuanbaoMonitor(
ws_url=ws_url,
token_provider=token_provider,
on_event=on_event,
max_reconnect=ws_max_reconnect,
ping_interval=ws_ping_interval,
ping_timeout=ws_ping_timeout,
close_timeout=ws_close_timeout,
auth_timeout=ws_auth_timeout,
)
async def _sync_commands_menu(self, token: str) -> None:
try:
await sync_commands_menu(
self._token_manager.api_base,
token,
self._http_client,
)
except Exception as e:
logger.warning(f"[Yuanbao] Command menu sync failed: {e}")
async def _token_auto_refresh_loop(self) -> None:
interval = self.config.get("token_refresh_interval_seconds", 300)
while self._status == ChannelStatus.CONNECTED:
try:
refreshed = await self._refresh_token_if_needed()
if refreshed:
logger.info(f"[Yuanbao] Token refreshed for bot_app_id={self._token_manager.bot_app_id}")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"[Yuanbao] Token refresh failed: {e}")
await asyncio.sleep(interval)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
self._send_cache.mark_edited(msg_id, content[:200])
return DeliveryResult(success=False, error="edit_message not supported by Yuanbao adapter")
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
self._send_cache.mark_deleted(msg_id)
self._bot_message_ids.discard(msg_id)
return DeliveryResult(success=False, error="delete_message not supported by Yuanbao adapter")
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._http_client or not self._token_manager:
return DeliveryResult(success=False, error="Client not initialized")
token = await self._token_manager.get_token()
api_base = self._token_manager.api_base
payload = {
"open_id": chat_id,
"msg_id": msg_id,
"emoji": emoji,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
async with self._http_client.post(
f"{api_base}/api/v1/bot/reaction/send",
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
return DeliveryResult(success=True)
return DeliveryResult(
success=False,
error=f"send_reaction failed: HTTP {resp.status}",
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return DeliveryResult(success=False, error="pin_message not supported by Yuanbao adapter")
async def send_poll(self, chat_id: str, question: str, options: list[str]) -> DeliveryResult:
return DeliveryResult(success=False, error="send_poll not supported by Yuanbao adapter")
async def send_chat_action(self, chat_id: str, action: str) -> DeliveryResult:
return DeliveryResult(success=False, error="send_chat_action not supported by Yuanbao adapter")
async def send_typing_indicator(self, chat_id: str) -> DeliveryResult:
return DeliveryResult(success=False, error="send_typing_indicator not supported by Yuanbao adapter")
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._token_manager or not self._http_client:
return {}
user_id = channel_user_id.removeprefix("yb:")
if not user_id:
return {}
try:
token = await self._token_manager.get_token()
headers = {"Authorization": f"Bearer {token}"}
async with self._http_client.get(
f"{self._token_manager.api_base}/api/v1/user/{user_id}",
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
return await resp.json()
logger.debug(f"[Yuanbao] get_user_info returned {resp.status} for {user_id}")
return {"open_id": user_id}
except Exception as e:
logger.warning(f"[Yuanbao] Failed to get user info for {user_id}: {e}")
return {"open_id": user_id}
async def get_group_list(self) -> list[dict[str, Any]]:
if not self._token_manager or not self._http_client:
return []
from .directory import get_group_list as _get_group_list
token = await self._token_manager.get_token()
groups = await _get_group_list(self._token_manager.api_base, token, self._http_client)
return [{"group_open_id": g.group_open_id, "name": g.name, "member_count": g.member_count} for g in groups]
async def get_group_detail(self, group_open_id: str) -> dict[str, Any] | None:
if not self._token_manager or not self._http_client:
return None
from .directory import get_group_detail as _get_group_detail
token = await self._token_manager.get_token()
group = await _get_group_detail(self._token_manager.api_base, token, self._http_client, group_open_id)
if group:
return {"group_open_id": group.group_open_id, "name": group.name, "member_count": group.member_count}
return None
async def get_group_members(self, group_open_id: str) -> list[dict[str, Any]]:
if not self._token_manager or not self._http_client:
return []
from .directory import get_group_members as _get_group_members
token = await self._token_manager.get_token()
members = await _get_group_members(self._token_manager.api_base, token, self._http_client, group_open_id)
return [{"open_id": m.open_id, "username": m.username, "display_name": m.display_name} for m in members]
async def list_directory(self, user_ids: list[str] | None = None) -> dict[str, Any]:
if not self._token_manager or not self._http_client:
return {"peers": [], "groups": []}
from .directory import list_directory as _list_directory
token = await self._token_manager.get_token()
result = await _list_directory(self._token_manager.api_base, token, self._http_client, user_ids)
return {
"peers": [
{"open_id": p.open_id, "username": p.username, "display_name": p.display_name} for p in result.peers
],
"groups": [
{"group_open_id": g.group_open_id, "name": g.name, "member_count": g.member_count}
for g in result.groups
],
}
_env_var_pattern = re.compile(r"\$\{(\w+)\}")
@staticmethod
def _resolve_env_var(value: str) -> str:
def _replace(match):
var_name = match.group(1)
return os.environ.get(var_name, match.group(0))
return YuanbaoAdapter._env_var_pattern.sub(_replace, value)
@property
def markdown_hint_enabled(self) -> bool:
return self.config.get("markdownHintEnabled", True)
@property
def markdown_system_hint(self) -> str | None:
if not self.markdown_hint_enabled:
return None
return "请用纯文本或 Markdown 格式回复,但不要将完整的回复内容包裹在 Markdown 代码块中。"
@property
def credential_source(self) -> str:
return self._credential_source
@property
def history_limit(self) -> int:
return self.config.get("historyLimit", 100)
@property
def disable_block_streaming(self) -> bool:
return self.config.get("disableBlockStreaming", False)
@property
def send_cache(self):
return self._send_cache
@property
def debug_enabled(self) -> bool:
debug_bot_ids = self.config.get("debugBotIds", [])
if not debug_bot_ids:
return False
bot_id = self._bot_info.get("bot_app_id", "") if self._bot_info else ""
return bot_id in debug_bot_ids
def _is_allowed_domain(self, url: str) -> bool:
try:
from urllib.parse import urlparse
parsed = urlparse(url)
hostname = parsed.hostname or ""
if hostname in self.ALLOWED_API_DOMAINS:
return True
custom_api = self.config.get("apiBase")
if custom_api:
custom_parsed = urlparse(custom_api)
if hostname == custom_parsed.hostname:
return True
return False
except Exception:
return False
@property
def status(self) -> str:
return self._status.value