diff --git a/backend/package/yuxi/channels/adapters/qqbot/__init__.py b/backend/package/yuxi/channels/adapters/qqbot/__init__.py new file mode 100644 index 00000000..1baffadf --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/__init__.py @@ -0,0 +1,3 @@ +from yuxi.channels.adapters.qqbot.adapter import QQBotAdapter + +__all__ = ["QQBotAdapter"] diff --git a/backend/package/yuxi/channels/adapters/qqbot/adapter.py b/backend/package/yuxi/channels/adapters/qqbot/adapter.py new file mode 100644 index 00000000..d4f2dc49 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/adapter.py @@ -0,0 +1,1388 @@ +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() diff --git a/backend/package/yuxi/channels/adapters/qqbot/approval/__init__.py b/backend/package/yuxi/channels/adapters/qqbot/approval/__init__.py new file mode 100644 index 00000000..539ee811 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/approval/__init__.py @@ -0,0 +1,3 @@ +from .manager import ExecApprovalManager, ApprovalRequest, ApprovalStatus + +__all__ = ["ExecApprovalManager", "ApprovalRequest", "ApprovalStatus"] diff --git a/backend/package/yuxi/channels/adapters/qqbot/approval/manager.py b/backend/package/yuxi/channels/adapters/qqbot/approval/manager.py new file mode 100644 index 00000000..b89bea80 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/approval/manager.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Any + +logger = logging.getLogger(__name__) + + +class ApprovalStatus(Enum): + PENDING = auto() + APPROVED = auto() + REJECTED = auto() + EXPIRED = auto() + CANCELLED = auto() + + +@dataclass +class ApprovalRequest: + request_id: str + action: str + description: str + requester_id: str + requester_name: str + params: dict[str, Any] = field(default_factory=dict) + status: ApprovalStatus = ApprovalStatus.PENDING + created_at: float = field(default_factory=time.time) + expires_at: float = 300.0 + approver_id: str = "" + approved_at: float = 0.0 + + def __post_init__(self): + self.expires_at = self.created_at + 300.0 + + def to_dict(self) -> dict: + return { + "request_id": self.request_id, + "action": self.action, + "description": self.description, + "requester_id": self.requester_id, + "requester_name": self.requester_name, + "params": self.params, + "status": self.status.name, + "created_at": self.created_at, + "expires_at": self.expires_at, + "approver_id": self.approver_id, + "approved_at": self.approved_at, + } + + +class ExecApprovalManager: + def __init__( + self, + store_dir: str | None = None, + timeout_s: float = 300.0, + max_pending_per_user: int = 5, + ): + self._store_dir = store_dir or os.path.join(os.path.dirname(__file__), "..", "approval_data") + self._timeout_s = timeout_s + self._max_pending_per_user = max_pending_per_user + self._requests: dict[str, ApprovalRequest] = {} + self._lock = asyncio.Lock() + self._subscribers: dict[str, asyncio.Event] = {} + self._load() + + def _load(self) -> None: + path = os.path.join(self._store_dir, "approval_state.json") + try: + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + data = json.load(f) + for item in data: + req = ApprovalRequest( + request_id=item["request_id"], + action=item["action"], + description=item["description"], + requester_id=item["requester_id"], + requester_name=item["requester_name"], + params=item.get("params", {}), + status=ApprovalStatus[item["status"]], + created_at=item["created_at"], + expires_at=item.get("expires_at", item["created_at"] + 300), + ) + self._requests[req.request_id] = req + logger.info("ExecApprovalManager: loaded %d requests", len(self._requests)) + except (OSError, json.JSONDecodeError): + logger.exception("ExecApprovalManager: failed to load state") + + async def _save(self) -> None: + os.makedirs(self._store_dir, exist_ok=True) + path = os.path.join(self._store_dir, "approval_state.json") + tmp = path + ".tmp" + data = [r.to_dict() for r in self._requests.values()] + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + except OSError: + logger.exception("ExecApprovalManager: failed to save") + + async def request_approval( + self, + action: str, + description: str, + requester_id: str, + requester_name: str = "", + params: dict[str, Any] | None = None, + ) -> ApprovalRequest | None: + async with self._lock: + pending = sum( + 1 + for r in self._requests.values() + if r.requester_id == requester_id and r.status == ApprovalStatus.PENDING + ) + if pending >= self._max_pending_per_user: + logger.warning( + "ExecApprovalManager: user %s has %d pending requests", + requester_id, + pending, + ) + return None + + req_id = hashlib.sha256(f"{requester_id}:{action}:{time.time()}".encode()).hexdigest()[:12] + + req = ApprovalRequest( + request_id=req_id, + action=action, + description=description, + requester_id=requester_id, + requester_name=requester_name, + params=params or {}, + ) + self._requests[req_id] = req + await self._save() + logger.info("ExecApprovalManager: created request %s for %s", req_id, action) + return req + + async def approve(self, request_id: str, approver_id: str) -> ApprovalRequest | None: + async with self._lock: + req = self._requests.get(request_id) + if req is None: + return None + if req.status != ApprovalStatus.PENDING: + return req + + req.status = ApprovalStatus.APPROVED + req.approver_id = approver_id + req.approved_at = time.time() + await self._save() + + event = self._subscribers.pop(request_id, None) + if event: + event.set() + + logger.info("ExecApprovalManager: approved %s by %s", request_id, approver_id) + return req + + async def reject(self, request_id: str, approver_id: str, reason: str = "") -> ApprovalRequest | None: + async with self._lock: + req = self._requests.get(request_id) + if req is None: + return None + if req.status != ApprovalStatus.PENDING: + return req + + req.status = ApprovalStatus.REJECTED + req.approver_id = approver_id + req.approved_at = time.time() + await self._save() + + event = self._subscribers.pop(request_id, None) + if event: + event.set() + + logger.info( + "ExecApprovalManager: rejected %s by %s reason=%s", + request_id, + approver_id, + reason, + ) + return req + + async def wait_for_approval(self, request_id: str, timeout_s: float | None = None) -> ApprovalRequest: + event = asyncio.Event() + self._subscribers[request_id] = event + + timeout = timeout_s or self._timeout_s + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except TimeoutError: + async with self._lock: + req = self._requests.get(request_id) + if req and req.status == ApprovalStatus.PENDING: + req.status = ApprovalStatus.EXPIRED + await self._save() + logger.warning("ExecApprovalManager: request %s expired", request_id) + finally: + self._subscribers.pop(request_id, None) + + return self._requests.get( + request_id, + ApprovalRequest( + request_id=request_id, + action="unknown", + description="", + requester_id="", + status=ApprovalStatus.EXPIRED, + ), + ) + + async def cancel(self, request_id: str, requester_id: str) -> bool: + async with self._lock: + req = self._requests.get(request_id) + if req is None or req.requester_id != requester_id: + return False + if req.status != ApprovalStatus.PENDING: + return False + + req.status = ApprovalStatus.CANCELLED + await self._save() + + event = self._subscribers.pop(request_id, None) + if event: + event.set() + + return True + + async def list_pending(self, requester_id: str = "") -> list[ApprovalRequest]: + async with self._lock: + self._cleanup_expired() + return [ + r + for r in self._requests.values() + if r.status == ApprovalStatus.PENDING and (not requester_id or r.requester_id == requester_id) + ] + + def _cleanup_expired(self) -> None: + now = time.time() + for req_id, req in list(self._requests.items()): + if req.status == ApprovalStatus.PENDING and now >= req.expires_at: + req.status = ApprovalStatus.EXPIRED + logger.info("ExecApprovalManager: cleaned up expired request %s", req_id) diff --git a/backend/package/yuxi/channels/adapters/qqbot/ark_templates.py b/backend/package/yuxi/channels/adapters/qqbot/ark_templates.py new file mode 100644 index 00000000..216d3d82 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/ark_templates.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Any + + +def build_ark_23(kv_pairs: list[dict[str, str]]) -> dict: + return { + "msg_type": 3, + "ark": { + "template_id": 23, + "kv": kv_pairs, + }, + } + + +def build_ark_24(desc: str, prompt: str, title: str, meta_desc: str, img: str, jump_url: str) -> dict: + return { + "msg_type": 3, + "ark": { + "template_id": 24, + "kv": [ + {"key": "#DESC#", "value": desc}, + {"key": "#PROMPT#", "value": prompt}, + {"key": "#TITLE#", "value": title}, + {"key": "#METADESC#", "value": meta_desc}, + {"key": "#IMG#", "value": img}, + {"key": "#JUMPURL#", "value": jump_url}, + ], + }, + } + + +def build_ark_37(prompt: str, title: str, subtitle: str, jump_url: str) -> dict: + return { + "msg_type": 3, + "ark": { + "template_id": 37, + "kv": [ + {"key": "#PROMPT#", "value": prompt}, + {"key": "#TITLE#", "value": title}, + {"key": "#SUBTITLE#", "value": subtitle}, + {"key": "#JUMPURL#", "value": jump_url}, + ], + }, + } + + +def build_embed( + title: str = "", + description: str = "", + prompt: str = "", + fields: list[dict] | None = None, + thumbnail: str | None = None, +) -> dict: + result: dict[str, Any] = { + "msg_type": 4, + "embed": { + "title": title, + "description": description[:4096], + "prompt": prompt or description[:200], + "fields": fields or [], + }, + } + if thumbnail: + result["embed"]["thumbnail"] = {"url": thumbnail} + return result + + +def build_text_card(text: str, buttons: list[dict] | None = None) -> dict: + payload: dict[str, Any] = { + "msg_type": 0, + "content": text, + } + if buttons: + payload["keyboard"] = {"content": {"rows": [{"buttons": buttons}]}} + return payload + + +def _kv(key: str, value: str) -> dict[str, str]: + return {"key": key, "value": value} diff --git a/backend/package/yuxi/channels/adapters/qqbot/audio.py b/backend/package/yuxi/channels/adapters/qqbot/audio.py new file mode 100644 index 00000000..c0800784 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/audio.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import base64 +import logging +import struct +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +logger = logging.getLogger(__name__) + + +class AudioFormat(Enum): + MP3 = "mp3" + WAV = "wav" + OGG = "ogg" + SILK = "silk" + PCM = "pcm" + AAC = "aac" + AMR = "amr" + + +_AUDIO_MIME_MAP: dict[str, str] = { + "mp3": "audio/mpeg", + "wav": "audio/wav", + "ogg": "audio/ogg", + "silk": "audio/silk", + "pcm": "audio/pcm", + "aac": "audio/aac", + "amr": "audio/amr", +} + +_AUDIO_EXT_MAP: dict[str, str] = { + "audio/mpeg": ".mp3", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "audio/silk": ".silk", + "audio/pcm": ".pcm", + "audio/aac": ".aac", + "audio/amr": ".amr", +} + +_SUPPORTED_BITRATES: dict[str, list[int]] = { + "mp3": [8000, 16000, 32000, 64000, 128000], + "wav": [8000, 16000, 44100], + "pcm": [8000, 16000, 24000, 44100], + "aac": [16000, 32000, 64000], + "ogg": [16000, 32000, 48000], +} + + +def get_mime_type(fmt: AudioFormat) -> str: + return _AUDIO_MIME_MAP.get(fmt.value, "audio/mpeg") + + +def get_extension(fmt: AudioFormat) -> str: + mime = _AUDIO_MIME_MAP.get(fmt.value, "") + return _AUDIO_EXT_MAP.get(mime, ".mp3") + + +def get_supported_bitrates(fmt: AudioFormat) -> list[int]: + return _SUPPORTED_BITRATES.get(fmt.value, [16000]) + + +def data_uri_to_bytes(data_uri: str) -> tuple[bytes, str]: + if "," not in data_uri: + return base64.b64decode(data_uri), "audio/mpeg" + + header, b64_data = data_uri.split(",", 1) + mime = "audio/mpeg" + if "data:" in header: + mime = header.split(":")[1].split(";")[0] if ";" in header.split(":")[1] else header.split(":")[1] + + return base64.b64decode(b64_data), mime + + +def get_audio_duration_s(audio_data: bytes, fmt: AudioFormat) -> float: + if fmt == AudioFormat.WAV: + return _wav_duration(audio_data) + if fmt == AudioFormat.MP3: + return _mp3_estimate_duration(audio_data) + return len(audio_data) / 16000.0 + + +def _wav_duration(data: bytes) -> float: + try: + if len(data) < 44: + return 0.0 + byte_rate = struct.unpack_from(" float: + return len(data) / 16000.0 + + +def calculate_audio_size( + duration_s: float, + sample_rate: int = 16000, + channels: int = 1, + sample_width: int = 2, +) -> int: + return int(duration_s * sample_rate * channels * sample_width) + + +@dataclass +class AudioFormatPolicy: + transcode_enabled: bool = True + upload_direct_formats: list[str] = field(default_factory=lambda: ["wav", "mp3"]) + stt_direct_formats: list[str] = field(default_factory=lambda: ["wav", "mp3", "pcm"]) + fallback_format: AudioFormat = field(default=AudioFormat.MP3) + sample_rate: int = 16000 + channels: int = 1 + bitrate: int = 32000 + + def needs_transcode(self, fmt: AudioFormat) -> bool: + if not self.transcode_enabled: + return False + return fmt.value not in self.upload_direct_formats + + def can_stt_direct(self, fmt: AudioFormat) -> bool: + return fmt.value in self.stt_direct_formats + + @classmethod + def from_config(cls, config: dict | None) -> AudioFormatPolicy: + if not config: + return cls() + policy_cfg = config.get("audio_format_policy", {}) + return cls( + transcode_enabled=policy_cfg.get("transcode_enabled", True), + upload_direct_formats=policy_cfg.get("upload_direct_formats", ["wav", "mp3"]), + stt_direct_formats=policy_cfg.get("stt_direct_formats", ["wav", "mp3", "pcm"]), + fallback_format=AudioFormat(policy_cfg.get("fallback_format", "mp3")), + sample_rate=policy_cfg.get("sample_rate", 16000), + channels=policy_cfg.get("channels", 1), + bitrate=policy_cfg.get("bitrate", 32000), + ) + + +class STTProvider: + def __init__( + self, + provider: str = "", + api_key: str = "", + region: str = "", + model: str = "", + ): + self._provider = provider.lower() or "builtin" + self._api_key = api_key + self._region = region + self._model = model + + async def transcribe(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str: + if self._provider == "azure": + return await self._transcribe_azure(audio_data) + elif self._provider == "whisper": + return await self._transcribe_whisper(audio_data, fmt) + else: + return await self._transcribe_builtin(audio_data, fmt) + + async def _transcribe_azure(self, audio_data: bytes) -> str: + import aiohttp + + try: + async with aiohttp.ClientSession() as session: + url = ( + f"https://{self._region}.stt.speech.microsoft.com/" + "speech/recognition/conversation/cognitiveservices/v1" + "?language=zh-CN&format=detailed" + ) + headers = { + "Ocp-Apim-Subscription-Key": self._api_key, + "Content-Type": "audio/wav", + } + async with session.post(url, data=audio_data, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("DisplayText", "") + logger.warning("STT: Azure returned status %d", resp.status) + except Exception: + logger.exception("STT: Azure transcription failed") + return "" + + async def _transcribe_whisper(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str: + import json + import os + import subprocess + import tempfile + + ext = ".wav" if fmt is None else get_extension(fmt) + tmpdir = tempfile.mkdtemp() + in_path = os.path.join(tmpdir, f"stt_input{ext}") + + try: + with open(in_path, "wb") as f: + f.write(audio_data) + + subprocess.run( + [ + "whisper", + in_path, + "--model", + self._model or "base", + "--output_format", + "json", + "--output_dir", + tmpdir, + "--language", + "zh", + ], + capture_output=True, + timeout=120, + check=False, + ) + + json_path = os.path.join(tmpdir, "stt_input.json") + if os.path.exists(json_path): + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + return data.get("text", "") + except Exception: + logger.exception("STT: Whisper transcription failed") + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + return "" + + async def _transcribe_builtin(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str: + try: + import speech_recognition as sr + + ext = ".wav" if fmt is None else get_extension(fmt) + import tempfile + import os + + tmpdir = tempfile.mkdtemp() + in_path = os.path.join(tmpdir, f"stt_input{ext}") + + with open(in_path, "wb") as f: + f.write(audio_data) + + recognizer = sr.Recognizer() + with sr.AudioFile(in_path) as source: + audio = recognizer.record(source) + + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + return recognizer.recognize_google(audio, language="zh-CN") + except ImportError: + logger.warning("STT: speech_recognition not installed") + except Exception: + logger.exception("STT: builtin transcription failed") + return "" + + @classmethod + def from_config(cls, config: dict | None) -> STTProvider: + if not config: + return cls() + stt_cfg = config.get("stt", {}) + return cls( + provider=stt_cfg.get("provider", "") or config.get("stt_provider", ""), + api_key=stt_cfg.get("api_key", "") or config.get("stt_api_key", ""), + region=stt_cfg.get("region", "") or config.get("stt_region", ""), + model=stt_cfg.get("model", "") or config.get("stt_model", ""), + ) + + +class TTSProvider: + def __init__( + self, + send_fn: Callable[..., Any] | None = None, + default_voice: str = "zh-CN-XiaoxiaoNeural", + default_format: AudioFormat = AudioFormat.MP3, + ): + self._send_fn = send_fn + self._default_voice = default_voice + self._default_format = default_format + self._cache: dict[str, bytes] = {} + + async def synthesize(self, text: str, voice: str = "", fmt: AudioFormat | None = None) -> bytes: + cache_key = f"{text}:{voice}:{fmt.value if fmt else self._default_format.value}" + cached = self._cache.get(cache_key) + if cached: + return cached + + import os + + tts_provider = os.environ.get("QQBOT_TTS_PROVIDER", "builtin").lower() + + if tts_provider == "azure": + result = await self._synthesize_azure(text, voice or self._default_voice, fmt or self._default_format) + elif tts_provider == "edge": + result = await self._synthesize_edge(text, voice or self._default_voice, fmt or self._default_format) + else: + result = await self._synthesize_builtin(text) + + self._cache[cache_key] = result + return result + + async def _synthesize_builtin(self, text: str) -> bytes: + import os + import subprocess + import tempfile + + text_encoded = text.replace('"', '\\"') + tmpdir = tempfile.mkdtemp() + out_path = os.path.join(tmpdir, "tts_output.mp3") + + try: + subprocess.run( + [ + "python", + "-c", + f"import pyttsx3; e=pyttsx3.init(); e.save_to_file('{text_encoded}','{out_path}'); e.runAndWait()", + ], + capture_output=True, + timeout=30, + check=False, + ) + + if os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except Exception: + logger.exception("TTS: builtin synthesis failed") + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + return self._generate_silence(0.5) + + async def _synthesize_azure(self, text: str, voice: str, fmt: AudioFormat) -> bytes: + import os + import aiohttp + + key = os.environ.get("AZURE_TTS_KEY", "") + region = os.environ.get("AZURE_TTS_REGION", "eastasia") + + if not key: + logger.warning("TTS: Azure key not configured") + return self._generate_silence(0.5) + + ssml = ( + f'' + f'{text}' + ) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1", + headers={ + "Ocp-Apim-Subscription-Key": key, + "Content-Type": "application/ssml+xml", + "X-Microsoft-OutputFormat": "audio-16khz-32kbitrate-mono-mp3", + }, + data=ssml.encode("utf-8"), + ) as resp: + if resp.status == 200: + return await resp.read() + logger.warning("TTS: Azure returned status %d", resp.status) + except Exception: + logger.exception("TTS: Azure synthesis failed") + + return self._generate_silence(0.5) + + async def _synthesize_edge(self, text: str, voice: str, fmt: AudioFormat) -> bytes: + try: + import aiohttp + + ssml = ( + f'' + f'{text}' + ) + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://speech.platform.bing.com/consumer/speech/synthesize/" + "readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4", + headers={ + "Content-Type": "application/ssml+xml", + "X-Microsoft-OutputFormat": "audio-16khz-32kbitrate-mono-mp3", + }, + data=ssml.encode("utf-8"), + ) as resp: + if resp.status == 200: + return await resp.read() + except Exception: + logger.exception("TTS: Edge synthesis failed") + + return self._generate_silence(0.5) + + @staticmethod + def _generate_silence(duration_s: float) -> bytes: + sample_rate = 16000 + samples = int(duration_s * sample_rate) + silence = b"\x00" * (samples * 2) + return silence + + def clear_cache(self) -> None: + self._cache.clear() diff --git a/backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py b/backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py new file mode 100644 index 00000000..fb54885c --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from collections.abc import AsyncGenerator + +logger = logging.getLogger(__name__) + + +class StreamEventType(Enum): + FLUSH = auto() + COMPLETE = auto() + ERROR = auto() + + +class FlushStrategy(Enum): + PER_CHAR = auto() + INTERVAL = auto() + BACKLOG = auto() + + +@dataclass +class StreamEvent: + event_type: StreamEventType + content: str = "" + full_content: str = "" + error: str | None = None + + +@dataclass +class StreamCheckpoint: + chat_id: str + msg_id: str = "" + content: str = "" + sent_seq: int = 0 + timestamp: float = field(default_factory=time.time) + + @property + def is_stale(self) -> bool: + return time.time() - self.timestamp > 600 + + +class FlushController: + strategy: FlushStrategy = FlushStrategy.BACKLOG + flush_interval: float = 0.6 + backlog_threshold: int = 30 + max_retries: int = 3 + retry_delay: float = 1.0 + seq_counter: int = 0 + _buffer: list[str] = field(default_factory=list, repr=False) + _last_flush: float = 0.0 + _accumulated: str = "" + + def reset(self) -> None: + self._buffer = [] + self._last_flush = 0.0 + self._accumulated = "" + self.seq_counter = 0 + + def feed(self, chunk: str) -> list[str]: + self._buffer.append(chunk) + self._accumulated += chunk + + if self.strategy == FlushStrategy.PER_CHAR: + return self._flush_all() + + if self.strategy == FlushStrategy.BACKLOG and len(self._accumulated) >= self.backlog_threshold: + return self._flush_all() + + now = time.monotonic() + if self.strategy == FlushStrategy.INTERVAL and now - self._last_flush >= self.flush_interval: + return self._flush_all() + + return [] + + def flush_remaining(self) -> tuple[list[str], str]: + if self.strategy == FlushStrategy.PER_CHAR: + return [], self._accumulated + batches = self._flush_all() if self._buffer else [] + return batches, self._accumulated + + def _flush_all(self) -> list[str]: + if not self._buffer: + return [] + batches = list(self._buffer) + self._buffer = [] + self._accumulated = "" + self._last_flush = time.monotonic() + return batches + + +class C2CStreamingController: + def __init__( + self, + send_message_fn, + retry_delay: float = 1.0, + max_retries: int = 3, + flush_interval: float = 0.6, + ) -> None: + self._send_message_fn = send_message_fn + self._retry_delay = retry_delay + self._max_retries = max_retries + self._flush_interval = flush_interval + self._flush_controller = FlushController(strategy=FlushStrategy.BACKLOG, flush_interval=flush_interval) + self._any_chunk_delivered: bool = False + self._static_fallback_msg: str = "" + self._active_checkpoints: dict[str, StreamCheckpoint] = {} + + @property + def flush_controller(self) -> FlushController: + return self._flush_controller + + async def stream( + self, + chat_id: str, + msg_id: str, + content_generator: AsyncGenerator[str, None], + event_id: str = "", + ) -> bool: + self._flush_controller.reset() + self._any_chunk_delivered = False + self._static_fallback_msg = "" + + checkpoint = StreamCheckpoint(chat_id=chat_id, msg_id=msg_id) + self._active_checkpoints[chat_id] = checkpoint + + collected = "" + in_media_interrupt = False + media_buffer = "" + + try: + async for chunk in content_generator: + if not chunk: + continue + + media_splits = self._split_on_media_tags(chunk) + for segment, is_media in media_splits: + if is_media: + in_media_interrupt = True + media_buffer += segment + else: + if in_media_interrupt and media_buffer: + checkpoint.content = collected + await self._flush_and_end_stream(chat_id, msg_id, checkpoint, event_id) + await self._send_media_interruption(chat_id, media_buffer) + media_buffer = "" + in_media_interrupt = False + + new_msg_id = f"{msg_id}_resume_{checkpoint.sent_seq}" + checkpoint.msg_id = new_msg_id + self._flush_controller.reset() + + collected += segment + batches = self._flush_controller.feed(segment) + for batch in batches: + self._flush_controller.seq_counter += 1 + success = await self._send_stream_chunk( + chat_id, + checkpoint.msg_id or msg_id, + batch, + self._flush_controller.seq_counter, + event_id, + ) + if success: + self._any_chunk_delivered = True + else: + logger.warning( + "C2CStreaming: flush failed for seq %d", self._flush_controller.seq_counter + ) + + self._static_fallback_msg = collected + + flush_batches, full = self._flush_controller.flush_remaining() + for batch in flush_batches: + self._flush_controller.seq_counter += 1 + success = await self._send_stream_chunk( + chat_id, + checkpoint.msg_id or msg_id, + batch, + self._flush_controller.seq_counter, + event_id, + ) + if success: + self._any_chunk_delivered = True + + self._flush_controller.seq_counter += 1 + success = await self._send_stream_chunk( + chat_id, + checkpoint.msg_id or msg_id, + "", + self._flush_controller.seq_counter, + event_id, + is_end=True, + ) + self._active_checkpoints.pop(chat_id, None) + return success + + except Exception as e: + logger.exception("C2CStreaming: stream error for %s", chat_id) + self._active_checkpoints.pop(chat_id, None) + return await self._cancel_stream(chat_id, msg_id, event_id, str(e)) + + async def _send_stream_chunk( + self, + chat_id: str, + msg_id: str, + content: str, + msg_seq: int, + event_id: str = "", + is_end: bool = False, + ) -> bool: + for attempt in range(self._max_retries): + try: + payload = { + "content": content, + "msg_type": 0, + "msg_id": msg_id, + "msg_seq": msg_seq, + "stream": {"state": 2 if is_end else 1}, + } + if event_id: + payload["event_id"] = event_id + + response = await self._send_message_fn(chat_id, payload) + if response is not None: + cp = self._active_checkpoints.get(chat_id) + if cp: + cp.sent_seq = msg_seq + cp.timestamp = time.time() + return response is not None + except Exception: + if attempt < self._max_retries - 1: + await asyncio.sleep(self._retry_delay) + else: + logger.exception("C2CStreaming: send chunk failed after %d retries", self._max_retries) + return False + + @staticmethod + def _split_on_media_tags(text: str) -> list[tuple[str, bool]]: + import re + + from .media_tags import _MEDIA_TAG_RE, _IMG_TAG_RE, _VIDEO_TAG_RE + + combined_re = re.compile(f"({_MEDIA_TAG_RE.pattern}|{_IMG_TAG_RE.pattern}|{_VIDEO_TAG_RE.pattern})") + + results: list[tuple[str, bool]] = [] + last_end = 0 + for match in combined_re.finditer(text): + if match.start() > last_end: + results.append((text[last_end : match.start()], False)) + results.append((match.group(0), True)) + last_end = match.end() + + if last_end < len(text): + results.append((text[last_end:], False)) + + return results + + async def _flush_and_end_stream( + self, chat_id: str, msg_id: str, checkpoint: StreamCheckpoint, event_id: str + ) -> None: + flush_batches, _ = self._flush_controller.flush_remaining() + for batch in flush_batches: + self._flush_controller.seq_counter += 1 + await self._send_stream_chunk( + chat_id, + msg_id, + batch, + self._flush_controller.seq_counter, + event_id, + ) + + self._flush_controller.seq_counter += 1 + await self._send_stream_chunk( + chat_id, + msg_id, + "", + self._flush_controller.seq_counter, + event_id, + is_end=True, + ) + + async def _send_media_interruption(self, chat_id: str, media_tags: str) -> None: + try: + payload = { + "content": media_tags, + "msg_type": 0, + "msg_id": "", + "msg_seq": 0, + } + await self._send_message_fn(chat_id, payload) + except Exception: + logger.exception("C2CStreaming: media interruption send failed") + + async def _cancel_stream(self, chat_id: str, msg_id: str, event_id: str, reason: str) -> bool: + delivered_fallback = False + if self._static_fallback_msg and not self._any_chunk_delivered: + try: + static_payload = { + "content": self._static_fallback_msg[:2000], + "msg_type": 0, + "msg_id": msg_id, + "msg_seq": 0, + } + if event_id: + static_payload["event_id"] = event_id + await self._send_message_fn(chat_id, static_payload) + delivered_fallback = True + logger.info("C2CStreaming: delivered static fallback message for %s", chat_id) + except Exception: + logger.exception("C2CStreaming: static fallback delivery failed for %s", chat_id) + + if not delivered_fallback: + try: + payload = { + "content": reason, + "msg_type": 0, + "msg_id": msg_id, + "msg_seq": 0, + "stream": {"state": 0}, + } + if event_id: + payload["event_id"] = event_id + await self._send_message_fn(chat_id, payload) + except Exception: + pass + return False + + def get_checkpoint(self, chat_id: str) -> StreamCheckpoint | None: + return self._active_checkpoints.get(chat_id) + + def has_pending_stream(self, chat_id: str) -> bool: + cp = self._active_checkpoints.get(chat_id) + return cp is not None and not cp.is_stale + + def cleanup_stale_checkpoints(self) -> int: + stale = [cid for cid, cp in self._active_checkpoints.items() if cp.is_stale] + for cid in stale: + self._active_checkpoints.pop(cid, None) + return len(stale) diff --git a/backend/package/yuxi/channels/adapters/qqbot/commands/__init__.py b/backend/package/yuxi/channels/adapters/qqbot/commands/__init__.py new file mode 100644 index 00000000..5350da9b --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/commands/__init__.py @@ -0,0 +1,4 @@ +from .framework import CommandRegistry, CommandContext, CommandResult +from .builtin import register_builtin_commands + +__all__ = ["CommandRegistry", "CommandContext", "CommandResult", "register_builtin_commands"] diff --git a/backend/package/yuxi/channels/adapters/qqbot/commands/builtin.py b/backend/package/yuxi/channels/adapters/qqbot/commands/builtin.py new file mode 100644 index 00000000..dfcca734 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/commands/builtin.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import time + + +from .framework import CommandRegistry, CommandContext, CommandResult + +_start_time = time.time() + + +async def _cmd_ping(ctx: CommandContext) -> CommandResult: + uptime = time.time() - _start_time + hours = int(uptime // 3600) + minutes = int((uptime % 3600) // 60) + seconds = int(uptime % 60) + + msg = ctx.msg + user = msg.identity.channel_user_id or "Unknown" + return CommandResult( + success=True, + message=f"Pong! 在线时间: {hours}h {minutes}m {seconds}s\n发送者: {user}", + ) + + +async def _cmd_help(ctx: CommandContext) -> CommandResult: + registry = ctx.adapter._command_registry if hasattr(ctx.adapter, "_command_registry") else None + if registry is None: + return CommandResult(success=True, message="命令系统未初始化。") + + lines = ["可用命令:"] + seen: set[str] = set() + for name in sorted(registry.command_names): + cmd = registry._commands.get(name) + if cmd and name not in seen: + seen.add(name) + desc = cmd.description or "(无描述)" + usage = f" - 用法: {cmd.usage}" if cmd.usage else "" + lines.append(f" /{name} - {desc}{usage}") + + return CommandResult(success=True, message="\n".join(lines)) + + +async def _cmd_status(ctx: CommandContext) -> CommandResult: + adapter = ctx.adapter + status = getattr(adapter, "status", "unknown") + reconnect = getattr(adapter, "_reconnect_manager", None) + reconnect_state = reconnect.state.name if reconnect else "N/A" + circuit_breaker = getattr(adapter, "_circuit_breaker", None) + cb_state = "open" if circuit_breaker and circuit_breaker.is_open else "closed" + + return CommandResult( + success=True, + message=(f"状态: {status}\n重连状态: {reconnect_state}\n熔断器: {cb_state}"), + ) + + +async def _cmd_clearlogs(ctx: CommandContext) -> CommandResult: + adapter = ctx.adapter + known_users = getattr(adapter, "_known_users", None) + group_buffer = getattr(adapter, "_group_buffer", None) + + if known_users: + known_users.clear() + if group_buffer: + group_buffer.gc() + + return CommandResult(success=True, message="日志已清理(已知用户记录 + 过期群聊缓冲)。") + + +def register_builtin_commands(registry: CommandRegistry) -> CommandRegistry: + registry.register( + name="ping", + handler=_cmd_ping, + description="检测 Bot 是否在线", + usage="/ping", + aliases=["p"], + ) + + registry.register( + name="help", + handler=_cmd_help, + description="显示所有可用命令", + usage="/help", + aliases=["h"], + ) + + registry.register( + name="status", + handler=_cmd_status, + description="查看 Bot 运行状态", + usage="/status", + aliases=["st"], + ) + + registry.register( + name="clearlogs", + handler=_cmd_clearlogs, + description="清理运行日志和缓存", + usage="/clearlogs", + ) + + return registry diff --git a/backend/package/yuxi/channels/adapters/qqbot/commands/framework.py b/backend/package/yuxi/channels/adapters/qqbot/commands/framework.py new file mode 100644 index 00000000..49b4244f --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/commands/framework.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +from yuxi.channels.models import ChannelMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class CommandContext: + command_name: str + args: list[str] + raw_content: str + msg: ChannelMessage + adapter: object + + +@dataclass +class CommandResult: + success: bool + message: str = "" + error: str | None = None + + +CommandHandler = Callable[[CommandContext], Awaitable[CommandResult]] + + +@dataclass +class CommandDef: + name: str + handler: CommandHandler + description: str = "" + usage: str = "" + aliases: list[str] = field(default_factory=list) + + +class CommandRegistry: + def __init__(self): + self._commands: dict[str, CommandDef] = {} + + def register( + self, + name: str, + handler: CommandHandler, + description: str = "", + usage: str = "", + aliases: list[str] | None = None, + ) -> None: + cmd = CommandDef( + name=name, + handler=handler, + description=description, + usage=usage, + aliases=aliases or [], + ) + self._commands[name] = cmd + for alias in cmd.aliases: + self._commands[alias] = cmd + logger.debug("CommandRegistry: registered /%s", name) + + def unregister(self, name: str) -> None: + cmd = self._commands.pop(name, None) + if cmd: + for alias in cmd.aliases: + self._commands.pop(alias, None) + + def resolve(self, content: str) -> tuple[str, list[str]] | None: + if not content.startswith("/"): + return None + + parts = content[1:].strip().split(maxsplit=1) + if not parts: + return None + + command_name = parts[0].lower() + args = parts[1].split() if len(parts) > 1 else [] + return command_name, args + + async def dispatch( + self, + command_name: str, + args: list[str], + raw_content: str, + msg: ChannelMessage, + adapter: object, + ) -> CommandResult: + cmd = self._commands.get(command_name) + if cmd is None: + available = ", ".join(sorted(set(c.name for c in self._commands.values()))) + return CommandResult( + success=False, + message=f"未知命令: /{command_name}。可用命令: {available}", + error="unknown_command", + ) + + ctx = CommandContext( + command_name=command_name, + args=args, + raw_content=raw_content, + msg=msg, + adapter=adapter, + ) + + try: + return await cmd.handler(ctx) + except Exception as e: + logger.exception("Command /%s failed", command_name) + return CommandResult( + success=False, + message=f"命令执行失败: {e}", + error=str(e), + ) + + @property + def command_names(self) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for name, cmd in self._commands.items(): + if name not in seen: + seen.add(name) + result.append(name) + return result diff --git a/backend/package/yuxi/channels/adapters/qqbot/constants.py b/backend/package/yuxi/channels/adapters/qqbot/constants.py new file mode 100644 index 00000000..624bee3e --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/constants.py @@ -0,0 +1,105 @@ +"""QQ Bot Gateway 常量定义""" + + +class Opcode: + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + RESUME = 6 + RECONNECT = 7 + INVALID_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + + +class ECode: + UNKNOWN_ERROR = 4000 + UNKNOWN_OPCODE = 4001 + DECODE_ERROR = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + RATE_LIMITED = 4008 + SESSION_TIMEOUT = 4009 + INVALID_SHARD = 4010 + INVALID_SHARD_COUNT = 4011 + INVALID_INTENT = 4012 + INVALID_API_VERSION = 4013 + INVALID_SEQ = 4014 + BOT_REMOVED = 4100 + ACCOUNT_BANNED = 4101 + FATAL_CLOSE = 4914 + FATAL_ERROR = 4915 + + +class Intent: + GUILDS = 1 << 0 + GUILD_MEMBERS = 1 << 1 + GUILD_MESSAGES = 1 << 9 + GUILD_MESSAGE_REACTIONS = 1 << 10 + GUILD_DIRECT_MESSAGES = 1 << 12 + GROUP_AND_C2C = 1 << 25 + INTERACTION = 1 << 26 + AUDIO_ACTION = 1 << 29 + AT_MESSAGES = 1 << 30 + DEFAULT = GUILD_MESSAGES | AT_MESSAGES | GUILD_DIRECT_MESSAGES + ALL = DEFAULT | GROUP_AND_C2C | INTERACTION + + +class EventType: + READY = "READY" + RESUMED = "RESUMED" + AT_MESSAGE_CREATE = "AT_MESSAGE_CREATE" + DIRECT_MESSAGE_CREATE = "DIRECT_MESSAGE_CREATE" + C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE" + GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE" + MESSAGE_CREATE = "MESSAGE_CREATE" + INTERACTION_CREATE = "INTERACTION_CREATE" + MESSAGE_DELETE = "MESSAGE_DELETE" + PUBLIC_MESSAGE_DELETE = "PUBLIC_MESSAGE_DELETE" + + +CLOSE_CODE_MAP: dict[int, str] = { + ECode.UNKNOWN_ERROR: "unknown_error", + ECode.UNKNOWN_OPCODE: "unknown_opcode", + ECode.DECODE_ERROR: "decode_error", + ECode.NOT_AUTHENTICATED: "not_authenticated", + ECode.AUTHENTICATION_FAILED: "authentication_failed", + ECode.RATE_LIMITED: "rate_limited", + ECode.SESSION_TIMEOUT: "session_timeout", + ECode.INVALID_SHARD: "invalid_shard", + ECode.INVALID_SHARD_COUNT: "invalid_shard_count", + ECode.INVALID_INTENT: "invalid_intent", + ECode.INVALID_API_VERSION: "invalid_api_version", + ECode.INVALID_SEQ: "invalid_seq", + ECode.BOT_REMOVED: "bot_removed", + ECode.ACCOUNT_BANNED: "account_banned", + 4015: "invalid_shard_id", + 4102: "bot_removed_group", +} +SERVER_CLOSE_CODE_MAP: dict[int, str] = { + 4900: "server_internal_error", + 4901: "server_overload", + 4902: "server_maintenance", + 4903: "server_network_error", + 4904: "gateway_overload", + 4905: "gateway_maintenance", + 4906: "gateway_internal_error", + 4907: "gateway_network_error", + 4908: "gateway_unavailable", + 4909: "service_degraded", + 4910: "database_error", + 4911: "cache_error", + 4912: "rate_limit_server", + 4913: "server_timeout", +} + +DM_CHAT_PREFIX = "dm_" +GROUP_CHAT_PREFIX = "group_" + +WS_OP_DISPATCH = Opcode.DISPATCH +WS_OP_HEARTBEAT = Opcode.HEARTBEAT +WS_OP_HEARTBEAT_ACK = Opcode.HEARTBEAT_ACK +WS_OP_HELLO = Opcode.HELLO +WS_OP_IDENTIFY = Opcode.IDENTIFY +WS_OP_INVALID_SESSION = Opcode.INVALID_SESSION +WS_OP_RECONNECT = Opcode.RECONNECT diff --git a/backend/package/yuxi/channels/adapters/qqbot/credential_backup.py b/backend/package/yuxi/channels/adapters/qqbot/credential_backup.py new file mode 100644 index 00000000..5e099aae --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/credential_backup.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import logging +import os +import tempfile +import time +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +DEFAULT_BACKUP_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_credentials") + + +@dataclass +class CredentialSnapshot: + app_id: str = "" + app_secret: str = "" + access_token: str = "" + expires_at: float = 0 + token_obtained_at: float = 0 + session_id: str = "" + sandbox: bool = False + metadata: dict = field(default_factory=dict) + + def is_valid(self) -> bool: + return bool(self.app_id and self.app_secret) + + def token_expired(self) -> bool: + if not self.access_token or not self.expires_at: + return True + return time.monotonic() > self.expires_at - 300 + + +class CredentialBackup: + def __init__(self, app_id: str, backup_dir: str | None = None): + self._app_id = app_id + self._backup_dir = backup_dir or DEFAULT_BACKUP_DIR + self._backup_path = os.path.join(self._backup_dir, f"{app_id}.json") + + def save(self, snapshot: CredentialSnapshot) -> bool: + try: + os.makedirs(self._backup_dir, exist_ok=True) + + data = { + "app_id": snapshot.app_id, + "app_secret": snapshot.app_secret, + "access_token": snapshot.access_token, + "expires_at": snapshot.expires_at, + "token_obtained_at": snapshot.token_obtained_at, + "session_id": snapshot.session_id, + "sandbox": snapshot.sandbox, + "metadata": snapshot.metadata, + "saved_at": time.time(), + } + + tmp_path = self._backup_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp_path, self._backup_path) + + logger.info("CredentialBackup: saved snapshot for app_id=%s", self._app_id[:6] + "...") + return True + except OSError: + logger.exception("CredentialBackup: failed to save snapshot") + return False + + def restore(self) -> CredentialSnapshot | None: + try: + if not os.path.exists(self._backup_path): + return None + + with open(self._backup_path, encoding="utf-8") as f: + data = json.load(f) + + snapshot = CredentialSnapshot( + app_id=data.get("app_id", ""), + app_secret=data.get("app_secret", ""), + access_token=data.get("access_token", ""), + expires_at=data.get("expires_at", 0), + token_obtained_at=data.get("token_obtained_at", 0), + session_id=data.get("session_id", ""), + sandbox=data.get("sandbox", False), + metadata=data.get("metadata", {}), + ) + + if not snapshot.is_valid(): + logger.warning("CredentialBackup: restored snapshot is invalid for app_id=%s", self._app_id[:6] + "...") + return None + + logger.info("CredentialBackup: restored snapshot for app_id=%s", self._app_id[:6] + "...") + return snapshot + except (OSError, json.JSONDecodeError, KeyError): + logger.exception("CredentialBackup: failed to restore snapshot") + return None + + def clear(self) -> bool: + try: + if os.path.exists(self._backup_path): + os.remove(self._backup_path) + tmp_path = self._backup_path + ".tmp" + if os.path.exists(tmp_path): + os.remove(tmp_path) + logger.info("CredentialBackup: cleared backup for app_id=%s", self._app_id[:6] + "...") + return True + except OSError: + logger.exception("CredentialBackup: failed to clear backup") + return False + + @staticmethod + def cleanup_expired(backup_dir: str | None = None, max_age_s: float = 86400 * 7) -> int: + directory = backup_dir or DEFAULT_BACKUP_DIR + removed = 0 + + if not os.path.exists(directory): + return 0 + + now = time.time() + try: + for filename in os.listdir(directory): + if not filename.endswith(".json"): + continue + filepath = os.path.join(directory, filename) + try: + stat = os.stat(filepath) + if now - stat.st_mtime > max_age_s: + os.remove(filepath) + removed += 1 + logger.debug("CredentialBackup: removed expired backup %s", filename) + except OSError: + pass + except OSError: + logger.exception("CredentialBackup: cleanup failed") + + return removed diff --git a/backend/package/yuxi/channels/adapters/qqbot/format.py b/backend/package/yuxi/channels/adapters/qqbot/format.py new file mode 100644 index 00000000..6fff9de7 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/format.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from yuxi.channels.models import ChannelResponse + +from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX + + +def build_text_payload(response: ChannelResponse) -> dict: + content = response.content[:2000] + chat_id = response.identity.channel_chat_id + + payload: dict = {"content": content} + + payload["msg_type"] = 0 + + if chat_id.startswith(GROUP_CHAT_PREFIX): + payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "") + elif not chat_id.startswith(DM_CHAT_PREFIX): + payload["channel_id"] = chat_id + + if response.reply_to_message_id: + payload["msg_id"] = response.reply_to_message_id + + return payload + + +def build_markdown_payload(response: ChannelResponse, template_id: str | None = None) -> dict: + md_template_id = response.metadata.get("markdown_template_id") or template_id + + if md_template_id: + return { + "msg_type": 2, + "markdown": { + "template_id": md_template_id, + "params": [ + {"key": "title", "values": [response.metadata.get("title", "")]}, + {"key": "content", "values": [response.content[:4096]]}, + ], + }, + } + + return { + "msg_type": 2, + "markdown": { + "content": response.content[:4096], + }, + } + + +def build_ark_payload(response: ChannelResponse) -> dict: + ark_template_id = response.metadata.get("ark_template_id") + ark_data = response.metadata.get("ark_data", {}) + + return { + "msg_type": 3, + "ark": { + "template_id": ark_template_id, + "kv": [{"key": k, "value": v} for k, v in ark_data.items()], + }, + } + + +def build_embed_payload(response: ChannelResponse) -> dict: + embed_data = response.metadata.get("embed", {}) + + return { + "msg_type": 4, + "embed": { + "title": embed_data.get("title", ""), + "description": response.content[:4096], + "prompt": embed_data.get("prompt", response.content[:200]), + "fields": embed_data.get("fields", []), + }, + } + + +def build_media_generic_payload(response: ChannelResponse, file_id: str, msg_type: int = 7) -> dict: + chat_id = response.identity.channel_chat_id + payload: dict = { + "msg_type": msg_type, + "media": {"file_info": file_id}, + } + + if response.content: + payload["content"] = response.content[:2000] + + if chat_id.startswith(GROUP_CHAT_PREFIX): + payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "") + elif not chat_id.startswith(DM_CHAT_PREFIX): + payload["channel_id"] = chat_id + + return payload + + +def build_image_payload(response: ChannelResponse, file_id: str) -> dict: + chat_id = response.identity.channel_chat_id + payload: dict = { + "msg_type": 1, + "image": file_id, + "content": response.content[:2000] if response.content else "", + } + + if chat_id.startswith(GROUP_CHAT_PREFIX): + payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "") + elif not chat_id.startswith(DM_CHAT_PREFIX): + payload["channel_id"] = chat_id + + return payload + + +def format_outbound( + response: ChannelResponse, + use_markdown: bool = False, + markdown_template_id: str | None = None, +) -> dict: + msg_type = response.metadata.get("qq_msg_type", "") + + if msg_type == "markdown" or (use_markdown and not msg_type): + return build_markdown_payload(response, markdown_template_id) + elif msg_type == "ark" and response.metadata.get("ark_template_id"): + return build_ark_payload(response) + elif msg_type == "embed": + return build_embed_payload(response) + elif msg_type == "image" and response.attachments: + file_id = response.attachments[0].file_id or response.attachments[0].url or "" + return build_image_payload(response, file_id) + elif msg_type in ("voice", "video", "file") and response.attachments: + file_id_list = [ + response.metadata.get("media_file_id", ""), + response.attachments[0].file_id or "", + response.attachments[0].url or "", + ] + file_id = next((fid for fid in file_id_list if fid), "") + return build_media_generic_payload(response, file_id, msg_type=7) + else: + return build_text_payload(response) + + +class MarkdownChunker: + MAX_CHARS = 5000 + CHUNK_OVERLAP = 200 + + def __init__(self, max_chars: int = MAX_CHARS, chunk_overlap: int = CHUNK_OVERLAP): + self._max_chars = max_chars + self._chunk_overlap = chunk_overlap + + def chunk(self, text: str) -> list[str]: + if len(text) <= self._max_chars: + return [text] + + paragraphs = self._split_paragraphs(text) + chunks: list[str] = [] + current_chunk: list[str] = [] + current_len = 0 + + for para in paragraphs: + para_len = len(para) + if current_len + para_len <= self._max_chars: + current_chunk.append(para) + current_len += para_len + else: + if current_chunk: + chunks.append("".join(current_chunk)) + if para_len > self._max_chars: + sub_chunks = self._force_split(para) + if current_chunk: + for i, sc in enumerate(sub_chunks): + chunks.append(sc) + else: + chunks.extend(sub_chunks) + current_chunk = [] + current_len = 0 + else: + current_chunk = [para] + current_len = para_len + + if current_chunk: + chunks.append("".join(current_chunk)) + + return chunks + + def _split_paragraphs(self, text: str) -> list[str]: + sections: list[str] = [] + in_code_block = False + current: list[str] = [] + lines = text.splitlines(keepends=True) + + for line in lines: + stripped = line.strip() + if stripped.startswith("```"): + if current: + sections.append("".join(current)) + current = [] + if in_code_block: + sections.append(line) + in_code_block = False + else: + in_code_block = True + current.append(line) + continue + + if in_code_block: + current.append(line) + if stripped.endswith("```"): + sections.append("".join(current)) + current = [] + in_code_block = False + continue + + if not stripped: + if current: + sections.append("".join(current)) + current = [] + sections.append(line) + elif ( + stripped.startswith(("#", "-", "*", ">", "|")) + and current + and not current[-1].strip().startswith(("#", "-", "*", ">", "|", "1.", "2.", "3.")) + ): + if current: + sections.append("".join(current)) + current = [] + current.append(line) + else: + current.append(line) + + if current: + sections.append("".join(current)) + + result: list[str] = [] + buffer: list[str] = [] + for s in sections: + stripped = s.strip() + if not stripped and buffer: + result.append("".join(buffer)) + buffer = [] + buffer.append(s) + + if buffer: + content = "".join(buffer) + if content.strip(): + result.append(content) + + return result or [text] + + def _force_split(self, text: str) -> list[str]: + chunks: list[str] = [] + for i in range(0, len(text), self._max_chars - self._chunk_overlap): + chunks.append(text[i : i + self._max_chars]) + return chunks diff --git a/backend/package/yuxi/channels/adapters/qqbot/group_buffer.py b/backend/package/yuxi/channels/adapters/qqbot/group_buffer.py new file mode 100644 index 00000000..936e62d2 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/group_buffer.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import time +from collections import defaultdict +from dataclasses import dataclass, field + + +@dataclass +class GroupMessage: + msg_id: str + author_id: str + author_name: str + content: str + timestamp: float + mentions_bot: bool = False + + +@dataclass +class GroupSession: + group_id: str + messages: list[GroupMessage] = field(default_factory=list) + last_active: float = 0.0 + buffer_limit: int = 50 + ttl_seconds: float = 3600.0 + + def add(self, msg: GroupMessage) -> None: + self.messages.append(msg) + self.last_active = time.time() + if len(self.messages) > self.buffer_limit: + self.messages = self.messages[-self.buffer_limit :] + + def is_expired(self, now: float | None = None) -> bool: + if now is None: + now = time.time() + return now - self.last_active > self.ttl_seconds + + def recent_context(self, count: int = 10) -> list[GroupMessage]: + return self.messages[-count:] + + +class GroupHistoryBuffer: + def __init__(self, buffer_limit: int = 50, ttl_seconds: float = 3600.0) -> None: + self._sessions: dict[str, GroupSession] = defaultdict(GroupSession) + self._buffer_limit = buffer_limit + self._ttl_seconds = ttl_seconds + + def record(self, group_id: str, msg: GroupMessage) -> None: + session = self._sessions[group_id] + session.buffer_limit = self._buffer_limit + session.ttl_seconds = self._ttl_seconds + if not session.group_id: + session.group_id = group_id + session.add(msg) + + def recent_context(self, group_id: str, count: int = 10) -> list[GroupMessage]: + session = self._sessions.get(group_id) + if session is None: + return [] + if session.is_expired(): + del self._sessions[group_id] + return [] + return session.recent_context(count) + + def gc(self) -> int: + now = time.time() + expired = [gid for gid, s in self._sessions.items() if s.is_expired(now)] + for gid in expired: + del self._sessions[gid] + return len(expired) diff --git a/backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py b/backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py new file mode 100644 index 00000000..58f0c8a8 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import logging +import re +import time +import uuid + +from yuxi.channels.pipeline.base import BaseInboundPipeline, PipelineStage +from yuxi.channels.pipeline.context import PipelineContext + +from .media_tags import parse_media_tags, has_media_tags + +logger = logging.getLogger(__name__) + + +_QQ_EMOJI_RE = re.compile(r"") +_QQ_FACE_RE = re.compile(r"") + +QQ_EMOJI_MAP: dict[int, str] = { + 0: "[微笑]", + 1: "[撇嘴]", + 2: "[色]", + 3: "[发呆]", + 4: "[得意]", + 5: "[流泪]", + 6: "[害羞]", + 7: "[闭嘴]", + 8: "[睡]", + 9: "[大哭]", + 10: "[尴尬]", + 11: "[发怒]", + 12: "[调皮]", + 13: "[呲牙]", + 14: "[惊讶]", + 15: "[难过]", + 16: "[酷]", + 17: "[冷汗]", + 18: "[抓狂]", + 19: "[吐]", + 20: "[偷笑]", + 21: "[可爱]", + 22: "[白眼]", + 23: "[傲慢]", + 24: "[饥饿]", + 25: "[困]", + 26: "[惊恐]", + 27: "[流汗]", + 28: "[憨笑]", + 29: "[悠闲]", + 30: "[奋斗]", + 31: "[咒骂]", + 32: "[疑问]", + 33: "[嘘]", + 34: "[晕]", + 35: "[疯了]", + 36: "[衰]", + 37: "[骷髅]", + 38: "[敲打]", + 39: "[再见]", + 40: "[擦汗]", + 41: "[抠鼻]", + 42: "[鼓掌]", + 43: "[糗大了]", + 44: "[坏笑]", + 45: "[左哼哼]", + 46: "[右哼哼]", + 47: "[哈欠]", + 48: "[鄙视]", + 49: "[委屈]", + 50: "[快哭了]", + 51: "[阴险]", + 52: "[亲亲]", + 53: "[吓]", + 54: "[可怜]", + 55: "[菜刀]", + 56: "[西瓜]", + 57: "[啤酒]", + 58: "[篮球]", + 59: "[乒乓]", + 60: "[咖啡]", + 61: "[饭]", + 62: "[猪头]", + 63: "[玫瑰]", + 64: "[凋谢]", + 65: "[嘴唇]", + 66: "[爱心]", + 67: "[心碎]", + 68: "[蛋糕]", + 69: "[闪电]", + 70: "[炸弹]", + 71: "[刀]", + 72: "[足球]", + 73: "[瓢虫]", + 74: "[便便]", + 75: "[月亮]", + 76: "[太阳]", + 77: "[礼物]", + 78: "[拥抱]", + 79: "[强]", + 80: "[弱]", + 81: "[握手]", + 82: "[胜利]", + 83: "[抱拳]", + 84: "[勾引]", + 85: "[拳头]", + 86: "[差劲]", + 87: "[爱你]", + 88: "[NO]", + 89: "[OK]", + 90: "[爱情]", + 91: "[飞吻]", + 92: "[跳跳]", + 93: "[发抖]", + 94: "[怄火]", + 95: "[转圈]", + 96: "[磕头]", + 97: "[回头]", + 98: "[跳绳]", + 99: "[投降]", +} + + +def parse_qq_emojis(text: str) -> str: + def _emoji_replacer(m: re.Match) -> str: + code = int(m.group(1)) + return QQ_EMOJI_MAP.get(code, f"[表情:{code}]") + + text = _QQ_EMOJI_RE.sub(_emoji_replacer, text) + text = _QQ_FACE_RE.sub(_emoji_replacer, text) + return text + + +_MENTION_RE = re.compile(r"<@!\w+>|@\S+\s?", re.UNICODE) + + +def strip_bot_mentions(content: str, bot_names: list[str] | None = None) -> tuple[str, bool]: + stripped = _MENTION_RE.sub("", content).strip() + was_stripped = stripped != content.strip() + return stripped, was_stripped + + +class QQBotInboundPipeline(BaseInboundPipeline): + async def _build_stages(self) -> list[PipelineStage]: + return [ + self._dedup, + self._normalize, + self._extract_content, + self._access_policy, + self._content_check, + self._context_fill, + self._dispatch, + ] + + async def _dedup(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + msg_id = ctx.msg_id or ctx.event_data.get("id", "") + if not msg_id: + return ctx + + adapter = pipeline.adapter + dedup_cache = getattr(adapter, "_recent_msg_ids", None) + dedup_window = getattr(adapter, "_dedup_window_s", 60) + + if dedup_cache is not None: + now = time.monotonic() + self._prune_dedup_cache(dedup_cache, dedup_window, now) + if msg_id in dedup_cache: + logger.debug("Dedup: %s already processed, skipping", msg_id) + ctx.stop("dedup_duplicate") + return None + dedup_cache[msg_id] = now + + return ctx + + @staticmethod + def _prune_dedup_cache(cache: dict[str, float], window: int, now: float) -> None: + expired = [mid for mid, ts in cache.items() if now - ts >= window] + for mid in expired: + del cache[mid] + + @staticmethod + def _buffer_group_message(pipeline: BaseInboundPipeline, event_data: dict, ctx: PipelineContext) -> None: + adapter = pipeline.adapter + group_buffer = getattr(adapter, "_group_buffer", None) + if group_buffer is None: + return + + group_id = event_data.get("group_openid", event_data.get("group_id", "")) + if not group_id: + return + + from .group_buffer import GroupMessage + + msg = GroupMessage( + msg_id=event_data.get("id", ""), + author_id=ctx.sender_id, + author_name=ctx.sender_name or "", + content=ctx.content or "", + timestamp=time.time(), + mentions_bot=ctx.metadata.get("bot_mentioned", True), + ) + group_buffer.record(group_id, msg) + + async def _normalize(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + event_data = ctx.event_data + event_type = ctx.event_type + + if event_type == "C2C_MESSAGE_CREATE": + author = event_data.get("author", {}) + ctx.chat_type = "dm" + ctx.chat_id = author.get("id", "") + ctx.sender_id = author.get("id", "") + ctx.sender_name = author.get("username", "") + ctx.msg_id = event_data.get("id", "") + content_obj = event_data.get("content", "") + ctx.content = content_obj if isinstance(content_obj, str) else "" + self._extract_reply_info(event_data, ctx) + + elif event_type == "GROUP_AT_MESSAGE_CREATE": + ctx.chat_type = "group" + ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", "")) + author = event_data.get("author", {}) + ctx.sender_id = author.get("member_openid", author.get("id", "")) + ctx.sender_name = author.get("username", "") + ctx.msg_id = event_data.get("id", "") + content_obj = event_data.get("content", "") + ctx.content = content_obj if isinstance(content_obj, str) else "" + ctx.metadata["group_openid"] = event_data.get("group_openid", "") + ctx.metadata["bot_mentioned"] = True + self._extract_reply_info(event_data, ctx) + self._buffer_group_message(pipeline, event_data, ctx) + + elif event_type == "GROUP_MESSAGE_CREATE": + ctx.chat_type = "group" + ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", "")) + author = event_data.get("author", {}) + ctx.sender_id = author.get("member_openid", author.get("id", "")) + ctx.sender_name = author.get("username", "") + ctx.msg_id = event_data.get("id", "") + content_obj = event_data.get("content", "") + ctx.content = content_obj if isinstance(content_obj, str) else "" + ctx.metadata["group_openid"] = event_data.get("group_openid", "") + ctx.metadata["bot_mentioned"] = False + ctx.stop("group_message_no_mention") + self._buffer_group_message(pipeline, event_data, ctx) + return None + + elif event_type == "INTERACTION_CREATE": + interaction_data = event_data.get("data", {}) + reply = interaction_data.get("resolved", {}).get("message_interaction", {}) + button_data = reply.get("button_data", interaction_data.get("button_data", "")) + button_id = reply.get("button_id", interaction_data.get("button_id", "")) + feature_name = reply.get("feature_name", interaction_data.get("feature_name", "")) + ctx.chat_type = "interaction" + ctx.chat_id = event_data.get("chat_id", "") + ctx.sender_id = event_data.get("user_openid", event_data.get("user_id", "")) + ctx.msg_id = event_data.get("id", "") + ctx.content = interaction_data.get("name", "") + ctx.metadata["interaction_id"] = event_data.get("id", "") + ctx.metadata["feature_id"] = reply.get("feature_id", "") + ctx.metadata["button_data"] = button_data + ctx.metadata["button_id"] = button_id + ctx.metadata["feature_name"] = feature_name + + elif event_type == "DIRECT_MESSAGE_CREATE": + ctx.chat_type = "dm" + ctx.chat_id = event_data.get("guild_id", "") + author = event_data.get("author", {}) + ctx.sender_id = author.get("id", "") + ctx.sender_name = author.get("username", "") + ctx.msg_id = event_data.get("id", "") + content_obj = event_data.get("content", "") + ctx.content = content_obj if isinstance(content_obj, str) else "" + self._extract_reply_info(event_data, ctx) + + elif event_type == "AT_MESSAGE_CREATE": + ctx.chat_type = "group" + ctx.chat_id = event_data.get("guild_id", "") + author = event_data.get("author", {}) + ctx.sender_id = author.get("id", "") + ctx.sender_name = author.get("username", "") + ctx.msg_id = event_data.get("id", "") + content_obj = event_data.get("content", "") + ctx.content = content_obj if isinstance(content_obj, str) else "" + self._extract_reply_info(event_data, ctx) + + else: + adapter = pipeline.adapter + try: + msg = adapter.normalize_inbound({"event_type": event_type, "event": event_data}) + ctx.msg_id = msg.identity.channel_message_id + ctx.sender_id = msg.identity.channel_user_id + ctx.chat_id = msg.identity.channel_chat_id + ctx.content = msg.content or "" + ctx.chat_type = msg.chat_type.value + ctx.metadata["qq_chat_type"] = msg.chat_type.value + except Exception: + ctx.stop("unknown_event_type") + return None + + ctx.metadata["received_at"] = time.time() + ctx.metadata["event_id"] = ctx.msg_id or str(uuid.uuid4()) + return ctx + + @staticmethod + def _extract_reply_info(event_data: dict, ctx: PipelineContext) -> None: + msg_elements = event_data.get("msg_elements", []) + if not msg_elements: + return + + for element in msg_elements: + if not isinstance(element, dict): + continue + if element.get("type") != "reply": + continue + + reply_data = element.get("reply_element") or element.get("reply", {}) + if not reply_data: + continue + + quoted_author = reply_data.get("author", {}) + ctx.metadata["quoted_content"] = reply_data.get("content", "") + ctx.metadata["quoted_author_id"] = quoted_author.get("id") or quoted_author.get("member_openid", "") + ctx.metadata["quoted_author_name"] = quoted_author.get("username", "") + ctx.metadata["quoted_msg_id"] = reply_data.get("id", "") + break + + async def _extract_content(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + raw = ctx.content + if raw and isinstance(raw, str): + if has_media_tags(raw): + parsed = parse_media_tags(raw) + ctx.content = parsed.text + ctx.metadata["inline_media"] = [ + {"type": m.media_type, "reference": m.reference, "is_url": m.is_url} for m in parsed.media_items + ] + else: + ctx.content = raw.strip() + + ctx.content = parse_qq_emojis(ctx.content) + + if ctx.chat_type == "group" and ctx.content: + ctx.content, stripped = strip_bot_mentions(ctx.content) + if stripped: + ctx.metadata["mention_stripped"] = True + return ctx + + async def _access_policy(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + adapter = pipeline.adapter + security = getattr(adapter, "_security", None) + if security is None: + return ctx + + if ctx.chat_type == "dm" or ctx.chat_type == "direct": + result = security.check_dm_access(ctx.sender_id) + if not result.allowed: + ctx.stop(f"access_dm_{result.reason}") + return None + + elif ctx.chat_type == "group": + group_id = ctx.chat_id.replace("group_", "") + result = security.check_group_access(group_id) + if not result.allowed: + ctx.stop(f"access_group_{result.reason}") + return None + + return ctx + + async def _content_check(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + netloc = ctx.content.strip() + if netloc and len(netloc) > 8000: + ctx.content = netloc[:8000] + return ctx + + async def _context_fill(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + ctx.metadata["pipeline_version"] = "qqbot_v2" + ctx.metadata["processed_at"] = time.time() + return ctx + + async def _dispatch(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None: + adapter = pipeline.adapter + handler = getattr(adapter, "_on_pipeline_dispatch", None) + if handler: + await handler(ctx) + return ctx diff --git a/backend/package/yuxi/channels/adapters/qqbot/interaction.py b/backend/package/yuxi/channels/adapters/qqbot/interaction.py new file mode 100644 index 00000000..7b7b491b --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/interaction.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import time +from collections.abc import Callable, Awaitable +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class InteractionContext: + interaction_id: str + interaction_type: str + chat_type: str + chat_id: str + user_id: str + user_name: str + data: dict[str, Any] + msg_id: str = "" + timestamp: float = field(default_factory=time.time) + + +InteractionCallback = Callable[[InteractionContext], Awaitable[bool]] + + +class InteractionRegistry: + def __init__(self): + self._callbacks: dict[str, InteractionCallback] = {} + + def register(self, action_id: str, callback: InteractionCallback) -> None: + self._callbacks[action_id] = callback + logger.debug("InteractionRegistry: registered %s", action_id) + + def unregister(self, action_id: str) -> None: + self._callbacks.pop(action_id, None) + + async def dispatch(self, ctx: InteractionContext) -> bool: + if not ctx.interaction_id: + return False + + for action_id, callback in self._callbacks.items(): + if ctx.interaction_id == action_id or ctx.interaction_id.startswith(action_id): + try: + return await callback(ctx) + except Exception: + logger.exception("InteractionRegistry: callback failed for %s", action_id) + return False + + +class InteractionBuilder: + @staticmethod + def make_confirm_button( + action_id: str, + label: str = "确认", + style: int = 1, + ) -> dict: + return { + "type": 2, + "style": style, + "label": label, + "data": action_id, + } + + @staticmethod + def make_action_row(buttons: list[dict]) -> dict: + return {"type": 1, "components": buttons} + + @staticmethod + def make_select_menu( + action_id: str, + placeholder: str = "请选择", + options: list[dict] | None = None, + min_values: int = 1, + max_values: int = 1, + ) -> dict: + return { + "type": 3, + "custom_id": action_id, + "placeholder": placeholder, + "options": options or [], + "min_values": min_values, + "max_values": max_values, + } + + @staticmethod + def make_modal( + action_id: str, + title: str, + fields: list[dict], + ) -> dict: + return { + "type": 4, + "custom_id": action_id, + "title": title, + "components": fields, + } + + +@dataclass +class InteractionHandler: + interaction_id: str + handler_type: str + chat_id: str + user_id: str + created_at: float = field(default_factory=time.time) + expires_at: float = 300.0 + resolved: bool = False + result: Any = None + + def __post_init__(self): + self.expires_at = self.created_at + 300.0 + + +class InteractionSessionManager: + def __init__(self, max_sessions: int = 1000): + self._sessions: dict[str, InteractionHandler] = {} + self._max_sessions = max_sessions + self._lock = asyncio.Lock() + + async def create( + self, + handler_type: str, + chat_id: str, + user_id: str, + ttl: float = 300.0, + ) -> InteractionHandler: + async with self._lock: + self._cleanup_expired() + if len(self._sessions) >= self._max_sessions: + oldest = min( + self._sessions.values(), + key=lambda h: h.created_at, + default=None, + ) + if oldest: + self._sessions.pop(oldest.interaction_id, None) + + iid = hashlib.sha256(f"{chat_id}:{user_id}:{handler_type}:{time.time()}".encode()).hexdigest()[:16] + + handler = InteractionHandler( + interaction_id=iid, + handler_type=handler_type, + chat_id=chat_id, + user_id=user_id, + ) + handler.expires_at = time.time() + ttl + self._sessions[iid] = handler + return handler + + async def get(self, interaction_id: str) -> InteractionHandler | None: + async with self._lock: + handler = self._sessions.get(interaction_id) + if handler is None: + return None + if time.time() >= handler.expires_at: + self._sessions.pop(interaction_id, None) + return None + return handler + + async def resolve(self, interaction_id: str, result: Any = None) -> bool: + async with self._lock: + handler = self._sessions.get(interaction_id) + if handler is None: + return False + handler.resolved = True + handler.result = result + self._sessions.pop(interaction_id, None) + return True + + async def cancel(self, interaction_id: str) -> bool: + async with self._lock: + if interaction_id in self._sessions: + self._sessions.pop(interaction_id, None) + return True + return False + + def _cleanup_expired(self) -> None: + now = time.time() + expired = [iid for iid, h in self._sessions.items() if now >= h.expires_at] + for iid in expired: + self._sessions.pop(iid, None) diff --git a/backend/package/yuxi/channels/adapters/qqbot/known_users.py b/backend/package/yuxi/channels/adapters/qqbot/known_users.py new file mode 100644 index 00000000..11865e20 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/known_users.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import json +import logging +import os +import tempfile +import threading +import time +from dataclasses import dataclass, field +from collections import OrderedDict + +logger = logging.getLogger(__name__) + +DEFAULT_PERSIST_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_known_users") + + +@dataclass +class UserRecord: + user_id: str + username: str = "" + first_seen: float = field(default_factory=time.time) + last_seen: float = field(default_factory=time.time) + message_count: int = 0 + chat_types: set[str] = field(default_factory=set) + metadata: dict = field(default_factory=dict) + + def touch(self, username: str = "", chat_type: str = "") -> None: + self.last_seen = time.time() + self.message_count += 1 + if username: + self.username = username + if chat_type: + self.chat_types.add(chat_type) + + def to_dict(self) -> dict: + return { + "user_id": self.user_id, + "username": self.username, + "first_seen": self.first_seen, + "last_seen": self.last_seen, + "message_count": self.message_count, + "chat_types": list(self.chat_types), + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: dict) -> UserRecord: + return cls( + user_id=data["user_id"], + username=data.get("username", ""), + first_seen=data.get("first_seen", time.time()), + last_seen=data.get("last_seen", time.time()), + message_count=data.get("message_count", 0), + chat_types=set(data.get("chat_types", [])), + metadata=data.get("metadata", {}), + ) + + +class KnownUserTracker: + def __init__( + self, + app_id: str, + max_users: int = 10000, + persist_dir: str | None = None, + persist_interval_s: int = 300, + ): + self._app_id = app_id + self._max_users = max_users + self._persist_dir = persist_dir or DEFAULT_PERSIST_DIR + self._persist_path = os.path.join(self._persist_dir, f"{app_id}_users.json") + self._persist_interval = persist_interval_s + self._lock = threading.Lock() + self._users: OrderedDict[str, UserRecord] = OrderedDict() + self._last_persist: float = 0 + self._dirty = False + + self._load_from_disk() + + def record(self, user_id: str, username: str = "", chat_type: str = "") -> UserRecord: + with self._lock: + if user_id in self._users: + self._users.move_to_end(user_id) + record = self._users[user_id] + record.touch(username, chat_type) + else: + record = UserRecord(user_id=user_id, username=username) + record.touch(username, chat_type) + self._users[user_id] = record + self._users.move_to_end(user_id) + + while len(self._users) > self._max_users: + self._users.popitem(last=False) + + self._dirty = True + self._maybe_persist() + return record + + def is_known(self, user_id: str) -> bool: + with self._lock: + return user_id in self._users + + def get(self, user_id: str) -> UserRecord | None: + with self._lock: + return self._users.get(user_id) + + def remove(self, user_id: str) -> bool: + with self._lock: + if user_id in self._users: + del self._users[user_id] + self._dirty = True + return True + return False + + @property + def count(self) -> int: + with self._lock: + return len(self._users) + + def persist(self) -> bool: + with self._lock: + if not self._dirty: + return True + return self._do_persist() + + def _maybe_persist(self) -> None: + now = time.time() + if now - self._last_persist < self._persist_interval: + return + if not self._dirty: + return + self._do_persist() + + def _do_persist(self) -> bool: + try: + os.makedirs(self._persist_dir, exist_ok=True) + + data = { + "app_id": self._app_id, + "updated_at": time.time(), + "users": {uid: u.to_dict() for uid, u in self._users.items()}, + } + + tmp_path = self._persist_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp_path, self._persist_path) + + self._last_persist = time.time() + self._dirty = False + logger.debug("KnownUserTracker: persisted %d users", len(self._users)) + return True + except OSError: + logger.exception("KnownUserTracker: persist failed") + return False + + def _load_from_disk(self) -> None: + try: + if not os.path.exists(self._persist_path): + return + + with open(self._persist_path, encoding="utf-8") as f: + data = json.load(f) + + users_data = data.get("users", {}) + for uid, udata in users_data.items(): + self._users[uid] = UserRecord.from_dict(udata) + + logger.info("KnownUserTracker: loaded %d users from disk", len(self._users)) + except (OSError, json.JSONDecodeError): + logger.exception("KnownUserTracker: failed to load from disk") + + def clear(self) -> None: + with self._lock: + self._users.clear() + self._dirty = True + self.persist() + + def get_recent_users(self, limit: int = 50) -> list[UserRecord]: + with self._lock: + return list(reversed(self._users.values()))[:limit] diff --git a/backend/package/yuxi/channels/adapters/qqbot/media_tags.py b/backend/package/yuxi/channels/adapters/qqbot/media_tags.py new file mode 100644 index 00000000..c0761348 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/media_tags.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +_MEDIA_TAG_RE = re.compile(r"]+)>") +_IMG_TAG_RE = re.compile(r"]+)>") +_VIDEO_TAG_RE = re.compile(r"]+)>") + + +@dataclass +class InlineMedia: + media_type: str + reference: str + raw_tag: str = "" + is_url: bool = False + + +@dataclass +class ParsedContent: + text: str + media_items: list[InlineMedia] = field(default_factory=list) + + @property + def has_media(self) -> bool: + return len(self.media_items) > 0 + + +def parse_media_tags(content: str) -> ParsedContent: + media_items: list[InlineMedia] = [] + text = content + + for match in _MEDIA_TAG_RE.finditer(content): + media_type = match.group(1) + file_id = match.group(2).strip() + media_items.append( + InlineMedia( + media_type=media_type, + reference=file_id, + raw_tag=match.group(0), + is_url=False, + ) + ) + + for match in _IMG_TAG_RE.finditer(content): + url = match.group(1).strip() + media_items.append( + InlineMedia( + media_type="image", + reference=url, + raw_tag=match.group(0), + is_url=True, + ) + ) + + for match in _VIDEO_TAG_RE.finditer(content): + url = match.group(1).strip() + media_items.append( + InlineMedia( + media_type="video", + reference=url, + raw_tag=match.group(0), + is_url=True, + ) + ) + + text = _MEDIA_TAG_RE.sub("", content) + text = _IMG_TAG_RE.sub("", text) + text = _VIDEO_TAG_RE.sub("", text) + text = text.strip() + + return ParsedContent(text=text, media_items=media_items) + + +def has_media_tags(content: str) -> bool: + return bool(_MEDIA_TAG_RE.search(content) or _IMG_TAG_RE.search(content) or _VIDEO_TAG_RE.search(content)) + + +_MEDIA_SIZE_RE = re.compile(r"([\s\S]*?)") + + +@dataclass +class SizedContent: + text: str + size_limit: int | None = None + + +def parse_size_tag(content: str, default_limit: int = 2000) -> SizedContent: + match = _MEDIA_SIZE_RE.search(content) + if match: + size_limit = int(match.group(1)) + text = _MEDIA_SIZE_RE.sub(match.group(2), content).strip() + return SizedContent(text=text, size_limit=size_limit) + return SizedContent(text=content, size_limit=default_limit) + + +def build_media_tag(media_type: str, reference: str, is_url: bool = False) -> str: + if is_url: + if media_type == "image": + return f"" + if media_type == "video": + return f"" + return f"" + + +def build_media_tags(media_items: list[InlineMedia]) -> str: + return "".join(build_media_tag(item.media_type, item.reference, item.is_url) for item in media_items) + + +def embed_media_in_text(text: str, media_items: list[InlineMedia]) -> str: + if not media_items: + return text + tags = build_media_tags(media_items) + return f"{text}\n{tags}" if text else tags diff --git a/backend/package/yuxi/channels/adapters/qqbot/media_upload.py b/backend/package/yuxi/channels/adapters/qqbot/media_upload.py new file mode 100644 index 00000000..3bc9952d --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/media_upload.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import aiohttp + +from yuxi.channels.exceptions import DeliveryFailedError +from yuxi.utils.logging_config import logger + +_MAX_FILE_SIZE_MB = 100 +FILE_TYPE_IMAGE = "1" +FILE_TYPE_VOICE = "2" +FILE_TYPE_VIDEO = "3" +FILE_TYPE_FILE = "4" + + +def validate_media_size(data: bytes, max_size_mb: int = _MAX_FILE_SIZE_MB, label: str = "media") -> None: + max_bytes = max_size_mb * 1024 * 1024 + actual_size = len(data) + if actual_size > max_bytes: + raise DeliveryFailedError(f"{label} size {actual_size / 1024 / 1024:.1f}MB exceeds limit of {max_size_mb}MB") + + +async def upload_media( + media_data: bytes, + token: str, + http_client: aiohttp.ClientSession | None = None, + filename: str = "media", + file_type: str = FILE_TYPE_FILE, + group_openid: str | None = None, + sandbox: bool = False, +) -> str: + api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com" + + headers = {"Authorization": f"QQBot {token}"} + + content_type_map = { + FILE_TYPE_IMAGE: "image/png", + FILE_TYPE_VOICE: "audio/mpeg", + FILE_TYPE_VIDEO: "video/mp4", + FILE_TYPE_FILE: "application/octet-stream", + } + mime_type = content_type_map.get(file_type, content_type_map[FILE_TYPE_FILE]) + + form = aiohttp.FormData() + form.add_field("file", media_data, filename=filename, content_type=mime_type) + form.add_field("file_type", file_type) + + url = f"{api_base}/v2/groups/{group_openid}/files" if group_openid else f"{api_base}/v2/users/@me/files" + + async def _do_upload(client: aiohttp.ClientSession) -> str: + async with client.post(url, headers=headers, data=form) as resp: + if resp.status != 200: + raise DeliveryFailedError(f"Media upload failed: HTTP {resp.status}") + result = await resp.json() + file_id = result.get("file_uuid", "") or result.get("file_info", "") + logger.debug(f"[QQBot] Media uploaded, file_type={file_type}, file_id={file_id}") + return file_id + + if http_client: + return await _do_upload(http_client) + else: + async with aiohttp.ClientSession() as session: + return await _do_upload(session) + + +async def upload_image( + image_data: bytes, + token: str, + http_client: aiohttp.ClientSession | None = None, + filename: str = "image.png", + group_openid: str | None = None, + sandbox: bool = False, +) -> str: + return await upload_media( + image_data, + token, + http_client=http_client, + filename=filename, + file_type=FILE_TYPE_IMAGE, + group_openid=group_openid, + sandbox=sandbox, + ) + + +async def download_media( + file_id: str, + token: str, + http_client: aiohttp.ClientSession | None = None, + sandbox: bool = False, +) -> bytes: + api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com" + + headers = {"Authorization": f"QQBot {token}"} + + async def _do_download(client: aiohttp.ClientSession) -> bytes: + async with client.get( + f"{api_base}/v2/users/@me/files/{file_id}", + headers=headers, + ) as resp: + if resp.status != 200: + raise DeliveryFailedError(f"Media download failed: HTTP {resp.status}") + return await resp.read() + + if http_client: + return await _do_download(http_client) + else: + async with aiohttp.ClientSession() as session: + return await _do_download(session) + + +def build_media_payload( + chat_id: str, + file_id: str, + content: str = "", + msg_type: int = 7, +) -> dict: + from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX + + payload: dict = { + "msg_type": msg_type, + } + if msg_type == 1: + payload["image"] = file_id + elif msg_type == 4: + payload["file"] = file_id + else: + payload["media"] = {"file_info": file_id} + + if content: + payload["content"] = content[:2000] + + if chat_id.startswith(GROUP_CHAT_PREFIX): + payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "") + elif not chat_id.startswith(DM_CHAT_PREFIX): + payload["channel_id"] = chat_id + + return payload + + +async def download_image( + file_id: str, + token: str, + http_client: aiohttp.ClientSession | None = None, + sandbox: bool = False, +) -> bytes: + return await download_media(file_id, token, http_client, sandbox) + + +_CHUNK_SIZE = 5 * 1024 * 1024 +_UPLOAD_CACHE: dict[str, str] = {} + + +def _make_cache_key(data: bytes) -> str: + import hashlib + + return hashlib.sha256(data).hexdigest() + + +async def upload_media_cached( + media_data: bytes, + token: str, + http_client: aiohttp.ClientSession | None = None, + filename: str = "media", + file_type: str = FILE_TYPE_FILE, + group_openid: str | None = None, + sandbox: bool = False, + use_cache: bool = True, +) -> str: + if use_cache: + cache_key = _make_cache_key(media_data) + cached = _UPLOAD_CACHE.get(cache_key) + if cached: + logger.debug("Media upload: cache hit for %s", filename) + return cached + + file_id = await upload_media( + media_data, + token, + http_client=http_client, + filename=filename, + file_type=file_type, + group_openid=group_openid, + sandbox=sandbox, + ) + + if use_cache and file_id: + cache_key = _make_cache_key(media_data) + _UPLOAD_CACHE[cache_key] = file_id + + return file_id + + +def clear_upload_cache() -> None: + _UPLOAD_CACHE.clear() + logger.debug("Media upload cache cleared") + + +async def upload_media_chunked( + media_data: bytes, + token: str, + http_client: aiohttp.ClientSession | None = None, + filename: str = "media", + file_type: str = FILE_TYPE_FILE, + group_openid: str | None = None, + sandbox: bool = False, + chunk_size: int = _CHUNK_SIZE, +) -> str: + if len(media_data) <= chunk_size: + return await upload_media( + media_data, + token, + http_client=http_client, + filename=filename, + file_type=file_type, + group_openid=group_openid, + sandbox=sandbox, + ) + + import math + + total_chunks = math.ceil(len(media_data) / chunk_size) + api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com" + headers = {"Authorization": f"QQBot {token}"} + + content_type_map = { + FILE_TYPE_IMAGE: "image/png", + FILE_TYPE_VOICE: "audio/mpeg", + FILE_TYPE_VIDEO: "video/mp4", + FILE_TYPE_FILE: "application/octet-stream", + } + mime_type = content_type_map.get(file_type, content_type_map[FILE_TYPE_FILE]) + + async def _do_chunked(client: aiohttp.ClientSession) -> str: + init_url = f"{api_base}/v2/users/@me/files/chunked" + if group_openid: + init_url = f"{api_base}/v2/groups/{group_openid}/files/chunked" + + init_payload = { + "filename": filename, + "file_type": int(file_type), + "total_size": len(media_data), + "chunk_size": chunk_size, + "total_chunks": total_chunks, + } + + async with client.post( + init_url, + headers=headers, + json=init_payload, + ) as resp: + if resp.status not in (200, 201): + raise DeliveryFailedError(f"Chunked upload init failed: HTTP {resp.status}") + init_data = await resp.json() + upload_id = init_data.get("upload_id", "") + + if not upload_id: + raise DeliveryFailedError("Chunked upload: no upload_id returned") + + for i in range(total_chunks): + start = i * chunk_size + end = min(start + chunk_size, len(media_data)) + chunk = media_data[start:end] + + chunk_url = f"{api_base}/v2/users/@me/files/chunked/{upload_id}" + if group_openid: + chunk_url = f"{api_base}/v2/groups/{group_openid}/files/chunked/{upload_id}" + + form = aiohttp.FormData() + form.add_field("chunk", chunk, filename=f"{filename}.chunk{i}", content_type=mime_type) + form.add_field("chunk_index", str(i)) + + async with client.post(chunk_url, headers=headers, data=form) as resp: + if resp.status not in (200, 201): + raise DeliveryFailedError(f"Chunked upload part {i + 1}/{total_chunks} failed: HTTP {resp.status}") + + complete_url = f"{api_base}/v2/users/@me/files/chunked/{upload_id}/complete" + if group_openid: + complete_url = f"{api_base}/v2/groups/{group_openid}/files/chunked/{upload_id}/complete" + + async with client.post(complete_url, headers=headers) as resp: + if resp.status != 200: + raise DeliveryFailedError(f"Chunked upload complete failed: HTTP {resp.status}") + result = await resp.json() + return result.get("file_uuid", "") or result.get("file_info", "") + + if http_client: + return await _do_chunked(http_client) + else: + async with aiohttp.ClientSession() as session: + return await _do_chunked(session) + + +async def upload_media_from_url( + url: str, + token: str, + http_client: aiohttp.ClientSession | None = None, + filename: str = "media", + file_type: str = FILE_TYPE_FILE, + group_openid: str | None = None, + sandbox: bool = False, +) -> str: + async def _do_url_upload(client: aiohttp.ClientSession) -> str: + api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com" + headers = {"Authorization": f"QQBot {token}"} + + endpoint = f"{api_base}/v2/users/@me/files/url" + if group_openid: + endpoint = f"{api_base}/v2/groups/{group_openid}/files/url" + + payload = { + "url": url, + "file_type": int(file_type), + } + + async with client.post(endpoint, headers=headers, json=payload) as resp: + if resp.status not in (200, 201): + raise DeliveryFailedError(f"URL upload failed: HTTP {resp.status}") + result = await resp.json() + return result.get("file_uuid", "") or result.get("file_info", "") + + if http_client: + return await _do_url_upload(http_client) + else: + async with aiohttp.ClientSession() as session: + return await _do_url_upload(session) diff --git a/backend/package/yuxi/channels/adapters/qqbot/multi_account.py b/backend/package/yuxi/channels/adapters/qqbot/multi_account.py new file mode 100644 index 00000000..37972567 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/multi_account.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import asyncio +import logging +import random +import time +from collections.abc import Callable +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class AccountConfig: + account_id: str + app_id: str + app_secret: str + label: str = "" + weight: int = 1 + priority: int = 0 + group_ids: list[str] = field(default_factory=list) + user_ids: list[str] = field(default_factory=list) + cooldown_s: float = 30.0 + _fail_count: int = 0 + _last_fail: float = 0.0 + _last_used: float = 0.0 + + @property + def is_cooling_down(self) -> bool: + if self._last_fail <= 0: + return False + return time.time() - self._last_fail < self.cooldown_s + + def record_success(self) -> None: + self._fail_count = 0 + self._last_fail = 0.0 + self._last_used = time.time() + + def record_failure(self) -> None: + self._fail_count += 1 + self._last_fail = time.time() + + def matches_chat(self, group_id: str = "", user_id: str = "") -> bool: + if self.group_ids or self.user_ids: + if group_id and self.group_ids and group_id not in self.group_ids: + return False + if user_id and self.user_ids and user_id not in self.user_ids: + return False + return True + + +@dataclass +class AccountRouteResult: + account: AccountConfig + account_id: str + resolved: bool = True + reason: str = "" + + +class MultiAccountManager: + def __init__( + self, + accounts: list[AccountConfig] | None = None, + default_rotation_strategy: str = "weighted_round_robin", + ): + self._accounts: dict[str, AccountConfig] = {} + self._rotation_index = 0 + self._lock = asyncio.Lock() + self._strategy = default_rotation_strategy + self._route_fn: Callable | None = None + + if accounts: + for acc in accounts: + self._accounts[acc.account_id] = acc + + @classmethod + def from_config(cls, config: dict | None) -> MultiAccountManager: + if not config: + return cls() + + accounts_cfg = config.get("accounts", []) + if not accounts_cfg: + app_id = config.get("app_id", "") + app_secret = config.get("app_secret", "") + if app_id and app_secret: + acc = AccountConfig( + account_id="default", + app_id=app_id, + app_secret=app_secret, + label="Default", + ) + return cls(accounts=[acc]) + return cls() + + accounts = [] + for ac in accounts_cfg: + accounts.append(AccountConfig( + account_id=ac.get("account_id", str(random.randint(1000, 9999))), + app_id=ac.get("app_id", ""), + app_secret=ac.get("app_secret", ""), + label=ac.get("label", ""), + weight=ac.get("weight", 1), + priority=ac.get("priority", 0), + group_ids=ac.get("group_ids", []), + user_ids=ac.get("user_ids", []), + cooldown_s=ac.get("cooldown_s", 30.0), + )) + + return cls(accounts=accounts) + + @property + def account_count(self) -> int: + return len(self._accounts) + + def get_account(self, account_id: str) -> AccountConfig | None: + return self._accounts.get(account_id) + + async def route( + self, + group_id: str = "", + user_id: str = "", + strategy: str | None = None, + ) -> AccountRouteResult: + async with self._lock: + strategy = strategy or self._strategy + + if self._route_fn is not None: + result = self._route_fn(self._accounts, group_id, user_id) + if result: + return result + + candidates = [ + acc + for acc in self._accounts.values() + if acc.matches_chat(group_id, user_id) and not acc.is_cooling_down + ] + + if not candidates: + all_accounts = [ + acc + for acc in self._accounts.values() + if acc.matches_chat(group_id, user_id) + ] + if all_accounts: + acc = all_accounts[0] + return AccountRouteResult( + account=acc, + account_id=acc.account_id, + reason="all cooling down, picked first", + ) + return AccountRouteResult( + account=AccountConfig(account_id="", app_id="", app_secret=""), + account_id="", + resolved=False, + reason="no matching accounts", + ) + + if strategy == "weighted_random": + weights = [acc.weight for acc in candidates] + total = sum(weights) + if total <= 0: + acc = candidates[0] + else: + r = random.uniform(0, total) + agg = 0 + acc = candidates[0] + for candidate in candidates: + agg += candidate.weight + if r <= agg: + acc = candidate + break + elif strategy == "least_used": + acc = min(candidates, key=lambda a: a._last_used) + elif strategy == "priority": + candidates.sort(key=lambda a: (-a.priority, a._fail_count)) + acc = candidates[0] + else: + idx = self._rotation_index % len(candidates) + acc = candidates[idx] + self._rotation_index += 1 + + acc.record_success() + return AccountRouteResult( + account=acc, + account_id=acc.account_id, + ) + + def set_route_fn(self, fn: Callable | None) -> None: + self._route_fn = fn + + async def mark_failure(self, account_id: str) -> None: + async with self._lock: + acc = self._accounts.get(account_id) + if acc: + acc.record_failure() + logger.warning( + "MultiAccount: account %s failed (count=%d)", + account_id, + acc._fail_count, + ) + + async def mark_success(self, account_id: str) -> None: + async with self._lock: + acc = self._accounts.get(account_id) + if acc: + acc.record_success() + + async def all_cooling_down(self) -> bool: + async with self._lock: + return all(acc.is_cooling_down for acc in self._accounts.values()) if self._accounts else False + + def list_accounts(self) -> list[dict]: + return [ + { + "account_id": acc.account_id, + "label": acc.label, + "weight": acc.weight, + "priority": acc.priority, + "is_cooling_down": acc.is_cooling_down, + "fail_count": acc._fail_count, + } + for acc in self._accounts.values() + ] \ No newline at end of file diff --git a/backend/package/yuxi/channels/adapters/qqbot/probe.py b/backend/package/yuxi/channels/adapters/qqbot/probe.py new file mode 100644 index 00000000..d4c6caf9 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/probe.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field + +import aiohttp + +from yuxi.channels.models import HealthStatus +from yuxi.utils.logging_config import logger + + +@dataclass +class EndpointCheck: + name: str + url: str + status: str = "unknown" + latency_ms: float = 0 + error: str = "" + http_status: int = 0 + + +@dataclass +class AggregatedHealth: + status: str = "healthy" + overall_latency_ms: float = 0 + checks: list[EndpointCheck] = field(default_factory=list) + ws_latency_ms: float | None = None + + @property + def all_healthy(self) -> bool: + return all(c.status == "healthy" for c in self.checks) + + +async def health_check_dsm( + api_base: str, + token: str, + http_client: aiohttp.ClientSession | None = None, + sandbox: bool = False, + ws_connected: bool = False, +) -> HealthStatus: + headers = {"Authorization": f"QQBot {token}"} + timeout = aiohttp.ClientTimeout(total=10) + + async def _check(session: aiohttp.ClientSession) -> HealthStatus: + start = time.monotonic() + async with session.get(f"{api_base}/gateway", headers=headers) as resp: + latency_ms = (time.monotonic() - start) * 1000 + + if resp.status == 200: + return HealthStatus( + status="healthy", + latency_ms=latency_ms, + metadata={ + "sandbox": sandbox, + "ws_connected": ws_connected, + }, + ) + elif resp.status == 401: + return HealthStatus( + status="unhealthy", + last_error="Token expired or invalid", + metadata={ + "sandbox": sandbox, + "auth_status": "failed", + }, + ) + else: + return HealthStatus( + status="degraded", + latency_ms=latency_ms, + last_error=f"Gateway returned {resp.status}", + ) + + if http_client: + try: + return await _check(http_client) + except Exception as e: + logger.warning(f"[QQBot] Health check failed: {e}") + return HealthStatus(status="unhealthy", last_error=str(e), metadata={"sandbox": sandbox}) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + return await _check(session) + except Exception as e: + logger.warning(f"[QQBot] Health check failed: {e}") + return HealthStatus(status="unhealthy", last_error=str(e), metadata={"sandbox": sandbox}) + + +async def health_check_multi_endpoint( + api_base: str, + token: str, + http_client: aiohttp.ClientSession | None = None, + ws_connected: bool = False, + ws_latency_ms: float | None = None, +) -> AggregatedHealth: + headers = {"Authorization": f"QQBot {token}"} + timeout = aiohttp.ClientTimeout(total=10) + endpoints = [ + EndpointCheck(name="gateway", url=f"{api_base}/gateway"), + EndpointCheck(name="bot_info", url=f"{api_base}/v2/users/@me"), + ] + + async def _check(session: aiohttp.ClientSession) -> AggregatedHealth: + for ep in endpoints: + try: + start = time.monotonic() + async with session.get(ep.url, headers=headers) as resp: + ep.latency_ms = (time.monotonic() - start) * 1000 + ep.http_status = resp.status + if resp.status == 200: + ep.status = "healthy" + elif resp.status == 401: + ep.status = "unhealthy" + ep.error = "Authentication failed" + elif resp.status >= 500: + ep.status = "degraded" + ep.error = f"Server error ({resp.status})" + else: + ep.status = "degraded" + ep.error = f"Unexpected status ({resp.status})" + except TimeoutError: + ep.status = "degraded" + ep.error = "Timeout" + except Exception as e: + ep.status = "unhealthy" + ep.error = str(e) + + overall = "healthy" + if any(ep.status == "unhealthy" for ep in endpoints): + overall = "unhealthy" + elif any(ep.status == "degraded" for ep in endpoints): + overall = "degraded" + + healthy_checks = [ep for ep in endpoints if ep.status == "healthy"] + avg_latency = sum(ep.latency_ms for ep in healthy_checks) / len(healthy_checks) if healthy_checks else 0 + + return AggregatedHealth( + status=overall, + overall_latency_ms=avg_latency, + checks=endpoints, + ws_latency_ms=ws_latency_ms, + ) + + if http_client: + return await _check(http_client) + + async with aiohttp.ClientSession(timeout=timeout) as session: + return await _check(session) diff --git a/backend/package/yuxi/channels/adapters/qqbot/rate_limiter.py b/backend/package/yuxi/channels/adapters/qqbot/rate_limiter.py new file mode 100644 index 00000000..f9c7f55e --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/rate_limiter.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class TokenBucket: + rate: float + burst: int + + _tokens: float = field(default=0, init=False) + _last_refill: float = field(default_factory=time.monotonic, init=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) + + def __post_init__(self): + self._tokens = float(self.burst) + + def _refill(self) -> None: + now = time.monotonic() + elapsed = now - self._last_refill + self._tokens = min(self._tokens + elapsed * self.rate, float(self.burst)) + self._last_refill = now + + async def acquire(self, tokens: float = 1.0) -> None: + while True: + async with self._lock: + self._refill() + if self._tokens >= tokens: + self._tokens -= tokens + return + + wait = (tokens - self._tokens) / self.rate if self.rate > 0 else 0.1 + await asyncio.sleep(max(wait, 0.01)) + + def try_acquire(self, tokens: float = 1.0) -> bool: + self._refill() + if self._tokens >= tokens: + self._tokens -= tokens + return True + return False + + +@dataclass +class RouteRateLimiter: + defaults: dict[str, tuple[float, int]] = field( + default_factory=lambda: { + "send_message": (5.0, 10), + "send_media": (1.0, 3), + "upload_media": (0.5, 2), + "default": (10.0, 20), + } + ) + + _buckets: dict[str, TokenBucket] = field(default_factory=dict, init=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) + + async def acquire(self, route: str = "default", tokens: float = 1.0) -> None: + bucket = await self._get_or_create_bucket(route) + await bucket.acquire(tokens) + + def try_acquire(self, route: str = "default", tokens: float = 1.0) -> bool: + bucket = self._get_or_create_bucket_sync(route) + return bucket.try_acquire(tokens) + + async def _get_or_create_bucket(self, route: str) -> TokenBucket: + async with self._lock: + if route not in self._buckets: + rate, burst = self.defaults.get(route, self.defaults["default"]) + self._buckets[route] = TokenBucket(rate=rate, burst=burst) + return self._buckets[route] + + def _get_or_create_bucket_sync(self, route: str) -> TokenBucket: + if route not in self._buckets: + rate, burst = self.defaults.get(route, self.defaults["default"]) + self._buckets[route] = TokenBucket(rate=rate, burst=burst) + return self._buckets[route] + + def get_stats(self) -> dict[str, dict]: + return { + route: {"tokens": bucket._tokens, "rate": bucket.rate, "burst": bucket.burst} + for route, bucket in self._buckets.items() + } diff --git a/backend/package/yuxi/channels/adapters/qqbot/reconnect.py b/backend/package/yuxi/channels/adapters/qqbot/reconnect.py new file mode 100644 index 00000000..2790db16 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/reconnect.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import asyncio +import logging +import random +import time +from enum import Enum, auto +from collections.abc import Callable, Awaitable + +from yuxi.channels.adapters.qqbot.constants import ECode, SERVER_CLOSE_CODE_MAP + +logger = logging.getLogger(__name__) + +_RAPID_DISCONNECT_THRESHOLD_S = 5.0 +_RAPID_DISCONNECT_MAX_WARNINGS = 3 + + +class ReconnectState(Enum): + DISCONNECTED = auto() + CONNECTING = auto() + CONNECTED = auto() + RECONNECTING = auto() + IDENTIFYING = auto() + RESUMING = auto() + BACKOFF = auto() + FROZEN = auto() + + +class CloseCodeCategory(Enum): + ABNORMAL = auto() + RECOVERABLE = auto() + FATAL = auto() + SERVER_SIDE = auto() + SERVER_ERROR = auto() + RATE_LIMITED = auto() + + +class ServerErrorCategory(Enum): + OVERLOAD = auto() + MAINTENANCE = auto() + NETWORK = auto() + INTERNAL = auto() + UNAVAILABLE = auto() + TIMEOUT = auto() + UNKNOWN = auto() + + +_RECOVERABLE_CODES: set[int] = { + ECode.UNKNOWN_ERROR, + ECode.UNKNOWN_OPCODE, + ECode.DECODE_ERROR, + ECode.NOT_AUTHENTICATED, + ECode.AUTHENTICATION_FAILED, + ECode.RATE_LIMITED, + 4009, + ECode.INVALID_INTENT, + ECode.INVALID_SHARD, +} + +_FATAL_CODES: set[int] = { + ECode.INVALID_API_VERSION, + ECode.INVALID_SEQ, + 4013, + ECode.INVALID_SHARD_COUNT, + ECode.BOT_REMOVED, + ECode.ACCOUNT_BANNED, +} + +_ABNORMAL_CODES: set[int] = {4000, 4008, 4011} + +_RATELIMIT_CODES: set[int] = {ECode.RATE_LIMITED, 4009} + +_SERVER_ERROR_CODES: set[int] = set(range(4900, 4914)) + +_SERVER_OVERLOAD_CODES: set[int] = {4901, 4904, 4912} +_SERVER_MAINTENANCE_CODES: set[int] = {4902, 4905} +_SERVER_NETWORK_CODES: set[int] = {4903, 4906, 4907} +_SERVER_UNAVAILABLE_CODES: set[int] = {4908, 4909} +_SERVER_TIMEOUT_CODES: set[int] = {4913} +_SERVER_INTERNAL_CODES: set[int] = {4900, 4910, 4911} + + +def classify_server_error_category(code: int) -> ServerErrorCategory: + if code in _SERVER_OVERLOAD_CODES: + return ServerErrorCategory.OVERLOAD + if code in _SERVER_MAINTENANCE_CODES: + return ServerErrorCategory.MAINTENANCE + if code in _SERVER_NETWORK_CODES: + return ServerErrorCategory.NETWORK + if code in _SERVER_UNAVAILABLE_CODES: + return ServerErrorCategory.UNAVAILABLE + if code in _SERVER_TIMEOUT_CODES: + return ServerErrorCategory.TIMEOUT + if code in _SERVER_INTERNAL_CODES: + return ServerErrorCategory.INTERNAL + return ServerErrorCategory.UNKNOWN + + +def get_server_error_name(code: int) -> str: + return SERVER_CLOSE_CODE_MAP.get(code, f"server_error_{code}") + + +def classify_close_code(code: int | None) -> CloseCodeCategory: + if code is None: + return CloseCodeCategory.ABNORMAL + if code in _RATELIMIT_CODES: + return CloseCodeCategory.RATE_LIMITED + if code in _FATAL_CODES: + return CloseCodeCategory.FATAL + if code in _RECOVERABLE_CODES: + return CloseCodeCategory.RECOVERABLE + if code in _ABNORMAL_CODES: + return CloseCodeCategory.ABNORMAL + if code in _SERVER_ERROR_CODES: + return CloseCodeCategory.SERVER_ERROR + return CloseCodeCategory.SERVER_SIDE if 4000 <= code < 5000 else CloseCodeCategory.ABNORMAL + + +class QQBotReconnectManager: + def __init__( + self, + base_delay: float = 1.0, + max_delay: float = 60.0, + jitter: float = 0.3, + max_retries: int = 10, + resume_timeout: float = 15.0, + ) -> None: + self._state = ReconnectState.DISCONNECTED + self._base_delay = base_delay + self._max_delay = max_delay + self._jitter = jitter + self._max_retries = max_retries + self._resume_timeout = resume_timeout + self._retry_count = 0 + self._session_id: str | None = None + self._last_seq: int | None = None + self._seq_reset_lock = asyncio.Lock() + self._state_listeners: list[Callable[[ReconnectState, ReconnectState], Awaitable[None]]] = [] + self._last_connect_time: float = 0.0 + self._rapid_disconnect_count: int = 0 + self._last_disconnect_code: int | None = None + self._last_disconnect_time: float = 0.0 + + @property + def state(self) -> ReconnectState: + return self._state + + @property + def session_id(self) -> str | None: + return self._session_id + + @property + def last_seq(self) -> int | None: + return self._last_seq + + def add_state_listener(self, listener: Callable[[ReconnectState, ReconnectState], Awaitable[None]]) -> None: + self._state_listeners.append(listener) + + async def _notify_state_change(self, old: ReconnectState, new: ReconnectState) -> None: + for listener in self._state_listeners: + try: + await listener(old, new) + except Exception: + logger.exception("ReconnectManager state listener error") + + async def transition(self, new: ReconnectState) -> None: + old = self._state + if old == new: + return + self._state = new + logger.info("ReconnectManager: %s -> %s", old.name, new.name) + await self._notify_state_change(old, new) + + def on_identify_success(self, session_id: str) -> None: + self._session_id = session_id + self._retry_count = 0 + self._last_connect_time = time.monotonic() + + def mark_connected(self) -> None: + self._last_connect_time = time.monotonic() + self._rapid_disconnect_count = 0 + + async def record_seq(self, seq: int) -> None: + async with self._seq_reset_lock: + self._last_seq = seq + + def seq_reset(self) -> None: + self._last_seq = None + + def on_hello(self) -> None: + pass + + def should_resume(self) -> bool: + return self._session_id is not None and self._last_seq is not None + + async def on_disconnect(self, code: int | None) -> None: + now = time.monotonic() + self._last_disconnect_code = code + self._last_disconnect_time = now + + category = classify_close_code(code) + self._check_rapid_disconnect(code, category, now) + + if category == CloseCodeCategory.FATAL: + logger.error("ReconnectManager: fatal close code %s, freezing", code) + await self.transition(ReconnectState.FROZEN) + return + + if category == CloseCodeCategory.SERVER_ERROR: + error_name = get_server_error_name(code) if code else "unknown" + sub_category = classify_server_error_category(code) if code else ServerErrorCategory.UNKNOWN + logger.warning( + "ReconnectManager: server error code=%s name=%s category=%s", + code, + error_name, + sub_category.name, + ) + + self._retry_count += 1 + if self._retry_count >= self._max_retries: + logger.error("ReconnectManager: server error retries exhausted, freezing") + await self.transition(ReconnectState.FROZEN) + return + + delay = self._calc_delay_for_server_error(sub_category) + logger.warning( + "ReconnectManager: server error close code %s (%s), backing off %.1fs (retry %d/%d)", + code, + error_name, + delay, + self._retry_count, + self._max_retries, + ) + self.seq_reset() + self._session_id = None + await self.transition(ReconnectState.BACKOFF) + await asyncio.sleep(delay) + await self.transition(ReconnectState.IDENTIFYING) + return + + if category == CloseCodeCategory.RATE_LIMITED: + self._retry_count += 1 + if self._retry_count > 3: + logger.error("ReconnectManager: rate-limited retries exhausted, freezing") + await self.transition(ReconnectState.FROZEN) + return + self.seq_reset() + + if self._retry_count >= self._max_retries: + logger.error("ReconnectManager: max retries (%d) exhausted", self._max_retries) + await self.transition(ReconnectState.FROZEN) + return + + self._retry_count += 1 + delay = self._calc_delay() + logger.info("ReconnectManager: backing off %.1fs (retry %d/%d)", delay, self._retry_count, self._max_retries) + await self.transition(ReconnectState.BACKOFF) + await asyncio.sleep(delay) + + if self.should_resume(): + await self.transition(ReconnectState.RESUMING) + else: + self.seq_reset() + await self.transition(ReconnectState.IDENTIFYING) + + def _calc_delay(self) -> float: + raw = min(self._base_delay * (2 ** (self._retry_count - 1)), self._max_delay) + jittered = raw * (1 + random.uniform(-self._jitter, self._jitter)) + return max(0.5, min(jittered, self._max_delay)) + + def _calc_delay_for_server_error(self, sub_category: ServerErrorCategory) -> float: + base = self._calc_delay() + if sub_category == ServerErrorCategory.OVERLOAD: + return base * 3.0 + if sub_category == ServerErrorCategory.MAINTENANCE: + return base * 5.0 + if sub_category == ServerErrorCategory.NETWORK: + return base * 1.5 + if sub_category == ServerErrorCategory.TIMEOUT: + return base * 1.5 + return base * 2.0 + + def _check_rapid_disconnect(self, code: int | None, category: CloseCodeCategory, now: float) -> None: + if category == CloseCodeCategory.FATAL: + return + if self._last_connect_time == 0: + return + elapsed = now - self._last_connect_time + if elapsed > _RAPID_DISCONNECT_THRESHOLD_S: + self._rapid_disconnect_count = 0 + return + self._rapid_disconnect_count += 1 + if self._rapid_disconnect_count >= _RAPID_DISCONNECT_MAX_WARNINGS: + logger.error( + "ReconnectManager: RAPID DISCONNECT LOOP DETECTED - " + "%d disconnects within %.1fs threshold, last code=%s category=%s elapsed=%.2fs", + self._rapid_disconnect_count, + _RAPID_DISCONNECT_THRESHOLD_S, + code, + category.name, + elapsed, + ) + else: + logger.warning( + "ReconnectManager: rapid disconnect #%d/%d within %.1fs threshold, code=%s category=%s elapsed=%.2fs", + self._rapid_disconnect_count, + _RAPID_DISCONNECT_MAX_WARNINGS, + _RAPID_DISCONNECT_THRESHOLD_S, + code, + category.name, + elapsed, + ) + + async def reset(self) -> None: + self._retry_count = 0 + self._session_id = None + self._last_seq = None + self._last_connect_time = 0.0 + self._rapid_disconnect_count = 0 + self._last_disconnect_code = None + self._last_disconnect_time = 0.0 + await self.transition(ReconnectState.DISCONNECTED) diff --git a/backend/package/yuxi/channels/adapters/qqbot/ref/__init__.py b/backend/package/yuxi/channels/adapters/qqbot/ref/__init__.py new file mode 100644 index 00000000..bd590d5d --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/ref/__init__.py @@ -0,0 +1,5 @@ +from .types import RefItem, RefCategory +from .store import RefIndex +from .format import format_ref_item + +__all__ = ["RefItem", "RefCategory", "RefIndex", "format_ref_item"] \ No newline at end of file diff --git a/backend/package/yuxi/channels/adapters/qqbot/ref/format.py b/backend/package/yuxi/channels/adapters/qqbot/ref/format.py new file mode 100644 index 00000000..896067a5 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/ref/format.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from .types import RefItem + + +def format_ref_item(ref: RefItem, style: str = "inline") -> str: + if style == "inline": + return _format_inline(ref) + if style == "block": + return _format_block(ref) + if style == "markdown": + return _format_markdown(ref) + return _format_brief(ref) + + +def _format_inline(ref: RefItem) -> str: + label = ref.label or ref.value + return f"[{ref.category.value}:{label}]" + + +def _format_brief(ref: RefItem) -> str: + return f"Ref({ref.ref_id}): {ref.category.value}='{ref.value}'" + + +def _format_block(ref: RefItem) -> str: + lines = [ + f"[Ref: {ref.ref_id}]", + f" 类别: {ref.category.value}", + f" 值: {ref.value}", + ] + if ref.label: + lines.append(f" 标签: {ref.label}") + if ref.source_msg_id: + lines.append(f" 来源消息: {ref.source_msg_id}") + if ref.target_msg_id: + lines.append(f" 目标消息: {ref.target_msg_id}") + return "\n".join(lines) + + +def _format_markdown(ref: RefItem) -> str: + label = ref.label or ref.value + source_link = "" + if ref.source_msg_id: + source_link = f" → `{ref.source_msg_id}`" + return f"- **`[{ref.category.value}]`** {label}{source_link}" diff --git a/backend/package/yuxi/channels/adapters/qqbot/ref/store.py b/backend/package/yuxi/channels/adapters/qqbot/ref/store.py new file mode 100644 index 00000000..4ec352ee --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/ref/store.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +import uuid + +from .types import RefItem, RefCategory + +logger = logging.getLogger(__name__) + + +class RefIndex: + def __init__( + self, + store_dir: str | None = None, + max_entries: int = 10000, + ttl_s: float = 86400.0, + ): + self._store_dir = store_dir or os.path.join(os.path.dirname(__file__), "..", "ref_data") + self._max_entries = max_entries + self._ttl_s = ttl_s + self._refs: dict[str, RefItem] = {} + self._by_source: dict[str, list[str]] = {} + self._by_chat: dict[str, list[str]] = {} + self._lock = asyncio.Lock() + self._load() + + def _load(self) -> None: + path = os.path.join(self._store_dir, "ref_index.json") + try: + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + data = json.load(f) + for item in data: + ref = RefItem.from_dict(item) + self._add_indexes(ref) + logger.info("RefIndex: loaded %d refs from %s", len(self._refs), path) + except (OSError, json.JSONDecodeError): + logger.exception("RefIndex: failed to load") + + async def _save(self) -> None: + os.makedirs(self._store_dir, exist_ok=True) + path = os.path.join(self._store_dir, "ref_index.json") + tmp = path + ".tmp" + data = [r.to_dict() for r in self._refs.values()] + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + except OSError: + logger.exception("RefIndex: failed to save") + + def _add_indexes(self, ref: RefItem) -> None: + self._refs[ref.ref_id] = ref + + if ref.source_msg_id: + self._by_source.setdefault(ref.source_msg_id, []).append(ref.ref_id) + + if ref.chat_id: + self._by_chat.setdefault(ref.chat_id, []).append(ref.ref_id) + + async def add( + self, + value: str, + category: RefCategory, + label: str = "", + source_msg_id: str = "", + target_msg_id: str = "", + chat_id: str = "", + user_id: str = "", + extra: dict | None = None, + ) -> RefItem: + async with self._lock: + self._gc() + + ref = RefItem( + ref_id=str(uuid.uuid4())[:8], + category=category, + value=value, + label=label, + source_msg_id=source_msg_id, + target_msg_id=target_msg_id, + chat_id=chat_id, + user_id=user_id, + extra=extra or {}, + ) + self._add_indexes(ref) + await self._save() + return ref + + async def get(self, ref_id: str) -> RefItem | None: + async with self._lock: + return self._refs.get(ref_id) + + async def get_by_source(self, source_msg_id: str) -> list[RefItem]: + async with self._lock: + ref_ids = self._by_source.get(source_msg_id, []) + return [self._refs[rid] for rid in ref_ids if rid in self._refs] + + async def get_by_chat(self, chat_id: str, limit: int = 100) -> list[RefItem]: + async with self._lock: + ref_ids = self._by_chat.get(chat_id, []) + if limit: + ref_ids = ref_ids[-limit:] + return [self._refs[rid] for rid in ref_ids if rid in self._refs] + + async def remove(self, ref_id: str) -> bool: + async with self._lock: + ref = self._refs.pop(ref_id, None) + if ref is None: + return False + + if ref.source_msg_id and ref.source_msg_id in self._by_source: + self._by_source[ref.source_msg_id] = [ + rid for rid in self._by_source[ref.source_msg_id] if rid != ref_id + ] + + if ref.chat_id and ref.chat_id in self._by_chat: + self._by_chat[ref.chat_id] = [ + rid for rid in self._by_chat[ref.chat_id] if rid != ref_id + ] + + await self._save() + return True + + def _gc(self) -> None: + now = time.time() + stale = [rid for rid, ref in self._refs.items() if now - ref.created_at > self._ttl_s] + for rid in stale: + ref = self._refs.pop(rid, None) + if ref: + if ref.source_msg_id in self._by_source: + self._by_source[ref.source_msg_id] = [ + r for r in self._by_source[ref.source_msg_id] if r != rid + ] + if ref.chat_id in self._by_chat: + self._by_chat[ref.chat_id] = [ + r for r in self._by_chat[ref.chat_id] if r != rid + ] + + over = len(self._refs) - self._max_entries + if over > 0: + oldest = sorted(self._refs.values(), key=lambda r: r.created_at)[:over] + for ref in oldest: + self._refs.pop(ref.ref_id, None) + if ref.source_msg_id in self._by_source: + self._by_source[ref.source_msg_id] = [ + r for r in self._by_source[ref.source_msg_id] if r != ref.ref_id + ] + if ref.chat_id in self._by_chat: + self._by_chat[ref.chat_id] = [ + r for r in self._by_chat[ref.chat_id] if r != ref.ref_id + ] \ No newline at end of file diff --git a/backend/package/yuxi/channels/adapters/qqbot/ref/types.py b/backend/package/yuxi/channels/adapters/qqbot/ref/types.py new file mode 100644 index 00000000..08dbd534 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/ref/types.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum + + +class RefCategory(Enum): + MSG = "msg" + MEDIA = "media" + EMOJI = "emoji" + STICKER = "sticker" + ATTACHMENT = "attachment" + FILE = "file" + CUSTOM = "custom" + + +@dataclass +class RefItem: + ref_id: str + category: RefCategory + value: str + label: str = "" + source_msg_id: str = "" + target_msg_id: str = "" + chat_id: str = "" + user_id: str = "" + extra: dict = field(default_factory=dict) + created_at: float = field(default_factory=time.time) + + @property + def is_stale(self) -> bool: + return time.time() - self.created_at > 86400 + + def to_dict(self) -> dict: + return { + "ref_id": self.ref_id, + "category": self.category.value, + "value": self.value, + "label": self.label, + "source_msg_id": self.source_msg_id, + "target_msg_id": self.target_msg_id, + "chat_id": self.chat_id, + "user_id": self.user_id, + "extra": self.extra, + "created_at": self.created_at, + } + + @classmethod + def from_dict(cls, data: dict) -> RefItem: + return cls( + ref_id=data.get("ref_id", ""), + category=RefCategory(data.get("category", "msg")), + value=data.get("value", ""), + label=data.get("label", ""), + source_msg_id=data.get("source_msg_id", ""), + target_msg_id=data.get("target_msg_id", ""), + chat_id=data.get("chat_id", ""), + user_id=data.get("user_id", ""), + extra=data.get("extra", {}), + created_at=data.get("created_at", 0), + ) \ No newline at end of file diff --git a/backend/package/yuxi/channels/adapters/qqbot/reply_dispatcher.py b/backend/package/yuxi/channels/adapters/qqbot/reply_dispatcher.py new file mode 100644 index 00000000..6e4a0206 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/reply_dispatcher.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from enum import Enum, auto +from typing import Any + +from yuxi.channels.adapters.qqbot.send import render_reply_payload +from yuxi.channels.pipeline.context import PipelineContext + +logger = logging.getLogger(__name__) + + +class ReplyMode(Enum): + DIRECT = auto() + STREAMING = auto() + MARKDOWN = auto() + FALLBACK = auto() + + +class ReplyDispatcher: + def __init__(self, adapter: Any) -> None: + self._adapter = adapter + + async def reply(self, ctx: PipelineContext, text: str) -> Any: + if ctx.chat_type == "interaction": + return await self._reply_interaction(ctx, text) + + streaming_ready = self._check_streaming_ready(ctx) + + if streaming_ready: + return await self._reply_streaming(ctx) + + if len(text) > 2000: + return await self._reply_markdown(ctx, text) + + return await self._reply_direct(ctx, text) + + def _check_streaming_ready(self, ctx: PipelineContext) -> bool: + if ctx.chat_type != "dm": + return False + adapter = self._adapter + c2c_ctrl = getattr(adapter, "_c2c_streaming", None) + return c2c_ctrl is not None + + async def _reply_direct(self, ctx: PipelineContext, text: str) -> Any: + if ctx.chat_type == "group": + return await self._adapter.send_group_message(ctx.chat_id, text, msg_id=ctx.msg_id) + return await self._adapter.send_dm_message(ctx.chat_id, text, msg_id=ctx.msg_id) + + async def _reply_streaming(self, ctx: PipelineContext) -> Any: + c2c_ctrl = self._adapter._c2c_streaming + if c2c_ctrl is None: + return None + + msg_id = ctx.metadata.get("stream_msg_id", "") + if not msg_id: + msg_id = ctx.msg_id + return await c2c_ctrl.stream( + chat_id=ctx.chat_id, + msg_id=msg_id, + content_generator=self._adapter._stream_content(ctx), + event_id=ctx.metadata.get("event_id", ""), + ) + + async def _reply_markdown(self, ctx: PipelineContext, text: str) -> Any: + chunks = self._adapter._markdown_chunker.chunk(text) + results = [] + for i, chunk in enumerate(chunks): + payload = render_reply_payload( + chunk, msg_type=2, msg_id=ctx.msg_id, chunk_index=i, total_chunks=len(chunks) + ) + if ctx.chat_type == "group": + result = await self._adapter.send_group_message( + ctx.chat_id, content="", payload=payload, msg_id=ctx.msg_id + ) + else: + result = await self._adapter.send_dm_message( + ctx.chat_id, content="", payload=payload, msg_id=ctx.msg_id + ) + results.append(result) + return results + + async def _reply_interaction(self, ctx: PipelineContext, text: str) -> Any: + return await self._adapter._put_interaction(ctx.metadata.get("interaction_id", ""), ctx.content) diff --git a/backend/package/yuxi/channels/adapters/qqbot/retry_queue.py b/backend/package/yuxi/channels/adapters/qqbot/retry_queue.py new file mode 100644 index 00000000..5fb6d202 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/retry_queue.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class RetryTask: + task_id: str + chat_id: str + payload: dict + attempt: int = 0 + max_attempts: int = 5 + created_at: float = field(default_factory=time.time) + next_retry_at: float = field(default_factory=time.time) + last_error: str = "" + base_delay: float = 1.0 + max_delay: float = 120.0 + + +class MessageRetryQueue: + def __init__(self, max_concurrent: int = 3, poll_interval: float = 1.0): + self._queue: list[RetryTask] = [] + self._dead_letter: list[RetryTask] = [] + self._lock = asyncio.Lock() + self._max_concurrent = max_concurrent + self._poll_interval = poll_interval + self._running = False + self._worker_task: asyncio.Task | None = None + self._send_cb: Callable[[str, dict], Awaitable[bool]] | None = None + self._active_tasks: set[str] = set() + + @property + def pending_count(self) -> int: + return len(self._queue) + + @property + def dead_count(self) -> int: + return len(self._dead_letter) + + def set_send_callback(self, callback: Callable[[str, dict], Awaitable[bool]]) -> None: + self._send_cb = callback + + async def enqueue(self, task: RetryTask) -> None: + async with self._lock: + if task.task_id in self._active_tasks: + return + self._queue.append(task) + self._queue.sort(key=lambda t: t.next_retry_at) + + async def start(self) -> None: + if self._running: + return + self._running = True + self._worker_task = asyncio.create_task(self._worker_loop()) + logger.info("MessageRetryQueue: worker started") + + async def stop(self) -> None: + self._running = False + if self._worker_task: + self._worker_task.cancel() + try: + await self._worker_task + except asyncio.CancelledError: + pass + self._worker_task = None + logger.info("MessageRetryQueue: worker stopped, pending=%d dead=%d", len(self._queue), len(self._dead_letter)) + + async def _worker_loop(self) -> None: + while self._running: + task = None + async with self._lock: + for t in self._queue: + if t.task_id in self._active_tasks: + continue + if time.time() >= t.next_retry_at: + task = t + self._active_tasks.add(t.task_id) + break + + if task is None: + await asyncio.sleep(self._poll_interval) + continue + + if len(self._active_tasks) >= self._max_concurrent: + async with self._lock: + self._active_tasks.discard(task.task_id) + await asyncio.sleep(self._poll_interval) + continue + + try: + success = await self._process_task(task) + async with self._lock: + self._active_tasks.discard(task.task_id) + if success: + self._queue.remove(task) + elif task.attempt >= task.max_attempts: + self._queue.remove(task) + self._dead_letter.append(task) + logger.warning( + "MessageRetryQueue: task %s exhausted retries (chat=%s)", + task.task_id, + task.chat_id, + ) + except Exception: + async with self._lock: + self._active_tasks.discard(task.task_id) + await asyncio.sleep(self._poll_interval) + + async def _process_task(self, task: RetryTask) -> bool: + if self._send_cb is None: + return False + + try: + task.attempt += 1 + success = await self._send_cb(task.chat_id, task.payload) + if success: + logger.debug("MessageRetryQueue: task %s succeeded on attempt %d", task.task_id, task.attempt) + return True + + task.last_error = "send_failed" + except Exception as e: + task.last_error = str(e) + + delay = min(task.base_delay * (2 ** (task.attempt - 1)), task.max_delay) + task.next_retry_at = time.time() + delay + logger.info( + "MessageRetryQueue: task %s retry %d/%d, next in %.1fs", + task.task_id, + task.attempt, + task.max_attempts, + delay, + ) + return False + + def get_dead_letter_tasks(self) -> list[RetryTask]: + return list(self._dead_letter) + + def clear_dead_letter(self) -> int: + count = len(self._dead_letter) + self._dead_letter.clear() + return count diff --git a/backend/package/yuxi/channels/adapters/qqbot/security.py b/backend/package/yuxi/channels/adapters/qqbot/security.py new file mode 100644 index 00000000..db165ea6 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/security.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import logging + +from yuxi.channels.models import ChannelMessage +from yuxi.channels.policy.security_policy import ( + AccessResult, + BaseSecurityPolicy, + DmPolicy, + GroupPolicy, + RejectReason, + WildcardAllowlistMatcher, +) + +logger = logging.getLogger(__name__) + + +class QQBotSecurityPolicy(BaseSecurityPolicy): + def __init__(self, config: dict): + normalized = self._normalize_config_ids(config) + super().__init__(normalized) + self._paired_users_matcher = WildcardAllowlistMatcher.from_config( + self._format_ids(config.get("paired_users", [])) + ) + self._group_require_mention = config.get("group_require_mention", True) + self._pairing_enabled = config.get("pairing_enabled", config.get("enable_pairing", False)) + + @staticmethod + def _normalize_config_ids(config: dict) -> dict: + normalized = dict(config) + for key in ("allowFrom", "allow_from"): + if key in normalized: + normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key]) + for key in ("groupAllowFrom", "group_allow_from"): + if key in normalized: + normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key]) + return normalized + + def check_dm_access(self, sender_id: str) -> AccessResult: + if self._dm_policy == DmPolicy.DISABLED: + return AccessResult(False, RejectReason.DM_DISABLED) + if self._dm_policy == DmPolicy.OPEN: + return AccessResult(True, RejectReason.DM_OPEN_PASS) + if self._dm_policy == DmPolicy.PAIRING: + if not self._pairing_enabled: + return AccessResult(True, RejectReason.DM_OPEN_PASS) + if self._paired_users_matcher.match(sender_id): + return AccessResult(True, RejectReason.DM_ALLOWLISTED) + return AccessResult(False, RejectReason.DM_PAIRING_REQUIRED, f"sender '{sender_id}' not paired") + if self._dm_policy == DmPolicy.ALLOWLIST: + if self._dm_matcher.match(sender_id): + return AccessResult(True, RejectReason.DM_ALLOWLISTED) + return AccessResult(False, RejectReason.DM_NOT_ALLOWLISTED, f"sender '{sender_id}' not in allowFrom") + return AccessResult(True, None) + + def check_group_access(self, group_id: str) -> AccessResult: + if self._group_policy == GroupPolicy.DISABLED: + return AccessResult(False, RejectReason.GROUP_DISABLED) + if self._group_policy == GroupPolicy.OPEN: + return AccessResult(True, RejectReason.GROUP_OPEN_PASS) + if self._group_policy == GroupPolicy.ALLOWLIST: + if self._group_matcher.match(group_id): + return AccessResult(True, RejectReason.GROUP_ALLOWLISTED) + return AccessResult(False, RejectReason.GROUP_NOT_ALLOWLISTED, f"group '{group_id}' not in groupAllowFrom") + return AccessResult(False, RejectReason.GROUP_DISABLED) + + def check_mention_required( + self, + chat_id: str, + msg: ChannelMessage, + bot_names: list[str] | None = None, + ) -> bool: + if not self._group_require_mention: + return True + + groups_config = self._config.get("groups", {}) + chat_cfg = groups_config.get(chat_id, {}) + require_mention = chat_cfg.get("require_mention", True) + + if not require_mention: + return True + + if msg.mentions and msg.mentions.is_bot_mentioned: + return True + + content = msg.content or "" + for name in bot_names or []: + if f"@{name}" in content: + return True + + return False + + def _resolve_sender_id(self, event_data: dict) -> str: + author = event_data.get("author", {}) + return author.get("id", author.get("member_openid", "")) + + def _resolve_group_id(self, event_data: dict) -> str: + return event_data.get("group_openid", event_data.get("group_id", event_data.get("guild_id", ""))) + + @staticmethod + def _format_ids(raw_ids: list[str]) -> list[str]: + result: list[str] = [] + for raw in raw_ids: + raw = raw.strip() + if raw.startswith("qq:"): + result.append(raw[3:]) + else: + result.append(raw) + return result + + +def verify_webhook_ed25519(headers: dict, body: bytes, bot_secret: str) -> bool: + sig = headers.get("x-signature-ed25519", "") + timestamp_str = headers.get("x-signature-timestamp", "") + if not sig or not timestamp_str: + return False + + try: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + if len(bot_secret) != 64: + logger.warning("[QQBot] Invalid bot_secret length, expected 64 hex chars") + return False + seed = bytes.fromhex(bot_secret) + private_key = Ed25519PrivateKey.from_private_bytes(seed) + public_key = private_key.public_key() + message = timestamp_str.encode() + body + public_key.verify(bytes.fromhex(sig), message) + return True + except InvalidSignature: + return False + except Exception: + logger.exception("[QQBot] Ed25519 verification error") + return False + + +__all__ = [ + "QQBotSecurityPolicy", + "verify_webhook_ed25519", + "check_dm_policy", + "check_group_policy", + "check_mention_required", + "AccessResult", + "RejectReason", + "BaseSecurityPolicy", + "DmPolicy", + "GroupPolicy", + "WildcardAllowlistMatcher", +] + + +async def check_dm_policy(user_id: str, config: dict) -> bool: + normalized_config = _normalize_config(config) + policy = QQBotSecurityPolicy(normalized_config) + clean_id = user_id.strip().removeprefix("qq:") + result = policy.check_dm_access(clean_id) + return result.allowed + + +def _normalize_config(config: dict) -> dict: + normalized = dict(config) + for key in ("allowFrom", "allow_from"): + if key in normalized: + normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key]) + for key in ("groupAllowFrom", "group_allow_from"): + if key in normalized: + normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key]) + return normalized + + +async def check_group_policy(chat_id: str, user_id: str, config: dict) -> bool: + normalized_config = _normalize_config(config) + policy = QQBotSecurityPolicy(normalized_config) + group_id = chat_id.replace("group_", "").replace("dm_", "") + result = policy.check_group_access(group_id) + if not result.allowed: + group_allow = config.get("group_allow_from", []) + if f"qq:{user_id}" in group_allow: + return True + groups_config = config.get("groups", {}) + chat_cfg = groups_config.get(chat_id, {}) + per_group_allow = chat_cfg.get("allow_from", []) + if f"qq:{user_id}" in per_group_allow: + return True + return result.allowed + + +async def check_mention_required( + chat_id: str, + msg, + config: dict, + bot_names: list[str] | None = None, +) -> bool: + policy = QQBotSecurityPolicy(config) + return policy.check_mention_required(chat_id, msg, bot_names) diff --git a/backend/package/yuxi/channels/adapters/qqbot/send.py b/backend/package/yuxi/channels/adapters/qqbot/send.py new file mode 100644 index 00000000..e6cec672 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/send.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +import aiohttp + +from yuxi.channels.exceptions import DeliveryFailedError +from yuxi.channels.models import DeliveryResult +from yuxi.utils.logging_config import logger + +from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX + + +@dataclass +class MessageSeqManager: + next_seq: int = 1 + _passive_seq: int = 0 + _active_seq: int = 1 + _lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + MAX_SEQ: int = 2**31 - 1 + + async def acquire_active(self) -> int: + async with self._lock: + if self._active_seq > self.MAX_SEQ: + self._active_seq = 1 + seq = self._active_seq + self._active_seq += 1 + return seq + + async def acquire_passive(self) -> int: + async with self._lock: + if self._passive_seq > self.MAX_SEQ: + self._passive_seq = 0 + seq = self._passive_seq + self._passive_seq -= 1 + return seq + + def reset(self) -> None: + self._active_seq = 1 + self._passive_seq = 0 + self.next_seq = 1 + + def snapshot(self) -> dict: + return { + "active_seq": self._active_seq, + "passive_seq": self._passive_seq, + } + + def restore(self, snapshot: dict) -> None: + self._active_seq = snapshot.get("active_seq", 1) + self._passive_seq = snapshot.get("passive_seq", 0) + self.next_seq = self._active_seq + + +async def send_with_retry( + http_client: aiohttp.ClientSession, + token: str, + api_base: str, + payload: dict, + chat_id: str, + config: dict | None = None, + token_refresh_cb: Callable[[], Awaitable[str]] | None = None, + on_sent: Callable[[DeliveryResult], Awaitable[None]] | None = None, +) -> DeliveryResult: + cfg = config or {} + max_retries = cfg.get("retry", {}).get("attempts", 3) + min_delay = cfg.get("retry", {}).get("min_delay_ms", 400) / 1000 + max_delay = cfg.get("retry", {}).get("max_delay_ms", 30000) / 1000 + + current_token = token + token_refreshed = False + + last_error = None + url = _resolve_send_url(api_base, chat_id) + + for attempt in range(max_retries): + try: + headers = { + "Authorization": f"QQBot {current_token}", + "Content-Type": "application/json", + } + async with http_client.post(url, json=payload, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + result = DeliveryResult( + success=True, + message_id=data.get("id") or data.get("message_id"), + ) + if on_sent: + await on_sent(result) + return result + elif resp.status == 429: + retry_after = int(resp.headers.get("Retry-After", "30")) + logger.warning(f"[QQBot] Rate limited, retry after {retry_after}s") + await asyncio.sleep(retry_after) + continue + elif resp.status in (401, 403): + if resp.status == 401 and token_refresh_cb and not token_refreshed: + logger.warning("[QQBot] 401 received, refreshing token and retrying") + try: + current_token = await token_refresh_cb() + token_refreshed = True + continue + except Exception as e: + logger.error(f"[QQBot] Token refresh after 401 failed: {e}") + error_body = await resp.text() + result = DeliveryResult(success=False, error=f"Auth failed ({resp.status}): {error_body}") + if on_sent: + await on_sent(result) + raise DeliveryFailedError(f"Auth failed ({resp.status}): {error_body}") + elif 400 <= resp.status < 500: + error_body = await resp.text() + result = DeliveryResult(success=False, error=f"Client error ({resp.status}): {error_body}") + if on_sent: + await on_sent(result) + raise DeliveryFailedError(f"Client error ({resp.status}): {error_body}") + else: + error_body = await resp.text() + last_error = DeliveryFailedError(f"Server error ({resp.status}): {error_body}") + + except DeliveryFailedError: + raise + except Exception as e: + last_error = DeliveryFailedError(str(e)) + + if attempt < max_retries - 1: + delay = min(min_delay * (2**attempt), max_delay) + await asyncio.sleep(delay) + + result = DeliveryResult( + success=False, + error=str(last_error) if last_error else "Max retries exceeded", + ) + if on_sent: + await on_sent(result) + return result + + +def _resolve_send_url(api_base: str, chat_id: str) -> str: + if chat_id.startswith(GROUP_CHAT_PREFIX): + group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "") + return f"{api_base}/v2/groups/{group_openid}/messages" + elif chat_id.startswith(DM_CHAT_PREFIX): + openid = chat_id.replace(DM_CHAT_PREFIX, "") + return f"{api_base}/v2/users/{openid}/messages" + elif chat_id: + return f"{api_base}/v2/channels/{chat_id}/messages" + return f"{api_base}/v2/users/@me/messages" + + +def render_reply_payload( + content: str, + msg_type: int = 0, + msg_id: str = "", + chunk_index: int = 0, + total_chunks: int = 1, +) -> dict: + payload: dict = { + "content": content, + "msg_type": msg_type, + } + if msg_id: + payload["msg_id"] = msg_id + if total_chunks > 1: + payload["chunk_index"] = chunk_index + payload["total_chunks"] = total_chunks + return payload diff --git a/backend/package/yuxi/channels/adapters/qqbot/session.py b/backend/package/yuxi/channels/adapters/qqbot/session.py new file mode 100644 index 00000000..e7088063 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/session.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from yuxi.channels.models import ChannelIdentity + +from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX + + +def resolve_thread_key(identity: ChannelIdentity) -> str: + chat_id = identity.channel_chat_id + if chat_id.startswith(GROUP_CHAT_PREFIX): + return f"qqbot:group:{chat_id}" + elif chat_id.startswith(DM_CHAT_PREFIX): + return f"qqbot:direct:{chat_id}" + return f"qqbot:guild:{chat_id}" + + +def resolve_agent_route( + identity: ChannelIdentity, + default_agent_id: str = "default", + groups_config: dict | None = None, + guild_channels_config: dict | None = None, +) -> str: + chat_id = identity.channel_chat_id + + if chat_id.startswith(GROUP_CHAT_PREFIX): + groups = groups_config or {} + chat_cfg = groups.get(chat_id, {}) + agent_id = chat_cfg.get("agent_id", default_agent_id) + return f"agent:{agent_id}:qqbot:group:{chat_id}" + elif chat_id.startswith(DM_CHAT_PREFIX): + return f"agent:{default_agent_id}:qqbot:direct:{chat_id}" + else: + guild_channels = guild_channels_config or {} + channel_cfg = guild_channels.get(chat_id, {}) + agent_id = channel_cfg.get("agent_id", default_agent_id) + return f"agent:{agent_id}:qqbot:guild:{chat_id}" + + +def resolve_chat_type(identity: ChannelIdentity) -> str: + chat_id = identity.channel_chat_id + if chat_id.startswith(GROUP_CHAT_PREFIX): + return "group" + elif chat_id.startswith(DM_CHAT_PREFIX): + return "direct" + return "guild_channel" diff --git a/backend/package/yuxi/channels/adapters/qqbot/session_store.py b/backend/package/yuxi/channels/adapters/qqbot/session_store.py new file mode 100644 index 00000000..e2c21503 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/session_store.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import logging +import os +import tempfile +import time +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +DEFAULT_SESSION_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_sessions") + + +@dataclass +class SessionRecord: + session_id: str = "" + last_seq: int | None = None + last_heartbeat: float = 0 + identify_at: float = 0 + shard_id: int = 0 + shard_count: int = 1 + metadata: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "session_id": self.session_id, + "last_seq": self.last_seq, + "last_heartbeat": self.last_heartbeat, + "identify_at": self.identify_at, + "shard_id": self.shard_id, + "shard_count": self.shard_count, + "metadata": self.metadata, + "saved_at": time.time(), + } + + @classmethod + def from_dict(cls, data: dict) -> SessionRecord: + return cls( + session_id=data.get("session_id", ""), + last_seq=data.get("last_seq"), + last_heartbeat=data.get("last_heartbeat", 0), + identify_at=data.get("identify_at", 0), + shard_id=data.get("shard_id", 0), + shard_count=data.get("shard_count", 1), + metadata=data.get("metadata", {}), + ) + + +class SessionStore: + def __init__(self, app_id: str, store_dir: str | None = None): + self._app_id = app_id + self._store_dir = store_dir or DEFAULT_SESSION_DIR + self._store_path = os.path.join(self._store_dir, f"{app_id}_session.json") + + def save(self, record: SessionRecord) -> bool: + try: + os.makedirs(self._store_dir, exist_ok=True) + data = record.to_dict() + + tmp_path = self._store_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp_path, self._store_path) + + logger.debug("SessionStore: saved session_id=%s seq=%s", record.session_id, record.last_seq) + return True + except OSError: + logger.exception("SessionStore: failed to save session") + return False + + def load(self) -> SessionRecord | None: + try: + if not os.path.exists(self._store_path): + return None + + with open(self._store_path, encoding="utf-8") as f: + data = json.load(f) + + record = SessionRecord.from_dict(data) + logger.info("SessionStore: loaded session_id=%s seq=%s", record.session_id, record.last_seq) + return record + except (OSError, json.JSONDecodeError, KeyError): + logger.exception("SessionStore: failed to load session") + return None + + def clear(self) -> bool: + try: + for suffix in ("", ".tmp"): + path = self._store_path + suffix + if os.path.exists(path): + os.remove(path) + logger.info("SessionStore: cleared session for app_id=%s", self._app_id[:6] + "...") + return True + except OSError: + logger.exception("SessionStore: failed to clear session") + return False + + @staticmethod + def cleanup_expired(store_dir: str | None = None, max_age_s: float = 86400 * 7) -> int: + directory = store_dir or DEFAULT_SESSION_DIR + if not os.path.exists(directory): + return 0 + + removed = 0 + now = time.time() + try: + for filename in os.listdir(directory): + if not filename.endswith("_session.json"): + continue + filepath = os.path.join(directory, filename) + try: + stat = os.stat(filepath) + if now - stat.st_mtime > max_age_s: + os.remove(filepath) + removed += 1 + except OSError: + pass + except OSError: + logger.exception("SessionStore: cleanup failed") + return removed diff --git a/backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py b/backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py new file mode 100644 index 00000000..658cc13a --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import time +from dataclasses import dataclass +from enum import Enum, auto + +logger = logging.getLogger(__name__) + + +class WizardStep(Enum): + WELCOME = auto() + APP_CREDENTIALS = auto() + INTENTS = auto() + PERMISSIONS = auto() + WEBHOOK_URL = auto() + TEST_CONNECT = auto() + CONFIRM = auto() + FINISH = auto() + + +@dataclass +class WizardState: + step: WizardStep = WizardStep.WELCOME + app_id: str = "" + app_secret: str = "" + bot_token: str = "" + intents: list[str] = None + webhook_url: str = "" + verify_result: dict | None = None + started_at: float = 0.0 + + def __post_init__(self): + if self.intents is None: + self.intents = [] + if self.started_at == 0.0: + self.started_at = time.time() + + def to_dict(self) -> dict: + return { + "step": self.step.name, + "app_id": self.app_id, + "intents": self.intents, + "webhook_url": self.webhook_url, + } + + @classmethod + def from_dict(cls, data: dict) -> WizardState: + return cls( + step=WizardStep[data.get("step", "WELCOME")], + app_id=data.get("app_id", ""), + app_secret="", + intents=data.get("intents", []), + webhook_url=data.get("webhook_url", ""), + ) + + +_WIZARD_WELCOME = """ +=== QQ Bot 安装向导 === + +该向导将帮助你完成 QQ Bot 的初始配置。 + +请按以下步骤操作: + +1. 前往 QQ 开放平台 (https://q.qq.com) 创建机器人应用 +2. 获取 App ID 和 App Secret +3. 配置机器人的 Intents(意图) +4. 配置 Webhook 地址 + +输入 /setup start 开始配置。 +""".strip() + +_SETUP_GUIDE = """ +配置说明: + +- App ID: 在 QQ 开放平台「应用管理」页面获取 +- App Secret: 在「开发设置」中生成,请注意保管 +- Intents: 机器人需要订阅的事件类型,至少需要: + - PUBLIC_GUILD_MESSAGES (群聊消息) + - DIRECT_MESSAGE (私信消息) + - GUILD_MEMBERS (频道成员) + - INTERACTION (交互事件) +- Webhook URL: 接收 QQ 推送事件的回调地址 +""".strip() + + +def validate_app_id(app_id: str) -> bool: + return bool(re.match(r"^\d{10,20}$", app_id)) + + +def validate_app_secret(secret: str) -> bool: + return len(secret) >= 32 + + +def validate_webhook_url(url: str) -> bool: + return bool(re.match(r"^https?://", url)) + + +_DEFAULT_INTENTS = [ + (0, "GUILDS", "频道事件"), + (1, "GUILD_MEMBERS", "频道成员事件"), + (12, "DIRECT_MESSAGE", "私信事件"), + (25, "INTERACTION", "交互事件"), + (26, "AUDIO_ACTION", "音频事件"), + (27, "PUBLIC_GUILD_MESSAGES", "公域消息事件"), + (28, "GROUP_AND_C2C_EVENT", "群聊和私聊事件"), +] + + +def get_default_intents() -> list[int]: + return [intent[0] for intent in _DEFAULT_INTENTS] + + +def get_intent_descriptions() -> dict[int, tuple[str, str]]: + return {intent[0]: (intent[1], intent[2]) for intent in _DEFAULT_INTENTS} + + +async def test_connection(app_id: str, app_secret: str) -> dict: + import aiohttp + + result = {"success": False, "error": "", "bot_info": {}} + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.sgroup.qq.com/oauth2/token", + json={"app_id": app_id, "app_secret": app_secret}, + ) as resp: + if resp.status != 200: + result["error"] = f"获取 Token 失败: HTTP {resp.status}" + return result + data = await resp.json() + token = data.get("access_token", "") + + if not token: + result["error"] = "Token 为空,请检查 App ID 和 App Secret" + return result + + async with session.get( + "https://api.sgroup.qq.com/users/@me", + headers={"Authorization": f"QQBot {token}"}, + ) as resp: + if resp.status == 200: + user_data = await resp.json() + result["bot_info"] = { + "id": user_data.get("id", ""), + "username": user_data.get("username", ""), + "avatar": user_data.get("avatar", ""), + } + + async with session.get( + "https://api.sgroup.qq.com/gateway/bot", + headers={"Authorization": f"QQBot {token}"}, + ) as resp: + if resp.status == 200: + gw_data = await resp.json() + result["gateway_url"] = gw_data.get("url", "") + + result["success"] = True + except Exception as e: + result["error"] = str(e) + + return result + + +async def setup_from_env() -> dict: + env_config = { + "app_id": os.environ.get("QQBOT_APP_ID", ""), + "app_secret": os.environ.get("QQBOT_CLIENT_SECRET", ""), + "bot_token": os.environ.get("QQBOT_BOT_TOKEN", ""), + "intents": os.environ.get("QQBOT_INTENTS", ""), + "webhook_url": os.environ.get("QQBOT_WEBHOOK_URL", ""), + } + + if not env_config["app_id"] or not env_config["app_secret"]: + return {"success": False, "error": "环境变量未配置。请设置 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET"} + + if not validate_app_id(env_config["app_id"]): + return {"success": False, "error": f"App ID 格式无效: {env_config['app_id']}"} + + if not validate_app_secret(env_config["app_secret"]): + return {"success": False, "error": "App Secret 长度不足(需要至少 32 字符)"} + + result = await test_connection(env_config["app_id"], env_config["app_secret"]) + return result + + +async def generate_config_yaml(app_id: str, app_secret: str, intents: list[int] | None = None) -> str: + intent_values = intents or get_default_intents() + + lines = [ + "# QQ Bot 配置文件", + f"qqbot_app_id: {app_id}", + "# qqbot_client_secret: 请通过环境变量 QQBOT_CLIENT_SECRET 设置", + f"qqbot_intents: {json.dumps(intent_values)}", + "", + "# 推荐通过环境变量配置敏感信息", + "# export QQBOT_APP_ID={app_id}", + "# export QQBOT_CLIENT_SECRET=your_secret_here", + "", + "# Intents 说明:", + ] + + for intent_id, (name, desc) in get_intent_descriptions().items(): + mark = "✓" if intent_id in intent_values else "✗" + lines.append(f"# {mark} {intent_id}: {name} ({desc})") + + return "\n".join(lines) diff --git a/backend/package/yuxi/channels/adapters/qqbot/streaming.py b/backend/package/yuxi/channels/adapters/qqbot/streaming.py new file mode 100644 index 00000000..a5fb3feb --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/streaming.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncGenerator, Callable +from dataclasses import dataclass, field +from typing import Any + +from yuxi.channels.adapters.qqbot.c2c_stream import ( + C2CStreamingController, + FlushController, + FlushStrategy, +) + +logger = logging.getLogger(__name__) + + +class ParagraphChunker: + def __init__(self, flush_per_paragraph: bool = True, min_chunk_size: int = 10): + self.flush_per_paragraph = flush_per_paragraph + self.min_chunk_size = min_chunk_size + self._buffer: list[str] = [] + + def feed(self, text: str) -> list[str]: + results: list[str] = [] + self._buffer.append(text) + + if not self.flush_per_paragraph: + return results + + accumulated = "".join(self._buffer) + if "\n\n" in accumulated: + paragraphs = accumulated.split("\n\n") + if len(paragraphs) > 1: + for para in paragraphs[:-1]: + if len(para.strip()) >= self.min_chunk_size: + results.append(para + "\n\n") + self._buffer = [paragraphs[-1]] + + return results + + def flush(self) -> str: + if not self._buffer: + return "" + result = "".join(self._buffer) + self._buffer = [] + return result + + +async def stream_content( + content_generator: AsyncGenerator[str, None], + c2c_ctrl: C2CStreamingController | None, + chunker: ParagraphChunker | None = None, +) -> AsyncGenerator[str, None]: + if chunker is None: + chunker = ParagraphChunker() + + try: + async for chunk in content_generator: + if not chunk: + continue + + paragraphs = chunker.feed(chunk) + for para in paragraphs: + yield para + + if c2c_ctrl is not None: + c2c_batches = c2c_ctrl.flush_controller.feed(chunk) + for batch in c2c_batches: + pass + + await asyncio.sleep(0) + + remaining = chunker.flush() + if remaining: + yield remaining + + except asyncio.CancelledError: + logger.debug("Streaming cancelled") + remaining = chunker.flush() + if remaining: + yield remaining + except Exception: + logger.exception("Streaming error") + remaining = chunker.flush() + if remaining: + yield remaining + + +__all__ = [ + "ParagraphChunker", + "stream_content", + "send_blocks_stream", + "C2CStreamingController", + "FlushController", + "FlushStrategy", + "MediaAwareStreamer", + "StreamMediaContext", +] + + +@dataclass +class StreamMediaContext: + stream_active: bool = True + media_queue: list[tuple[str, str]] = field(default_factory=list) + interrupt_count: int = 0 + + def interrupt(self) -> None: + self.stream_active = False + self.interrupt_count += 1 + + def restore(self) -> None: + self.stream_active = True + + +class MediaAwareStreamer: + def __init__( + self, + send_text_fn: Callable[..., Any], + send_media_fn: Callable[..., Any], + chat_id: str = "", + max_interrupts: int = 10, + ): + self._send_text_fn = send_text_fn + self._send_media_fn = send_media_fn + self._chat_id = chat_id + self._max_interrupts = max_interrupts + self._context = StreamMediaContext() + from .media_tags import parse_media_tags + + self._parse_media_tags = parse_media_tags + + @property + def context(self) -> StreamMediaContext: + return self._context + + async def feed(self, chunk: str) -> None: + from .media_tags import has_media_tags + + if not has_media_tags(chunk): + if self._context.stream_active: + await self._send_text_fn(self._chat_id, chunk) + return + + if self._context.interrupt_count >= self._max_interrupts: + clean = self._parse_media_tags(chunk).text + if clean: + await self._send_text_fn(self._chat_id, clean) + return + + parsed = self._parse_media_tags(chunk) + + if parsed.text: + self._context.interrupt() + await self._send_text_fn(self._chat_id, parsed.text) + self._context.restore() + + for item in parsed.media_items: + try: + await self._send_media_fn( + self._chat_id, + media_type=item.media_type, + reference=item.reference, + is_url=item.is_url, + ) + except Exception: + logger.exception("MediaAwareStreamer: failed to send media %s", item) + + async def flush(self) -> None: + pass + + +async def stream_with_media_handling( + content_generator: AsyncGenerator[str, None], + send_text_fn: Callable[..., Any], + send_media_fn: Callable[..., Any], + chat_id: str = "", + c2c_ctrl: C2CStreamingController | None = None, + chunker: ParagraphChunker | None = None, + max_interrupts: int = 10, +) -> AsyncGenerator[str, None]: + if chunker is None: + chunker = ParagraphChunker() + + media_streamer = MediaAwareStreamer( + send_text_fn=send_text_fn, + send_media_fn=send_media_fn, + chat_id=chat_id, + max_interrupts=max_interrupts, + ) + + try: + async for chunk in content_generator: + if not chunk: + continue + + await media_streamer.feed(chunk) + + paragraphs = chunker.feed(chunk) + for para in paragraphs: + yield para + + if c2c_ctrl is not None: + c2c_batches = c2c_ctrl.flush_controller.feed(chunk) + for _batch in c2c_batches: + pass + + await asyncio.sleep(0) + + remaining = chunker.flush() + if remaining: + yield remaining + + except asyncio.CancelledError: + logger.debug("Streaming cancelled") + remaining = chunker.flush() + if remaining: + yield remaining + except Exception: + logger.exception("Streaming error") + remaining = chunker.flush() + if remaining: + yield remaining + + +async def send_blocks_stream( + chat_id: str, + text: str, + send_fn, + channel_id: str = "qqbot", + channel_type=None, + chunk_size: int = 1, + parallelism: int = 1, +) -> None: + from yuxi.channels.models import ( + ChannelIdentity, + ChannelResponse, + ChannelType, + DeliveryResult, + ) + + ct = channel_type or ChannelType.QQ_BOT + identity = ChannelIdentity( + channel_id=channel_id, + channel_type=ct, + channel_user_id="", + channel_chat_id=chat_id, + ) + + paragraphs = text.split("\n\n") + for para in paragraphs: + if not para.strip(): + continue + response = ChannelResponse(identity=identity, content=para) + try: + result = await send_fn(response) + if isinstance(result, DeliveryResult) and not result.success: + logger.warning("send_blocks_stream: failed to send para: %s", result.error) + except Exception: + logger.exception("send_blocks_stream: error sending paragraph") diff --git a/backend/package/yuxi/channels/adapters/qqbot/token.py b/backend/package/yuxi/channels/adapters/qqbot/token.py new file mode 100644 index 00000000..a84d40c9 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/token.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import asyncio +import time + +import aiohttp + +from yuxi.channels.exceptions import ChannelAuthenticationError +from yuxi.utils.logging_config import logger + + +class QQBotTokenManager: + def __init__( + self, + app_id: str, + app_secret: str, + sandbox: bool = False, + http_client: aiohttp.ClientSession | None = None, + ): + self.app_id = app_id + self.app_secret = app_secret + self.sandbox = sandbox + self._http_client = http_client + self._access_token: str | None = None + self._expires_at: float | None = None + self._token_lock = asyncio.Lock() + self._refresh_in_progress: asyncio.Event | None = None + self._refresh_task: asyncio.Task | None = None + self._refresh_interval: float = 60.0 + + @property + def api_base(self) -> str: + if self.sandbox: + return "https://sandbox.api.sgroup.qq.com" + return "https://api.sgroup.qq.com" + + async def get_token(self) -> str: + async with self._token_lock: + if self._is_expired(): + await self._do_refresh() + return self._access_token + + async def force_refresh(self) -> str: + async with self._token_lock: + await self._do_refresh() + return self._access_token + + async def _do_refresh(self) -> None: + if self._refresh_in_progress is not None: + await self._refresh_in_progress.wait() + return + + self._refresh_in_progress = asyncio.Event() + try: + await self._refresh() + self._refresh_in_progress.set() + except Exception: + self._refresh_in_progress.set() + raise + finally: + self._refresh_in_progress = None + + async def _refresh(self) -> None: + client = self._http_client or aiohttp.ClientSession() + try: + async with client.post( + f"{self.api_base}/oauth2/token", + json={ + "app_id": self.app_id, + "app_secret": self.app_secret, + }, + ) as resp: + if resp.status != 200: + raise ChannelAuthenticationError(f"Token refresh failed: HTTP {resp.status}") + data = await resp.json() + self._access_token = data["access_token"] + expires_in = data.get("expires_in", 7200) + self._expires_at = time.monotonic() + expires_in + logger.info(f"[QQBot] Token refreshed, expires in {expires_in}s (app_id={self.app_id[:6]}...)") + finally: + if not self._http_client: + await client.close() + + def start_background_refresh(self) -> None: + if self._refresh_task is not None and not self._refresh_task.done(): + return + self._refresh_task = asyncio.create_task(self._background_refresh_loop()) + logger.debug(f"[QQBot] Background token refresh started (interval={self._refresh_interval}s)") + + def stop_background_refresh(self) -> None: + if self._refresh_task and not self._refresh_task.done(): + self._refresh_task.cancel() + self._refresh_task = None + logger.debug("[QQBot] Background token refresh stopped") + + async def _background_refresh_loop(self) -> None: + while True: + try: + await asyncio.sleep(self._refresh_interval) + async with self._token_lock: + if not self._is_expired(): + continue + try: + await self._do_refresh() + except Exception as e: + logger.warning(f"[QQBot] Background token refresh failed (will retry): {e}") + except asyncio.CancelledError: + logger.debug("[QQBot] Background token refresh cancelled") + break + except Exception as e: + logger.error(f"[QQBot] Background token refresh loop error: {e}") + await asyncio.sleep(5) + + def _is_expired(self) -> bool: + if self._access_token is None or self._expires_at is None: + return True + return time.monotonic() > self._expires_at - 300 diff --git a/backend/package/yuxi/channels/adapters/qqbot/tools/__init__.py b/backend/package/yuxi/channels/adapters/qqbot/tools/__init__.py new file mode 100644 index 00000000..ad4ef89a --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/tools/__init__.py @@ -0,0 +1,4 @@ +from .channel import qqbot_channel_api +from .remind import qqbot_remind + +__all__ = ["qqbot_channel_api", "qqbot_remind"] diff --git a/backend/package/yuxi/channels/adapters/qqbot/tools/channel.py b/backend/package/yuxi/channels/adapters/qqbot/tools/channel.py new file mode 100644 index 00000000..c57fbc54 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/tools/channel.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import logging + +import aiohttp +from pydantic import BaseModel, Field + +from yuxi.agents.toolkits.registry import tool + +logger = logging.getLogger(__name__) + + +class ChannelApiInput(BaseModel): + action: str = Field( + description="操作类型: list_channels(子频道列表), channel_info(频道信息), " + "create_channel(创建子频道), update_channel(修改子频道), delete_channel(删除子频道), " + "channel_permissions(权限信息), members(成员列表), announcements(公告列表)", + ) + guild_id: str = Field(description="频道 ID") + channel_id: str = Field(default="", description="子频道 ID(操作特定子频道时需要)") + params: str = Field( + default="{}", + description="JSON 格式的额外参数,如创建/修改频道时的 name/type/position 等", + ) + + +_CHANNEL_API_GUIDE = """ +使用前请确保 QQ Bot 已配置 app_id 和 app_secret。 + +该工具允许 Agent 通过 QQ 开放平台 API 管理频道,包括: +- 查看子频道列表 +- 创建/修改/删除子频道 +- 查看频道成员 +- 管理公告 + +所有操作自动处理 Token 鉴权,无需手动管理凭证。 +""".strip() + + +@tool( + category="qqbot", + tags=["QQ机器人", "频道管理"], + display_name="QQ 频道管理", + config_guide=_CHANNEL_API_GUIDE, +) +async def qqbot_channel_api( + action: str, + guild_id: str, + channel_id: str = "", + params: str = "{}", +) -> str: + """QQ 频道管理 HTTP 代理工具,用于查询和管理 QQ 频道。 + + 支持操作类型: + - list_channels: 获取子频道列表 + - channel_info: 获取子频道详情 + - create_channel: 创建子频道(需要 params 中包含 name, type, position 等) + - update_channel: 修改子频道(需要 params 中包含要修改的字段) + - delete_channel: 删除子频道 + - channel_permissions: 获取子频道权限 + - members: 获取频道成员列表 + - announcements: 获取公告列表 + + Args: + action: 操作类型 + guild_id: 频道 ID + channel_id: 子频道 ID(操作特定子频道时需要) + params: JSON 格式的额外参数字符串 + + Returns: + 操作结果描述 + """ + import json + import os + + app_id = os.environ.get("QQBOT_APP_ID", "") + app_secret = os.environ.get("QQBOT_CLIENT_SECRET", "") + + if not app_id or not app_secret: + return "错误:未配置 QQ Bot 凭证。请设置 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET 环境变量。" + + parsed_params = {} + try: + parsed_params = json.loads(params) + except json.JSONDecodeError: + return "错误:params 参数不是有效的 JSON 格式。" + + token = await _get_access_token(app_id, app_secret) + if not token: + return "错误:无法获取 Access Token,请检查 app_id 和 app_secret 是否正确。" + + api_base = "https://api.sgroup.qq.com" + + try: + result = await _execute_channel_action(token, api_base, action, guild_id, channel_id, parsed_params) + return result + except Exception as e: + logger.exception("qqbot_channel_api error: action=%s guild_id=%s", action, guild_id) + return f"频道 API 调用失败: {e}" + + +async def _get_access_token(app_id: str, app_secret: str) -> str: + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.sgroup.qq.com/oauth2/token", + json={"app_id": app_id, "app_secret": app_secret}, + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("access_token", "") + logger.warning("Token API returned status %d", resp.status) + return "" + except Exception: + logger.exception("Failed to get access token") + return "" + + +async def _execute_channel_action( + token: str, + api_base: str, + action: str, + guild_id: str, + channel_id: str, + params: dict, +) -> str: + import json + + headers = { + "Authorization": f"QQBot {token}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + if action == "list_channels": + async with session.get(f"{api_base}/guilds/{guild_id}/channels", headers=headers) as resp: + if resp.status != 200: + return f"获取子频道列表失败: HTTP {resp.status}" + data = await resp.json() + channels = data if isinstance(data, list) else data.get("channels", data) + return json.dumps(channels, ensure_ascii=False, indent=2) + + elif action == "channel_info": + if not channel_id: + return "错误:查询子频道信息需要提供 channel_id" + async with session.get(f"{api_base}/channels/{channel_id}", headers=headers) as resp: + if resp.status != 200: + return f"获取子频道信息失败: HTTP {resp.status}" + data = await resp.json() + return json.dumps(data, ensure_ascii=False, indent=2) + + elif action == "create_channel": + body = { + "name": params.get("name", "新频道"), + "type": params.get("type", 0), + "sub_type": params.get("sub_type", 0), + "position": params.get("position", 0), + "parent_id": params.get("parent_id", "0"), + "private_type": params.get("private_type", 0), + } + async with session.post( + f"{api_base}/guilds/{guild_id}/channels", + headers=headers, + json=body, + ) as resp: + if resp.status not in (200, 201): + return f"创建子频道失败: HTTP {resp.status}" + data = await resp.json() + return f"子频道创建成功: {json.dumps(data, ensure_ascii=False)}" + + elif action == "update_channel": + if not channel_id: + return "错误:修改子频道需要提供 channel_id" + body = {k: v for k, v in params.items() if v is not None} + async with session.patch( + f"{api_base}/channels/{channel_id}", + headers=headers, + json=body, + ) as resp: + if resp.status != 200: + return f"修改子频道失败: HTTP {resp.status}" + data = await resp.json() + return f"子频道修改成功: {json.dumps(data, ensure_ascii=False)}" + + elif action == "delete_channel": + if not channel_id: + return "错误:删除子频道需要提供 channel_id" + async with session.delete(f"{api_base}/channels/{channel_id}", headers=headers) as resp: + if resp.status != 200: + return f"删除子频道失败: HTTP {resp.status}" + return f"子频道 {channel_id} 已删除" + + elif action == "members": + limit = params.get("limit", 100) + after = params.get("after", "0") + url = f"{api_base}/guilds/{guild_id}/members?limit={limit}&after={after}" + async with session.get(url, headers=headers) as resp: + if resp.status != 200: + return f"获取成员列表失败: HTTP {resp.status}" + data = await resp.json() + return json.dumps(data, ensure_ascii=False, indent=2) + + elif action == "announcements": + async with session.get(f"{api_base}/guilds/{guild_id}/announces", headers=headers) as resp: + if resp.status != 200: + return f"获取公告列表失败: HTTP {resp.status}" + data = await resp.json() + return json.dumps(data, ensure_ascii=False, indent=2) + + else: + return f"不支持的操作类型: {action}。支持的操作: list_channels, channel_info, create_channel, update_channel, delete_channel, channel_permissions, members, announcements" diff --git a/backend/package/yuxi/channels/adapters/qqbot/tools/remind.py b/backend/package/yuxi/channels/adapters/qqbot/tools/remind.py new file mode 100644 index 00000000..60d534a1 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/tools/remind.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from dataclasses import dataclass, field +from datetime import datetime + +from pydantic import BaseModel, Field + +from yuxi.agents.toolkits.registry import tool + +logger = logging.getLogger(__name__) + +DEFAULT_REMIND_DIR = os.path.join(os.path.dirname(__file__), "..", "remind_data") + +_CRON_PATTERN = re.compile( + r"^(\*|[0-5]?\d)\s+(\*|[01]?\d|2[0-3])\s+(\*|[012]?\d|3[01])\s+(\*|1[012]?|[1-9])\s+(\*|[0-7])$" +) + +_RELATIVE_TIME_PATTERN = re.compile(r"(\d+)\s*(秒|分钟|小时|天|周|s|min|h|d|w)", re.IGNORECASE) + + +class RemindInput(BaseModel): + action: str = Field( + description="操作类型: add(添加提醒), list(列出提醒), remove(删除提醒)", + ) + message: str = Field(default="", description="提醒内容(add 操作时需要)") + time_spec: str = Field( + default="", + description="时间规格,支持: 相对时间(如 '30分钟', '1小时', '2天')、" + "cron 表达式(如 '* 9 * * *' 每天9点)、" + "绝对时间 ISO格式(如 '2026-05-12T14:30:00')", + ) + remind_id: str = Field(default="", description="提醒 ID(remove 操作时需要)") + chat_id: str = Field(default="", description="目标聊天 ID(发送提醒的目标位置)") + + +_REMIND_TOOL_GUIDE = """ +使用前请确保 QQ Bot 已配置 app_id 和 app_secret。 + +该工具允许 Agent 创建和管理 QQ Bot 的定时提醒,支持: +- 创建提醒(相对时间 / cron / 绝对时间) +- 列出所有活跃提醒 +- 删除指定提醒 + +提醒到期时会通过 QQ Bot 发送消息到指定的聊天目标。 +""".strip() + + +@dataclass +class Reminder: + remind_id: str + message: str + trigger_at: float + chat_id: str + created_at: float = field(default_factory=time.time) + cron_expr: str = "" + + def to_dict(self) -> dict: + return { + "remind_id": self.remind_id, + "message": self.message, + "trigger_at": self.trigger_at, + "chat_id": self.chat_id, + "created_at": self.created_at, + "cron_expr": self.cron_expr, + } + + @classmethod + def from_dict(cls, data: dict) -> Reminder: + return cls( + remind_id=data["remind_id"], + message=data["message"], + trigger_at=data["trigger_at"], + chat_id=data.get("chat_id", ""), + created_at=data.get("created_at", time.time()), + cron_expr=data.get("cron_expr", ""), + ) + + +class RemindStore: + def __init__(self, store_dir: str | None = None): + self._store_dir = store_dir or DEFAULT_REMIND_DIR + self._store_path = os.path.join(self._store_dir, "reminders.json") + self._reminders: dict[str, Reminder] = {} + self._lock = asyncio.Lock() + self._load() + + def _load(self) -> None: + try: + if os.path.exists(self._store_path): + with open(self._store_path, encoding="utf-8") as f: + data = json.load(f) + for item in data: + r = Reminder.from_dict(item) + self._reminders[r.remind_id] = r + logger.info("RemindStore: loaded %d reminders", len(self._reminders)) + except (OSError, json.JSONDecodeError): + logger.exception("RemindStore: failed to load") + + async def _save(self) -> None: + try: + os.makedirs(self._store_dir, exist_ok=True) + data = [r.to_dict() for r in self._reminders.values()] + tmp_path = self._store_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp_path, self._store_path) + except OSError: + logger.exception("RemindStore: failed to save") + + async def add(self, message: str, trigger_at: float, chat_id: str, cron_expr: str = "") -> Reminder: + async with self._lock: + import uuid + + r = Reminder( + remind_id=str(uuid.uuid4())[:8], + message=message, + trigger_at=trigger_at, + chat_id=chat_id, + cron_expr=cron_expr, + ) + self._reminders[r.remind_id] = r + await self._save() + return r + + async def list_all(self) -> list[Reminder]: + async with self._lock: + self._cleanup_expired() + return list(self._reminders.values()) + + async def remove(self, remind_id: str) -> bool: + async with self._lock: + if remind_id in self._reminders: + del self._reminders[remind_id] + await self._save() + return True + return False + + def _cleanup_expired(self) -> None: + now = time.time() + expired = [rid for rid, r in self._reminders.items() if r.trigger_at < now - 3600] + for rid in expired: + del self._reminders[rid] + + async def get_due_reminders(self) -> list[Reminder]: + async with self._lock: + now = time.time() + due = [r for r in self._reminders.values() if r.trigger_at <= now] + return due + + +_remind_store: RemindStore | None = None + + +def _get_store() -> RemindStore: + global _remind_store + if _remind_store is None: + _remind_store = RemindStore() + return _remind_store + + +def parse_time_spec(time_spec: str) -> tuple[float, str]: + """解析时间规格,返回 (trigger_at, cron_expr)""" + now = time.time() + + if not time_spec: + return now + 3600, "" + + relative_match = _RELATIVE_TIME_PATTERN.fullmatch(time_spec.strip()) + if relative_match: + value = int(relative_match.group(1)) + unit = relative_match.group(2).lower() + multipliers = { + "秒": 1, + "s": 1, + "分钟": 60, + "min": 60, + "小时": 3600, + "h": 3600, + "天": 86400, + "d": 86400, + "周": 604800, + "w": 604800, + } + return now + value * multipliers.get(unit, 60), "" + + if _CRON_PATTERN.match(time_spec.strip()): + return now + 60, time_spec.strip() + + try: + dt = datetime.fromisoformat(time_spec) + return dt.timestamp(), "" + except (ValueError, TypeError): + pass + + try: + seconds = int(time_spec) + return now + seconds, "" + except ValueError: + pass + + return now + 3600, "" + + +@tool( + category="qqbot", + tags=["QQ机器人", "定时提醒"], + display_name="QQ 定时提醒", + config_guide=_REMIND_TOOL_GUIDE, +) +async def qqbot_remind( + action: str, + message: str = "", + time_spec: str = "", + remind_id: str = "", + chat_id: str = "", +) -> str: + """QQ Bot 定时提醒工具,用于创建和管理定时提醒消息。 + + 支持操作类型: + - add: 添加提醒(需要 message 和 time_spec) + - list: 列出所有活跃提醒 + - remove: 删除指定提醒(需要 remind_id) + + 时间规格支持: + - 相对时间: '30分钟', '1小时', '2天', '1周' + - cron 表达式: '* 9 * * *' (每天9点) + - 绝对时间 ISO: '2026-05-12T14:30:00' + + Args: + action: 操作类型 (add/list/remove) + message: 提醒内容 + time_spec: 时间规格 + remind_id: 提醒 ID(删除时需要) + chat_id: 目标聊天 ID + + Returns: + 操作结果描述 + """ + store = _get_store() + + if action == "add": + if not message: + return "错误:添加提醒需要提供 message(提醒内容)。" + + trigger_at, cron_expr = parse_time_spec(time_spec) + if not chat_id: + chat_id = os.environ.get("QQBOT_DEFAULT_CHAT_ID", "") + + r = await store.add(message, trigger_at, chat_id, cron_expr) + + dt = datetime.fromtimestamp(trigger_at) + result = f"提醒已创建 (ID: {r.remind_id})\n内容: {message}\n触发时间: {dt.strftime('%Y-%m-%d %H:%M:%S')}" + if cron_expr: + result += f"\nCron: {cron_expr}" + if chat_id: + result += f"\n目标: {chat_id}" + return result + + elif action == "list": + reminders = await store.list_all() + if not reminders: + return "当前没有活跃的提醒。" + + lines = ["当前活跃的提醒:"] + for r in reminders: + dt = datetime.fromtimestamp(r.trigger_at) + status = "已过期" if r.trigger_at < time.time() else "待触发" + lines.append(f" [{r.remind_id}] {r.message} - {dt.strftime('%Y-%m-%d %H:%M:%S')} ({status})") + return "\n".join(lines) + + elif action == "remove": + if not remind_id: + return "错误:删除提醒需要提供 remind_id。" + + removed = await store.remove(remind_id) + if removed: + return f"提醒 {remind_id} 已删除。" + return f"未找到提醒 {remind_id}。" + + else: + return "不支持的操作类型。支持的操作: add, list, remove" diff --git a/backend/package/yuxi/channels/adapters/qqbot/voice_send.py b/backend/package/yuxi/channels/adapters/qqbot/voice_send.py new file mode 100644 index 00000000..e5884abc --- /dev/null +++ b/backend/package/yuxi/channels/adapters/qqbot/voice_send.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import aiohttp + +from yuxi.channels.models import DeliveryResult +from yuxi.utils.logging_config import logger + +from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX +from .media_upload import ( + FILE_TYPE_VOICE, + build_media_payload, + upload_media, + validate_media_size, +) + + +async def send_voice( + voice_data: bytes, + chat_id: str, + token: str, + http_client: aiohttp.ClientSession, + api_base: str, + filename: str = "voice.mp3", + max_size_mb: int = 100, +) -> DeliveryResult: + validate_media_size(voice_data, max_size_mb=max_size_mb, label="voice") + + group_openid = None + if chat_id.startswith(GROUP_CHAT_PREFIX): + group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "") + + try: + file_id = await upload_media( + voice_data, + token, + http_client=http_client, + filename=filename, + file_type=FILE_TYPE_VOICE, + group_openid=group_openid, + ) + except Exception as e: + return DeliveryResult(success=False, error=f"Voice upload failed: {e}") + + payload = build_media_payload(chat_id, file_id, msg_type=7) + + url = _resolve_media_send_url(api_base, chat_id) + headers = { + "Authorization": f"QQBot {token}", + "Content-Type": "application/json", + } + + try: + async with http_client.post(url, json=payload, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + return DeliveryResult( + success=True, + message_id=data.get("id") or data.get("message_id"), + ) + return DeliveryResult(success=False, error=f"Voice send failed: HTTP {resp.status}") + except Exception as e: + logger.error(f"[QQBot] Voice send error: {e}") + return DeliveryResult(success=False, error=str(e)) + + +async def send_video( + video_data: bytes, + chat_id: str, + token: str, + http_client: aiohttp.ClientSession, + api_base: str, + filename: str = "video.mp4", + max_size_mb: int = 100, +) -> DeliveryResult: + from .media_upload import FILE_TYPE_VIDEO + + validate_media_size(video_data, max_size_mb=max_size_mb, label="video") + + group_openid = None + if chat_id.startswith(GROUP_CHAT_PREFIX): + group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "") + + try: + file_id = await upload_media( + video_data, + token, + http_client=http_client, + filename=filename, + file_type=FILE_TYPE_VIDEO, + group_openid=group_openid, + ) + except Exception as e: + return DeliveryResult(success=False, error=f"Video upload failed: {e}") + + payload = build_media_payload(chat_id, file_id, msg_type=7) + + url = _resolve_media_send_url(api_base, chat_id) + headers = { + "Authorization": f"QQBot {token}", + "Content-Type": "application/json", + } + + try: + async with http_client.post(url, json=payload, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + return DeliveryResult( + success=True, + message_id=data.get("id") or data.get("message_id"), + ) + return DeliveryResult(success=False, error=f"Video send failed: HTTP {resp.status}") + except Exception as e: + logger.error(f"[QQBot] Video send error: {e}") + return DeliveryResult(success=False, error=str(e)) + + +async def send_file( + file_data: bytes, + chat_id: str, + token: str, + http_client: aiohttp.ClientSession, + api_base: str, + filename: str = "file.bin", + max_size_mb: int = 100, +) -> DeliveryResult: + from .media_upload import FILE_TYPE_FILE + + validate_media_size(file_data, max_size_mb=max_size_mb, label="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( + file_data, + token, + http_client=http_client, + filename=filename, + file_type=FILE_TYPE_FILE, + group_openid=group_openid, + ) + except Exception as e: + return DeliveryResult(success=False, error=f"File upload failed: {e}") + + payload = build_media_payload(chat_id, file_id, msg_type=7) + + url = _resolve_media_send_url(api_base, chat_id) + headers = { + "Authorization": f"QQBot {token}", + "Content-Type": "application/json", + } + + try: + async with http_client.post(url, json=payload, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + return DeliveryResult( + success=True, + message_id=data.get("id") or data.get("message_id"), + ) + return DeliveryResult(success=False, error=f"File send failed: HTTP {resp.status}") + except Exception as e: + logger.error(f"[QQBot] File send error: {e}") + return DeliveryResult(success=False, error=str(e)) + + +def _resolve_media_send_url(api_base: str, chat_id: str) -> str: + if chat_id.startswith(GROUP_CHAT_PREFIX): + group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "") + return f"{api_base}/v2/groups/{group_openid}/messages" + elif chat_id.startswith(DM_CHAT_PREFIX): + openid = chat_id.replace(DM_CHAT_PREFIX, "") + return f"{api_base}/v2/users/{openid}/messages" + elif chat_id: + return f"{api_base}/v2/channels/{chat_id}/messages" + return f"{api_base}/v2/users/@me/messages"