ForcePilot/backend/package/yuxi/channels/adapters/qqbot/adapter.py
Kris 552aef767c feat(qqbot): 实现QQ机器人适配器完整功能模块
新增QQ Bot适配器完整代码栈,包含:
1. 基础适配器入口与工具类封装
2. 会话管理、重试队列与流量控制
3. 命令系统与内置指令(ping/help/status等)
4. 富媒体消息处理与格式转换
5. 引用存储与审批管理
6. 凭证备份与会话持久化
7. 健康检查与交互回调系统
2026-05-12 00:48:04 +08:00

1389 lines
52 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
from collections.abc import AsyncIterator, AsyncGenerator
from datetime import datetime
from typing import Any
import aiohttp
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelNotConnectedError,
DeliveryFailedError,
MessageFormatError,
)
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MessageType,
MentionsInfo,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
from .constants import (
DM_CHAT_PREFIX,
GROUP_CHAT_PREFIX,
Intent,
Opcode,
ECode,
)
from .format import format_outbound, MarkdownChunker
from .probe import health_check_dsm
from .credential_backup import CredentialBackup, CredentialSnapshot
from .session_store import SessionStore, SessionRecord
from .known_users import KnownUserTracker
from .rate_limiter import RouteRateLimiter
from .security import QQBotSecurityPolicy, verify_webhook_ed25519
from .send import send_with_retry, MessageSeqManager
from .token import QQBotTokenManager
from .reconnect import QQBotReconnectManager, ReconnectState
from .c2c_stream import C2CStreamingController
from .inbound_pipeline import QQBotInboundPipeline
from .group_buffer import GroupHistoryBuffer
from .reply_dispatcher import ReplyDispatcher
from .streaming import ParagraphChunker, stream_content
from .commands.framework import CommandRegistry
from .commands.builtin import register_builtin_commands
from .interaction import InteractionRegistry, InteractionSessionManager, InteractionContext
from .approval.manager import ExecApprovalManager
from .audio import TTSProvider, STTProvider, AudioFormatPolicy, AudioFormat
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:
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._session_id: str | 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._receive_task: asyncio.Task | None = None
self._message_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue()
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())
self._interaction_registry = InteractionRegistry()
self._interaction_sessions = InteractionSessionManager()
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)
# ========== 生命周期 ==========
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}'...")
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
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._session_id or "")
self._reconnect_manager.mark_connected()
await self._reconnect_manager.transition(ReconnectState.CONNECTED)
self._backup_credentials()
self._save_session()
logger.info(
f"[QQBot] Channel '{self.channel_id}' started "
f"(sandbox={self._sandbox}, heartbeat={self._heartbeat_interval:.0f}s)"
)
except ChannelAuthenticationError:
self._status = ChannelStatus.ERROR
await self._reconnect_manager.on_disconnect(None)
await self._cleanup_ws()
raise
except Exception as e:
self._status = ChannelStatus.ERROR
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._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:
pass
self._ws = None
self._seq = None
self._session_id = None
# ========== WebSocket 内部逻辑 ==========
async def _wait_for_hello(self) -> dict:
if not self._ws:
raise ChannelAuthenticationError("WebSocket not connected")
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")
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._session_id,
"seq": self._seq,
},
}
await self._ws.send_json(payload)
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
break
send_time = time.monotonic()
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
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
logger.debug("[QQBot] Heartbeat ACK received")
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:
pipeline = QQBotInboundPipeline(self)
await pipeline.initialize()
ctx = await 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:
try:
await self._message_handler(msg)
except Exception:
logger.exception("[QQBot] Error in message handler")
await self._message_queue.put(msg)
def _build_channel_message(self, ctx) -> ChannelMessage | None:
chat_type_map = {
"dm": ChatType.DIRECT,
"group": ChatType.GROUP,
"interaction": ChatType.GROUP,
}
chat_type = chat_type_map.get(ctx.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(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,
)
try:
await self._rate_limiter.acquire("send_message")
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 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 (
upload_media,
validate_media_size,
FILE_TYPE_IMAGE,
FILE_TYPE_VOICE,
FILE_TYPE_VIDEO,
FILE_TYPE_FILE,
)
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:
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.get()
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()
return token_before != self._token_manager._access_token
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._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._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 self._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 None:
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
payload = format_outbound(response)
payload["msg_id"] = msg_id
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=content,
)
)
async def send_group_message(
self, chat_id: str, content: str = "", payload: dict | None = None, msg_id: str = ""
) -> DeliveryResult:
if payload is None:
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
payload = format_outbound(response)
payload["msg_id"] = msg_id
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=content,
)
)
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.put(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 ""
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()