Files
hermes-agent/gateway/platforms/qqbot/adapter.py
T

2319 lines
96 KiB
Python

"""
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:<platform>:<chat_type>:<chat_id>[:<user_id>]``."""
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:<session_key>:<decision>`` →
tools.approval.resolve_gateway_approval; ``update_prompt:<answer>`` →
``~/.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>``."""
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 ``<path>``, 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