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()