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, 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_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 .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._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._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._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() def _init_typing_keepalive(self) -> TypingKeepAlive: return TypingKeepAlive(send_notify_fn=self._send_input_notify) 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}'...") 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 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: 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() 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 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: 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: chat_id = msg.identity.channel_chat_id try: await self._typing_keepalive.start(chat_id) await self._message_handler(msg) except Exception: logger.exception("[QQBot] Error in message handler") 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(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") result = await self._circuit_breaker.call(_do_send) self._last_outbound_at = time.time() 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_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: 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() 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._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 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 payload = format_outbound(response) payload["msg_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 payload = format_outbound(response) payload["msg_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._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)