""" QQ Bot platform adapter using the Official QQ Bot API (v2). Connects to the QQ Bot WebSocket Gateway for inbound events and uses the REST API (``api.sgroup.qq.com``) for outbound messages and media uploads. Configuration in config.yaml: platforms: qq: enabled: true extra: app_id: "your-app-id" # or QQ_APP_ID env var client_secret: "your-secret" # or QQ_CLIENT_SECRET env var markdown_support: true # enable QQ markdown (msg_type 2) dm_policy: "pairing" # open | allowlist | disabled | pairing allow_from: ["openid_1"] group_policy: "pairing" # open | allowlist | disabled | pairing group_allow_from: ["group_openid_1"] stt: # Voice-to-text config (optional) provider: "zai" # zai (GLM-ASR), openai (Whisper), etc. baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" apiKey: "your-stt-api-key" # or set QQ_STT_API_KEY env var model: "glm-asr" # glm-asr, whisper-1, etc. Voice transcription priority: 1. QQ's built-in ``asr_refer_text`` (Tencent ASR — free, always tried first) 2. Configured STT provider via ``stt`` config or ``QQ_STT_*`` env vars Reference: https://bot.q.qq.com/wiki/develop/api-v2/ """ from __future__ import annotations import asyncio import json import logging import os import re import time import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse try: import aiohttp AIOHTTP_AVAILABLE = True except ImportError: AIOHTTP_AVAILABLE = False aiohttp = None # type: ignore[assignment] try: import httpx HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult, _ssrf_redirect_guard, cache_document_from_bytes, cache_image_from_bytes, ) from gateway.platforms.helpers import strip_markdown from gateway.platforms.media_cache import ext_for_mime logger = logging.getLogger(__name__) class QQCloseError(Exception): """Raised when the QQ WebSocket closes; carries code + reason for the reconnect loop.""" def __init__(self, code, reason=""): self.code = int(code) if code else None self.reason = str(reason) if reason else "" super().__init__(f"WebSocket closed (code={self.code}, reason={self.reason})") from gateway.platforms.qqbot.constants import ( API_BASE, TOKEN_URL, GATEWAY_URL_PATH, DEFAULT_API_TIMEOUT, FILE_UPLOAD_TIMEOUT, CONNECT_TIMEOUT_SECONDS, RECONNECT_BACKOFF, MAX_RECONNECT_ATTEMPTS, RATE_LIMIT_DELAY, QUICK_DISCONNECT_THRESHOLD, MAX_QUICK_DISCONNECT_COUNT, MAX_MESSAGE_LENGTH, DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE, MSG_TYPE_TEXT, MSG_TYPE_MARKDOWN, MSG_TYPE_MEDIA, MSG_TYPE_INPUT_NOTIFY, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO, MEDIA_TYPE_VOICE, MEDIA_TYPE_FILE, ) from gateway.platforms.qqbot.utils import coerce_list as _coerce_list, build_user_agent from gateway.platforms.qqbot.chunked_upload import ( ChunkedUploader, UploadDailyLimitExceededError, UploadFileTooLargeError, ) from gateway.platforms.qqbot.keyboards import ( ApprovalRequest, InlineKeyboard, InteractionEvent, build_approval_keyboard, build_update_prompt_keyboard, parse_approval_button_data, parse_interaction_event, parse_update_prompt_button_data, ) from gateway.platforms._shared import get_scoped_secret as _resolve_qq_secret def check_qq_requirements() -> bool: """Check if QQ runtime dependencies are available.""" return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE _VOICE_EXTENSIONS = (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".speex", ".flac") _STT_PROVIDER_BASE_URLS = { "zai": "https://open.bigmodel.cn/api/coding/paas/v4", "openai": "https://api.openai.com/v1", "glm": "https://open.bigmodel.cn/api/coding/paas/v4", } _AUDIO_URL_EXTENSIONS = {".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} class QQAdapter(BasePlatformAdapter): """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API.""" # QQ Bot API does not support editing sent messages. SUPPORTS_MESSAGE_EDITING = False MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH _TYPING_INPUT_SECONDS = 60 # input_notify duration reported to QQ _TYPING_DEBOUNCE_SECONDS = 50 # refresh before it expires # WS close codes that are unrecoverable → stop reconnecting. _FATAL_CLOSE_CODES = { 4001: "invalid opcode", 4002: "invalid payload", 4010: "invalid shard", 4011: "sharding required", 4012: "invalid API version", 4013: "invalid intent", 4014: "intent not authorized", 4914: "offline/sandbox-only", 4915: "banned", } # WS close codes that invalidate the session → clear it and re-identify on # the next Hello. 4009 (connection timeout) is deliberately absent: it is # resumable per the QQ protocol and must keep session state. _SESSION_INVALID_CLOSE_CODES = {4006, 4007} | set(range(4900, 4914)) @property def _log_tag(self) -> str: """Log prefix including app_id for multi-instance disambiguation.""" app_id = getattr(self, "_app_id", None) return f"QQBot:{app_id}" if app_id else "QQBot" def _fail_pending(self, reason: str) -> None: """Fail all pending response futures.""" for fut in self._pending_responses.values(): if not fut.done(): fut.set_exception(RuntimeError(reason)) self._pending_responses.clear() def _mark_transport_disconnected(self) -> None: """Mark QQ WS down without stopping the reconnect loop (base's _running doubles as lifecycle flag; the listener must survive transient drops).""" if self.has_fatal_error: return self._write_runtime_status_safe( "disconnected", platform_state="disconnected", error_code=None, error_message=None ) @property def is_connected(self) -> bool: """Return True only when the QQ WebSocket transport is usable.""" return bool(self._running and self._ws and not self._ws.closed) def __init__(self, config: PlatformConfig): super().__init__(config, Platform.QQBOT) extra = config.extra or {} self._app_id = str(extra.get("app_id") or _resolve_qq_secret("QQ_APP_ID", "")).strip() self._client_secret = str( extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "") ).strip() self._markdown_support = bool(extra.get("markdown_support", True)) # Auth/ACL policies self._dm_policy = str(extra.get("dm_policy", "pairing")).strip().lower() self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) self._group_policy = str(extra.get("group_policy", "pairing")).strip().lower() self._group_allow_from = _coerce_list( extra.get("group_allow_from") or extra.get("groupAllowFrom") ) # Connection state self._session: Optional[aiohttp.ClientSession] = None self._ws: Optional[aiohttp.ClientWebSocketResponse] = None self._http_client: Optional[httpx.AsyncClient] = None self._listen_task: Optional[asyncio.Task] = None self._heartbeat_task: Optional[asyncio.Task] = None self._heartbeat_interval: float = 30.0 # seconds, updated by Hello self._session_id: Optional[str] = None self._last_seq: Optional[int] = None self._chat_type_map: Dict[str, str] = {} # chat_id → "c2c"|"group"|"guild"|"dm" # Request/response correlation self._pending_responses: Dict[str, asyncio.Future] = {} self._seen_messages: Dict[str, float] = {} # Last inbound message ID per chat — used by send_typing self._last_msg_id: Dict[str, str] = {} # Typing debounce: chat_id → last send_typing timestamp self._typing_sent_at: Dict[str, float] = {} # Token cache self._access_token: Optional[str] = None self._token_expires_at: float = 0.0 self._token_lock = asyncio.Lock() # Inline-keyboard interaction routing: invoked for every INTERACTION_CREATE # after the adapter ACKed it. Defaults to the approval/update-prompt # dispatcher; override via set_interaction_callback() (None drops clicks). self._interaction_callback: Optional[ Callable[[InteractionEvent], Awaitable[None]] ] = self._default_interaction_dispatch # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def name(self) -> str: return "QQBot" @property def enforces_own_access_policy(self) -> bool: """QQBot gates DM/group access at intake via dm_policy/group_policy.""" return True # ------------------------------------------------------------------ # Connection lifecycle # ------------------------------------------------------------------ async def connect(self, *, is_reconnect: bool = False) -> bool: """ Authenticate, obtain gateway URL, and open the WebSocket. ``is_reconnect`` is accepted for interface conformance only (QQBot has no server-side update queue). """ for ok, code, message, hint in ( (AIOHTTP_AVAILABLE, "qq_missing_dependency", "QQ startup failed: aiohttp not installed", ". Run: pip install aiohttp"), (HTTPX_AVAILABLE, "qq_missing_dependency", "QQ startup failed: httpx not installed", ". Run: pip install httpx"), (self._app_id and self._client_secret, "qq_missing_credentials", "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required", ""), ): if not ok: self._set_fatal_error(code, message, retryable=True) logger.warning("[%s] %s%s", self._log_tag, message, hint) return False # Prevent duplicate connections with the same credentials if not self._acquire_platform_lock("qqbot-appid", self._app_id, "QQBot app ID"): return False try: # Tighter keepalive pool so idle CLOSE_WAIT sockets drain faster behind proxies. from gateway.platforms._http_client_limits import platform_httpx_limits from tools.url_safety import create_ssrf_safe_async_client self._http_client = create_ssrf_safe_async_client( timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits(), ) await self._ensure_token() gateway_url = await self._get_gateway_url() logger.info("[%s] Gateway URL: %s", self._log_tag, gateway_url) await self._open_ws(gateway_url) self._listen_task = asyncio.create_task(self._listen_loop()) self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) self._mark_connected() logger.info("[%s] Connected", self._log_tag) # Plugin-registered native handlers (ctx.register_platform_handler). self._wire_plugin_handlers(None) return True except Exception as exc: message = f"QQ startup failed: {exc}" self._set_fatal_error("qq_connect_error", message, retryable=True) logger.error("[%s] %s", self._log_tag, message, exc_info=True) await self._cleanup() self._release_platform_lock() return False async def disconnect(self) -> None: """Close all connections and stop listeners.""" self._running = False self._mark_disconnected() self._listen_task = await self._cancel_task(self._listen_task) self._heartbeat_task = await self._cancel_task(self._heartbeat_task) await self._cleanup() self._release_platform_lock() logger.info("[%s] Disconnected", self._log_tag) @staticmethod async def _cancel_task(task: Optional[asyncio.Task]) -> None: """Cancel and await *task* (if any); always returns None for reassignment.""" if task: task.cancel() try: await task except asyncio.CancelledError: pass return None async def _close_ws(self) -> None: """Close the WebSocket + its aiohttp session (keeps _http_client alive).""" if self._ws and not self._ws.closed: await self._ws.close() self._ws = None if self._session and not self._session.closed: await self._session.close() self._session = None async def _cleanup(self) -> None: """Close WebSocket, HTTP session, and client; fail pending futures.""" await self._close_ws() if self._http_client: await self._http_client.aclose() self._http_client = None self._fail_pending("Disconnected") # ------------------------------------------------------------------ # Token management # ------------------------------------------------------------------ async def _ensure_token(self) -> str: """Return a valid access token, refreshing if needed (with singleflight).""" if self._access_token and time.time() < self._token_expires_at - 60: return self._access_token async with self._token_lock: # Double-check after acquiring lock if self._access_token and time.time() < self._token_expires_at - 60: return self._access_token try: resp = await self._http_client.post( TOKEN_URL, json={"appId": self._app_id, "clientSecret": self._client_secret}, timeout=DEFAULT_API_TIMEOUT, ) resp.raise_for_status() data = resp.json() except Exception as exc: raise RuntimeError(f"Failed to get QQ Bot access token: {exc}") from exc token = data.get("access_token") if not token: raise RuntimeError(f"QQ Bot token response missing access_token: {data}") expires_in = int(data.get("expires_in", 7200)) self._access_token = token self._token_expires_at = time.time() + expires_in logger.info("[%s] Access token refreshed, expires in %ds", self._log_tag, expires_in) return self._access_token async def _get_gateway_url(self) -> str: """Fetch the WebSocket gateway URL from the REST API.""" token = await self._ensure_token() try: resp = await self._http_client.get( f"{API_BASE}{GATEWAY_URL_PATH}", headers={"Authorization": f"QQBot {token}", "User-Agent": build_user_agent()}, timeout=DEFAULT_API_TIMEOUT, ) resp.raise_for_status() data = resp.json() except Exception as exc: raise RuntimeError(f"Failed to get QQ Bot gateway URL: {exc}") from exc url = data.get("url") if not url: raise RuntimeError(f"QQ Bot gateway response missing url: {data}") return url # ------------------------------------------------------------------ # WebSocket lifecycle # ------------------------------------------------------------------ async def _open_ws(self, gateway_url: str) -> None: """Open a WebSocket connection to the QQ Bot gateway.""" await self._close_ws() # Honor proxy env vars for the WebSocket (WSL setups need this). self._session = aiohttp.ClientSession(trust_env=gateway_trust_env()) ws_proxy = ( os.getenv("WSS_PROXY") or os.getenv("wss_proxy") or os.getenv("HTTPS_PROXY") or os.getenv("https_proxy") or os.getenv("ALL_PROXY") or os.getenv("all_proxy") ) self._ws = await self._session.ws_connect( gateway_url, headers={"User-Agent": build_user_agent()}, timeout=CONNECT_TIMEOUT_SECONDS, proxy=ws_proxy, ) logger.info("[%s] WebSocket connected to %s", self._log_tag, gateway_url) async def _listen_loop(self) -> None: """Read WebSocket events and reconnect on errors. Close codes: 4004 → refresh token; 4006/4007/49xx → clear session and re-identify; 4008 → rate limited, back off; _FATAL_CLOSE_CODES → stop. """ backoff_idx = 0 connect_time = 0.0 quick_disconnect_count = 0 while self._running: try: connect_time = time.monotonic() await self._read_events() backoff_idx = 0 quick_disconnect_count = 0 except asyncio.CancelledError: return except QQCloseError as exc: if not self._running: return code = exc.code logger.warning( "[%s] WebSocket closed: code=%s reason=%s", self._log_tag, code, exc.reason ) # Quick disconnect detection (permission issues, misconfiguration) duration = time.monotonic() - connect_time if duration < QUICK_DISCONNECT_THRESHOLD and connect_time > 0: quick_disconnect_count += 1 logger.info( "[%s] Quick disconnect (%.1fs), count: %d", self._log_tag, duration, quick_disconnect_count, ) if quick_disconnect_count >= MAX_QUICK_DISCONNECT_COUNT: logger.error( "[%s] Too many quick disconnects. " "Check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform", self._log_tag, ) self._set_fatal_error( "qq_quick_disconnect", "Too many quick disconnects — check bot permissions", retryable=True, ) return else: quick_disconnect_count = 0 self._mark_transport_disconnected() self._fail_pending("Connection closed") desc = self._FATAL_CLOSE_CODES.get(code) if desc: logger.error("[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc) self._set_fatal_error(f"qq_{desc}", f"Bot is {desc}", retryable=False) return if code == 4008: logger.info( "[%s] Rate limited (4008), waiting %ds", self._log_tag, RATE_LIMIT_DELAY ) if backoff_idx >= MAX_RECONNECT_ATTEMPTS: self._mark_disconnected() return await asyncio.sleep(RATE_LIMIT_DELAY) if await self._reconnect(backoff_idx): backoff_idx = 0 quick_disconnect_count = 0 else: backoff_idx += 1 continue if code == 4004: logger.info( "[%s] Invalid token (4004), will refresh and reconnect", self._log_tag ) self._access_token = None self._token_expires_at = 0.0 if code in self._SESSION_INVALID_CLOSE_CODES: logger.info( "[%s] Session error (%d), clearing session for re-identify", self._log_tag, code, ) self._session_id = None self._last_seq = None if await self._reconnect(backoff_idx): backoff_idx = 0 quick_disconnect_count = 0 else: backoff_idx += 1 if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag) self._mark_disconnected() return except Exception as exc: if not self._running: return logger.warning("[%s] WebSocket error: %s", self._log_tag, exc) self._mark_transport_disconnected() self._fail_pending("Connection interrupted") if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached", self._log_tag) self._mark_disconnected() return if await self._reconnect(backoff_idx): backoff_idx = 0 quick_disconnect_count = 0 else: backoff_idx += 1 async def _reconnect(self, backoff_idx: int) -> bool: """Attempt to reconnect the WebSocket. Returns True on success.""" delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] logger.info("[%s] Reconnecting in %ds (attempt %d)...", self._log_tag, delay, backoff_idx + 1) await asyncio.sleep(delay) self._heartbeat_interval = 30.0 # reset until Hello try: await self._ensure_token() gateway_url = await self._get_gateway_url() await self._open_ws(gateway_url) self._mark_connected() logger.info("[%s] Reconnected", self._log_tag) return True except Exception as exc: logger.warning("[%s] Reconnect failed: %s", self._log_tag, exc) return False async def _read_events(self) -> None: """Read WebSocket frames until connection closes.""" if not self._ws: raise RuntimeError("WebSocket not connected") if self._ws.closed: # Returning normally here would make _listen_loop treat it as a clean # read and retry with backoff reset → 100% CPU spin. Raise instead. raise RuntimeError("WebSocket closed") while self._running and self._ws and not self._ws.closed: msg = await self._ws.receive() if msg.type == aiohttp.WSMsgType.TEXT: payload = self._parse_json(msg.data) if payload: self._dispatch_payload(payload) elif msg.type == aiohttp.WSMsgType.CLOSE: raise QQCloseError(msg.data, msg.extra) elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WebSocket closed") async def _heartbeat_loop(self) -> None: """Send op 1 heartbeats with the latest seq at 80% of the Hello interval.""" try: while self._running: await asyncio.sleep(self._heartbeat_interval) if not self._ws or self._ws.closed: continue try: await self._ws.send_json({"op": 1, "d": self._last_seq}) except Exception as exc: logger.debug("[%s] Heartbeat failed: %s", self._log_tag, exc) except asyncio.CancelledError: pass async def _send_ws_auth(self, name: str, payload: Dict[str, Any], sent_msg: str, *log_args) -> bool: """Send an Identify/Resume payload; returns False if the send raised.""" try: if self._ws and not self._ws.closed: await self._ws.send_json(payload) logger.info("[%s] " + sent_msg, self._log_tag, *log_args) else: logger.warning("[%s] Cannot send %s: WebSocket not connected", self._log_tag, name) except Exception as exc: logger.error("[%s] Failed to send %s: %s", self._log_tag, name, exc) return False return True async def _send_identify(self) -> None: """Send op 2 Identify (reply to Hello); server answers with READY. Intents: C2C_GROUP_AT_MESSAGES | PUBLIC_GUILD_MESSAGES | DIRECT_MESSAGE | INTERACTION. """ token = await self._ensure_token() payload = { "op": 2, "d": { "token": f"QQBot {token}", "intents": (1 << 25) | (1 << 30) | (1 << 12) | (1 << 26), "shard": [0, 1], "properties": {"$os": "macOS", "$browser": "hermes-agent", "$device": "hermes-agent"}, }, } await self._send_ws_auth("Identify", payload, "Identify sent") async def _send_resume(self) -> None: """Send op 6 Resume after a reconnect; on failure clear session → Identify next Hello.""" token = await self._ensure_token() payload = { "op": 6, "d": {"token": f"QQBot {token}", "session_id": self._session_id, "seq": self._last_seq}, } if not await self._send_ws_auth( "Resume", payload, "Resume sent (session_id=%s, seq=%s)", self._session_id, self._last_seq ): self._session_id = None self._last_seq = None @staticmethod def _create_task(coro): """Schedule a coroutine; returns None (no error) when no loop is running (tests call _dispatch_payload synchronously).""" try: return asyncio.get_running_loop().create_task(coro) except RuntimeError: return None def _dispatch_payload(self, payload: Dict[str, Any]) -> None: """Route inbound WebSocket payloads (dispatch synchronously, spawn async handlers).""" op = payload.get("op") t = payload.get("t") s = payload.get("s") d = payload.get("d") if isinstance(s, int) and (self._last_seq is None or s > self._last_seq): self._last_seq = s if op == 10: # Hello — reply with Resume (have session) or Identify d_data = d if isinstance(d, dict) else {} interval_ms = d_data.get("heartbeat_interval", 30000) self._heartbeat_interval = interval_ms / 1000.0 * 0.8 # 80% of server interval logger.debug( "[%s] Hello received, heartbeat_interval=%dms (sending every %.1fs)", self._log_tag, interval_ms, self._heartbeat_interval, ) if self._session_id and self._last_seq is not None: self._create_task(self._send_resume()) else: self._create_task(self._send_identify()) return if op == 0 and t: # Dispatch if t == "READY": self._handle_ready(d) elif t == "RESUMED": logger.info("[%s] Session resumed", self._log_tag) elif t in self._INBOUND_HANDLERS: asyncio.create_task(self._on_message(t, d)) elif t == "INTERACTION_CREATE": self._create_task(self._on_interaction(d)) else: logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t) return if op == 11: # Heartbeat ACK return if op == 7: # Server Reconnect — close so _read_events raises and we reconnect w/ Resume logger.info("[%s] Server requested reconnect (op 7)", self._log_tag) if self._ws and not self._ws.closed: self._create_task(self._ws.close()) return if op == 9: # Invalid Session — d=True resumable, d=False re-identify from scratch if d is not None and bool(d): logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag) else: logger.info( "[%s] Invalid session (op 9, not resumable), clearing session", self._log_tag ) self._session_id = None self._last_seq = None if self._ws and not self._ws.closed: self._create_task(self._ws.close()) return logger.debug("[%s] Unknown op: %s", self._log_tag, op) def _handle_ready(self, d: Any) -> None: """Handle the READY event — store session_id for resume.""" if isinstance(d, dict): self._session_id = d.get("session_id") logger.info("[%s] Ready, session_id=%s", self._log_tag, self._session_id) # ------------------------------------------------------------------ # JSON helpers # ------------------------------------------------------------------ @staticmethod def _parse_json(raw: Any) -> Optional[Dict[str, Any]]: try: payload = json.loads(raw) except Exception: logger.warning("[QQBot] Failed to parse JSON: %r", raw) return None return payload if isinstance(payload, dict) else None @staticmethod def _next_msg_seq(msg_id: str) -> int: """Generate a message sequence number in 0..65535 range.""" time_part = int(time.time()) % 100000000 rand = int(uuid.uuid4().hex[:4], 16) return (time_part ^ rand) % 65536 # ------------------------------------------------------------------ # Inbound message handling # ------------------------------------------------------------------ async def handle_message(self, event: MessageEvent) -> None: """Cache the last message ID per chat, then delegate to base.""" if event.message_id and event.source.chat_id: self._last_msg_id[event.source.chat_id] = event.message_id await super().handle_message(event) async def _on_message(self, event_type: str, d: Any) -> None: """Process an inbound QQ Bot message event.""" if not isinstance(d, dict): return msg_id = str(d.get("id", "")) if not msg_id or self._is_duplicate(msg_id): logger.debug("[%s] Duplicate or missing message id: %s", self._log_tag, msg_id) return timestamp = str(d.get("timestamp", "")) content = str(d.get("content", "")).strip() author = d.get("author") if isinstance(d.get("author"), dict) else {} handler = self._INBOUND_HANDLERS.get(event_type) if handler: await getattr(self, handler)(d, msg_id, content, author, timestamp) # ------------------------------------------------------------------ # Inline-keyboard interactions (INTERACTION_CREATE) # ------------------------------------------------------------------ def set_interaction_callback( self, callback: Optional[Callable[[InteractionEvent], Awaitable[None]]], ) -> None: """Register (or clear) the callback invoked per ACKed INTERACTION_CREATE.""" self._interaction_callback = callback async def _on_interaction(self, d: Any) -> None: """Parse INTERACTION_CREATE, ACK it promptly (else the client shows an error icon on the button), then dispatch to the registered callback.""" if not isinstance(d, dict): return try: event = parse_interaction_event(d) except Exception as exc: logger.warning("[%s] Failed to parse INTERACTION_CREATE: %s", self._log_tag, exc) return if not event.id: logger.warning("[%s] INTERACTION_CREATE missing id, skipping ACK", self._log_tag) return try: await self._acknowledge_interaction(event.id) except Exception as exc: logger.warning("[%s] Failed to ACK interaction %s: %s", self._log_tag, event.id, exc) logger.info( "[%s] Interaction: scene=%s button_data=%r operator=%s", self._log_tag, event.scene, event.button_data, event.operator_openid, ) callback = self._interaction_callback if callback is None: logger.debug( "[%s] No interaction callback registered; dropping button click %r", self._log_tag, event.button_data, ) return try: await callback(event) except Exception as exc: logger.error("[%s] Interaction callback raised: %s", self._log_tag, exc, exc_info=True) async def _acknowledge_interaction(self, interaction_id: str, code: int = 0) -> None: """ACK a button interaction via ``PUT /interactions/{id}`` (code 0 = success).""" if not self._http_client: raise RuntimeError("HTTP client not initialized — not connected?") resp = await self._http_client.put( f"{API_BASE}/interactions/{interaction_id}", headers=await self._auth_headers(), json={"code": code}, timeout=DEFAULT_API_TIMEOUT, ) if resp.status_code >= 400: raise RuntimeError(f"Interaction ACK failed [{resp.status_code}]: {resp.text[:200]}") # Button decision → ``choice`` for tools.approval.resolve_gateway_approval. The # 3-button layout folds "session" into "always"; ``/approve session`` still works. _APPROVAL_BUTTON_TO_CHOICE = {"allow-once": "once", "allow-always": "always", "deny": "deny"} @staticmethod def _parse_gateway_session_key(session_key: str) -> Optional[Dict[str, str]]: """Parse ``agent:main:::[:]``.""" parts = str(session_key or "").split(":") if len(parts) < 5 or parts[0] != "agent" or parts[1] != "main": return None parsed = {"platform": parts[2], "chat_type": parts[3], "chat_id": parts[4]} if len(parts) > 5: parsed["user_id"] = parts[5] return parsed def _is_authorized_interaction_for_session( self, event: InteractionEvent, session_key: str, ) -> bool: """Authorize approval/update interactions against session + operator.""" parsed = self._parse_gateway_session_key(session_key) operator = str(event.operator_openid or "").strip() if not parsed or parsed.get("platform") != "qqbot" or not operator: return False chat_type = parsed.get("chat_type", "") chat_id = parsed.get("chat_id", "") if chat_type == "c2c": return bool(chat_id) and operator == chat_id if chat_type in {"group", "guild"}: event_chat = str(event.group_openid or event.guild_id or "").strip() if not event_chat or event_chat != chat_id: return False session_user = str(parsed.get("user_id", "")).strip() return bool(session_user) and operator == session_user return False async def _default_interaction_dispatch( self, event: InteractionEvent, ) -> None: """Default interaction callback: ``approve::`` → tools.approval.resolve_gateway_approval; ``update_prompt:`` → ``~/.hermes/.update_response``; anything else is ignored at DEBUG.""" button_data = event.button_data if not button_data: return approval = parse_approval_button_data(button_data) if approval is not None: session_key, decision = approval choice = self._APPROVAL_BUTTON_TO_CHOICE.get(decision) if choice is None: logger.warning( "[%s] Unknown approval decision %r (session=%s)", self._log_tag, decision, session_key, ) return if not self._is_authorized_interaction_for_session(event, session_key): logger.warning( "[%s] Rejected unauthorized approval click for session %s " "(operator=%s)", self._log_tag, session_key, event.operator_openid, ) return try: from tools.approval import resolve_gateway_approval # lazy: keep adapter light count = resolve_gateway_approval(session_key, choice) logger.info( "[%s] Button resolved %d approval(s) for session %s (choice=%s, operator=%s)", self._log_tag, count, session_key, choice, event.operator_openid, ) except Exception as exc: logger.error( "[%s] resolve_gateway_approval failed for session %s: %s", self._log_tag, session_key, exc, ) return update_answer = parse_update_prompt_button_data(button_data) if update_answer is not None: update_session_key = f"agent:main:qqbot:{event.scene}:{event.group_openid or event.guild_id or event.user_openid}" if not self._is_authorized_interaction_for_session(event, update_session_key): logger.warning( "[%s] Rejected unauthorized update prompt click (operator=%s)", self._log_tag, event.operator_openid, ) return self._write_update_response(update_answer, event.operator_openid) return logger.debug( "[%s] Unrecognised button_data %r from interaction %s", self._log_tag, button_data, event.id, ) @staticmethod def _write_update_response(answer: str, operator: str = "") -> None: """Atomically (tmp + rename) write the update-prompt answer to ``.update_response``, polled by the detached ``hermes update --gateway`` watcher.""" try: from hermes_constants import get_hermes_home response_path = get_hermes_home() / ".update_response" tmp = response_path.with_suffix(".tmp") tmp.write_text(answer, encoding="utf-8") tmp.replace(response_path) logger.info("QQ update prompt answered %r by %s", answer, operator or "(unknown)") except Exception as exc: logger.error("Failed to write update response: %s", exc) async def _handle_c2c_message( self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str, ) -> None: """Handle a C2C (private) message event.""" user_openid = str(author.get("user_openid", "")) if not user_openid: return if not self._is_dm_intake_allowed(user_openid): return attachments_raw = d.get("attachments") logger.info( "[%s] C2C message: id=%s content=%r attachments=%s", self._log_tag, msg_id, content[:50] if content else "", ( f"{len(attachments_raw) if isinstance(attachments_raw, list) else 0} items" if attachments_raw else "None" ), ) if attachments_raw and isinstance(attachments_raw, list): for _i, _att in enumerate(attachments_raw): if isinstance(_att, dict): logger.info( "[%s] attachment[%d]: content_type=%s url=%s filename=%s", self._log_tag, _i, _att.get("content_type", ""), str(_att.get("url", ""))[:80], _att.get("filename", ""), ) text, image_urls, image_media_types, n_voice = await self._absorb_attachments( content, attachments_raw ) logger.info( "[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), n_voice ) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm", ) async def _handle_group_message( self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str, ) -> None: """Handle a group @-message event.""" group_openid = str(d.get("group_openid", "")) if not group_openid: return if not self._is_group_allowed(group_openid, str(author.get("member_openid", ""))): return text, image_urls, image_media_types, _ = await self._absorb_attachments( self._strip_at_mention(content), d.get("attachments") ) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, chat_id=group_openid, qq_chat_type="group", user_id=str(author.get("member_openid", "")), chat_type="group", ) async def _handle_guild_message( self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str, ) -> None: """Handle a guild/channel message event.""" channel_id = str(d.get("channel_id", "")) if not channel_id: return # group_policy ACL — guild channels are group-like; without it any guild # member could bypass the allowlist. guild_id = str(d.get("guild_id", "")) author_id = str(author.get("id", "")) if not self._is_group_allowed(guild_id or channel_id, author_id): logger.debug( "[%s] Guild message blocked by ACL: channel=%s user=%s", self._log_tag, channel_id, author_id, ) return member = d.get("member") if isinstance(d.get("member"), dict) else {} nick = str(member.get("nick", "")) or str(author.get("username", "")) text, image_urls, image_media_types, _ = await self._absorb_attachments( content, d.get("attachments") ) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, chat_id=channel_id, qq_chat_type="guild", user_id=str(author.get("id", "")), user_name=nick or None, chat_type="group", ) async def _handle_dm_message( self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str, ) -> None: """Handle a guild DM message event.""" guild_id = str(d.get("guild_id", "")) if not guild_id: return # dm_policy ACL — without it any guild member could bypass the allowlist via DM. author_id = str(author.get("id", "")) if not self._is_dm_intake_allowed(author_id): logger.debug( "[%s] Guild DM blocked by ACL: guild=%s user=%s", self._log_tag, guild_id, author_id, ) return text, image_urls, image_media_types, _ = await self._absorb_attachments( content, d.get("attachments") ) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, chat_id=guild_id, qq_chat_type="dm", user_id=str(author.get("id", "")), chat_type="dm", ) _INBOUND_HANDLERS = { "C2C_MESSAGE_CREATE": "_handle_c2c_message", "GROUP_AT_MESSAGE_CREATE": "_handle_group_message", "GUILD_MESSAGE_CREATE": "_handle_guild_message", "GUILD_AT_MESSAGE_CREATE": "_handle_guild_message", "DIRECT_MESSAGE_CREATE": "_handle_dm_message", } # ------------------------------------------------------------------ # Shared inbound pipeline (all four message kinds) # ------------------------------------------------------------------ @staticmethod def _append_block(text: str, block: str) -> str: """Append *block* to *text* after a blank line (or return block alone if text is blank).""" return (text + "\n\n" + block).strip() if text.strip() else block async def _absorb_attachments( self, text: str, attachments: Any, ) -> Tuple[str, List[str], List[str], int]: """Run attachments through _process_attachments and fold transcripts/file info into *text*. Returns (text, image_urls, image_media_types, n_voice).""" att = await self._process_attachments(attachments) voice_transcripts = att["voice_transcripts"] if voice_transcripts: text = self._append_block(text, "\n".join(voice_transcripts)) if att["attachment_info"]: text = self._append_block(text, att["attachment_info"]) return text, att["image_urls"], att["image_media_types"], len(voice_transcripts) async def _emit_inbound( self, d: Dict[str, Any], msg_id: str, timestamp: str, text: str, image_urls: List[str], image_media_types: List[str], *, chat_id: str, qq_chat_type: str, **source_kwargs: Any, ) -> None: """Merge quoted context, drop empty events, remember the QQ chat kind and dispatch.""" quoted = await self._process_quoted_context(d) text = self._merge_quote_into(text, quoted["quote_block"]) if quoted["image_urls"]: image_urls = image_urls + quoted["image_urls"] image_media_types = image_media_types + quoted["image_media_types"] if not text.strip() and not image_urls: return self._chat_type_map[chat_id] = qq_chat_type event = MessageEvent( source=self.build_source(chat_id=chat_id, **source_kwargs), text=text, message_type=self._detect_message_type(image_urls, image_media_types), raw_message=d, message_id=msg_id, media_urls=image_urls, media_types=image_media_types, timestamp=self._parse_qq_timestamp(timestamp), ) await self.handle_message(event) # ------------------------------------------------------------------ # Quoted-message handling # ------------------------------------------------------------------ async def _process_quoted_context( self, d: Dict[str, Any], ) -> Dict[str, Any]: """Process the quoted message a user is replying to. A quote-reply has ``message_type == 103`` with the referenced message's content + attachments in ``msg_elements`` (normally just [0]). Quoted attachments run through the same _process_attachments pipeline, so quoted voice gets STT and quoted images are cached identically. Returns ``{"quote_block", "image_urls", "image_media_types"}``; quote_block is "" when nothing is quoted. """ empty = {"quote_block": "", "image_urls": [], "image_media_types": []} try: if int(d.get("message_type", 0) or 0) != 103: return empty except (TypeError, ValueError): return empty elements = d.get("msg_elements") if not isinstance(elements, list) or not elements: return empty quoted_text_parts: List[str] = [] all_attachments: List[Dict[str, Any]] = [] for elem in elements: if not isinstance(elem, dict): continue etext = str(elem.get("content", "")).strip() if etext: quoted_text_parts.append(etext) eatts = elem.get("attachments") if isinstance(eatts, list): for a in eatts: if isinstance(a, dict): all_attachments.append(a) att_result = await self._process_attachments(all_attachments) quoted_images = att_result.get("image_urls") or [] lines: List[str] = [] if quoted_text_parts: lines.append(" ".join(quoted_text_parts)) lines.extend(att_result.get("voice_transcripts") or []) if att_result.get("attachment_info"): lines.append(att_result["attachment_info"]) if not lines and not quoted_images: return empty # Images-only quote still gets a marker so the LLM knows context was referenced. quote_block = ( "[Quoted message]:\n" + "\n".join(lines) if lines else "[Quoted message]: (image)" ) return { "quote_block": quote_block, "image_urls": quoted_images, "image_media_types": att_result.get("image_media_types") or [], } @staticmethod def _merge_quote_into(text: str, quote_block: str) -> str: """Prepend ``quote_block`` to *text*, separated by a blank line.""" if not quote_block: return text if text.strip(): return f"{quote_block}\n\n{text}".strip() return quote_block # ------------------------------------------------------------------ # Attachment processing # ------------------------------------------------------------------ @staticmethod def _detect_message_type(media_urls: list, media_types: list): """Determine MessageType from attachment content types.""" if not media_urls: return MessageType.TEXT if not media_types: return MessageType.PHOTO first_type = media_types[0].lower() if "audio" in first_type or "voice" in first_type or "silk" in first_type: return MessageType.VOICE if "video" in first_type: return MessageType.VIDEO if "image" in first_type or "photo" in first_type: return MessageType.PHOTO logger.debug("Unknown media content_type '%s', defaulting to TEXT", first_type) return MessageType.TEXT async def _process_attachments( self, attachments: Any, ) -> Dict[str, Any]: """Process inbound attachments uniformly (images, voice, other files). Returns ``{"image_urls", "image_media_types", "voice_transcripts", "attachment_info"}`` (cached image paths + MIME types, "[Voice] ..." transcripts, and a text description of non-image/non-voice files). """ image_urls: List[str] = [] image_media_types: List[str] = [] voice_transcripts: List[str] = [] other_attachments: List[str] = [] for att in attachments if isinstance(attachments, list) else (): if not isinstance(att, dict): continue ct = str(att.get("content_type", "")).strip().lower() url = str(att.get("url", "")).strip() filename = str(att.get("filename", "")) if not url: continue if url.startswith("//"): url = f"https:{url}" logger.debug( "[%s] Processing attachment: content_type=%s, url=%s, filename=%s", self._log_tag, ct, url[:80], filename, ) if self._is_voice_content_type(ct, filename): asr_refer = att.get("asr_refer_text") voice_wav_url = att.get("voice_wav_url") transcript = await self._stt_voice_attachment( url, ct, filename, asr_refer_text=(asr_refer.strip() if isinstance(asr_refer, str) else "") or None, voice_wav_url=(voice_wav_url.strip() if isinstance(voice_wav_url, str) else "") or None, ) if transcript: voice_transcripts.append(f"[Voice] {transcript}") logger.debug("[%s] Voice transcript: %s", self._log_tag, transcript) else: logger.warning("[%s] Voice STT failed for %s", self._log_tag, url[:60]) voice_transcripts.append("[Voice] [语音识别失败]") elif ct.startswith("image/"): try: cached_path = await self._download_and_cache(url, ct, filename) if cached_path and os.path.isfile(cached_path): image_urls.append(cached_path) image_media_types.append(ct or "image/jpeg") elif cached_path: logger.warning( "[%s] Cached image path does not exist: %s", self._log_tag, cached_path ) except Exception as exc: logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc) else: try: cached_path = await self._download_and_cache(url, ct, filename) if cached_path: label = "video" if ct.startswith("video/") else "file" other_attachments.append(f"[{label}: {filename or ct} ({cached_path})]") except Exception as exc: logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc) return { "image_urls": image_urls, "image_media_types": image_media_types, "voice_transcripts": voice_transcripts, "attachment_info": "\n".join(other_attachments), } async def _download_and_cache( self, url: str, content_type: str, original_name: str = "", ) -> Optional[str]: """Download a URL and cache it locally (``original_name`` falls back to the URL basename).""" from tools.url_safety import is_safe_url if not is_safe_url(url): raise ValueError(f"Blocked unsafe URL: {url[:80]}") if not self._http_client: return None try: resp = await self._http_client.get(url, timeout=30.0, headers=self._qq_media_headers()) resp.raise_for_status() data = resp.content except Exception as exc: logger.debug("[%s] Download failed for %s: %s", self._log_tag, url[:80], exc) return None if content_type.startswith("image/"): # Historical qqbot mapping: trust mimetypes' guess (never the shared # table) and fall back to .jpg. ext = ext_for_mime( content_type, use_defaults=False, use_mimetypes=True, fallback=".jpg" ) or ".jpg" return cache_image_from_bytes(data, ext) if content_type == "voice" or content_type.startswith("audio/"): # QQ voice is usually .amr/.silk — convert to .wav for STT engines. return await self._convert_audio_to_wav(data, url) filename = original_name or Path(urlparse(url).path).name or "qq_attachment" return cache_document_from_bytes(data, filename) @staticmethod def _is_voice_content_type(content_type: str, filename: str) -> bool: """Check if an attachment is a voice/audio message.""" ct = content_type.strip().lower() if ct == "voice" or ct.startswith("audio/"): return True # content_type="file" is an explicit upload: never route .wav/.mp3 files into STT. if ct == "file": return False return filename.strip().lower().endswith(_VOICE_EXTENSIONS) def _qq_media_headers(self) -> Dict[str, str]: """Authorization header for QQ multimedia CDN downloads (required, else non-200).""" if self._access_token: return {"Authorization": f"QQBot {self._access_token}"} return {} async def _stt_voice_attachment( self, url: str, content_type: str, filename: str, *, asr_refer_text: Optional[str] = None, voice_wav_url: Optional[str] = None, ) -> Optional[str]: """Transcribe a voice attachment. Priority: QQ's free ``asr_refer_text`` → STT on ``voice_wav_url`` (pre-converted WAV, no SILK decode) → STT on the original URL (SILK→WAV). Returns the transcript or None.""" if asr_refer_text: logger.debug("[%s] STT: using QQ asr_refer_text: %r", self._log_tag, asr_refer_text[:100]) return asr_refer_text download_url = url is_pre_wav = False if voice_wav_url: if voice_wav_url.startswith("//"): voice_wav_url = f"https:{voice_wav_url}" download_url = voice_wav_url is_pre_wav = True logger.debug("[%s] STT: using voice_wav_url (pre-converted WAV)", self._log_tag) from tools.url_safety import is_safe_url if not is_safe_url(download_url): logger.warning("[QQ] STT blocked unsafe URL: %s", download_url[:80]) return None try: if not self._http_client: logger.warning("[%s] STT: no HTTP client", self._log_tag) return None download_headers = self._qq_media_headers() # QQ CDN requires Authorization logger.debug( "[%s] STT: downloading voice from %s (pre_wav=%s, headers=%s)", self._log_tag, download_url[:80], is_pre_wav, bool(download_headers), ) resp = await self._http_client.get( download_url, timeout=30.0, headers=download_headers, follow_redirects=True ) resp.raise_for_status() audio_data = resp.content logger.debug( "[%s] STT: downloaded %d bytes, content_type=%s", self._log_tag, len(audio_data), resp.headers.get("content-type", "unknown"), ) if len(audio_data) < 10: logger.warning( "[%s] STT: downloaded data too small (%d bytes), skipping", self._log_tag, len(audio_data), ) return None if is_pre_wav: wav_path = self._write_temp(audio_data, ".wav") logger.debug( "[%s] STT: using pre-converted WAV directly (%d bytes)", self._log_tag, len(audio_data), ) else: logger.debug("[%s] STT: converting to wav, filename=%r", self._log_tag, filename) wav_path = await self._convert_audio_to_wav_file(audio_data, filename) if not wav_path or not Path(wav_path).exists(): logger.warning("[%s] STT: ffmpeg conversion produced no output", self._log_tag) return None logger.debug("[%s] STT: calling ASR on %s", self._log_tag, wav_path) try: transcript = await self._call_stt(wav_path) finally: self._unlink_quiet(wav_path) if transcript: logger.debug("[%s] STT success: %r", self._log_tag, transcript[:100]) else: logger.warning("[%s] STT: ASR returned empty transcript", self._log_tag) return transcript except (httpx.HTTPStatusError, httpx.TransportError, IOError) as exc: logger.warning( "[%s] STT failed for voice attachment: %s: %s", self._log_tag, type(exc).__name__, exc, ) return None @staticmethod def _write_temp(data: bytes, suffix: str) -> str: """Write *data* to a persistent NamedTemporaryFile and return its path.""" import tempfile with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(data) return tmp.name @staticmethod def _unlink_quiet(path: str) -> None: try: os.unlink(path) except OSError: pass @staticmethod def _wav_ok(wav_path: str) -> bool: """True when *wav_path* exists and holds more than a bare 44-byte header.""" return Path(wav_path).exists() and Path(wav_path).stat().st_size > 44 async def _convert_audio_to_wav_file( self, audio_data: bytes, filename: str ) -> Optional[str]: """Convert audio bytes to a temp .wav: pilk (SILK, which ffmpeg can't decode) → ffmpeg → raw-PCM last resort. Returns the wav path or None.""" ext = Path(filename).suffix.lower() or self._guess_ext_from_data(audio_data) logger.info( "[%s] STT: audio_data size=%d, ext=%r, first_20_bytes=%r", self._log_tag, len(audio_data), ext, audio_data[:20], ) src_path = self._write_temp(audio_data, ext) wav_path = src_path.rsplit(".", 1)[0] + ".wav" result = ( await self._convert_silk_to_wav(src_path, wav_path) or await self._convert_ffmpeg_to_wav(src_path, wav_path) or await self._convert_raw_to_wav(audio_data, wav_path) ) self._unlink_quiet(src_path) return result @staticmethod def _guess_ext_from_data(data: bytes) -> str: """Guess file extension from magic bytes (unknown → .amr, QQ's most common).""" if data[:6] == b"#!SILK" or data[:2] == b"\x02!": return ".silk" if data[:4] == b"RIFF": return ".wav" if data[:4] == b"fLaC": return ".flac" if data[:2] in {b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"}: return ".mp3" if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53": return ".ogg" return ".amr" @staticmethod def _looks_like_silk(data: bytes) -> bool: """Check if bytes look like a SILK audio file.""" return data[:6] == b"#!SILK" or data[:2] == b"\x02!" async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]: """Convert to WAV with pilk: as-is first, then copied to .silk (pilk checks the extension).""" try: import pilk except ImportError: logger.warning( "[%s] pilk not installed — cannot decode SILK audio. Run: pip install pilk", self._log_tag, ) return None try: pilk.silk_to_wav(src_path, wav_path, rate=16000) if self._wav_ok(wav_path): logger.debug( "[%s] pilk converted %s to wav (%d bytes)", self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, ) return wav_path except Exception as exc: logger.debug("[%s] pilk direct conversion failed: %s", self._log_tag, exc) silk_path = src_path.rsplit(".", 1)[0] + ".silk" try: import shutil shutil.copy2(src_path, silk_path) pilk.silk_to_wav(silk_path, wav_path, rate=16000) if self._wav_ok(wav_path): logger.debug( "[%s] pilk converted %s (as .silk) to wav (%d bytes)", self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, ) return wav_path except Exception as exc: logger.debug("[%s] pilk .silk conversion failed: %s", self._log_tag, exc) finally: self._unlink_quiet(silk_path) return None async def _convert_raw_to_wav(self, audio_data: bytes, wav_path: str) -> Optional[str]: """Last resort: wrap bytes as raw PCM 16-bit mono 16kHz WAV (garbage if not PCM, but the ASR engine returns empty instead of crashing).""" try: import wave with wave.open(wav_path, "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(16000) wf.writeframes(audio_data) return wav_path except Exception as exc: logger.debug("[%s] raw PCM fallback failed: %s", self._log_tag, exc) return None async def _convert_ffmpeg_to_wav(self, src_path: str, wav_path: str) -> Optional[str]: """Convert audio file to WAV using ffmpeg.""" try: proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", wav_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) await asyncio.wait_for(proc.wait(), timeout=30) if proc.returncode != 0: stderr = await proc.stderr.read() if proc.stderr else b"" logger.warning( "[%s] ffmpeg failed for %s: %s", self._log_tag, Path(src_path).name, stderr[:200].decode(errors="replace"), ) return None except (asyncio.TimeoutError, FileNotFoundError) as exc: logger.warning("[%s] ffmpeg conversion error: %s", self._log_tag, exc) return None if not self._wav_ok(wav_path): logger.warning( "[%s] ffmpeg produced no/small output for %s", self._log_tag, Path(src_path).name ) return None logger.debug( "[%s] ffmpeg converted %s to wav (%d bytes)", self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, ) return wav_path def _resolve_stt_config(self) -> Optional[Dict[str, str]]: """Resolve STT backend: ``extra["stt"]`` config first, then ``QQ_STT_*`` env vars; None when unconfigured (QQ's built-in ASR still works).""" stt_cfg = (self.config.extra or {}).get("stt") if isinstance(stt_cfg, dict) and stt_cfg.get("enabled") is not False: base_url = stt_cfg.get("baseUrl") or stt_cfg.get("base_url", "") api_key = stt_cfg.get("apiKey") or stt_cfg.get("api_key", "") model = stt_cfg.get("model", "") if base_url and api_key: return {"base_url": base_url.rstrip("/"), "api_key": api_key, "model": model or "whisper-1"} if api_key: # provider-only config provider = stt_cfg.get("provider", "zai") base_url = _STT_PROVIDER_BASE_URLS.get(provider, "") if base_url: return { "base_url": base_url, "api_key": api_key, "model": model or ("glm-asr" if provider in {"zai", "glm"} else "whisper-1"), } qq_stt_key = _resolve_qq_secret("QQ_STT_API_KEY", "") if qq_stt_key: base_url = _resolve_qq_secret("QQ_STT_BASE_URL", _STT_PROVIDER_BASE_URLS["zai"]) return { "base_url": base_url.rstrip("/"), "api_key": qq_stt_key, "model": _resolve_qq_secret("QQ_STT_MODEL", "glm-asr"), } return None async def _call_stt(self, wav_path: str) -> Optional[str]: """Transcribe a wav via an OpenAI-compatible STT API; None if unconfigured/failed.""" stt_cfg = self._resolve_stt_config() if not stt_cfg: logger.warning("[%s] STT not configured (no stt config or QQ_STT_API_KEY)", self._log_tag) return None base_url, api_key, model = stt_cfg["base_url"], stt_cfg["api_key"], stt_cfg["model"] try: with open(wav_path, "rb") as f: resp = await self._http_client.post( f"{base_url}/audio/transcriptions", headers={"Authorization": f"Bearer {api_key}"}, files={"file": (Path(wav_path).name, f, "audio/wav")}, data={"model": model}, timeout=30.0, ) resp.raise_for_status() result = resp.json() # Zhipu/GLM: {"choices": [{"message": {"content": ...}}]}; OpenAI/Whisper: {"text": ...} choices = result.get("choices", []) if choices: content = choices[0].get("message", {}).get("content", "") if content.strip(): return content.strip() text = result.get("text", "") return text.strip() or None except (httpx.HTTPStatusError, IOError) as exc: logger.warning( "[%s] STT API call failed (model=%s, base=%s): %s", self._log_tag, model, base_url[:50], exc, ) return None async def _convert_audio_to_wav( self, audio_data: bytes, source_url: str ) -> Optional[str]: """Convert audio bytes to .wav (pilk for SILK, else ffmpeg) and cache the result; on conversion failure the original bytes are cached as ``qq_voice``.""" ext = Path(urlparse(source_url).path).suffix.lower() if ext not in _AUDIO_URL_EXTENSIONS: ext = self._guess_ext_from_data(audio_data) src_path = self._write_temp(audio_data, ext) wav_path = src_path.rsplit(".", 1)[0] + ".wav" try: if ext == ".silk" or self._looks_like_silk(audio_data): result = await self._convert_silk_to_wav(src_path, wav_path) else: result = await self._convert_ffmpeg_to_wav(src_path, wav_path) if not result: logger.warning( "[%s] audio conversion failed for %s (format=%s)", self._log_tag, source_url[:60], ext, ) return cache_document_from_bytes(audio_data, f"qq_voice{ext}") except Exception: return cache_document_from_bytes(audio_data, f"qq_voice{ext}") finally: self._unlink_quiet(src_path) try: wav_data = Path(wav_path).read_bytes() os.unlink(wav_path) return cache_document_from_bytes(wav_data, "qq_voice.wav") except Exception as exc: logger.debug("[%s] Failed to read converted wav: %s", self._log_tag, exc) return None # ------------------------------------------------------------------ # Outbound messaging — REST API # ------------------------------------------------------------------ async def _api_request( self, method: str, path: str, body: Optional[Dict[str, Any]] = None, timeout: float = DEFAULT_API_TIMEOUT, ) -> Dict[str, Any]: """Make an authenticated REST API request to QQ Bot API.""" if not self._http_client: raise RuntimeError("HTTP client not initialized — not connected?") headers = await self._auth_headers() try: resp = await self._http_client.request( method, f"{API_BASE}{path}", headers=headers, json=body, timeout=timeout ) data = resp.json() if resp.status_code >= 400: raise RuntimeError( f"QQ Bot API error [{resp.status_code}] {path}: {data.get('message', data)}" ) return data except httpx.TimeoutException as exc: raise RuntimeError(f"QQ Bot API timeout [{path}]: {exc}") from exc async def _auth_headers(self) -> Dict[str, str]: """JSON REST headers with a fresh bot token.""" token = await self._ensure_token() return { "Authorization": f"QQBot {token}", "Content-Type": "application/json", "User-Agent": build_user_agent(), } async def _upload_media( self, target_type: str, target_id: str, file_type: int, url: Optional[str] = None, file_data: Optional[str] = None, srv_send_msg: bool = False, file_name: Optional[str] = None, ) -> Dict[str, Any]: """Upload media and return file_info.""" kind = "users" if target_type == "c2c" else "groups" path = f"/v2/{kind}/{target_id}/files" body: Dict[str, Any] = {"file_type": file_type, "srv_send_msg": srv_send_msg} if url: body["url"] = url elif file_data: body["file_data"] = file_data if file_type == MEDIA_TYPE_FILE and file_name: body["file_name"] = file_name for attempt in range(3): # retry transient upload failures try: return await self._api_request("POST", path, body, timeout=FILE_UPLOAD_TIMEOUT) except RuntimeError as exc: err_msg = str(exc) if any(kw in err_msg for kw in ("400", "401", "Invalid", "timeout", "Timeout")): raise if attempt < 2: await asyncio.sleep(1.5 * (attempt + 1)) else: raise # Maximum time (seconds) to wait for reconnection before giving up on send. _RECONNECT_WAIT_SECONDS = 15.0 # How often (seconds) to poll is_connected while waiting. _RECONNECT_POLL_INTERVAL = 0.5 async def _wait_for_reconnection(self) -> bool: """Poll is_connected for up to _RECONNECT_WAIT_SECONDS — covers the race where send() lands between a disconnect and _listen_loop's reconnect.""" logger.info("[%s] Not connected — waiting for reconnection (up to %.0fs)", self._log_tag, self._RECONNECT_WAIT_SECONDS) waited = 0.0 while waited < self._RECONNECT_WAIT_SECONDS: await asyncio.sleep(self._RECONNECT_POLL_INTERVAL) waited += self._RECONNECT_POLL_INTERVAL if self.is_connected: logger.info("[%s] Reconnected after %.1fs", self._log_tag, waited) return True logger.warning("[%s] Still not connected after %.0fs", self._log_tag, self._RECONNECT_WAIT_SECONDS) return False @property def _NOT_CONNECTED(self) -> SendResult: return SendResult(success=False, error="Not connected", retryable=True) async def _ensure_connected(self) -> bool: """True when connected now or after waiting for the listener to reconnect.""" return self.is_connected or await self._wait_for_reconnection() async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send text/markdown: format, split via truncate_message(), retry transient failures.""" del metadata if not await self._ensure_connected(): return self._NOT_CONNECTED if not content or not content.strip(): return SendResult(success=True) formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) last_result = SendResult(success=False, error="No chunks") for chunk in chunks: last_result = await self._send_chunk(chat_id, chunk, reply_to) if not last_result.success: return last_result # Only reply_to the first chunk reply_to = None return last_result async def _send_chunk( self, chat_id: str, content: str, reply_to: Optional[str] = None, ) -> SendResult: """Send a single chunk with retry + exponential backoff.""" last_exc: Optional[Exception] = None chat_type = self._guess_chat_type(chat_id) for attempt in range(3): try: if chat_type == "c2c": return await self._send_c2c_text(chat_id, content, reply_to) elif chat_type == "group": return await self._send_group_text(chat_id, content, reply_to) elif chat_type == "guild": return await self._send_guild_text(chat_id, content, reply_to) else: return SendResult(success=False, error=f"Unknown chat type for {chat_id}") except Exception as exc: last_exc = exc err = str(exc).lower() if any(k in err for k in ("invalid", "forbidden", "not found", "bad request")): break # permanent — don't retry if attempt < 2: delay = 1.0 * (2 ** attempt) logger.warning( "[%s] send retry %d/3 after %.1fs: %s", self._log_tag, attempt + 1, delay, exc, ) await asyncio.sleep(delay) error_msg = (str(last_exc) or type(last_exc).__name__) if last_exc else "Unknown error" logger.error("[%s] Send failed: %s", self._log_tag, error_msg) retryable = not any( k in error_msg.lower() for k in ("invalid", "forbidden", "not found") ) return SendResult(success=False, error=error_msg, retryable=retryable) @staticmethod def _messages_path(chat_type: str, target_id: str) -> str: """REST path for outbound messages to a c2c user or a group.""" kind = "users" if chat_type == "c2c" else "groups" return f"/v2/{kind}/{target_id}/messages" async def _post_message(self, path: str, body: Dict[str, Any]) -> SendResult: """POST a message body and wrap the response as a successful SendResult.""" data = await self._api_request("POST", path, body) return SendResult( success=True, message_id=str(data.get("id", uuid.uuid4().hex[:12])), raw_response=data ) async def _send_text_to( self, chat_type: str, target_id: str, content: str, reply_to: Optional[str] = None, keyboard: Optional[InlineKeyboard] = None, ) -> SendResult: """Send text (optionally with an inline keyboard) to a c2c user or group.""" self._next_msg_seq(reply_to or target_id) body = self._build_text_body(content, reply_to) if reply_to: body["msg_id"] = reply_to if keyboard is not None: body["keyboard"] = keyboard.to_dict() return await self._post_message(self._messages_path(chat_type, target_id), body) async def _send_c2c_text(self, openid, content, reply_to=None, keyboard=None) -> SendResult: return await self._send_text_to("c2c", openid, content, reply_to, keyboard) async def _send_group_text(self, group_openid, content, reply_to=None, keyboard=None) -> SendResult: return await self._send_text_to("group", group_openid, content, reply_to, keyboard) async def _send_guild_text( self, channel_id: str, content: str, reply_to: Optional[str] = None ) -> SendResult: """Send text to a guild channel via REST API.""" body: Dict[str, Any] = {"content": content[: self.MAX_MESSAGE_LENGTH]} if reply_to: body["msg_id"] = reply_to return await self._post_message(f"/channels/{channel_id}/messages", body) # ------------------------------------------------------------------ # Inline-keyboard outbound helpers (approval / update-prompt flows) # ------------------------------------------------------------------ async def send_with_keyboard( self, chat_id: str, content: str, keyboard: InlineKeyboard, reply_to: Optional[str] = None, ) -> SendResult: """Send ONE text message with an inline keyboard (no chunking — splitting would orphan the buttons; keep bodies short). Guild chats are unsupported.""" if not await self._ensure_connected(): return self._NOT_CONNECTED chat_type = self._guess_chat_type(chat_id) truncated = self.format_message(content)[: self.MAX_MESSAGE_LENGTH] try: if chat_type == "c2c": return await self._send_c2c_text(chat_id, truncated, reply_to, keyboard=keyboard) if chat_type == "group": return await self._send_group_text(chat_id, truncated, reply_to, keyboard=keyboard) return SendResult( success=False, error=f"Inline keyboards not supported for chat_type {chat_type!r}", retryable=False, ) except Exception as exc: logger.error("[%s] send_with_keyboard failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) async def send_approval_request( self, chat_id: str, req: ApprovalRequest, reply_to: Optional[str] = None, ) -> SendResult: """Send a 3-button approval request (allow-once / allow-always / deny); clicks come back as INTERACTION_CREATE decoded by parse_approval_button_data.""" from gateway.platforms.qqbot.keyboards import build_approval_text return await self.send_with_keyboard( chat_id, build_approval_text(req), build_approval_keyboard( req.session_key, allow_permanent=getattr(req, "allow_permanent", True) ), reply_to=reply_to, ) # Cross-adapter gateway contract: gateway/run.py detects send_exec_approval / # send_update_prompt on the adapter class for button-based approval/update UX. async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, ) -> SendResult: """Button-based exec-approval prompt (called by gateway/run.py while the agent blocks on approval); clicks resolve via _default_interaction_dispatch.""" del metadata # QQ has no thread_id / DM targeting overrides. del allow_session # QQ's 3-button keyboard has no session tier. if smart_denied: description += " Owner override applies to this one operation only." req = ApprovalRequest( session_key=session_key, title="Execute this command?", description=description, command_preview=command, timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, allow_permanent=allow_permanent and not smart_denied, ) # QQ requires a msg_id for passive replies; the last inbound id is the natural one. return await self.send_approval_request(chat_id, req, reply_to=self._last_msg_id.get(chat_id)) _APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout async def send_update_prompt( self, chat_id: str, prompt: str, default: str = "", session_key: str = "", metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Yes/No update-confirmation prompt; button clicks (``update_prompt:y|n``) are written to ``~/.hermes/.update_response`` by the interaction callback.""" del session_key, metadata # present for contract parity only. default_hint = f" (default: {default})" if default else "" content = f"⚕ **Update Needs Your Input**\n\n{prompt}{default_hint}" return await self.send_with_keyboard( chat_id, content, build_update_prompt_keyboard(), reply_to=self._last_msg_id.get(chat_id) ) def _build_text_body( self, content: str, reply_to: Optional[str] = None ) -> Dict[str, Any]: """Build the message body for C2C/group text sending.""" msg_seq = self._next_msg_seq(reply_to or "default") text = content[: self.MAX_MESSAGE_LENGTH] if self._markdown_support: return {"markdown": {"content": text}, "msg_type": MSG_TYPE_MARKDOWN, "msg_seq": msg_seq} body: Dict[str, Any] = {"content": text, "msg_type": MSG_TYPE_TEXT, "msg_seq": msg_seq} if reply_to: body["message_reference"] = {"message_id": reply_to} return body # ------------------------------------------------------------------ # Native media sending # ------------------------------------------------------------------ async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image natively via QQ Bot API upload; URL sources fall back to text.""" del metadata result = await self._send_media(chat_id, image_url, MEDIA_TYPE_IMAGE, "image", caption, reply_to) if result.success or not self._is_url(image_url): return result logger.warning( "[%s] Image send failed, falling back to text: %s", self._log_tag, result.error ) fallback = f"{caption}\n{image_url}" if caption else image_url return await self.send(chat_id=chat_id, content=fallback, reply_to=reply_to) async def send_image_file(self, chat_id, image_path, caption=None, reply_to=None, **kwargs) -> SendResult: """Send a local image file natively.""" return await self._send_media(chat_id, image_path, MEDIA_TYPE_IMAGE, "image", caption, reply_to) async def send_voice(self, chat_id, audio_path, caption=None, reply_to=None, **kwargs) -> SendResult: """Send a voice message natively.""" return await self._send_media(chat_id, audio_path, MEDIA_TYPE_VOICE, "voice", caption, reply_to) async def send_video(self, chat_id, video_path, caption=None, reply_to=None, **kwargs) -> SendResult: """Send a video natively.""" return await self._send_media(chat_id, video_path, MEDIA_TYPE_VIDEO, "video", caption, reply_to) async def send_document( self, chat_id, file_path, caption=None, file_name=None, reply_to=None, **kwargs ) -> SendResult: """Send a file/document natively.""" return await self._send_media( chat_id, file_path, MEDIA_TYPE_FILE, "file", caption, reply_to, file_name=file_name ) async def _send_media( self, chat_id: str, media_source: str, file_type: int, kind: str, caption: Optional[str] = None, reply_to: Optional[str] = None, file_name: Optional[str] = None, ) -> SendResult: """Upload media and send as a native message. HTTP(S) URLs → single ``POST .../files`` with ``url=`` (QQ fetches it). Local files → chunked upload (prepare / PUT parts / complete), up to the platform's ~100 MB per-file limit. """ if not await self._ensure_connected(): return self._NOT_CONNECTED chat_type = self._guess_chat_type(chat_id) if chat_type == "guild": return SendResult(success=False, error="Guild media send not supported via this path") try: if self._is_url(media_source): resolved_name = file_name or Path(urlparse(media_source).path).name or "media" upload = await self._upload_media( chat_type, chat_id, file_type, url=media_source, srv_send_msg=False, file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None, ) else: resolved_name, upload = await self._upload_local_file( chat_type, chat_id, media_source, file_type, file_name ) file_info = upload.get("file_info") or (upload.get("data", {}) or {}).get("file_info") if not file_info: return SendResult(success=False, error=f"Upload returned no file_info: {upload}") body: Dict[str, Any] = { "msg_type": MSG_TYPE_MEDIA, "media": {"file_info": file_info}, "msg_seq": self._next_msg_seq(chat_id), } if caption: body["content"] = caption[: self.MAX_MESSAGE_LENGTH] if reply_to: body["msg_id"] = reply_to return await self._post_message(self._messages_path(chat_type, chat_id), body) except UploadDailyLimitExceededError as exc: # Non-retryable quota hit; give the model actionable text. logger.warning( "[%s] Daily upload limit exceeded for %s (%s)", self._log_tag, exc.file_name, exc.file_size_human, ) return SendResult( success=False, error=( f"QQ daily upload limit exceeded for {exc.file_name!r} " f"({exc.file_size_human}). Retry tomorrow." ), retryable=False, ) except UploadFileTooLargeError as exc: logger.warning( "[%s] File too large: %s (%s, platform limit %s)", self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human, ) return SendResult( success=False, error=( f"{exc.file_name!r} ({exc.file_size_human}) exceeds the " f"QQ per-file upload limit ({exc.limit_human})." ), retryable=False, ) except Exception as exc: logger.error("[%s] Media send failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) async def _upload_local_file( self, chat_type: str, chat_id: str, media_source: str, file_type: int, file_name: Optional[str], ) -> Tuple[str, Dict[str, Any]]: """Chunked-upload a local file; returns ``(resolved_name, complete_response)`` whose ``file_info`` goes into the RichMedia body. Raises UploadDailyLimitExceededError / UploadFileTooLargeError from the uploader, ValueError for placeholder paths like ````, FileNotFoundError. """ if not self._http_client: raise RuntimeError("HTTP client not initialized — not connected?") local_path = Path(media_source).expanduser() if not local_path.is_absolute(): local_path = (Path.cwd() / local_path).resolve() if not local_path.exists() or not local_path.is_file(): if media_source.startswith("<") or len(media_source) < 3: raise ValueError(f"Invalid media source (looks like a placeholder): {media_source!r}") raise FileNotFoundError(f"Media file not found: {local_path}") resolved_name = file_name or local_path.name uploader = ChunkedUploader( api_request=self._api_request, http_put=self._http_client.put, log_tag=self._log_tag ) complete = await uploader.upload( chat_type=chat_type, target_id=chat_id, file_path=str(local_path), file_type=file_type, file_name=resolved_name, ) return resolved_name, complete # ------------------------------------------------------------------ # Typing indicator # ------------------------------------------------------------------ async def send_typing(self, chat_id: str, metadata=None) -> None: """C2C-only input notify, debounced to ~50s (API shows a 60s indicator); needs the last inbound msg_id from ``_last_msg_id``.""" if not self.is_connected or self._guess_chat_type(chat_id) != "c2c": return msg_id = self._last_msg_id.get(chat_id) if not msg_id: return now = time.time() if now - self._typing_sent_at.get(chat_id, 0.0) < self._TYPING_DEBOUNCE_SECONDS: return try: body = { "msg_type": MSG_TYPE_INPUT_NOTIFY, "msg_id": msg_id, "input_notify": {"input_type": 1, "input_second": self._TYPING_INPUT_SECONDS}, "msg_seq": self._next_msg_seq(chat_id), } await self._api_request("POST", f"/v2/users/{chat_id}/messages", body) self._typing_sent_at[chat_id] = now except Exception as exc: logger.debug("[%s] send_typing failed: %s", self._log_tag, exc) # ------------------------------------------------------------------ # Format # ------------------------------------------------------------------ def format_message(self, content: str) -> str: """Pass markdown through when supported, else strip it (as BlueBubbles/SMS do).""" return content if self._markdown_support else strip_markdown(content) # ------------------------------------------------------------------ # Chat info # ------------------------------------------------------------------ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return chat info based on chat type heuristics.""" chat_type = self._guess_chat_type(chat_id) return { "name": chat_id, "type": "group" if chat_type in {"group", "guild"} else "dm", } # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod def _is_url(source: str) -> bool: return urlparse(str(source)).scheme in {"http", "https"} def _guess_chat_type(self, chat_id: str) -> str: """Determine chat type from stored inbound metadata, fallback to 'c2c'.""" return self._chat_type_map.get(chat_id, "c2c") @staticmethod def _strip_at_mention(content: str) -> str: """Strip the @bot mention prefix from group message content.""" return re.sub(r"^@\S+\s*", "", content.strip()) def _open_dm_opted_in(self) -> bool: if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}: return True return _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} def _is_dm_allowed(self, user_id: str) -> bool: if self._dm_policy == "disabled": return False if self._dm_policy == "allowlist": return self._entry_matches(self._allow_from, user_id) if self._dm_policy == "open": return self._open_dm_opted_in() return False def _is_dm_intake_allowed(self, user_id: str) -> bool: principal = str(user_id or "").strip() if not principal: return False if self._dm_policy == "disabled": return False if self._dm_policy == "allowlist": return self._entry_matches(self._allow_from, principal) if self._dm_policy == "pairing": return True if self._dm_policy == "open": return self._open_dm_opted_in() return False def _is_group_allowed(self, group_id: str, user_id: str) -> bool: if self._group_policy == "disabled": return False if self._group_policy == "allowlist": return self._entry_matches(self._group_allow_from, group_id) if self._group_policy == "pairing": return False return self._group_policy == "open" @staticmethod def _entry_matches(entries: List[str], target: str) -> bool: normalized_target = str(target).strip().lower() return any(str(e).strip().lower() in ("*", normalized_target) for e in entries) def _parse_qq_timestamp(self, raw: str) -> datetime: """Parse a QQ timestamp — ISO 8601 string (current) or integer ms (legacy).""" if not raw: return datetime.now(tz=timezone.utc) try: return datetime.fromisoformat(raw) except (ValueError, TypeError): pass try: return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) except (ValueError, TypeError): pass return datetime.now(tz=timezone.utc) def _is_duplicate(self, msg_id: str) -> bool: now = time.time() if len(self._seen_messages) > DEDUP_MAX_SIZE: cutoff = now - DEDUP_WINDOW_SECONDS self._seen_messages = {k: ts for k, ts in self._seen_messages.items() if ts > cutoff} if msg_id in self._seen_messages: return True self._seen_messages[msg_id] = now return False