主要变更: 1. 修复速率限流器使用setdefault替代重复创建令牌桶 2. 重构交互注册表匹配逻辑,优化精确匹配查找 3. 重构去重缓存逻辑,移到适配器实例方法 4. 重构发送URL解析,增加合法性校验并拆分公共方法 5. 优化流式消息处理逻辑,简化flush_controller调用 6. 重构群聊类型判断代码,简化语法 7. 修复重连管理器对None类型关闭分类的处理 8. 新增消息缓存、线程模拟器、发送初始化模块 9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录 10. 新增配置提示与向导二维码绑定功能 11. 优化媒体上传逻辑,增加重试机制与缓存 12. 新增审批键盘模板构建函数 13. 重构消息格式处理,修正媒体发送字段与长度限制 14. 修复令牌过期时间计算,使用time.time替代monotonic 15. 新增群组激活缓冲区与用户追踪器增强功能 16. 修复换行符问题,统一文件结尾格式
1722 lines
66 KiB
Python
1722 lines
66 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
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,
|
|
ChannelException,
|
|
ChannelNotConnectedError,
|
|
DeliveryFailedError,
|
|
MessageFormatError,
|
|
)
|
|
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 .approval.manager import ExecApprovalManager
|
|
from .audio import AudioFormat, AudioFormatPolicy, STTProvider, TTSProvider
|
|
from .c2c_stream import C2CStreamingController
|
|
from .commands.builtin import register_builtin_commands
|
|
from .commands.framework import CommandRegistry
|
|
from .commands.streaming_cmd import register_streaming_command
|
|
from .constants import (
|
|
DM_CHAT_PREFIX,
|
|
GROUP_CHAT_PREFIX,
|
|
ECode,
|
|
Intent,
|
|
Opcode,
|
|
)
|
|
from .credential_backup import CredentialBackup, CredentialSnapshot
|
|
from .format import MarkdownChunker, format_outbound
|
|
from .group_buffer import GroupHistoryBuffer
|
|
from .inbound_pipeline import QQBotInboundPipeline
|
|
from .interaction import InteractionContext, InteractionRegistry, InteractionSessionManager
|
|
from .known_users import KnownUserTracker
|
|
from .message_cache import MessageCache
|
|
from .message_queue import MessageQueue
|
|
from .probe import health_check_dsm
|
|
from .rate_limiter import RouteRateLimiter
|
|
from .reconnect import QQBotReconnectManager, ReconnectState
|
|
from .reply_dispatcher import ReplyDispatcher
|
|
from .security import QQBotSecurityPolicy, verify_webhook_ed25519
|
|
from .send import MessageSeqManager, send_with_retry
|
|
from .sender_init import SenderInfo, build_sender_headers, init_sender
|
|
from .session_store import SessionRecord, SessionStore
|
|
from .streaming import ParagraphChunker, stream_content
|
|
from .token import QQBotTokenManager
|
|
from .typing_keepalive import TypingKeepAlive
|
|
|
|
|
|
async def _handler_to_generator(handler, msg: ChannelMessage) -> AsyncGenerator[str, None]:
|
|
try:
|
|
async for chunk in handler(msg):
|
|
yield chunk
|
|
except TypeError:
|
|
result = await handler(msg)
|
|
if isinstance(result, str):
|
|
yield result
|
|
elif result is not None:
|
|
logger.warning(
|
|
"Handler returned unexpected type %s for msg_id=%s, converting via str()",
|
|
type(result).__name__,
|
|
msg.identity.channel_message_id,
|
|
)
|
|
yield str(result)
|
|
|
|
|
|
@register_builtin_adapter
|
|
class QQBotAdapter(BaseChannelAdapter):
|
|
channel_id = "qqbot"
|
|
channel_type = ChannelType.QQ_BOT
|
|
|
|
text_chunk_limit = 2000
|
|
supports_markdown = True
|
|
supports_streaming = True
|
|
streaming_modes = ["off", "block"]
|
|
max_media_size_mb = 100
|
|
|
|
capabilities = ChannelCapabilities(
|
|
chat_types=["direct", "group", "guild_channel"],
|
|
replies=True,
|
|
edit=False,
|
|
unsend=False,
|
|
reply=True,
|
|
media=True,
|
|
supports_markdown=True,
|
|
supports_streaming=True,
|
|
streaming_modes=["off", "block"],
|
|
text_chunk_limit=2000,
|
|
max_media_size_mb=100,
|
|
)
|
|
meta = ChannelMeta(id="qqbot", label="QQ Bot")
|
|
|
|
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: QQBotTokenManager | None = None
|
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5)
|
|
self._bot_info: dict[str, Any] | None = None
|
|
self._sandbox = self.config.get("sandbox", False)
|
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
self._seq: int | None = None
|
|
self._heartbeat_interval: float = 30.0
|
|
self._heartbeat_task: asyncio.Task | None = None
|
|
self._last_heartbeat_ack: float = 0
|
|
self._heartbeat_miss_count: int = 0
|
|
self._heartbeat_latency_ms: float = 0
|
|
self._last_heartbeat_send_time: float = 0
|
|
self._receive_task: asyncio.Task | None = None
|
|
queue_global_limit = int(self.config.get("queue_global_limit", 1000))
|
|
self._message_queue = MessageQueue(
|
|
global_limit=queue_global_limit,
|
|
per_user_limit=int(self.config.get("queue_per_user_limit", 20)),
|
|
per_group_limit=int(self.config.get("queue_per_group_limit", 50)),
|
|
max_concurrency=int(self.config.get("queue_max_concurrency", 10)),
|
|
)
|
|
self._recent_msg_ids: dict[str, float] = {}
|
|
self._dedup_window_s: int = int(self.config.get("dedup_window_s", 60))
|
|
|
|
chat_types = self.config.get("chat_types", ["direct", "group", "guild_channel"])
|
|
has_guild = "guild_channel" in chat_types
|
|
self.capabilities = ChannelCapabilities(
|
|
chat_types=list(chat_types),
|
|
replies=True,
|
|
edit=has_guild,
|
|
unsend=has_guild,
|
|
reply=True,
|
|
media=True,
|
|
supports_markdown=True,
|
|
supports_streaming=True,
|
|
streaming_modes=["off", "block"],
|
|
text_chunk_limit=2000,
|
|
max_media_size_mb=100,
|
|
)
|
|
|
|
self._security = QQBotSecurityPolicy(self.config)
|
|
self._credential_backup: CredentialBackup | None = None
|
|
self._session_store: SessionStore | None = None
|
|
self._known_users: KnownUserTracker | None = None
|
|
self._rate_limiter = RouteRateLimiter()
|
|
reconnect_cfg = self.config.get("reconnect", {})
|
|
self._reconnect_manager = QQBotReconnectManager(
|
|
max_retries=reconnect_cfg.get("max_retries", 10),
|
|
base_delay=reconnect_cfg.get("base_delay", 1.0),
|
|
max_delay=reconnect_cfg.get("max_delay", 60.0),
|
|
jitter=reconnect_cfg.get("jitter", 0.3),
|
|
)
|
|
self._reconnect_manager.add_state_listener(self._on_reconnect_state_change)
|
|
self._c2c_streaming: C2CStreamingController | None = None
|
|
self._markdown_chunker: MarkdownChunker = MarkdownChunker()
|
|
self._group_buffer = GroupHistoryBuffer()
|
|
self._msg_seq_manager = MessageSeqManager()
|
|
self._reply_dispatcher = ReplyDispatcher(self)
|
|
self._par_chunker = ParagraphChunker()
|
|
self._command_registry = register_builtin_commands(CommandRegistry())
|
|
register_streaming_command(self._command_registry)
|
|
self._interaction_registry = InteractionRegistry()
|
|
self._interaction_sessions = InteractionSessionManager()
|
|
self._inbound_pipeline = QQBotInboundPipeline(self)
|
|
self._approval_manager = ExecApprovalManager()
|
|
self._tts_provider = TTSProvider(
|
|
default_voice=self.config.get("tts_default_voice", "zh-CN-XiaoxiaoNeural"),
|
|
default_format=AudioFormat(self.config.get("tts_default_format", "mp3")),
|
|
)
|
|
self._stt_provider = STTProvider.from_config(self.config)
|
|
self._audio_format_policy = AudioFormatPolicy.from_config(self.config)
|
|
self._typing_keepalive = self._init_typing_keepalive()
|
|
self._sender_info = self._init_sender_info()
|
|
self._message_cache = MessageCache(
|
|
max_messages=int(self.config.get("message_cache_max", 500)),
|
|
ttl_s=float(self.config.get("message_cache_ttl_s", 3600.0)),
|
|
)
|
|
self._group_stream_buffers: dict[str, str] = {}
|
|
self._group_stream_senders: dict[str, Any] = {}
|
|
|
|
def _init_typing_keepalive(self) -> TypingKeepAlive:
|
|
return TypingKeepAlive(send_notify_fn=self._send_input_notify)
|
|
|
|
def _init_sender_info(self) -> SenderInfo:
|
|
plugin_version = self.config.get("plugin_version", "1.0.0")
|
|
openclaw_version = self.config.get("openclaw_version", "1.0.0")
|
|
return init_sender(plugin_version=plugin_version, openclaw_version=openclaw_version)
|
|
|
|
async def _send_input_notify(self, chat_id: str) -> None:
|
|
if not self._http_client or not self._token_manager:
|
|
return
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
url = f"{api_base}/v2/chat/{chat_id}/input_notify"
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
try:
|
|
async with self._http_client.post(url, headers=headers, json={}) as resp:
|
|
if resp.status != 200:
|
|
logger.debug("TypingKeepAlive: input_notify HTTP %d for %s", resp.status, chat_id)
|
|
except Exception:
|
|
logger.debug("TypingKeepAlive: input_notify failed for %s", chat_id, exc_info=True)
|
|
|
|
# ========== çå½å¨æ ==========
|
|
|
|
async def connect(self) -> None:
|
|
if self._status == ChannelStatus.CONNECTED:
|
|
return
|
|
|
|
self._status = ChannelStatus.CONNECTING
|
|
await self._reconnect_manager.transition(ReconnectState.CONNECTING)
|
|
|
|
self._restore_credentials()
|
|
self._init_session_store()
|
|
self._init_known_users()
|
|
|
|
logger.info(f"[QQBot] Starting channel '{self.channel_id}'...")
|
|
|
|
await self._inbound_pipeline.initialize()
|
|
|
|
try:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = (
|
|
self.config.get("app_secret")
|
|
or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
or self._read_app_secret_file()
|
|
)
|
|
if not app_id or not app_secret:
|
|
raise ChannelAuthenticationError(
|
|
"Missing app_id or app_secret in config, "
|
|
"QQBOT_APP_ID/QQBOT_CLIENT_SECRET env vars, or client_secret_file"
|
|
)
|
|
|
|
self._http_client = aiohttp.ClientSession()
|
|
|
|
self._token_manager = QQBotTokenManager(
|
|
app_id=app_id,
|
|
app_secret=app_secret,
|
|
sandbox=self._sandbox,
|
|
http_client=self._http_client,
|
|
)
|
|
|
|
token = await self._token_manager.get_token()
|
|
logger.debug("[QQBot] Access token obtained")
|
|
|
|
self._token_manager.start_background_refresh()
|
|
|
|
ws_url = await self._get_gateway_url(token)
|
|
logger.debug(f"[QQBot] Gateway URL: {ws_url}")
|
|
|
|
self._ws = await self._http_client.ws_connect(ws_url)
|
|
|
|
hello_data = await self._wait_for_hello()
|
|
self._heartbeat_interval = hello_data.get("heartbeat_interval", 30000) / 1000.0
|
|
|
|
session_id = self._reconnect_manager._session_id
|
|
last_seq = self._reconnect_manager._last_seq
|
|
if session_id and last_seq is not None:
|
|
logger.info("[QQBot] Attempting RESUME with session_id=%s seq=%s", session_id[:16], last_seq)
|
|
await self._send_resume()
|
|
try:
|
|
ready_data = await asyncio.wait_for(self._wait_for_ready(), timeout=5.0)
|
|
if ready_data:
|
|
logger.info("[QQBot] RESUME successful, session recovered")
|
|
self._reconnect_manager.mark_connected()
|
|
await self._reconnect_manager.transition(ReconnectState.CONNECTED)
|
|
self._status = ChannelStatus.CONNECTED
|
|
self._c2c_streaming = C2CStreamingController(
|
|
send_message_fn=self._send_stream_message,
|
|
flush_interval=self.config.get("stream_flush_interval", 0.6),
|
|
)
|
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
self._receive_task = asyncio.create_task(self._ws_receive_loop())
|
|
self._backup_credentials()
|
|
self._save_session()
|
|
self._register_runtime_contexts()
|
|
logger.info("[QQBot] Channel '%s' resumed successfully", self.channel_id)
|
|
return
|
|
except (TimeoutError, Exception):
|
|
logger.info("[QQBot] RESUME failed, falling back to IDENTIFY")
|
|
|
|
await self._send_identify()
|
|
|
|
self._bot_info = {
|
|
"app_id": app_id,
|
|
"username": self.config.get("bot_name", f"QQBot_{app_id[:6]}"),
|
|
}
|
|
|
|
self._c2c_streaming = C2CStreamingController(
|
|
send_message_fn=self._send_stream_message,
|
|
flush_interval=self.config.get("stream_flush_interval", 0.6),
|
|
)
|
|
|
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
self._receive_task = asyncio.create_task(self._ws_receive_loop())
|
|
|
|
self._status = ChannelStatus.CONNECTED
|
|
await self._reconnect_manager.on_identify_success(self._reconnect_manager.session_id or "")
|
|
self._reconnect_manager.mark_connected()
|
|
await self._reconnect_manager.transition(ReconnectState.CONNECTED)
|
|
|
|
self._backup_credentials()
|
|
self._save_session()
|
|
|
|
self._register_runtime_contexts()
|
|
|
|
logger.info(
|
|
f"[QQBot] Channel '{self.channel_id}' started "
|
|
f"(sandbox={self._sandbox}, heartbeat={self._heartbeat_interval:.0f}s)"
|
|
)
|
|
|
|
except Exception as e:
|
|
self._status = ChannelStatus.ERROR
|
|
if not isinstance(e, ChannelAuthenticationError):
|
|
logger.error(f"[QQBot] Failed to start channel '{self.channel_id}': {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
await self._cleanup_ws()
|
|
raise
|
|
|
|
async def disconnect(self) -> None:
|
|
if self._status == ChannelStatus.DISCONNECTED:
|
|
return
|
|
|
|
logger.info(f"[QQBot] Stopping channel '{self.channel_id}'...")
|
|
self._status = ChannelStatus.DISCONNECTED
|
|
await self._reconnect_manager.reset()
|
|
await self._cleanup_ws()
|
|
|
|
if self._token_manager:
|
|
self._token_manager.stop_background_refresh()
|
|
|
|
if self._http_client:
|
|
await self._http_client.close()
|
|
self._http_client = None
|
|
|
|
self._token_manager = None
|
|
self._c2c_streaming = None
|
|
|
|
if self._credential_backup:
|
|
self._credential_backup.clear()
|
|
self._credential_backup = None
|
|
|
|
if self._typing_keepalive:
|
|
await self._typing_keepalive.stop_all()
|
|
|
|
if self._session_store:
|
|
self._session_store.clear()
|
|
self._session_store = None
|
|
|
|
logger.info(f"[QQBot] Channel '{self.channel_id}' stopped")
|
|
|
|
async def _cleanup_ws(self) -> None:
|
|
for task in (self._heartbeat_task, self._receive_task):
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
self._heartbeat_task = None
|
|
self._receive_task = None
|
|
|
|
if self._ws and not self._ws.closed:
|
|
try:
|
|
await self._ws.close()
|
|
except Exception:
|
|
logger.debug("[QQBot] Error closing WebSocket during cleanup", exc_info=True)
|
|
self._ws = None
|
|
self._seq = None
|
|
|
|
# ========== WebSocket å
é¨é»è¾ ==========
|
|
|
|
async def _wait_for_hello(self) -> dict:
|
|
if not self._ws:
|
|
raise ChannelAuthenticationError("WebSocket not connected")
|
|
try:
|
|
async with asyncio.timeout(30):
|
|
async for msg in self._ws:
|
|
data = json.loads(msg.data)
|
|
op = data.get("op")
|
|
if op == Opcode.HELLO:
|
|
return data.get("d", {})
|
|
logger.debug(f"[QQBot] Skipping op={op} before HELLO")
|
|
except TimeoutError:
|
|
raise ChannelAuthenticationError("Timeout waiting for HELLO from QQ Bot Gateway (30s)")
|
|
|
|
async def _send_identify(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
token = await self._token_manager.get_token()
|
|
payload = {
|
|
"op": Opcode.IDENTIFY,
|
|
"d": {
|
|
"token": f"QQBot {token}",
|
|
"intents": self.config.get("intents", Intent.DEFAULT),
|
|
"shard": [0, 1],
|
|
"properties": {},
|
|
},
|
|
}
|
|
await self._ws.send_json(payload)
|
|
|
|
async def _send_resume(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
token = await self._token_manager.get_token()
|
|
payload = {
|
|
"op": Opcode.RESUME,
|
|
"d": {
|
|
"token": f"QQBot {token}",
|
|
"session_id": self._reconnect_manager.session_id,
|
|
"seq": self._reconnect_manager.last_seq,
|
|
},
|
|
}
|
|
await self._ws.send_json(payload)
|
|
|
|
async def _wait_for_ready(self) -> dict | None:
|
|
if not self._ws:
|
|
return None
|
|
async for msg in self._ws:
|
|
data = json.loads(msg.data)
|
|
op = data.get("op")
|
|
t = data.get("t", "")
|
|
if op == Opcode.DISPATCH and t in ("READY", "RESUMED"):
|
|
return data.get("d", {})
|
|
if op in (Opcode.INVALID_SESSION, Opcode.RECONNECT):
|
|
return None
|
|
return None
|
|
|
|
async def _heartbeat_loop(self) -> None:
|
|
max_misses = 3
|
|
while self._status == ChannelStatus.CONNECTED and self._ws and not self._ws.closed:
|
|
try:
|
|
await asyncio.sleep(self._heartbeat_interval)
|
|
|
|
if self._heartbeat_miss_count >= max_misses:
|
|
logger.warning(f"[QQBot] Heartbeat missed {max_misses} consecutive ACKs, disconnecting")
|
|
self._status = ChannelStatus.RECONNECTING
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
break
|
|
|
|
send_time = time.monotonic()
|
|
self._last_heartbeat_send_time = send_time
|
|
await self._ws.send_json({"op": Opcode.HEARTBEAT, "d": self._seq})
|
|
|
|
await asyncio.sleep(5)
|
|
ack_received = self._last_heartbeat_ack > send_time
|
|
if not ack_received:
|
|
self._heartbeat_miss_count += 1
|
|
logger.warning(
|
|
"[QQBot] Heartbeat ACK missed (%d/%d), latency=%.0fms",
|
|
self._heartbeat_miss_count,
|
|
max_misses,
|
|
self._heartbeat_latency_ms,
|
|
)
|
|
|
|
if self._heartbeat_latency_ms > 0:
|
|
adaptive = max(2.0, min(60.0, self._heartbeat_latency_ms / 1000 * 5))
|
|
self._heartbeat_interval = adaptive
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"[QQBot] Heartbeat failed: {e}")
|
|
self._status = ChannelStatus.RECONNECTING
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
break
|
|
|
|
async def _ws_receive_loop(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
try:
|
|
async for msg in self._ws:
|
|
data = json.loads(msg.data)
|
|
op = data.get("op")
|
|
|
|
if op == Opcode.DISPATCH:
|
|
self._seq = data.get("s", self._seq)
|
|
if self._seq:
|
|
await self._reconnect_manager.record_seq(self._seq)
|
|
event_type = data.get("t", "")
|
|
event_data = data.get("d", {})
|
|
await self._dispatch_event(event_type, event_data)
|
|
|
|
elif op == Opcode.HEARTBEAT_ACK:
|
|
self._last_heartbeat_ack = time.monotonic()
|
|
self._heartbeat_miss_count = 0
|
|
if self._last_heartbeat_send_time > 0:
|
|
self._heartbeat_latency_ms = (self._last_heartbeat_ack - self._last_heartbeat_send_time) * 1000
|
|
logger.debug(
|
|
"[QQBot] Heartbeat ACK received, latency=%.0fms",
|
|
self._heartbeat_latency_ms,
|
|
)
|
|
|
|
elif op == Opcode.RECONNECT:
|
|
logger.warning("[QQBot] Server requested reconnect")
|
|
await self._reconnect_manager.on_disconnect(ECode.SESSION_TIMEOUT)
|
|
break
|
|
|
|
elif op == Opcode.INVALID_SESSION:
|
|
logger.warning("[QQBot] Invalid session, will reconnect")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
break
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except aiohttp.ClientConnectionError as e:
|
|
logger.error(f"[QQBot] WebSocket connection error: {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] WebSocket receive error: {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
finally:
|
|
state = self._reconnect_manager.state
|
|
if state in (ReconnectState.IDENTIFYING, ReconnectState.RESUMING):
|
|
logger.info("[QQBot] WebSocket disconnected, attempting reconnect")
|
|
await self._attempt_reconnect()
|
|
elif state == ReconnectState.FROZEN:
|
|
logger.error("[QQBot] Reconnect manager frozen, giving up")
|
|
self._status = ChannelStatus.ERROR
|
|
|
|
async def _dispatch_event(self, event_type: str, event_data: dict) -> None:
|
|
if not self._inbound_pipeline._stages:
|
|
await self._inbound_pipeline.initialize()
|
|
await self._inbound_pipeline.process(event_type, event_data)
|
|
|
|
def _prune_old_msg_ids(self, now: float) -> None:
|
|
expired = [mid for mid, ts in self._recent_msg_ids.items() if now - ts >= self._dedup_window_s]
|
|
for mid in expired:
|
|
del self._recent_msg_ids[mid]
|
|
|
|
async def _on_pipeline_dispatch(self, ctx) -> None:
|
|
if self._known_users and ctx.sender_id:
|
|
self._known_users.record(
|
|
user_id=ctx.sender_id,
|
|
username=ctx.sender_name or "",
|
|
chat_type=ctx.chat_type or "",
|
|
)
|
|
|
|
msg = self._build_channel_message(ctx)
|
|
if msg is None:
|
|
return
|
|
|
|
if msg.message_type.value == "command":
|
|
handled = await self._handle_command(msg)
|
|
if handled:
|
|
return
|
|
|
|
if msg.message_type.value == "interaction":
|
|
handled = await self._handle_interaction(msg, ctx)
|
|
if handled:
|
|
return
|
|
|
|
if self.config.get("auto_download_attachments", True) and msg.attachments:
|
|
await self._download_attachments(msg.attachments)
|
|
|
|
if self._message_handler:
|
|
chat_id = msg.identity.channel_chat_id
|
|
try:
|
|
await self._typing_keepalive.start(chat_id)
|
|
await self._message_handler(msg)
|
|
except ChannelException as e:
|
|
if e.retryable:
|
|
logger.warning(
|
|
"[QQBot] Retryable error in message handler (chat=%s): %s",
|
|
chat_id,
|
|
e,
|
|
)
|
|
else:
|
|
logger.exception(
|
|
"[QQBot] Unrecoverable error in message handler (chat=%s)",
|
|
chat_id,
|
|
)
|
|
await self._send_error_notification(msg, str(e))
|
|
except Exception:
|
|
logger.exception("[QQBot] Unexpected error in message handler (chat=%s)", chat_id)
|
|
await self._send_error_notification(msg, "内部错误,请稍后重试")
|
|
finally:
|
|
await self._typing_keepalive.stop(chat_id)
|
|
|
|
await self._message_queue.enqueue(msg)
|
|
self._last_inbound_at = time.time()
|
|
|
|
def _build_channel_message(self, ctx) -> ChannelMessage | None:
|
|
|
|
chat_type_map = {
|
|
"dm": ChatType.DIRECT,
|
|
"group": ChatType.GROUP,
|
|
}
|
|
chat_type = chat_type_map.get(ctx.chat_type)
|
|
if chat_type is None and ctx.chat_type == "interaction":
|
|
chat_id = ctx.chat_id or ""
|
|
if chat_id.startswith(DM_CHAT_PREFIX):
|
|
chat_type = ChatType.DIRECT
|
|
else:
|
|
chat_type = ChatType.GROUP
|
|
elif chat_type is None:
|
|
chat_type = ChatType.GROUP
|
|
|
|
if ctx.chat_type == "dm":
|
|
channel_chat_id = f"{DM_CHAT_PREFIX}{ctx.sender_id}"
|
|
elif ctx.chat_type == "group":
|
|
channel_chat_id = f"{GROUP_CHAT_PREFIX}{ctx.chat_id}"
|
|
else:
|
|
channel_chat_id = ctx.chat_id
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=ctx.sender_id,
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=ctx.msg_id,
|
|
)
|
|
|
|
content = ctx.content or ""
|
|
message_type = MessageType.TEXT
|
|
if content.startswith("/"):
|
|
message_type = MessageType.COMMAND
|
|
|
|
mentions = None
|
|
if ctx.chat_type == "group":
|
|
mentions = MentionsInfo(is_bot_mentioned=True, raw_text=content)
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
message_type=message_type,
|
|
chat_type=chat_type,
|
|
content=content,
|
|
mentions=mentions,
|
|
metadata={
|
|
"event_type": ctx.event_type,
|
|
"pipeline_version": "qqbot_v2",
|
|
**(ctx.metadata or {}),
|
|
},
|
|
timestamp=datetime.now(),
|
|
)
|
|
|
|
async def _handle_command(self, msg: ChannelMessage) -> bool:
|
|
content = msg.content or ""
|
|
resolved = self._command_registry.resolve(content)
|
|
if resolved is None:
|
|
return False
|
|
|
|
command_name, args = resolved
|
|
result = await self._command_registry.dispatch(
|
|
command_name,
|
|
args,
|
|
content,
|
|
msg,
|
|
self,
|
|
)
|
|
|
|
chat_id = msg.identity.channel_chat_id
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=msg.identity.channel_user_id,
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=result.message,
|
|
)
|
|
try:
|
|
delivery = await self.send(response)
|
|
if not delivery.success:
|
|
logger.warning("[QQBot] Command response failed: %s", delivery.error)
|
|
except Exception:
|
|
logger.exception("[QQBot] Failed to send command response")
|
|
|
|
return True
|
|
|
|
async def _handle_interaction(self, msg: ChannelMessage, ctx) -> bool:
|
|
try:
|
|
interaction_ctx = InteractionContext(
|
|
interaction_id=ctx.metadata.get("interaction_id", ""),
|
|
interaction_type=ctx.metadata.get("interaction_type", ""),
|
|
chat_type=ctx.chat_type or "",
|
|
chat_id=ctx.chat_id or "",
|
|
user_id=ctx.sender_id or "",
|
|
user_name=ctx.sender_name or "",
|
|
data=ctx.metadata.get("interaction_data", {}),
|
|
msg_id=ctx.msg_id or "",
|
|
)
|
|
|
|
handled = await self._interaction_registry.dispatch(interaction_ctx)
|
|
if not handled:
|
|
session_handler = await self._interaction_sessions.get(interaction_ctx.interaction_id)
|
|
if session_handler:
|
|
await self._interaction_sessions.resolve(
|
|
interaction_ctx.interaction_id,
|
|
interaction_ctx.data,
|
|
)
|
|
return True
|
|
return handled
|
|
except Exception:
|
|
logger.exception("[QQBot] Interaction handling failed")
|
|
return False
|
|
|
|
# ========== 消息收发 ==========
|
|
|
|
async def _send_error_notification(self, msg: ChannelMessage, error_msg: str) -> None:
|
|
try:
|
|
response = ChannelResponse(
|
|
identity=msg.identity,
|
|
content=f"抱歉,处理您的消息时遇到了问题:{error_msg}",
|
|
)
|
|
await self.send(response)
|
|
except Exception:
|
|
logger.warning("[QQBot] Failed to send error notification to user", exc_info=True)
|
|
|
|
async def send(self, response: ChannelResponse) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
chat_id = response.identity.channel_chat_id
|
|
payload = self.format_outbound(response)
|
|
if response.metadata.get("qq_msg_type") != "markdown":
|
|
payload["msg_seq"] = await self._msg_seq_manager.acquire_active()
|
|
|
|
silent = response.metadata.get("silent") or self.config.get("silent", False)
|
|
if silent:
|
|
payload["no_notify"] = True
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
async def _refresh_token() -> str:
|
|
return await self._token_manager.force_refresh()
|
|
|
|
async def _do_send():
|
|
return await send_with_retry(
|
|
self._http_client,
|
|
token,
|
|
api_base,
|
|
payload,
|
|
chat_id,
|
|
self.config,
|
|
token_refresh_cb=_refresh_token,
|
|
sender_headers=build_sender_headers(self._sender_info),
|
|
)
|
|
|
|
try:
|
|
await self._rate_limiter.acquire("send_message")
|
|
result = await self._circuit_breaker.call(_do_send)
|
|
self._last_outbound_at = time.time()
|
|
self._message_cache.record_sent(result, chat_id, content=response.content)
|
|
return result
|
|
except CircuitBreakerOpenError:
|
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
|
except (DeliveryFailedError, Exception) as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def send_stream_chunk(
|
|
self,
|
|
chat_id: str,
|
|
msg_id: str,
|
|
chunk: str,
|
|
finished: bool = False,
|
|
) -> DeliveryResult:
|
|
is_group = chat_id.startswith(GROUP_CHAT_PREFIX) if GROUP_CHAT_PREFIX else False
|
|
|
|
if not is_group:
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_chat_id=chat_id,
|
|
channel_user_id="",
|
|
)
|
|
response = ChannelResponse(identity=identity, content=chunk)
|
|
return await self.send(response)
|
|
|
|
return await self._send_group_stream_chunk(chat_id, msg_id, chunk, finished)
|
|
|
|
async def _send_group_stream_chunk(
|
|
self,
|
|
chat_id: str,
|
|
msg_id: str,
|
|
chunk: str,
|
|
finished: bool = False,
|
|
) -> DeliveryResult:
|
|
existing = self._group_stream_buffers.get(chat_id, "")
|
|
accumulated = existing + chunk
|
|
|
|
if not finished:
|
|
paragraphs = accumulated.split("\n\n")
|
|
if len(paragraphs) > 1:
|
|
for para in paragraphs[:-1]:
|
|
if para.strip():
|
|
await self._send_group_paragraph(chat_id, para)
|
|
self._group_stream_buffers[chat_id] = paragraphs[-1]
|
|
else:
|
|
self._group_stream_buffers[chat_id] = accumulated
|
|
return DeliveryResult(success=True)
|
|
|
|
self._group_stream_buffers.pop(chat_id, None)
|
|
self._group_stream_senders.pop(chat_id, None)
|
|
|
|
from yuxi.channels.models import ChannelType
|
|
|
|
from .streaming import send_blocks_stream
|
|
|
|
await send_blocks_stream(
|
|
chat_id=chat_id,
|
|
text=accumulated,
|
|
send_fn=self.send,
|
|
channel_id=self.channel_id,
|
|
channel_type=ChannelType.QQ_BOT,
|
|
chunk_size=self.text_chunk_limit,
|
|
)
|
|
return DeliveryResult(success=True)
|
|
|
|
async def _send_group_paragraph(self, chat_id: str, text: str) -> DeliveryResult:
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_chat_id=chat_id,
|
|
channel_user_id="",
|
|
)
|
|
response = ChannelResponse(identity=identity, content=text.strip())
|
|
return await self.send(response)
|
|
|
|
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
if media_type not in ("image", "voice", "video", "file"):
|
|
return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}")
|
|
|
|
if not isinstance(data, bytes):
|
|
return DeliveryResult(success=False, error="Media data must be bytes")
|
|
|
|
from .media_upload import (
|
|
FILE_TYPE_FILE,
|
|
FILE_TYPE_IMAGE,
|
|
FILE_TYPE_VIDEO,
|
|
FILE_TYPE_VOICE,
|
|
upload_media,
|
|
validate_media_size,
|
|
)
|
|
|
|
validate_media_size(data, max_size_mb=self.max_media_size_mb, label=media_type)
|
|
|
|
token = await self._token_manager.get_token()
|
|
|
|
file_type_map = {
|
|
"image": FILE_TYPE_IMAGE,
|
|
"voice": FILE_TYPE_VOICE,
|
|
"video": FILE_TYPE_VIDEO,
|
|
"file": FILE_TYPE_FILE,
|
|
}
|
|
file_type = file_type_map.get(media_type, FILE_TYPE_FILE)
|
|
|
|
group_openid = None
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
|
|
|
|
try:
|
|
file_id = await upload_media(
|
|
data,
|
|
token,
|
|
http_client=self._http_client,
|
|
filename=f"upload.{media_type}",
|
|
file_type=file_type,
|
|
group_openid=group_openid,
|
|
sandbox=self._sandbox,
|
|
)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
_MEDIA_TYPE_TO_MESSAGE_TYPE = {
|
|
"image": MessageType.IMAGE,
|
|
"voice": MessageType.AUDIO,
|
|
"video": MessageType.VIDEO,
|
|
"file": MessageType.FILE,
|
|
}
|
|
message_type = _MEDIA_TYPE_TO_MESSAGE_TYPE.get(media_type, MessageType.FILE)
|
|
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
message_type=message_type,
|
|
content="",
|
|
attachments=[Attachment(type=media_type, file_id=file_id)],
|
|
metadata={"qq_msg_type": media_type, "media_file_id": file_id},
|
|
)
|
|
return await self.send(response)
|
|
|
|
async def send_keyboard(self, chat_id: str, text: str, buttons: list[dict]) -> DeliveryResult:
|
|
from .ark_templates import build_text_card
|
|
|
|
payload = build_text_card(text, buttons)
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_ark(
|
|
self,
|
|
chat_id: str,
|
|
template_id: int,
|
|
kv_pairs: list[dict[str, str]],
|
|
) -> DeliveryResult:
|
|
payload = {
|
|
"msg_type": 3,
|
|
"ark": {"template_id": template_id, "kv": kv_pairs},
|
|
}
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_embed(
|
|
self,
|
|
chat_id: str,
|
|
title: str = "",
|
|
description: str = "",
|
|
prompt: str = "",
|
|
fields: list[dict] | None = None,
|
|
) -> DeliveryResult:
|
|
if chat_id.startswith(DM_CHAT_PREFIX) or chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
logger.debug("[QQBot] Embed not supported in C2C/Group, degrading to text")
|
|
return await self.send(
|
|
ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=f"{title}\n{description}" if title else description,
|
|
)
|
|
)
|
|
|
|
from .ark_templates import build_embed
|
|
|
|
payload = build_embed(title=title, description=description, prompt=prompt, fields=fields)
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_tts_voice(
|
|
self,
|
|
chat_id: str,
|
|
text: str,
|
|
voice: str = "",
|
|
fmt: AudioFormat | None = None,
|
|
) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
try:
|
|
audio_data = await self._tts_provider.synthesize(text, voice, fmt)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"TTS synthesis failed: {e}")
|
|
|
|
output_fmt = fmt or self._tts_provider._default_format
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
from .voice_send import send_voice
|
|
|
|
return await send_voice(
|
|
audio_data,
|
|
chat_id,
|
|
token,
|
|
self._http_client,
|
|
api_base,
|
|
filename=f"tts.{output_fmt.value}",
|
|
)
|
|
|
|
async def transcribe_voice(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str:
|
|
try:
|
|
return await self._stt_provider.transcribe(audio_data, fmt)
|
|
except Exception:
|
|
logger.exception("[QQBot] STT transcription failed")
|
|
return ""
|
|
|
|
async def _send_raw(self, chat_id: str, payload: dict) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
payload["msg_seq"] = await self._msg_seq_manager.acquire_active()
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
async def _refresh_token() -> str:
|
|
return await self._token_manager.force_refresh()
|
|
|
|
async def _do_send():
|
|
return await send_with_retry(
|
|
self._http_client,
|
|
token,
|
|
api_base,
|
|
payload,
|
|
chat_id,
|
|
self.config,
|
|
token_refresh_cb=_refresh_token,
|
|
)
|
|
|
|
try:
|
|
return await self._circuit_breaker.call(_do_send)
|
|
except CircuitBreakerOpenError:
|
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
|
except (DeliveryFailedError, Exception) as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
|
from .format import build_text_payload
|
|
|
|
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
|
|
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support editing group messages")
|
|
if chat_id.startswith(DM_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support editing direct messages")
|
|
|
|
payload = build_text_payload(
|
|
ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
)
|
|
payload.pop("msg_type", None)
|
|
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
url = f"{api_base}/v2/channels/{chat_id}/messages/{msg_id}"
|
|
|
|
try:
|
|
async with self._http_client.patch(url, json=payload, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return DeliveryResult(success=True, message_id=msg_id)
|
|
return DeliveryResult(success=False, error=f"Edit failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] Edit message failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def delete_message(self, chat_id: str, msg_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
|
|
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support deleting group messages")
|
|
if chat_id.startswith(DM_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support deleting direct messages")
|
|
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
url = f"{api_base}/v2/channels/{chat_id}/messages/{msg_id}"
|
|
|
|
try:
|
|
async with self._http_client.delete(url, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return DeliveryResult(success=True, message_id=msg_id)
|
|
return DeliveryResult(success=False, error=f"Delete failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] Delete message failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def receive(self) -> AsyncIterator[ChannelMessage]:
|
|
while self._status == ChannelStatus.CONNECTED:
|
|
try:
|
|
msg = await self._message_queue.dequeue()
|
|
yield msg
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
# ========== æ ¼å¼è½¬æ¢ ==========
|
|
|
|
def normalize_inbound(self, raw: dict) -> ChannelMessage:
|
|
event_type = raw.get("event_type", "")
|
|
event_data = raw.get("event", {})
|
|
|
|
chat_type = ChatType.GROUP
|
|
channel_chat_id = ""
|
|
|
|
if event_type == "at_message_create":
|
|
chat_type = ChatType.GROUP
|
|
channel_chat_id = f"{GROUP_CHAT_PREFIX}{event_data.get('group_openid', '')}"
|
|
elif event_type == "direct_message_create":
|
|
chat_type = ChatType.DIRECT
|
|
channel_chat_id = f"{DM_CHAT_PREFIX}{event_data.get('author', {}).get('id', '')}"
|
|
elif event_type == "message_create":
|
|
chat_type = ChatType.GUILD_CHANNEL
|
|
channel_chat_id = event_data.get("channel_id", "")
|
|
elif event_type in ("guild_member_add", "group_add_robot"):
|
|
return self._build_event_message(raw, EventType.MEMBER_JOINED)
|
|
elif event_type in ("guild_member_remove", "group_del_robot"):
|
|
return self._build_event_message(raw, EventType.MEMBER_LEFT)
|
|
elif event_type == "guild_member_update":
|
|
return self._build_event_message(raw, EventType.MEMBER_JOINED)
|
|
elif event_type == "message_delete":
|
|
return self._build_event_message(raw, EventType.MESSAGE_DELETED)
|
|
else:
|
|
logger.warning(f"[QQBot] Unknown event type: {event_type}")
|
|
raise MessageFormatError()
|
|
|
|
author = event_data.get("author", {})
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=str(author.get("id", "")),
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=str(event_data.get("id", "")),
|
|
)
|
|
|
|
content = event_data.get("content", "")
|
|
message_type = MessageType.TEXT
|
|
if content and content.startswith("/"):
|
|
message_type = MessageType.COMMAND
|
|
|
|
attachments = []
|
|
for attach in event_data.get("attachments", []):
|
|
attach_type = attach.get("content_type", "")
|
|
if attach_type.startswith("image/"):
|
|
attachments.append(
|
|
Attachment(
|
|
type="image",
|
|
url=attach.get("url"),
|
|
filename=attach.get("filename"),
|
|
)
|
|
)
|
|
else:
|
|
attachments.append(
|
|
Attachment(
|
|
type="file",
|
|
url=attach.get("url"),
|
|
filename=attach.get("filename"),
|
|
size_bytes=attach.get("size", 0),
|
|
)
|
|
)
|
|
|
|
mentions = None
|
|
if event_type == "at_message_create":
|
|
mentions = MentionsInfo(
|
|
is_bot_mentioned=True,
|
|
raw_text=content,
|
|
)
|
|
|
|
metadata = {
|
|
"event_type": event_type,
|
|
"qq_chat_type": chat_type.value,
|
|
}
|
|
if event_data.get("guild_id"):
|
|
metadata["guild_id"] = event_data["guild_id"]
|
|
if event_data.get("channel_id"):
|
|
metadata["channel_id"] = event_data["channel_id"]
|
|
if event_data.get("timestamp"):
|
|
metadata["event_timestamp"] = event_data["timestamp"]
|
|
|
|
timestamp = datetime.now()
|
|
if event_data.get("timestamp"):
|
|
try:
|
|
timestamp = datetime.fromisoformat(event_data["timestamp"])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
message_type=message_type,
|
|
chat_type=chat_type,
|
|
content=content,
|
|
attachments=attachments,
|
|
mentions=mentions,
|
|
metadata=metadata,
|
|
timestamp=timestamp,
|
|
)
|
|
|
|
def _build_event_message(self, raw: dict, event_type: EventType) -> ChannelMessage:
|
|
event_data = raw.get("event", {})
|
|
author = event_data.get("user", event_data.get("op_user", event_data.get("author", {})))
|
|
|
|
chat_id = ""
|
|
chat_type = ChatType.GROUP
|
|
group_id = event_data.get("group_openid", "")
|
|
guild_id = event_data.get("guild_id", "")
|
|
channel_id = event_data.get("channel_id", "")
|
|
|
|
if group_id:
|
|
chat_id = f"{GROUP_CHAT_PREFIX}{group_id}"
|
|
chat_type = ChatType.GROUP
|
|
elif channel_id:
|
|
chat_id = channel_id
|
|
chat_type = ChatType.GUILD_CHANNEL
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=str(author.get("id", "")),
|
|
channel_chat_id=chat_id,
|
|
channel_message_id=str(event_data.get("id", "")),
|
|
)
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
chat_type=chat_type,
|
|
message_type=MessageType.TEXT,
|
|
content="",
|
|
event_type=event_type,
|
|
metadata={
|
|
"event_type": raw.get("event_type", ""),
|
|
"guild_id": guild_id,
|
|
"group_openid": group_id,
|
|
"channel_id": channel_id,
|
|
"guild_name": event_data.get("guild", {}).get("name", ""),
|
|
},
|
|
)
|
|
|
|
def format_outbound(self, response: ChannelResponse) -> dict:
|
|
return format_outbound(
|
|
response,
|
|
use_markdown=self.config.get("use_markdown", False),
|
|
markdown_template_id=self.config.get("markdown_template_id"),
|
|
)
|
|
|
|
# ========== å¥åº·æ£æ?==========
|
|
|
|
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_dsm(
|
|
self._token_manager.api_base,
|
|
token,
|
|
self._http_client,
|
|
sandbox=self._sandbox,
|
|
ws_connected=self._status == ChannelStatus.CONNECTED,
|
|
)
|
|
except Exception as e:
|
|
return HealthStatus(status="unhealthy", last_error=str(e))
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
if not self._token_manager:
|
|
raise ChannelNotConnectedError()
|
|
from .media_upload import download_media as _download
|
|
|
|
token = await self._token_manager.get_token()
|
|
return await _download(file_id, token, self._http_client, self._sandbox)
|
|
|
|
# ========== Token 管理 ==========
|
|
|
|
async def _refresh_token_if_needed(self) -> bool:
|
|
if self._token_manager is None:
|
|
return False
|
|
token_before = self._token_manager.access_token
|
|
await self._token_manager.get_token()
|
|
changed = token_before != self._token_manager.access_token
|
|
if changed:
|
|
self._backup_credentials()
|
|
return changed
|
|
|
|
async def _get_valid_token(self) -> str:
|
|
if self._token_manager is None:
|
|
raise ChannelNotConnectedError()
|
|
return await self._token_manager.get_token()
|
|
|
|
def _backup_credentials(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
backup_dir = self.config.get("credential_backup_dir")
|
|
self._credential_backup = CredentialBackup(app_id, backup_dir)
|
|
|
|
snapshot = CredentialSnapshot(
|
|
app_id=app_id,
|
|
app_secret=(
|
|
self.config.get("app_secret")
|
|
or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
or self._read_app_secret_file()
|
|
),
|
|
access_token=self._token_manager.access_token if self._token_manager else "",
|
|
expires_at=self._token_manager.expires_at if self._token_manager else 0,
|
|
session_id=self._reconnect_manager.session_id or "",
|
|
sandbox=self._sandbox,
|
|
)
|
|
self._credential_backup.save(snapshot)
|
|
|
|
def _restore_credentials(self) -> bool:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return False
|
|
|
|
backup_dir = self.config.get("credential_backup_dir")
|
|
self._credential_backup = CredentialBackup(app_id, backup_dir)
|
|
|
|
snapshot = self._credential_backup.restore()
|
|
if snapshot is None:
|
|
return False
|
|
if snapshot.token_expired():
|
|
logger.info("[QQBot] Restored token expired, will refresh")
|
|
return False
|
|
|
|
if not self.config.get("app_secret") and snapshot.app_secret:
|
|
logger.info("[QQBot] Restored app_secret from credential backup")
|
|
|
|
self._reconnect_manager._session_id = snapshot.session_id
|
|
return True
|
|
|
|
def _init_session_store(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
store_dir = self.config.get("session_store_dir")
|
|
self._session_store = SessionStore(app_id, store_dir)
|
|
|
|
record = self._session_store.load()
|
|
if record and record.session_id:
|
|
logger.info(
|
|
"[QQBot] Restored session_id=%s seq=%s",
|
|
record.session_id,
|
|
record.last_seq,
|
|
)
|
|
self._reconnect_manager._session_id = record.session_id
|
|
if record.last_seq is not None:
|
|
self._reconnect_manager._last_seq = record.last_seq
|
|
|
|
def _save_session(self) -> None:
|
|
if self._session_store is None:
|
|
return
|
|
|
|
record = SessionRecord(
|
|
session_id=self._reconnect_manager._session_id or "",
|
|
last_seq=self._reconnect_manager._last_seq,
|
|
last_heartbeat=time.monotonic(),
|
|
identify_at=time.time(),
|
|
shard_id=self.config.get("shard_id", 0),
|
|
shard_count=self.config.get("shard_count", 1),
|
|
)
|
|
self._session_store.save(record)
|
|
|
|
def _init_known_users(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
max_users = self.config.get("known_users_max", 10000)
|
|
persist_dir = self.config.get("known_users_persist_dir")
|
|
self._known_users = KnownUserTracker(
|
|
app_id=app_id,
|
|
max_users=max_users,
|
|
persist_dir=persist_dir,
|
|
)
|
|
|
|
# ========== å®å
¨çç¥ ==========
|
|
|
|
async def _check_security(self, msg: ChannelMessage) -> bool:
|
|
chat_id = msg.identity.channel_chat_id
|
|
user_id = msg.identity.channel_user_id
|
|
chat_type = msg.chat_type
|
|
|
|
if chat_type == ChatType.DIRECT:
|
|
result = self._security.check_dm_access(user_id)
|
|
if not result.allowed:
|
|
logger.debug(f"[QQBot] DM access denied: {result.reason} {result.detail}")
|
|
return result.allowed
|
|
elif chat_type == ChatType.GROUP:
|
|
group_policy = self.config.get("group_policy", "")
|
|
if group_policy == "open":
|
|
return True
|
|
if group_policy == "disabled":
|
|
return False
|
|
group_id = chat_id.replace(GROUP_CHAT_PREFIX, "")
|
|
result = self._security.check_group_access(group_id)
|
|
if not result.allowed:
|
|
logger.debug(f"[QQBot] Group access denied: {result.reason} {result.detail}")
|
|
return False
|
|
bot_names = []
|
|
if self._bot_info:
|
|
bot_names = [self._bot_info.get("username", "")]
|
|
return self._security.check_mention_required(chat_id, msg, bot_names)
|
|
return True
|
|
|
|
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
|
|
bot_secret = self.config.get("bot_secret", "")
|
|
if not bot_secret:
|
|
return True
|
|
return verify_webhook_ed25519(headers, body, bot_secret)
|
|
|
|
# ========== å
鍿¹æ³ ==========
|
|
|
|
async def _get_gateway_url(self, token: str) -> str:
|
|
if not self._http_client:
|
|
raise ChannelNotConnectedError()
|
|
api_base = self._token_manager.api_base if self._token_manager else ""
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
|
|
async with self._http_client.get(f"{api_base}/gateway", headers=headers) as resp:
|
|
if resp.status != 200:
|
|
raise ChannelAuthenticationError(f"Failed to get gateway URL: HTTP {resp.status}")
|
|
data = await resp.json()
|
|
return data.get("url", "")
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
return self._status.value
|
|
|
|
async def pre_connect(self) -> dict:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = self.config.get("app_secret") or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
if not app_id or not app_secret:
|
|
return {"status": "error", "message": "Missing app_id or app_secret in config or env vars"}
|
|
|
|
api_base = "https://sandbox.api.sgroup.qq.com" if self._sandbox else "https://api.sgroup.qq.com"
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
f"{api_base}/oauth2/token",
|
|
json={"app_id": app_id, "app_secret": app_secret},
|
|
) as resp:
|
|
if resp.status == 200:
|
|
return {"status": "ok", "app_id": app_id[:6] + "...", "sandbox": self._sandbox}
|
|
return {"status": "error", "message": f"Auth failed: HTTP {resp.status}"}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
|
if not self._http_client or not self._token_manager:
|
|
raise ChannelNotConnectedError()
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
|
|
async with self._http_client.get(f"{api_base}/v2/users/{channel_user_id}", headers=headers) as resp:
|
|
if resp.status != 200:
|
|
raise DeliveryFailedError(f"Failed to get user info: HTTP {resp.status}")
|
|
return await resp.json()
|
|
|
|
async def send_dm_message(
|
|
self, chat_id: str, content: str, msg_id: str = "", payload: dict | None = None
|
|
) -> DeliveryResult:
|
|
if payload is not None:
|
|
return await self._send_raw_payload(chat_id, payload)
|
|
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
if msg_id:
|
|
response.reply_to_message_id = msg_id
|
|
return await self.send(response)
|
|
|
|
async def send_group_message(
|
|
self, chat_id: str, content: str = "", payload: dict | None = None, msg_id: str = ""
|
|
) -> DeliveryResult:
|
|
if payload is not None:
|
|
return await self._send_raw_payload(chat_id, payload)
|
|
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
if msg_id:
|
|
response.reply_to_message_id = msg_id
|
|
return await self.send(response)
|
|
|
|
async def _send_raw_payload(self, chat_id: str, payload: dict) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
raise ChannelNotConnectedError()
|
|
|
|
from .send import send_with_retry
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
async def _refresh_token() -> str:
|
|
return await self._token_manager.force_refresh()
|
|
|
|
return await send_with_retry(
|
|
http_client=self._http_client,
|
|
token=token,
|
|
api_base=api_base,
|
|
payload=payload,
|
|
chat_id=chat_id,
|
|
config=self.config,
|
|
token_refresh_cb=_refresh_token,
|
|
)
|
|
|
|
async def _send_stream_message(self, chat_id: str, payload: dict) -> Any:
|
|
if not self._http_client or not self._token_manager:
|
|
return None
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
from .send import resolve_send_url
|
|
|
|
url = resolve_send_url(api_base, chat_id)
|
|
headers = {
|
|
"Authorization": f"QQBot {token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
try:
|
|
async with self._http_client.post(url, json=payload, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return await resp.json()
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
async def _stream_content(self, ctx) -> AsyncGenerator[str, None]:
|
|
handler = self._message_handler
|
|
if handler is None:
|
|
if False:
|
|
yield ""
|
|
return
|
|
|
|
msg = self._build_channel_message(ctx)
|
|
if msg is None:
|
|
if False:
|
|
yield ""
|
|
return
|
|
|
|
async for chunk in stream_content(
|
|
_handler_to_generator(handler, msg),
|
|
self._c2c_streaming,
|
|
self._par_chunker,
|
|
):
|
|
yield chunk
|
|
|
|
async def _put_interaction(self, interaction_id: str, content: str) -> DeliveryResult:
|
|
if self._http_client and self._token_manager:
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
payload = {"code": 0}
|
|
url = f"{api_base}/interactions/{interaction_id}"
|
|
try:
|
|
async with self._http_client.put(url, json=payload, headers=headers) as resp:
|
|
if resp.status not in (200, 202):
|
|
logger.warning(f"[QQBot] Interaction ACK failed for {interaction_id}: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.warning(f"[QQBot] Interaction ACK error for {interaction_id}: {e}")
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id="",
|
|
channel_message_id=interaction_id,
|
|
),
|
|
content=content,
|
|
chat_type=ChatType.GROUP,
|
|
message_type=MessageType.TEXT,
|
|
metadata={"interaction_id": interaction_id},
|
|
)
|
|
await self._message_queue.enqueue(msg)
|
|
return DeliveryResult(success=True, message_id=interaction_id)
|
|
|
|
async def _download_attachments(self, attachments: list[Attachment]) -> None:
|
|
from .media_upload import download_media
|
|
|
|
if not self._http_client or not self._token_manager or not attachments:
|
|
return
|
|
|
|
token = await self._token_manager.get_token()
|
|
for attach in attachments:
|
|
if attach.data is not None:
|
|
continue
|
|
url = attach.url
|
|
if not url:
|
|
continue
|
|
try:
|
|
data = await download_media(url, token)
|
|
attach.data = data
|
|
except Exception:
|
|
logger.debug(f"[QQBot] Failed to auto-download attachment: {url}")
|
|
|
|
async def _read_app_secret_file(self) -> str:
|
|
file_path = self.config.get("client_secret_file", "")
|
|
if not file_path:
|
|
return ""
|
|
try:
|
|
with open(file_path, encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except OSError as e:
|
|
logger.warning(f"[QQBot] Failed to read client_secret_file '{file_path}': {e}")
|
|
return ""
|
|
|
|
def is_configured(self) -> bool:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = (
|
|
self.config.get("app_secret") or os.environ.get("QQBOT_CLIENT_SECRET", "") or self._read_app_secret_file()
|
|
)
|
|
if app_id and app_secret:
|
|
return True
|
|
|
|
backup_dir = self.config.get("credential_backup_dir")
|
|
backup = CredentialBackup(app_id or "unknown", backup_dir)
|
|
snapshot = backup.restore()
|
|
if snapshot is not None:
|
|
return True
|
|
return False
|
|
|
|
def has_configured_state(self) -> bool:
|
|
return self.is_configured()
|
|
|
|
def unconfigured_reason(self) -> str:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = (
|
|
self.config.get("app_secret") or os.environ.get("QQBOT_CLIENT_SECRET", "") or self._read_app_secret_file()
|
|
)
|
|
if not app_id:
|
|
return "missing_app_id"
|
|
if not app_secret:
|
|
return "missing_app_secret"
|
|
return ""
|
|
|
|
async def logout_account(self, ctx=None) -> dict:
|
|
env_token = bool(os.environ.get("QQBOT_APP_ID"))
|
|
self.config.pop("app_id", None)
|
|
self.config.pop("app_secret", None)
|
|
self.config.pop("client_secret_file", None)
|
|
|
|
if self._credential_backup:
|
|
self._credential_backup.clear()
|
|
if self._session_store:
|
|
self._session_store.clear()
|
|
|
|
try:
|
|
await self.disconnect()
|
|
except Exception:
|
|
logger.debug("[QQBot] Error during logout disconnect", exc_info=True)
|
|
|
|
return {"envToken": env_token}
|
|
|
|
def build_channel_summary(self) -> dict:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
return {
|
|
"configured": self.is_configured(),
|
|
"tokenSource": "env"
|
|
if os.environ.get("QQBOT_CLIENT_SECRET")
|
|
else ("file" if self.config.get("client_secret_file") else "config"),
|
|
"running": self._status == ChannelStatus.CONNECTED,
|
|
"connected": self._ws is not None and not (self._ws.closed if hasattr(self._ws, "closed") else False),
|
|
"status": self._status.value,
|
|
"sandbox": self._sandbox,
|
|
"appId": app_id[:6] + "..." if app_id else "",
|
|
}
|
|
|
|
def build_account_snapshot(self) -> dict:
|
|
return {
|
|
"channelId": self.channel_id,
|
|
"lastInboundAt": getattr(self, "_last_inbound_at", None),
|
|
"lastOutboundAt": getattr(self, "_last_outbound_at", None),
|
|
"wsSeq": self._seq,
|
|
"sessionId": self._reconnect_manager.session_id,
|
|
"heartbeatLatencyMs": self._heartbeat_latency_ms,
|
|
"reconnectState": self._reconnect_manager.state.name if self._reconnect_manager else "N/A",
|
|
}
|
|
|
|
async def _on_reconnect_state_change(self, old: ReconnectState, new: ReconnectState) -> None:
|
|
logger.debug(f"[QQBot] Reconnect state: {old.name} -> {new.name}")
|
|
|
|
async def _attempt_reconnect(self) -> None:
|
|
await self._cleanup_ws()
|
|
await self.connect()
|
|
|
|
def should_suppress_local_approval_prompt(self) -> bool:
|
|
return self.config.get("suppress_local_approval_prompt", True)
|
|
|
|
def has_native_approval_routing(self) -> bool:
|
|
return self.config.get("approval_native_routing", True)
|
|
|
|
def _register_runtime_contexts(self) -> None:
|
|
self._runtime_contexts = {
|
|
"approval.native": {
|
|
"enabled": self.has_native_approval_routing(),
|
|
"channel_id": self.channel_id,
|
|
"suppress_local_prompt": self.should_suppress_local_approval_prompt(),
|
|
"keyboard_provider": "qqbot_inline",
|
|
},
|
|
}
|
|
logger.debug(
|
|
"[QQBot] Runtime contexts registered: approval.native=%s",
|
|
self._runtime_contexts.get("approval.native", {}).get("enabled"),
|
|
)
|
|
|
|
def get_runtime_contexts(self) -> dict:
|
|
return getattr(self, "_runtime_contexts", {})
|