ForcePilot/backend/package/yuxi/channels/adapters/yuanbao/adapter.py
Kris ba060ca9c5 refactor(yuanbao): 整理元宝适配器代码并新增功能支持
1. 调整导入顺序和导入项顺序优化代码结构
2. 新增位置消息类型映射支持
3. 新增频道帖子事件的消息分发处理
4. 重构WebSocket认证失败日志格式
5. 优化令牌刷新错误提示的换行格式
6. 简化事件队列满时的日志输出
7. 新增系统事件处理和打字状态上报支持
8. 实现打字指示器接口的实际调用逻辑
9. 更新通道能力配置,补充缺失的能力项
2026-05-13 16:17:49 +08:00

1104 lines
44 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.exceptions import (
ChannelAuthenticationError,
ChannelNotConnectedError,
DeliveryFailedError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MentionsInfo,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
from .chunking import chunk_text
from .dispatch import BotMenuAction, CardAction, DispatchAction, DispatchContext, InteractiveDispatcher
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 VisionResult, download_and_analyze
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,
edit=False,
unsend=False,
pin=False,
polls=False,
typing=False,
send_ephemeral=False,
effects=False,
)
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)
elif action == DispatchAction.SYSTEM_EVENT:
await self._handle_system_event(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_system_event(self, event: dict) -> None:
sys_type = event.get("type", "unknown")
logger.info(f"[Yuanbao] System event: {sys_type}")
if sys_type == "typing":
logger.debug(
f"[Yuanbao] User typing: open_id={event.get('open_id')} group_open_id={event.get('group_open_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:
return DeliveryResult(success=False, error="edit_message not supported by Yuanbao adapter")
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
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:
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}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
async with self._http_client.post(
f"{api_base}/api/v1/bot/typing",
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status == 200:
return DeliveryResult(success=True)
return DeliveryResult(
success=False,
error=f"send_typing_indicator failed: HTTP {resp.status}",
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
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