ForcePilot/backend/package/yuxi/channels/adapters/qqbot/adapter.py
Kris ef5483dc1a refactor(qqbot): 重构QQ机器人适配器代码,优化多项功能与结构
主要变更:
1. 修复速率限流器使用setdefault替代重复创建令牌桶
2. 重构交互注册表匹配逻辑,优化精确匹配查找
3. 重构去重缓存逻辑,移到适配器实例方法
4. 重构发送URL解析,增加合法性校验并拆分公共方法
5. 优化流式消息处理逻辑,简化flush_controller调用
6. 重构群聊类型判断代码,简化语法
7. 修复重连管理器对None类型关闭分类的处理
8. 新增消息缓存、线程模拟器、发送初始化模块
9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录
10. 新增配置提示与向导二维码绑定功能
11. 优化媒体上传逻辑,增加重试机制与缓存
12. 新增审批键盘模板构建函数
13. 重构消息格式处理,修正媒体发送字段与长度限制
14. 修复令牌过期时间计算,使用time.time替代monotonic
15. 新增群组激活缓冲区与用户追踪器增强功能
16. 修复换行符问题,统一文件结尾格式
2026-05-13 16:13:48 +08:00

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", {})