这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
1066 lines
43 KiB
Python
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
|