feat(qqbot): 实现QQ机器人适配器完整功能模块

新增QQ Bot适配器完整代码栈,包含:
1. 基础适配器入口与工具类封装
2. 会话管理、重试队列与流量控制
3. 命令系统与内置指令(ping/help/status等)
4. 富媒体消息处理与格式转换
5. 引用存储与审批管理
6. 凭证备份与会话持久化
7. 健康检查与交互回调系统
This commit is contained in:
Kris 2026-05-12 00:48:04 +08:00
parent a1f8288d20
commit 552aef767c
40 changed files with 7532 additions and 0 deletions

View File

@ -0,0 +1,3 @@
from yuxi.channels.adapters.qqbot.adapter import QQBotAdapter
__all__ = ["QQBotAdapter"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
from .manager import ExecApprovalManager, ApprovalRequest, ApprovalStatus
__all__ = ["ExecApprovalManager", "ApprovalRequest", "ApprovalStatus"]

View File

@ -0,0 +1,248 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import time
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any
logger = logging.getLogger(__name__)
class ApprovalStatus(Enum):
PENDING = auto()
APPROVED = auto()
REJECTED = auto()
EXPIRED = auto()
CANCELLED = auto()
@dataclass
class ApprovalRequest:
request_id: str
action: str
description: str
requester_id: str
requester_name: str
params: dict[str, Any] = field(default_factory=dict)
status: ApprovalStatus = ApprovalStatus.PENDING
created_at: float = field(default_factory=time.time)
expires_at: float = 300.0
approver_id: str = ""
approved_at: float = 0.0
def __post_init__(self):
self.expires_at = self.created_at + 300.0
def to_dict(self) -> dict:
return {
"request_id": self.request_id,
"action": self.action,
"description": self.description,
"requester_id": self.requester_id,
"requester_name": self.requester_name,
"params": self.params,
"status": self.status.name,
"created_at": self.created_at,
"expires_at": self.expires_at,
"approver_id": self.approver_id,
"approved_at": self.approved_at,
}
class ExecApprovalManager:
def __init__(
self,
store_dir: str | None = None,
timeout_s: float = 300.0,
max_pending_per_user: int = 5,
):
self._store_dir = store_dir or os.path.join(os.path.dirname(__file__), "..", "approval_data")
self._timeout_s = timeout_s
self._max_pending_per_user = max_pending_per_user
self._requests: dict[str, ApprovalRequest] = {}
self._lock = asyncio.Lock()
self._subscribers: dict[str, asyncio.Event] = {}
self._load()
def _load(self) -> None:
path = os.path.join(self._store_dir, "approval_state.json")
try:
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
data = json.load(f)
for item in data:
req = ApprovalRequest(
request_id=item["request_id"],
action=item["action"],
description=item["description"],
requester_id=item["requester_id"],
requester_name=item["requester_name"],
params=item.get("params", {}),
status=ApprovalStatus[item["status"]],
created_at=item["created_at"],
expires_at=item.get("expires_at", item["created_at"] + 300),
)
self._requests[req.request_id] = req
logger.info("ExecApprovalManager: loaded %d requests", len(self._requests))
except (OSError, json.JSONDecodeError):
logger.exception("ExecApprovalManager: failed to load state")
async def _save(self) -> None:
os.makedirs(self._store_dir, exist_ok=True)
path = os.path.join(self._store_dir, "approval_state.json")
tmp = path + ".tmp"
data = [r.to_dict() for r in self._requests.values()]
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
except OSError:
logger.exception("ExecApprovalManager: failed to save")
async def request_approval(
self,
action: str,
description: str,
requester_id: str,
requester_name: str = "",
params: dict[str, Any] | None = None,
) -> ApprovalRequest | None:
async with self._lock:
pending = sum(
1
for r in self._requests.values()
if r.requester_id == requester_id and r.status == ApprovalStatus.PENDING
)
if pending >= self._max_pending_per_user:
logger.warning(
"ExecApprovalManager: user %s has %d pending requests",
requester_id,
pending,
)
return None
req_id = hashlib.sha256(f"{requester_id}:{action}:{time.time()}".encode()).hexdigest()[:12]
req = ApprovalRequest(
request_id=req_id,
action=action,
description=description,
requester_id=requester_id,
requester_name=requester_name,
params=params or {},
)
self._requests[req_id] = req
await self._save()
logger.info("ExecApprovalManager: created request %s for %s", req_id, action)
return req
async def approve(self, request_id: str, approver_id: str) -> ApprovalRequest | None:
async with self._lock:
req = self._requests.get(request_id)
if req is None:
return None
if req.status != ApprovalStatus.PENDING:
return req
req.status = ApprovalStatus.APPROVED
req.approver_id = approver_id
req.approved_at = time.time()
await self._save()
event = self._subscribers.pop(request_id, None)
if event:
event.set()
logger.info("ExecApprovalManager: approved %s by %s", request_id, approver_id)
return req
async def reject(self, request_id: str, approver_id: str, reason: str = "") -> ApprovalRequest | None:
async with self._lock:
req = self._requests.get(request_id)
if req is None:
return None
if req.status != ApprovalStatus.PENDING:
return req
req.status = ApprovalStatus.REJECTED
req.approver_id = approver_id
req.approved_at = time.time()
await self._save()
event = self._subscribers.pop(request_id, None)
if event:
event.set()
logger.info(
"ExecApprovalManager: rejected %s by %s reason=%s",
request_id,
approver_id,
reason,
)
return req
async def wait_for_approval(self, request_id: str, timeout_s: float | None = None) -> ApprovalRequest:
event = asyncio.Event()
self._subscribers[request_id] = event
timeout = timeout_s or self._timeout_s
try:
await asyncio.wait_for(event.wait(), timeout=timeout)
except TimeoutError:
async with self._lock:
req = self._requests.get(request_id)
if req and req.status == ApprovalStatus.PENDING:
req.status = ApprovalStatus.EXPIRED
await self._save()
logger.warning("ExecApprovalManager: request %s expired", request_id)
finally:
self._subscribers.pop(request_id, None)
return self._requests.get(
request_id,
ApprovalRequest(
request_id=request_id,
action="unknown",
description="",
requester_id="",
status=ApprovalStatus.EXPIRED,
),
)
async def cancel(self, request_id: str, requester_id: str) -> bool:
async with self._lock:
req = self._requests.get(request_id)
if req is None or req.requester_id != requester_id:
return False
if req.status != ApprovalStatus.PENDING:
return False
req.status = ApprovalStatus.CANCELLED
await self._save()
event = self._subscribers.pop(request_id, None)
if event:
event.set()
return True
async def list_pending(self, requester_id: str = "") -> list[ApprovalRequest]:
async with self._lock:
self._cleanup_expired()
return [
r
for r in self._requests.values()
if r.status == ApprovalStatus.PENDING and (not requester_id or r.requester_id == requester_id)
]
def _cleanup_expired(self) -> None:
now = time.time()
for req_id, req in list(self._requests.items()):
if req.status == ApprovalStatus.PENDING and now >= req.expires_at:
req.status = ApprovalStatus.EXPIRED
logger.info("ExecApprovalManager: cleaned up expired request %s", req_id)

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any
def build_ark_23(kv_pairs: list[dict[str, str]]) -> dict:
return {
"msg_type": 3,
"ark": {
"template_id": 23,
"kv": kv_pairs,
},
}
def build_ark_24(desc: str, prompt: str, title: str, meta_desc: str, img: str, jump_url: str) -> dict:
return {
"msg_type": 3,
"ark": {
"template_id": 24,
"kv": [
{"key": "#DESC#", "value": desc},
{"key": "#PROMPT#", "value": prompt},
{"key": "#TITLE#", "value": title},
{"key": "#METADESC#", "value": meta_desc},
{"key": "#IMG#", "value": img},
{"key": "#JUMPURL#", "value": jump_url},
],
},
}
def build_ark_37(prompt: str, title: str, subtitle: str, jump_url: str) -> dict:
return {
"msg_type": 3,
"ark": {
"template_id": 37,
"kv": [
{"key": "#PROMPT#", "value": prompt},
{"key": "#TITLE#", "value": title},
{"key": "#SUBTITLE#", "value": subtitle},
{"key": "#JUMPURL#", "value": jump_url},
],
},
}
def build_embed(
title: str = "",
description: str = "",
prompt: str = "",
fields: list[dict] | None = None,
thumbnail: str | None = None,
) -> dict:
result: dict[str, Any] = {
"msg_type": 4,
"embed": {
"title": title,
"description": description[:4096],
"prompt": prompt or description[:200],
"fields": fields or [],
},
}
if thumbnail:
result["embed"]["thumbnail"] = {"url": thumbnail}
return result
def build_text_card(text: str, buttons: list[dict] | None = None) -> dict:
payload: dict[str, Any] = {
"msg_type": 0,
"content": text,
}
if buttons:
payload["keyboard"] = {"content": {"rows": [{"buttons": buttons}]}}
return payload
def _kv(key: str, value: str) -> dict[str, str]:
return {"key": key, "value": value}

View File

@ -0,0 +1,412 @@
from __future__ import annotations
import base64
import logging
import struct
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class AudioFormat(Enum):
MP3 = "mp3"
WAV = "wav"
OGG = "ogg"
SILK = "silk"
PCM = "pcm"
AAC = "aac"
AMR = "amr"
_AUDIO_MIME_MAP: dict[str, str] = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"ogg": "audio/ogg",
"silk": "audio/silk",
"pcm": "audio/pcm",
"aac": "audio/aac",
"amr": "audio/amr",
}
_AUDIO_EXT_MAP: dict[str, str] = {
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
"audio/silk": ".silk",
"audio/pcm": ".pcm",
"audio/aac": ".aac",
"audio/amr": ".amr",
}
_SUPPORTED_BITRATES: dict[str, list[int]] = {
"mp3": [8000, 16000, 32000, 64000, 128000],
"wav": [8000, 16000, 44100],
"pcm": [8000, 16000, 24000, 44100],
"aac": [16000, 32000, 64000],
"ogg": [16000, 32000, 48000],
}
def get_mime_type(fmt: AudioFormat) -> str:
return _AUDIO_MIME_MAP.get(fmt.value, "audio/mpeg")
def get_extension(fmt: AudioFormat) -> str:
mime = _AUDIO_MIME_MAP.get(fmt.value, "")
return _AUDIO_EXT_MAP.get(mime, ".mp3")
def get_supported_bitrates(fmt: AudioFormat) -> list[int]:
return _SUPPORTED_BITRATES.get(fmt.value, [16000])
def data_uri_to_bytes(data_uri: str) -> tuple[bytes, str]:
if "," not in data_uri:
return base64.b64decode(data_uri), "audio/mpeg"
header, b64_data = data_uri.split(",", 1)
mime = "audio/mpeg"
if "data:" in header:
mime = header.split(":")[1].split(";")[0] if ";" in header.split(":")[1] else header.split(":")[1]
return base64.b64decode(b64_data), mime
def get_audio_duration_s(audio_data: bytes, fmt: AudioFormat) -> float:
if fmt == AudioFormat.WAV:
return _wav_duration(audio_data)
if fmt == AudioFormat.MP3:
return _mp3_estimate_duration(audio_data)
return len(audio_data) / 16000.0
def _wav_duration(data: bytes) -> float:
try:
if len(data) < 44:
return 0.0
byte_rate = struct.unpack_from("<I", data, 28)[0]
data_size = struct.unpack_from("<I", data, 40)[0]
if byte_rate == 0:
return 0.0
return data_size / byte_rate
except (struct.error, IndexError):
return 0.0
def _mp3_estimate_duration(data: bytes) -> float:
return len(data) / 16000.0
def calculate_audio_size(
duration_s: float,
sample_rate: int = 16000,
channels: int = 1,
sample_width: int = 2,
) -> int:
return int(duration_s * sample_rate * channels * sample_width)
@dataclass
class AudioFormatPolicy:
transcode_enabled: bool = True
upload_direct_formats: list[str] = field(default_factory=lambda: ["wav", "mp3"])
stt_direct_formats: list[str] = field(default_factory=lambda: ["wav", "mp3", "pcm"])
fallback_format: AudioFormat = field(default=AudioFormat.MP3)
sample_rate: int = 16000
channels: int = 1
bitrate: int = 32000
def needs_transcode(self, fmt: AudioFormat) -> bool:
if not self.transcode_enabled:
return False
return fmt.value not in self.upload_direct_formats
def can_stt_direct(self, fmt: AudioFormat) -> bool:
return fmt.value in self.stt_direct_formats
@classmethod
def from_config(cls, config: dict | None) -> AudioFormatPolicy:
if not config:
return cls()
policy_cfg = config.get("audio_format_policy", {})
return cls(
transcode_enabled=policy_cfg.get("transcode_enabled", True),
upload_direct_formats=policy_cfg.get("upload_direct_formats", ["wav", "mp3"]),
stt_direct_formats=policy_cfg.get("stt_direct_formats", ["wav", "mp3", "pcm"]),
fallback_format=AudioFormat(policy_cfg.get("fallback_format", "mp3")),
sample_rate=policy_cfg.get("sample_rate", 16000),
channels=policy_cfg.get("channels", 1),
bitrate=policy_cfg.get("bitrate", 32000),
)
class STTProvider:
def __init__(
self,
provider: str = "",
api_key: str = "",
region: str = "",
model: str = "",
):
self._provider = provider.lower() or "builtin"
self._api_key = api_key
self._region = region
self._model = model
async def transcribe(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str:
if self._provider == "azure":
return await self._transcribe_azure(audio_data)
elif self._provider == "whisper":
return await self._transcribe_whisper(audio_data, fmt)
else:
return await self._transcribe_builtin(audio_data, fmt)
async def _transcribe_azure(self, audio_data: bytes) -> str:
import aiohttp
try:
async with aiohttp.ClientSession() as session:
url = (
f"https://{self._region}.stt.speech.microsoft.com/"
"speech/recognition/conversation/cognitiveservices/v1"
"?language=zh-CN&format=detailed"
)
headers = {
"Ocp-Apim-Subscription-Key": self._api_key,
"Content-Type": "audio/wav",
}
async with session.post(url, data=audio_data, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("DisplayText", "")
logger.warning("STT: Azure returned status %d", resp.status)
except Exception:
logger.exception("STT: Azure transcription failed")
return ""
async def _transcribe_whisper(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str:
import json
import os
import subprocess
import tempfile
ext = ".wav" if fmt is None else get_extension(fmt)
tmpdir = tempfile.mkdtemp()
in_path = os.path.join(tmpdir, f"stt_input{ext}")
try:
with open(in_path, "wb") as f:
f.write(audio_data)
subprocess.run(
[
"whisper",
in_path,
"--model",
self._model or "base",
"--output_format",
"json",
"--output_dir",
tmpdir,
"--language",
"zh",
],
capture_output=True,
timeout=120,
check=False,
)
json_path = os.path.join(tmpdir, "stt_input.json")
if os.path.exists(json_path):
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
return data.get("text", "")
except Exception:
logger.exception("STT: Whisper transcription failed")
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
return ""
async def _transcribe_builtin(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str:
try:
import speech_recognition as sr
ext = ".wav" if fmt is None else get_extension(fmt)
import tempfile
import os
tmpdir = tempfile.mkdtemp()
in_path = os.path.join(tmpdir, f"stt_input{ext}")
with open(in_path, "wb") as f:
f.write(audio_data)
recognizer = sr.Recognizer()
with sr.AudioFile(in_path) as source:
audio = recognizer.record(source)
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
return recognizer.recognize_google(audio, language="zh-CN")
except ImportError:
logger.warning("STT: speech_recognition not installed")
except Exception:
logger.exception("STT: builtin transcription failed")
return ""
@classmethod
def from_config(cls, config: dict | None) -> STTProvider:
if not config:
return cls()
stt_cfg = config.get("stt", {})
return cls(
provider=stt_cfg.get("provider", "") or config.get("stt_provider", ""),
api_key=stt_cfg.get("api_key", "") or config.get("stt_api_key", ""),
region=stt_cfg.get("region", "") or config.get("stt_region", ""),
model=stt_cfg.get("model", "") or config.get("stt_model", ""),
)
class TTSProvider:
def __init__(
self,
send_fn: Callable[..., Any] | None = None,
default_voice: str = "zh-CN-XiaoxiaoNeural",
default_format: AudioFormat = AudioFormat.MP3,
):
self._send_fn = send_fn
self._default_voice = default_voice
self._default_format = default_format
self._cache: dict[str, bytes] = {}
async def synthesize(self, text: str, voice: str = "", fmt: AudioFormat | None = None) -> bytes:
cache_key = f"{text}:{voice}:{fmt.value if fmt else self._default_format.value}"
cached = self._cache.get(cache_key)
if cached:
return cached
import os
tts_provider = os.environ.get("QQBOT_TTS_PROVIDER", "builtin").lower()
if tts_provider == "azure":
result = await self._synthesize_azure(text, voice or self._default_voice, fmt or self._default_format)
elif tts_provider == "edge":
result = await self._synthesize_edge(text, voice or self._default_voice, fmt or self._default_format)
else:
result = await self._synthesize_builtin(text)
self._cache[cache_key] = result
return result
async def _synthesize_builtin(self, text: str) -> bytes:
import os
import subprocess
import tempfile
text_encoded = text.replace('"', '\\"')
tmpdir = tempfile.mkdtemp()
out_path = os.path.join(tmpdir, "tts_output.mp3")
try:
subprocess.run(
[
"python",
"-c",
f"import pyttsx3; e=pyttsx3.init(); e.save_to_file('{text_encoded}','{out_path}'); e.runAndWait()",
],
capture_output=True,
timeout=30,
check=False,
)
if os.path.exists(out_path):
with open(out_path, "rb") as f:
return f.read()
except Exception:
logger.exception("TTS: builtin synthesis failed")
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
return self._generate_silence(0.5)
async def _synthesize_azure(self, text: str, voice: str, fmt: AudioFormat) -> bytes:
import os
import aiohttp
key = os.environ.get("AZURE_TTS_KEY", "")
region = os.environ.get("AZURE_TTS_REGION", "eastasia")
if not key:
logger.warning("TTS: Azure key not configured")
return self._generate_silence(0.5)
ssml = (
f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN">'
f'<voice name="{voice}">{text}</voice></speak>'
)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1",
headers={
"Ocp-Apim-Subscription-Key": key,
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "audio-16khz-32kbitrate-mono-mp3",
},
data=ssml.encode("utf-8"),
) as resp:
if resp.status == 200:
return await resp.read()
logger.warning("TTS: Azure returned status %d", resp.status)
except Exception:
logger.exception("TTS: Azure synthesis failed")
return self._generate_silence(0.5)
async def _synthesize_edge(self, text: str, voice: str, fmt: AudioFormat) -> bytes:
try:
import aiohttp
ssml = (
f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN">'
f'<voice name="{voice}">{text}</voice></speak>'
)
async with aiohttp.ClientSession() as session:
async with session.post(
"https://speech.platform.bing.com/consumer/speech/synthesize/"
"readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4",
headers={
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "audio-16khz-32kbitrate-mono-mp3",
},
data=ssml.encode("utf-8"),
) as resp:
if resp.status == 200:
return await resp.read()
except Exception:
logger.exception("TTS: Edge synthesis failed")
return self._generate_silence(0.5)
@staticmethod
def _generate_silence(duration_s: float) -> bytes:
sample_rate = 16000
samples = int(duration_s * sample_rate)
silence = b"\x00" * (samples * 2)
return silence
def clear_cache(self) -> None:
self._cache.clear()

View File

@ -0,0 +1,343 @@
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import Enum, auto
from collections.abc import AsyncGenerator
logger = logging.getLogger(__name__)
class StreamEventType(Enum):
FLUSH = auto()
COMPLETE = auto()
ERROR = auto()
class FlushStrategy(Enum):
PER_CHAR = auto()
INTERVAL = auto()
BACKLOG = auto()
@dataclass
class StreamEvent:
event_type: StreamEventType
content: str = ""
full_content: str = ""
error: str | None = None
@dataclass
class StreamCheckpoint:
chat_id: str
msg_id: str = ""
content: str = ""
sent_seq: int = 0
timestamp: float = field(default_factory=time.time)
@property
def is_stale(self) -> bool:
return time.time() - self.timestamp > 600
class FlushController:
strategy: FlushStrategy = FlushStrategy.BACKLOG
flush_interval: float = 0.6
backlog_threshold: int = 30
max_retries: int = 3
retry_delay: float = 1.0
seq_counter: int = 0
_buffer: list[str] = field(default_factory=list, repr=False)
_last_flush: float = 0.0
_accumulated: str = ""
def reset(self) -> None:
self._buffer = []
self._last_flush = 0.0
self._accumulated = ""
self.seq_counter = 0
def feed(self, chunk: str) -> list[str]:
self._buffer.append(chunk)
self._accumulated += chunk
if self.strategy == FlushStrategy.PER_CHAR:
return self._flush_all()
if self.strategy == FlushStrategy.BACKLOG and len(self._accumulated) >= self.backlog_threshold:
return self._flush_all()
now = time.monotonic()
if self.strategy == FlushStrategy.INTERVAL and now - self._last_flush >= self.flush_interval:
return self._flush_all()
return []
def flush_remaining(self) -> tuple[list[str], str]:
if self.strategy == FlushStrategy.PER_CHAR:
return [], self._accumulated
batches = self._flush_all() if self._buffer else []
return batches, self._accumulated
def _flush_all(self) -> list[str]:
if not self._buffer:
return []
batches = list(self._buffer)
self._buffer = []
self._accumulated = ""
self._last_flush = time.monotonic()
return batches
class C2CStreamingController:
def __init__(
self,
send_message_fn,
retry_delay: float = 1.0,
max_retries: int = 3,
flush_interval: float = 0.6,
) -> None:
self._send_message_fn = send_message_fn
self._retry_delay = retry_delay
self._max_retries = max_retries
self._flush_interval = flush_interval
self._flush_controller = FlushController(strategy=FlushStrategy.BACKLOG, flush_interval=flush_interval)
self._any_chunk_delivered: bool = False
self._static_fallback_msg: str = ""
self._active_checkpoints: dict[str, StreamCheckpoint] = {}
@property
def flush_controller(self) -> FlushController:
return self._flush_controller
async def stream(
self,
chat_id: str,
msg_id: str,
content_generator: AsyncGenerator[str, None],
event_id: str = "",
) -> bool:
self._flush_controller.reset()
self._any_chunk_delivered = False
self._static_fallback_msg = ""
checkpoint = StreamCheckpoint(chat_id=chat_id, msg_id=msg_id)
self._active_checkpoints[chat_id] = checkpoint
collected = ""
in_media_interrupt = False
media_buffer = ""
try:
async for chunk in content_generator:
if not chunk:
continue
media_splits = self._split_on_media_tags(chunk)
for segment, is_media in media_splits:
if is_media:
in_media_interrupt = True
media_buffer += segment
else:
if in_media_interrupt and media_buffer:
checkpoint.content = collected
await self._flush_and_end_stream(chat_id, msg_id, checkpoint, event_id)
await self._send_media_interruption(chat_id, media_buffer)
media_buffer = ""
in_media_interrupt = False
new_msg_id = f"{msg_id}_resume_{checkpoint.sent_seq}"
checkpoint.msg_id = new_msg_id
self._flush_controller.reset()
collected += segment
batches = self._flush_controller.feed(segment)
for batch in batches:
self._flush_controller.seq_counter += 1
success = await self._send_stream_chunk(
chat_id,
checkpoint.msg_id or msg_id,
batch,
self._flush_controller.seq_counter,
event_id,
)
if success:
self._any_chunk_delivered = True
else:
logger.warning(
"C2CStreaming: flush failed for seq %d", self._flush_controller.seq_counter
)
self._static_fallback_msg = collected
flush_batches, full = self._flush_controller.flush_remaining()
for batch in flush_batches:
self._flush_controller.seq_counter += 1
success = await self._send_stream_chunk(
chat_id,
checkpoint.msg_id or msg_id,
batch,
self._flush_controller.seq_counter,
event_id,
)
if success:
self._any_chunk_delivered = True
self._flush_controller.seq_counter += 1
success = await self._send_stream_chunk(
chat_id,
checkpoint.msg_id or msg_id,
"",
self._flush_controller.seq_counter,
event_id,
is_end=True,
)
self._active_checkpoints.pop(chat_id, None)
return success
except Exception as e:
logger.exception("C2CStreaming: stream error for %s", chat_id)
self._active_checkpoints.pop(chat_id, None)
return await self._cancel_stream(chat_id, msg_id, event_id, str(e))
async def _send_stream_chunk(
self,
chat_id: str,
msg_id: str,
content: str,
msg_seq: int,
event_id: str = "",
is_end: bool = False,
) -> bool:
for attempt in range(self._max_retries):
try:
payload = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
"msg_seq": msg_seq,
"stream": {"state": 2 if is_end else 1},
}
if event_id:
payload["event_id"] = event_id
response = await self._send_message_fn(chat_id, payload)
if response is not None:
cp = self._active_checkpoints.get(chat_id)
if cp:
cp.sent_seq = msg_seq
cp.timestamp = time.time()
return response is not None
except Exception:
if attempt < self._max_retries - 1:
await asyncio.sleep(self._retry_delay)
else:
logger.exception("C2CStreaming: send chunk failed after %d retries", self._max_retries)
return False
@staticmethod
def _split_on_media_tags(text: str) -> list[tuple[str, bool]]:
import re
from .media_tags import _MEDIA_TAG_RE, _IMG_TAG_RE, _VIDEO_TAG_RE
combined_re = re.compile(f"({_MEDIA_TAG_RE.pattern}|{_IMG_TAG_RE.pattern}|{_VIDEO_TAG_RE.pattern})")
results: list[tuple[str, bool]] = []
last_end = 0
for match in combined_re.finditer(text):
if match.start() > last_end:
results.append((text[last_end : match.start()], False))
results.append((match.group(0), True))
last_end = match.end()
if last_end < len(text):
results.append((text[last_end:], False))
return results
async def _flush_and_end_stream(
self, chat_id: str, msg_id: str, checkpoint: StreamCheckpoint, event_id: str
) -> None:
flush_batches, _ = self._flush_controller.flush_remaining()
for batch in flush_batches:
self._flush_controller.seq_counter += 1
await self._send_stream_chunk(
chat_id,
msg_id,
batch,
self._flush_controller.seq_counter,
event_id,
)
self._flush_controller.seq_counter += 1
await self._send_stream_chunk(
chat_id,
msg_id,
"",
self._flush_controller.seq_counter,
event_id,
is_end=True,
)
async def _send_media_interruption(self, chat_id: str, media_tags: str) -> None:
try:
payload = {
"content": media_tags,
"msg_type": 0,
"msg_id": "",
"msg_seq": 0,
}
await self._send_message_fn(chat_id, payload)
except Exception:
logger.exception("C2CStreaming: media interruption send failed")
async def _cancel_stream(self, chat_id: str, msg_id: str, event_id: str, reason: str) -> bool:
delivered_fallback = False
if self._static_fallback_msg and not self._any_chunk_delivered:
try:
static_payload = {
"content": self._static_fallback_msg[:2000],
"msg_type": 0,
"msg_id": msg_id,
"msg_seq": 0,
}
if event_id:
static_payload["event_id"] = event_id
await self._send_message_fn(chat_id, static_payload)
delivered_fallback = True
logger.info("C2CStreaming: delivered static fallback message for %s", chat_id)
except Exception:
logger.exception("C2CStreaming: static fallback delivery failed for %s", chat_id)
if not delivered_fallback:
try:
payload = {
"content": reason,
"msg_type": 0,
"msg_id": msg_id,
"msg_seq": 0,
"stream": {"state": 0},
}
if event_id:
payload["event_id"] = event_id
await self._send_message_fn(chat_id, payload)
except Exception:
pass
return False
def get_checkpoint(self, chat_id: str) -> StreamCheckpoint | None:
return self._active_checkpoints.get(chat_id)
def has_pending_stream(self, chat_id: str) -> bool:
cp = self._active_checkpoints.get(chat_id)
return cp is not None and not cp.is_stale
def cleanup_stale_checkpoints(self) -> int:
stale = [cid for cid, cp in self._active_checkpoints.items() if cp.is_stale]
for cid in stale:
self._active_checkpoints.pop(cid, None)
return len(stale)

View File

@ -0,0 +1,4 @@
from .framework import CommandRegistry, CommandContext, CommandResult
from .builtin import register_builtin_commands
__all__ = ["CommandRegistry", "CommandContext", "CommandResult", "register_builtin_commands"]

View File

@ -0,0 +1,102 @@
from __future__ import annotations
import time
from .framework import CommandRegistry, CommandContext, CommandResult
_start_time = time.time()
async def _cmd_ping(ctx: CommandContext) -> CommandResult:
uptime = time.time() - _start_time
hours = int(uptime // 3600)
minutes = int((uptime % 3600) // 60)
seconds = int(uptime % 60)
msg = ctx.msg
user = msg.identity.channel_user_id or "Unknown"
return CommandResult(
success=True,
message=f"Pong! 在线时间: {hours}h {minutes}m {seconds}s\n发送者: {user}",
)
async def _cmd_help(ctx: CommandContext) -> CommandResult:
registry = ctx.adapter._command_registry if hasattr(ctx.adapter, "_command_registry") else None
if registry is None:
return CommandResult(success=True, message="命令系统未初始化。")
lines = ["可用命令:"]
seen: set[str] = set()
for name in sorted(registry.command_names):
cmd = registry._commands.get(name)
if cmd and name not in seen:
seen.add(name)
desc = cmd.description or "(无描述)"
usage = f" - 用法: {cmd.usage}" if cmd.usage else ""
lines.append(f" /{name} - {desc}{usage}")
return CommandResult(success=True, message="\n".join(lines))
async def _cmd_status(ctx: CommandContext) -> CommandResult:
adapter = ctx.adapter
status = getattr(adapter, "status", "unknown")
reconnect = getattr(adapter, "_reconnect_manager", None)
reconnect_state = reconnect.state.name if reconnect else "N/A"
circuit_breaker = getattr(adapter, "_circuit_breaker", None)
cb_state = "open" if circuit_breaker and circuit_breaker.is_open else "closed"
return CommandResult(
success=True,
message=(f"状态: {status}\n重连状态: {reconnect_state}\n熔断器: {cb_state}"),
)
async def _cmd_clearlogs(ctx: CommandContext) -> CommandResult:
adapter = ctx.adapter
known_users = getattr(adapter, "_known_users", None)
group_buffer = getattr(adapter, "_group_buffer", None)
if known_users:
known_users.clear()
if group_buffer:
group_buffer.gc()
return CommandResult(success=True, message="日志已清理(已知用户记录 + 过期群聊缓冲)。")
def register_builtin_commands(registry: CommandRegistry) -> CommandRegistry:
registry.register(
name="ping",
handler=_cmd_ping,
description="检测 Bot 是否在线",
usage="/ping",
aliases=["p"],
)
registry.register(
name="help",
handler=_cmd_help,
description="显示所有可用命令",
usage="/help",
aliases=["h"],
)
registry.register(
name="status",
handler=_cmd_status,
description="查看 Bot 运行状态",
usage="/status",
aliases=["st"],
)
registry.register(
name="clearlogs",
handler=_cmd_clearlogs,
description="清理运行日志和缓存",
usage="/clearlogs",
)
return registry

View File

@ -0,0 +1,125 @@
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from yuxi.channels.models import ChannelMessage
logger = logging.getLogger(__name__)
@dataclass
class CommandContext:
command_name: str
args: list[str]
raw_content: str
msg: ChannelMessage
adapter: object
@dataclass
class CommandResult:
success: bool
message: str = ""
error: str | None = None
CommandHandler = Callable[[CommandContext], Awaitable[CommandResult]]
@dataclass
class CommandDef:
name: str
handler: CommandHandler
description: str = ""
usage: str = ""
aliases: list[str] = field(default_factory=list)
class CommandRegistry:
def __init__(self):
self._commands: dict[str, CommandDef] = {}
def register(
self,
name: str,
handler: CommandHandler,
description: str = "",
usage: str = "",
aliases: list[str] | None = None,
) -> None:
cmd = CommandDef(
name=name,
handler=handler,
description=description,
usage=usage,
aliases=aliases or [],
)
self._commands[name] = cmd
for alias in cmd.aliases:
self._commands[alias] = cmd
logger.debug("CommandRegistry: registered /%s", name)
def unregister(self, name: str) -> None:
cmd = self._commands.pop(name, None)
if cmd:
for alias in cmd.aliases:
self._commands.pop(alias, None)
def resolve(self, content: str) -> tuple[str, list[str]] | None:
if not content.startswith("/"):
return None
parts = content[1:].strip().split(maxsplit=1)
if not parts:
return None
command_name = parts[0].lower()
args = parts[1].split() if len(parts) > 1 else []
return command_name, args
async def dispatch(
self,
command_name: str,
args: list[str],
raw_content: str,
msg: ChannelMessage,
adapter: object,
) -> CommandResult:
cmd = self._commands.get(command_name)
if cmd is None:
available = ", ".join(sorted(set(c.name for c in self._commands.values())))
return CommandResult(
success=False,
message=f"未知命令: /{command_name}。可用命令: {available}",
error="unknown_command",
)
ctx = CommandContext(
command_name=command_name,
args=args,
raw_content=raw_content,
msg=msg,
adapter=adapter,
)
try:
return await cmd.handler(ctx)
except Exception as e:
logger.exception("Command /%s failed", command_name)
return CommandResult(
success=False,
message=f"命令执行失败: {e}",
error=str(e),
)
@property
def command_names(self) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for name, cmd in self._commands.items():
if name not in seen:
seen.add(name)
result.append(name)
return result

View File

@ -0,0 +1,105 @@
"""QQ Bot Gateway 常量定义"""
class Opcode:
DISPATCH = 0
HEARTBEAT = 1
IDENTIFY = 2
RESUME = 6
RECONNECT = 7
INVALID_SESSION = 9
HELLO = 10
HEARTBEAT_ACK = 11
class ECode:
UNKNOWN_ERROR = 4000
UNKNOWN_OPCODE = 4001
DECODE_ERROR = 4002
NOT_AUTHENTICATED = 4003
AUTHENTICATION_FAILED = 4004
RATE_LIMITED = 4008
SESSION_TIMEOUT = 4009
INVALID_SHARD = 4010
INVALID_SHARD_COUNT = 4011
INVALID_INTENT = 4012
INVALID_API_VERSION = 4013
INVALID_SEQ = 4014
BOT_REMOVED = 4100
ACCOUNT_BANNED = 4101
FATAL_CLOSE = 4914
FATAL_ERROR = 4915
class Intent:
GUILDS = 1 << 0
GUILD_MEMBERS = 1 << 1
GUILD_MESSAGES = 1 << 9
GUILD_MESSAGE_REACTIONS = 1 << 10
GUILD_DIRECT_MESSAGES = 1 << 12
GROUP_AND_C2C = 1 << 25
INTERACTION = 1 << 26
AUDIO_ACTION = 1 << 29
AT_MESSAGES = 1 << 30
DEFAULT = GUILD_MESSAGES | AT_MESSAGES | GUILD_DIRECT_MESSAGES
ALL = DEFAULT | GROUP_AND_C2C | INTERACTION
class EventType:
READY = "READY"
RESUMED = "RESUMED"
AT_MESSAGE_CREATE = "AT_MESSAGE_CREATE"
DIRECT_MESSAGE_CREATE = "DIRECT_MESSAGE_CREATE"
C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE"
GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE"
MESSAGE_CREATE = "MESSAGE_CREATE"
INTERACTION_CREATE = "INTERACTION_CREATE"
MESSAGE_DELETE = "MESSAGE_DELETE"
PUBLIC_MESSAGE_DELETE = "PUBLIC_MESSAGE_DELETE"
CLOSE_CODE_MAP: dict[int, str] = {
ECode.UNKNOWN_ERROR: "unknown_error",
ECode.UNKNOWN_OPCODE: "unknown_opcode",
ECode.DECODE_ERROR: "decode_error",
ECode.NOT_AUTHENTICATED: "not_authenticated",
ECode.AUTHENTICATION_FAILED: "authentication_failed",
ECode.RATE_LIMITED: "rate_limited",
ECode.SESSION_TIMEOUT: "session_timeout",
ECode.INVALID_SHARD: "invalid_shard",
ECode.INVALID_SHARD_COUNT: "invalid_shard_count",
ECode.INVALID_INTENT: "invalid_intent",
ECode.INVALID_API_VERSION: "invalid_api_version",
ECode.INVALID_SEQ: "invalid_seq",
ECode.BOT_REMOVED: "bot_removed",
ECode.ACCOUNT_BANNED: "account_banned",
4015: "invalid_shard_id",
4102: "bot_removed_group",
}
SERVER_CLOSE_CODE_MAP: dict[int, str] = {
4900: "server_internal_error",
4901: "server_overload",
4902: "server_maintenance",
4903: "server_network_error",
4904: "gateway_overload",
4905: "gateway_maintenance",
4906: "gateway_internal_error",
4907: "gateway_network_error",
4908: "gateway_unavailable",
4909: "service_degraded",
4910: "database_error",
4911: "cache_error",
4912: "rate_limit_server",
4913: "server_timeout",
}
DM_CHAT_PREFIX = "dm_"
GROUP_CHAT_PREFIX = "group_"
WS_OP_DISPATCH = Opcode.DISPATCH
WS_OP_HEARTBEAT = Opcode.HEARTBEAT
WS_OP_HEARTBEAT_ACK = Opcode.HEARTBEAT_ACK
WS_OP_HELLO = Opcode.HELLO
WS_OP_IDENTIFY = Opcode.IDENTIFY
WS_OP_INVALID_SESSION = Opcode.INVALID_SESSION
WS_OP_RECONNECT = Opcode.RECONNECT

View File

@ -0,0 +1,135 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
DEFAULT_BACKUP_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_credentials")
@dataclass
class CredentialSnapshot:
app_id: str = ""
app_secret: str = ""
access_token: str = ""
expires_at: float = 0
token_obtained_at: float = 0
session_id: str = ""
sandbox: bool = False
metadata: dict = field(default_factory=dict)
def is_valid(self) -> bool:
return bool(self.app_id and self.app_secret)
def token_expired(self) -> bool:
if not self.access_token or not self.expires_at:
return True
return time.monotonic() > self.expires_at - 300
class CredentialBackup:
def __init__(self, app_id: str, backup_dir: str | None = None):
self._app_id = app_id
self._backup_dir = backup_dir or DEFAULT_BACKUP_DIR
self._backup_path = os.path.join(self._backup_dir, f"{app_id}.json")
def save(self, snapshot: CredentialSnapshot) -> bool:
try:
os.makedirs(self._backup_dir, exist_ok=True)
data = {
"app_id": snapshot.app_id,
"app_secret": snapshot.app_secret,
"access_token": snapshot.access_token,
"expires_at": snapshot.expires_at,
"token_obtained_at": snapshot.token_obtained_at,
"session_id": snapshot.session_id,
"sandbox": snapshot.sandbox,
"metadata": snapshot.metadata,
"saved_at": time.time(),
}
tmp_path = self._backup_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, self._backup_path)
logger.info("CredentialBackup: saved snapshot for app_id=%s", self._app_id[:6] + "...")
return True
except OSError:
logger.exception("CredentialBackup: failed to save snapshot")
return False
def restore(self) -> CredentialSnapshot | None:
try:
if not os.path.exists(self._backup_path):
return None
with open(self._backup_path, encoding="utf-8") as f:
data = json.load(f)
snapshot = CredentialSnapshot(
app_id=data.get("app_id", ""),
app_secret=data.get("app_secret", ""),
access_token=data.get("access_token", ""),
expires_at=data.get("expires_at", 0),
token_obtained_at=data.get("token_obtained_at", 0),
session_id=data.get("session_id", ""),
sandbox=data.get("sandbox", False),
metadata=data.get("metadata", {}),
)
if not snapshot.is_valid():
logger.warning("CredentialBackup: restored snapshot is invalid for app_id=%s", self._app_id[:6] + "...")
return None
logger.info("CredentialBackup: restored snapshot for app_id=%s", self._app_id[:6] + "...")
return snapshot
except (OSError, json.JSONDecodeError, KeyError):
logger.exception("CredentialBackup: failed to restore snapshot")
return None
def clear(self) -> bool:
try:
if os.path.exists(self._backup_path):
os.remove(self._backup_path)
tmp_path = self._backup_path + ".tmp"
if os.path.exists(tmp_path):
os.remove(tmp_path)
logger.info("CredentialBackup: cleared backup for app_id=%s", self._app_id[:6] + "...")
return True
except OSError:
logger.exception("CredentialBackup: failed to clear backup")
return False
@staticmethod
def cleanup_expired(backup_dir: str | None = None, max_age_s: float = 86400 * 7) -> int:
directory = backup_dir or DEFAULT_BACKUP_DIR
removed = 0
if not os.path.exists(directory):
return 0
now = time.time()
try:
for filename in os.listdir(directory):
if not filename.endswith(".json"):
continue
filepath = os.path.join(directory, filename)
try:
stat = os.stat(filepath)
if now - stat.st_mtime > max_age_s:
os.remove(filepath)
removed += 1
logger.debug("CredentialBackup: removed expired backup %s", filename)
except OSError:
pass
except OSError:
logger.exception("CredentialBackup: cleanup failed")
return removed

View File

@ -0,0 +1,250 @@
from __future__ import annotations
from yuxi.channels.models import ChannelResponse
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
def build_text_payload(response: ChannelResponse) -> dict:
content = response.content[:2000]
chat_id = response.identity.channel_chat_id
payload: dict = {"content": content}
payload["msg_type"] = 0
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
if response.reply_to_message_id:
payload["msg_id"] = response.reply_to_message_id
return payload
def build_markdown_payload(response: ChannelResponse, template_id: str | None = None) -> dict:
md_template_id = response.metadata.get("markdown_template_id") or template_id
if md_template_id:
return {
"msg_type": 2,
"markdown": {
"template_id": md_template_id,
"params": [
{"key": "title", "values": [response.metadata.get("title", "")]},
{"key": "content", "values": [response.content[:4096]]},
],
},
}
return {
"msg_type": 2,
"markdown": {
"content": response.content[:4096],
},
}
def build_ark_payload(response: ChannelResponse) -> dict:
ark_template_id = response.metadata.get("ark_template_id")
ark_data = response.metadata.get("ark_data", {})
return {
"msg_type": 3,
"ark": {
"template_id": ark_template_id,
"kv": [{"key": k, "value": v} for k, v in ark_data.items()],
},
}
def build_embed_payload(response: ChannelResponse) -> dict:
embed_data = response.metadata.get("embed", {})
return {
"msg_type": 4,
"embed": {
"title": embed_data.get("title", ""),
"description": response.content[:4096],
"prompt": embed_data.get("prompt", response.content[:200]),
"fields": embed_data.get("fields", []),
},
}
def build_media_generic_payload(response: ChannelResponse, file_id: str, msg_type: int = 7) -> dict:
chat_id = response.identity.channel_chat_id
payload: dict = {
"msg_type": msg_type,
"media": {"file_info": file_id},
}
if response.content:
payload["content"] = response.content[:2000]
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
return payload
def build_image_payload(response: ChannelResponse, file_id: str) -> dict:
chat_id = response.identity.channel_chat_id
payload: dict = {
"msg_type": 1,
"image": file_id,
"content": response.content[:2000] if response.content else "",
}
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
return payload
def format_outbound(
response: ChannelResponse,
use_markdown: bool = False,
markdown_template_id: str | None = None,
) -> dict:
msg_type = response.metadata.get("qq_msg_type", "")
if msg_type == "markdown" or (use_markdown and not msg_type):
return build_markdown_payload(response, markdown_template_id)
elif msg_type == "ark" and response.metadata.get("ark_template_id"):
return build_ark_payload(response)
elif msg_type == "embed":
return build_embed_payload(response)
elif msg_type == "image" and response.attachments:
file_id = response.attachments[0].file_id or response.attachments[0].url or ""
return build_image_payload(response, file_id)
elif msg_type in ("voice", "video", "file") and response.attachments:
file_id_list = [
response.metadata.get("media_file_id", ""),
response.attachments[0].file_id or "",
response.attachments[0].url or "",
]
file_id = next((fid for fid in file_id_list if fid), "")
return build_media_generic_payload(response, file_id, msg_type=7)
else:
return build_text_payload(response)
class MarkdownChunker:
MAX_CHARS = 5000
CHUNK_OVERLAP = 200
def __init__(self, max_chars: int = MAX_CHARS, chunk_overlap: int = CHUNK_OVERLAP):
self._max_chars = max_chars
self._chunk_overlap = chunk_overlap
def chunk(self, text: str) -> list[str]:
if len(text) <= self._max_chars:
return [text]
paragraphs = self._split_paragraphs(text)
chunks: list[str] = []
current_chunk: list[str] = []
current_len = 0
for para in paragraphs:
para_len = len(para)
if current_len + para_len <= self._max_chars:
current_chunk.append(para)
current_len += para_len
else:
if current_chunk:
chunks.append("".join(current_chunk))
if para_len > self._max_chars:
sub_chunks = self._force_split(para)
if current_chunk:
for i, sc in enumerate(sub_chunks):
chunks.append(sc)
else:
chunks.extend(sub_chunks)
current_chunk = []
current_len = 0
else:
current_chunk = [para]
current_len = para_len
if current_chunk:
chunks.append("".join(current_chunk))
return chunks
def _split_paragraphs(self, text: str) -> list[str]:
sections: list[str] = []
in_code_block = False
current: list[str] = []
lines = text.splitlines(keepends=True)
for line in lines:
stripped = line.strip()
if stripped.startswith("```"):
if current:
sections.append("".join(current))
current = []
if in_code_block:
sections.append(line)
in_code_block = False
else:
in_code_block = True
current.append(line)
continue
if in_code_block:
current.append(line)
if stripped.endswith("```"):
sections.append("".join(current))
current = []
in_code_block = False
continue
if not stripped:
if current:
sections.append("".join(current))
current = []
sections.append(line)
elif (
stripped.startswith(("#", "-", "*", ">", "|"))
and current
and not current[-1].strip().startswith(("#", "-", "*", ">", "|", "1.", "2.", "3."))
):
if current:
sections.append("".join(current))
current = []
current.append(line)
else:
current.append(line)
if current:
sections.append("".join(current))
result: list[str] = []
buffer: list[str] = []
for s in sections:
stripped = s.strip()
if not stripped and buffer:
result.append("".join(buffer))
buffer = []
buffer.append(s)
if buffer:
content = "".join(buffer)
if content.strip():
result.append(content)
return result or [text]
def _force_split(self, text: str) -> list[str]:
chunks: list[str] = []
for i in range(0, len(text), self._max_chars - self._chunk_overlap):
chunks.append(text[i : i + self._max_chars])
return chunks

View File

@ -0,0 +1,69 @@
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass, field
@dataclass
class GroupMessage:
msg_id: str
author_id: str
author_name: str
content: str
timestamp: float
mentions_bot: bool = False
@dataclass
class GroupSession:
group_id: str
messages: list[GroupMessage] = field(default_factory=list)
last_active: float = 0.0
buffer_limit: int = 50
ttl_seconds: float = 3600.0
def add(self, msg: GroupMessage) -> None:
self.messages.append(msg)
self.last_active = time.time()
if len(self.messages) > self.buffer_limit:
self.messages = self.messages[-self.buffer_limit :]
def is_expired(self, now: float | None = None) -> bool:
if now is None:
now = time.time()
return now - self.last_active > self.ttl_seconds
def recent_context(self, count: int = 10) -> list[GroupMessage]:
return self.messages[-count:]
class GroupHistoryBuffer:
def __init__(self, buffer_limit: int = 50, ttl_seconds: float = 3600.0) -> None:
self._sessions: dict[str, GroupSession] = defaultdict(GroupSession)
self._buffer_limit = buffer_limit
self._ttl_seconds = ttl_seconds
def record(self, group_id: str, msg: GroupMessage) -> None:
session = self._sessions[group_id]
session.buffer_limit = self._buffer_limit
session.ttl_seconds = self._ttl_seconds
if not session.group_id:
session.group_id = group_id
session.add(msg)
def recent_context(self, group_id: str, count: int = 10) -> list[GroupMessage]:
session = self._sessions.get(group_id)
if session is None:
return []
if session.is_expired():
del self._sessions[group_id]
return []
return session.recent_context(count)
def gc(self) -> int:
now = time.time()
expired = [gid for gid, s in self._sessions.items() if s.is_expired(now)]
for gid in expired:
del self._sessions[gid]
return len(expired)

View File

@ -0,0 +1,384 @@
from __future__ import annotations
import logging
import re
import time
import uuid
from yuxi.channels.pipeline.base import BaseInboundPipeline, PipelineStage
from yuxi.channels.pipeline.context import PipelineContext
from .media_tags import parse_media_tags, has_media_tags
logger = logging.getLogger(__name__)
_QQ_EMOJI_RE = re.compile(r"<emoji:(\d+)>")
_QQ_FACE_RE = re.compile(r"<face:(\d+)>")
QQ_EMOJI_MAP: dict[int, str] = {
0: "[微笑]",
1: "[撇嘴]",
2: "[色]",
3: "[发呆]",
4: "[得意]",
5: "[流泪]",
6: "[害羞]",
7: "[闭嘴]",
8: "[睡]",
9: "[大哭]",
10: "[尴尬]",
11: "[发怒]",
12: "[调皮]",
13: "[呲牙]",
14: "[惊讶]",
15: "[难过]",
16: "[酷]",
17: "[冷汗]",
18: "[抓狂]",
19: "[吐]",
20: "[偷笑]",
21: "[可爱]",
22: "[白眼]",
23: "[傲慢]",
24: "[饥饿]",
25: "[困]",
26: "[惊恐]",
27: "[流汗]",
28: "[憨笑]",
29: "[悠闲]",
30: "[奋斗]",
31: "[咒骂]",
32: "[疑问]",
33: "[嘘]",
34: "[晕]",
35: "[疯了]",
36: "[衰]",
37: "[骷髅]",
38: "[敲打]",
39: "[再见]",
40: "[擦汗]",
41: "[抠鼻]",
42: "[鼓掌]",
43: "[糗大了]",
44: "[坏笑]",
45: "[左哼哼]",
46: "[右哼哼]",
47: "[哈欠]",
48: "[鄙视]",
49: "[委屈]",
50: "[快哭了]",
51: "[阴险]",
52: "[亲亲]",
53: "[吓]",
54: "[可怜]",
55: "[菜刀]",
56: "[西瓜]",
57: "[啤酒]",
58: "[篮球]",
59: "[乒乓]",
60: "[咖啡]",
61: "[饭]",
62: "[猪头]",
63: "[玫瑰]",
64: "[凋谢]",
65: "[嘴唇]",
66: "[爱心]",
67: "[心碎]",
68: "[蛋糕]",
69: "[闪电]",
70: "[炸弹]",
71: "[刀]",
72: "[足球]",
73: "[瓢虫]",
74: "[便便]",
75: "[月亮]",
76: "[太阳]",
77: "[礼物]",
78: "[拥抱]",
79: "[强]",
80: "[弱]",
81: "[握手]",
82: "[胜利]",
83: "[抱拳]",
84: "[勾引]",
85: "[拳头]",
86: "[差劲]",
87: "[爱你]",
88: "[NO]",
89: "[OK]",
90: "[爱情]",
91: "[飞吻]",
92: "[跳跳]",
93: "[发抖]",
94: "[怄火]",
95: "[转圈]",
96: "[磕头]",
97: "[回头]",
98: "[跳绳]",
99: "[投降]",
}
def parse_qq_emojis(text: str) -> str:
def _emoji_replacer(m: re.Match) -> str:
code = int(m.group(1))
return QQ_EMOJI_MAP.get(code, f"[表情:{code}]")
text = _QQ_EMOJI_RE.sub(_emoji_replacer, text)
text = _QQ_FACE_RE.sub(_emoji_replacer, text)
return text
_MENTION_RE = re.compile(r"<@!\w+>|@\S+\s?", re.UNICODE)
def strip_bot_mentions(content: str, bot_names: list[str] | None = None) -> tuple[str, bool]:
stripped = _MENTION_RE.sub("", content).strip()
was_stripped = stripped != content.strip()
return stripped, was_stripped
class QQBotInboundPipeline(BaseInboundPipeline):
async def _build_stages(self) -> list[PipelineStage]:
return [
self._dedup,
self._normalize,
self._extract_content,
self._access_policy,
self._content_check,
self._context_fill,
self._dispatch,
]
async def _dedup(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
msg_id = ctx.msg_id or ctx.event_data.get("id", "")
if not msg_id:
return ctx
adapter = pipeline.adapter
dedup_cache = getattr(adapter, "_recent_msg_ids", None)
dedup_window = getattr(adapter, "_dedup_window_s", 60)
if dedup_cache is not None:
now = time.monotonic()
self._prune_dedup_cache(dedup_cache, dedup_window, now)
if msg_id in dedup_cache:
logger.debug("Dedup: %s already processed, skipping", msg_id)
ctx.stop("dedup_duplicate")
return None
dedup_cache[msg_id] = now
return ctx
@staticmethod
def _prune_dedup_cache(cache: dict[str, float], window: int, now: float) -> None:
expired = [mid for mid, ts in cache.items() if now - ts >= window]
for mid in expired:
del cache[mid]
@staticmethod
def _buffer_group_message(pipeline: BaseInboundPipeline, event_data: dict, ctx: PipelineContext) -> None:
adapter = pipeline.adapter
group_buffer = getattr(adapter, "_group_buffer", None)
if group_buffer is None:
return
group_id = event_data.get("group_openid", event_data.get("group_id", ""))
if not group_id:
return
from .group_buffer import GroupMessage
msg = GroupMessage(
msg_id=event_data.get("id", ""),
author_id=ctx.sender_id,
author_name=ctx.sender_name or "",
content=ctx.content or "",
timestamp=time.time(),
mentions_bot=ctx.metadata.get("bot_mentioned", True),
)
group_buffer.record(group_id, msg)
async def _normalize(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
event_data = ctx.event_data
event_type = ctx.event_type
if event_type == "C2C_MESSAGE_CREATE":
author = event_data.get("author", {})
ctx.chat_type = "dm"
ctx.chat_id = author.get("id", "")
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
elif event_type == "GROUP_AT_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", ""))
author = event_data.get("author", {})
ctx.sender_id = author.get("member_openid", author.get("id", ""))
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
ctx.metadata["group_openid"] = event_data.get("group_openid", "")
ctx.metadata["bot_mentioned"] = True
self._extract_reply_info(event_data, ctx)
self._buffer_group_message(pipeline, event_data, ctx)
elif event_type == "GROUP_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", ""))
author = event_data.get("author", {})
ctx.sender_id = author.get("member_openid", author.get("id", ""))
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
ctx.metadata["group_openid"] = event_data.get("group_openid", "")
ctx.metadata["bot_mentioned"] = False
ctx.stop("group_message_no_mention")
self._buffer_group_message(pipeline, event_data, ctx)
return None
elif event_type == "INTERACTION_CREATE":
interaction_data = event_data.get("data", {})
reply = interaction_data.get("resolved", {}).get("message_interaction", {})
button_data = reply.get("button_data", interaction_data.get("button_data", ""))
button_id = reply.get("button_id", interaction_data.get("button_id", ""))
feature_name = reply.get("feature_name", interaction_data.get("feature_name", ""))
ctx.chat_type = "interaction"
ctx.chat_id = event_data.get("chat_id", "")
ctx.sender_id = event_data.get("user_openid", event_data.get("user_id", ""))
ctx.msg_id = event_data.get("id", "")
ctx.content = interaction_data.get("name", "")
ctx.metadata["interaction_id"] = event_data.get("id", "")
ctx.metadata["feature_id"] = reply.get("feature_id", "")
ctx.metadata["button_data"] = button_data
ctx.metadata["button_id"] = button_id
ctx.metadata["feature_name"] = feature_name
elif event_type == "DIRECT_MESSAGE_CREATE":
ctx.chat_type = "dm"
ctx.chat_id = event_data.get("guild_id", "")
author = event_data.get("author", {})
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
elif event_type == "AT_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("guild_id", "")
author = event_data.get("author", {})
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
else:
adapter = pipeline.adapter
try:
msg = adapter.normalize_inbound({"event_type": event_type, "event": event_data})
ctx.msg_id = msg.identity.channel_message_id
ctx.sender_id = msg.identity.channel_user_id
ctx.chat_id = msg.identity.channel_chat_id
ctx.content = msg.content or ""
ctx.chat_type = msg.chat_type.value
ctx.metadata["qq_chat_type"] = msg.chat_type.value
except Exception:
ctx.stop("unknown_event_type")
return None
ctx.metadata["received_at"] = time.time()
ctx.metadata["event_id"] = ctx.msg_id or str(uuid.uuid4())
return ctx
@staticmethod
def _extract_reply_info(event_data: dict, ctx: PipelineContext) -> None:
msg_elements = event_data.get("msg_elements", [])
if not msg_elements:
return
for element in msg_elements:
if not isinstance(element, dict):
continue
if element.get("type") != "reply":
continue
reply_data = element.get("reply_element") or element.get("reply", {})
if not reply_data:
continue
quoted_author = reply_data.get("author", {})
ctx.metadata["quoted_content"] = reply_data.get("content", "")
ctx.metadata["quoted_author_id"] = quoted_author.get("id") or quoted_author.get("member_openid", "")
ctx.metadata["quoted_author_name"] = quoted_author.get("username", "")
ctx.metadata["quoted_msg_id"] = reply_data.get("id", "")
break
async def _extract_content(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
raw = ctx.content
if raw and isinstance(raw, str):
if has_media_tags(raw):
parsed = parse_media_tags(raw)
ctx.content = parsed.text
ctx.metadata["inline_media"] = [
{"type": m.media_type, "reference": m.reference, "is_url": m.is_url} for m in parsed.media_items
]
else:
ctx.content = raw.strip()
ctx.content = parse_qq_emojis(ctx.content)
if ctx.chat_type == "group" and ctx.content:
ctx.content, stripped = strip_bot_mentions(ctx.content)
if stripped:
ctx.metadata["mention_stripped"] = True
return ctx
async def _access_policy(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
adapter = pipeline.adapter
security = getattr(adapter, "_security", None)
if security is None:
return ctx
if ctx.chat_type == "dm" or ctx.chat_type == "direct":
result = security.check_dm_access(ctx.sender_id)
if not result.allowed:
ctx.stop(f"access_dm_{result.reason}")
return None
elif ctx.chat_type == "group":
group_id = ctx.chat_id.replace("group_", "")
result = security.check_group_access(group_id)
if not result.allowed:
ctx.stop(f"access_group_{result.reason}")
return None
return ctx
async def _content_check(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
netloc = ctx.content.strip()
if netloc and len(netloc) > 8000:
ctx.content = netloc[:8000]
return ctx
async def _context_fill(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
ctx.metadata["pipeline_version"] = "qqbot_v2"
ctx.metadata["processed_at"] = time.time()
return ctx
async def _dispatch(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
adapter = pipeline.adapter
handler = getattr(adapter, "_on_pipeline_dispatch", None)
if handler:
await handler(ctx)
return ctx

View File

@ -0,0 +1,185 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
import time
from collections.abc import Callable, Awaitable
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class InteractionContext:
interaction_id: str
interaction_type: str
chat_type: str
chat_id: str
user_id: str
user_name: str
data: dict[str, Any]
msg_id: str = ""
timestamp: float = field(default_factory=time.time)
InteractionCallback = Callable[[InteractionContext], Awaitable[bool]]
class InteractionRegistry:
def __init__(self):
self._callbacks: dict[str, InteractionCallback] = {}
def register(self, action_id: str, callback: InteractionCallback) -> None:
self._callbacks[action_id] = callback
logger.debug("InteractionRegistry: registered %s", action_id)
def unregister(self, action_id: str) -> None:
self._callbacks.pop(action_id, None)
async def dispatch(self, ctx: InteractionContext) -> bool:
if not ctx.interaction_id:
return False
for action_id, callback in self._callbacks.items():
if ctx.interaction_id == action_id or ctx.interaction_id.startswith(action_id):
try:
return await callback(ctx)
except Exception:
logger.exception("InteractionRegistry: callback failed for %s", action_id)
return False
class InteractionBuilder:
@staticmethod
def make_confirm_button(
action_id: str,
label: str = "确认",
style: int = 1,
) -> dict:
return {
"type": 2,
"style": style,
"label": label,
"data": action_id,
}
@staticmethod
def make_action_row(buttons: list[dict]) -> dict:
return {"type": 1, "components": buttons}
@staticmethod
def make_select_menu(
action_id: str,
placeholder: str = "请选择",
options: list[dict] | None = None,
min_values: int = 1,
max_values: int = 1,
) -> dict:
return {
"type": 3,
"custom_id": action_id,
"placeholder": placeholder,
"options": options or [],
"min_values": min_values,
"max_values": max_values,
}
@staticmethod
def make_modal(
action_id: str,
title: str,
fields: list[dict],
) -> dict:
return {
"type": 4,
"custom_id": action_id,
"title": title,
"components": fields,
}
@dataclass
class InteractionHandler:
interaction_id: str
handler_type: str
chat_id: str
user_id: str
created_at: float = field(default_factory=time.time)
expires_at: float = 300.0
resolved: bool = False
result: Any = None
def __post_init__(self):
self.expires_at = self.created_at + 300.0
class InteractionSessionManager:
def __init__(self, max_sessions: int = 1000):
self._sessions: dict[str, InteractionHandler] = {}
self._max_sessions = max_sessions
self._lock = asyncio.Lock()
async def create(
self,
handler_type: str,
chat_id: str,
user_id: str,
ttl: float = 300.0,
) -> InteractionHandler:
async with self._lock:
self._cleanup_expired()
if len(self._sessions) >= self._max_sessions:
oldest = min(
self._sessions.values(),
key=lambda h: h.created_at,
default=None,
)
if oldest:
self._sessions.pop(oldest.interaction_id, None)
iid = hashlib.sha256(f"{chat_id}:{user_id}:{handler_type}:{time.time()}".encode()).hexdigest()[:16]
handler = InteractionHandler(
interaction_id=iid,
handler_type=handler_type,
chat_id=chat_id,
user_id=user_id,
)
handler.expires_at = time.time() + ttl
self._sessions[iid] = handler
return handler
async def get(self, interaction_id: str) -> InteractionHandler | None:
async with self._lock:
handler = self._sessions.get(interaction_id)
if handler is None:
return None
if time.time() >= handler.expires_at:
self._sessions.pop(interaction_id, None)
return None
return handler
async def resolve(self, interaction_id: str, result: Any = None) -> bool:
async with self._lock:
handler = self._sessions.get(interaction_id)
if handler is None:
return False
handler.resolved = True
handler.result = result
self._sessions.pop(interaction_id, None)
return True
async def cancel(self, interaction_id: str) -> bool:
async with self._lock:
if interaction_id in self._sessions:
self._sessions.pop(interaction_id, None)
return True
return False
def _cleanup_expired(self) -> None:
now = time.time()
expired = [iid for iid, h in self._sessions.items() if now >= h.expires_at]
for iid in expired:
self._sessions.pop(iid, None)

View File

@ -0,0 +1,180 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import time
from dataclasses import dataclass, field
from collections import OrderedDict
logger = logging.getLogger(__name__)
DEFAULT_PERSIST_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_known_users")
@dataclass
class UserRecord:
user_id: str
username: str = ""
first_seen: float = field(default_factory=time.time)
last_seen: float = field(default_factory=time.time)
message_count: int = 0
chat_types: set[str] = field(default_factory=set)
metadata: dict = field(default_factory=dict)
def touch(self, username: str = "", chat_type: str = "") -> None:
self.last_seen = time.time()
self.message_count += 1
if username:
self.username = username
if chat_type:
self.chat_types.add(chat_type)
def to_dict(self) -> dict:
return {
"user_id": self.user_id,
"username": self.username,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"message_count": self.message_count,
"chat_types": list(self.chat_types),
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict) -> UserRecord:
return cls(
user_id=data["user_id"],
username=data.get("username", ""),
first_seen=data.get("first_seen", time.time()),
last_seen=data.get("last_seen", time.time()),
message_count=data.get("message_count", 0),
chat_types=set(data.get("chat_types", [])),
metadata=data.get("metadata", {}),
)
class KnownUserTracker:
def __init__(
self,
app_id: str,
max_users: int = 10000,
persist_dir: str | None = None,
persist_interval_s: int = 300,
):
self._app_id = app_id
self._max_users = max_users
self._persist_dir = persist_dir or DEFAULT_PERSIST_DIR
self._persist_path = os.path.join(self._persist_dir, f"{app_id}_users.json")
self._persist_interval = persist_interval_s
self._lock = threading.Lock()
self._users: OrderedDict[str, UserRecord] = OrderedDict()
self._last_persist: float = 0
self._dirty = False
self._load_from_disk()
def record(self, user_id: str, username: str = "", chat_type: str = "") -> UserRecord:
with self._lock:
if user_id in self._users:
self._users.move_to_end(user_id)
record = self._users[user_id]
record.touch(username, chat_type)
else:
record = UserRecord(user_id=user_id, username=username)
record.touch(username, chat_type)
self._users[user_id] = record
self._users.move_to_end(user_id)
while len(self._users) > self._max_users:
self._users.popitem(last=False)
self._dirty = True
self._maybe_persist()
return record
def is_known(self, user_id: str) -> bool:
with self._lock:
return user_id in self._users
def get(self, user_id: str) -> UserRecord | None:
with self._lock:
return self._users.get(user_id)
def remove(self, user_id: str) -> bool:
with self._lock:
if user_id in self._users:
del self._users[user_id]
self._dirty = True
return True
return False
@property
def count(self) -> int:
with self._lock:
return len(self._users)
def persist(self) -> bool:
with self._lock:
if not self._dirty:
return True
return self._do_persist()
def _maybe_persist(self) -> None:
now = time.time()
if now - self._last_persist < self._persist_interval:
return
if not self._dirty:
return
self._do_persist()
def _do_persist(self) -> bool:
try:
os.makedirs(self._persist_dir, exist_ok=True)
data = {
"app_id": self._app_id,
"updated_at": time.time(),
"users": {uid: u.to_dict() for uid, u in self._users.items()},
}
tmp_path = self._persist_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, self._persist_path)
self._last_persist = time.time()
self._dirty = False
logger.debug("KnownUserTracker: persisted %d users", len(self._users))
return True
except OSError:
logger.exception("KnownUserTracker: persist failed")
return False
def _load_from_disk(self) -> None:
try:
if not os.path.exists(self._persist_path):
return
with open(self._persist_path, encoding="utf-8") as f:
data = json.load(f)
users_data = data.get("users", {})
for uid, udata in users_data.items():
self._users[uid] = UserRecord.from_dict(udata)
logger.info("KnownUserTracker: loaded %d users from disk", len(self._users))
except (OSError, json.JSONDecodeError):
logger.exception("KnownUserTracker: failed to load from disk")
def clear(self) -> None:
with self._lock:
self._users.clear()
self._dirty = True
self.persist()
def get_recent_users(self, limit: int = 50) -> list[UserRecord]:
with self._lock:
return list(reversed(self._users.values()))[:limit]

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
_MEDIA_TAG_RE = re.compile(r"<qqmedia:(image|voice|video|file):([^>]+)>")
_IMG_TAG_RE = re.compile(r"<qqimg:([^>]+)>")
_VIDEO_TAG_RE = re.compile(r"<qqvideo:([^>]+)>")
@dataclass
class InlineMedia:
media_type: str
reference: str
raw_tag: str = ""
is_url: bool = False
@dataclass
class ParsedContent:
text: str
media_items: list[InlineMedia] = field(default_factory=list)
@property
def has_media(self) -> bool:
return len(self.media_items) > 0
def parse_media_tags(content: str) -> ParsedContent:
media_items: list[InlineMedia] = []
text = content
for match in _MEDIA_TAG_RE.finditer(content):
media_type = match.group(1)
file_id = match.group(2).strip()
media_items.append(
InlineMedia(
media_type=media_type,
reference=file_id,
raw_tag=match.group(0),
is_url=False,
)
)
for match in _IMG_TAG_RE.finditer(content):
url = match.group(1).strip()
media_items.append(
InlineMedia(
media_type="image",
reference=url,
raw_tag=match.group(0),
is_url=True,
)
)
for match in _VIDEO_TAG_RE.finditer(content):
url = match.group(1).strip()
media_items.append(
InlineMedia(
media_type="video",
reference=url,
raw_tag=match.group(0),
is_url=True,
)
)
text = _MEDIA_TAG_RE.sub("", content)
text = _IMG_TAG_RE.sub("", text)
text = _VIDEO_TAG_RE.sub("", text)
text = text.strip()
return ParsedContent(text=text, media_items=media_items)
def has_media_tags(content: str) -> bool:
return bool(_MEDIA_TAG_RE.search(content) or _IMG_TAG_RE.search(content) or _VIDEO_TAG_RE.search(content))
_MEDIA_SIZE_RE = re.compile(r"<qqsize:(\d+)>([\s\S]*?)</qqsize>")
@dataclass
class SizedContent:
text: str
size_limit: int | None = None
def parse_size_tag(content: str, default_limit: int = 2000) -> SizedContent:
match = _MEDIA_SIZE_RE.search(content)
if match:
size_limit = int(match.group(1))
text = _MEDIA_SIZE_RE.sub(match.group(2), content).strip()
return SizedContent(text=text, size_limit=size_limit)
return SizedContent(text=content, size_limit=default_limit)
def build_media_tag(media_type: str, reference: str, is_url: bool = False) -> str:
if is_url:
if media_type == "image":
return f"<qqimg:{reference}>"
if media_type == "video":
return f"<qqvideo:{reference}>"
return f"<qqmedia:{media_type}:{reference}>"
def build_media_tags(media_items: list[InlineMedia]) -> str:
return "".join(build_media_tag(item.media_type, item.reference, item.is_url) for item in media_items)
def embed_media_in_text(text: str, media_items: list[InlineMedia]) -> str:
if not media_items:
return text
tags = build_media_tags(media_items)
return f"{text}\n{tags}" if text else tags

View File

@ -0,0 +1,324 @@
from __future__ import annotations
import aiohttp
from yuxi.channels.exceptions import DeliveryFailedError
from yuxi.utils.logging_config import logger
_MAX_FILE_SIZE_MB = 100
FILE_TYPE_IMAGE = "1"
FILE_TYPE_VOICE = "2"
FILE_TYPE_VIDEO = "3"
FILE_TYPE_FILE = "4"
def validate_media_size(data: bytes, max_size_mb: int = _MAX_FILE_SIZE_MB, label: str = "media") -> None:
max_bytes = max_size_mb * 1024 * 1024
actual_size = len(data)
if actual_size > max_bytes:
raise DeliveryFailedError(f"{label} size {actual_size / 1024 / 1024:.1f}MB exceeds limit of {max_size_mb}MB")
async def upload_media(
media_data: bytes,
token: str,
http_client: aiohttp.ClientSession | None = None,
filename: str = "media",
file_type: str = FILE_TYPE_FILE,
group_openid: str | None = None,
sandbox: bool = False,
) -> str:
api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com"
headers = {"Authorization": f"QQBot {token}"}
content_type_map = {
FILE_TYPE_IMAGE: "image/png",
FILE_TYPE_VOICE: "audio/mpeg",
FILE_TYPE_VIDEO: "video/mp4",
FILE_TYPE_FILE: "application/octet-stream",
}
mime_type = content_type_map.get(file_type, content_type_map[FILE_TYPE_FILE])
form = aiohttp.FormData()
form.add_field("file", media_data, filename=filename, content_type=mime_type)
form.add_field("file_type", file_type)
url = f"{api_base}/v2/groups/{group_openid}/files" if group_openid else f"{api_base}/v2/users/@me/files"
async def _do_upload(client: aiohttp.ClientSession) -> str:
async with client.post(url, headers=headers, data=form) as resp:
if resp.status != 200:
raise DeliveryFailedError(f"Media upload failed: HTTP {resp.status}")
result = await resp.json()
file_id = result.get("file_uuid", "") or result.get("file_info", "")
logger.debug(f"[QQBot] Media uploaded, file_type={file_type}, file_id={file_id}")
return file_id
if http_client:
return await _do_upload(http_client)
else:
async with aiohttp.ClientSession() as session:
return await _do_upload(session)
async def upload_image(
image_data: bytes,
token: str,
http_client: aiohttp.ClientSession | None = None,
filename: str = "image.png",
group_openid: str | None = None,
sandbox: bool = False,
) -> str:
return await upload_media(
image_data,
token,
http_client=http_client,
filename=filename,
file_type=FILE_TYPE_IMAGE,
group_openid=group_openid,
sandbox=sandbox,
)
async def download_media(
file_id: str,
token: str,
http_client: aiohttp.ClientSession | None = None,
sandbox: bool = False,
) -> bytes:
api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com"
headers = {"Authorization": f"QQBot {token}"}
async def _do_download(client: aiohttp.ClientSession) -> bytes:
async with client.get(
f"{api_base}/v2/users/@me/files/{file_id}",
headers=headers,
) as resp:
if resp.status != 200:
raise DeliveryFailedError(f"Media download failed: HTTP {resp.status}")
return await resp.read()
if http_client:
return await _do_download(http_client)
else:
async with aiohttp.ClientSession() as session:
return await _do_download(session)
def build_media_payload(
chat_id: str,
file_id: str,
content: str = "",
msg_type: int = 7,
) -> dict:
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
payload: dict = {
"msg_type": msg_type,
}
if msg_type == 1:
payload["image"] = file_id
elif msg_type == 4:
payload["file"] = file_id
else:
payload["media"] = {"file_info": file_id}
if content:
payload["content"] = content[:2000]
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
return payload
async def download_image(
file_id: str,
token: str,
http_client: aiohttp.ClientSession | None = None,
sandbox: bool = False,
) -> bytes:
return await download_media(file_id, token, http_client, sandbox)
_CHUNK_SIZE = 5 * 1024 * 1024
_UPLOAD_CACHE: dict[str, str] = {}
def _make_cache_key(data: bytes) -> str:
import hashlib
return hashlib.sha256(data).hexdigest()
async def upload_media_cached(
media_data: bytes,
token: str,
http_client: aiohttp.ClientSession | None = None,
filename: str = "media",
file_type: str = FILE_TYPE_FILE,
group_openid: str | None = None,
sandbox: bool = False,
use_cache: bool = True,
) -> str:
if use_cache:
cache_key = _make_cache_key(media_data)
cached = _UPLOAD_CACHE.get(cache_key)
if cached:
logger.debug("Media upload: cache hit for %s", filename)
return cached
file_id = await upload_media(
media_data,
token,
http_client=http_client,
filename=filename,
file_type=file_type,
group_openid=group_openid,
sandbox=sandbox,
)
if use_cache and file_id:
cache_key = _make_cache_key(media_data)
_UPLOAD_CACHE[cache_key] = file_id
return file_id
def clear_upload_cache() -> None:
_UPLOAD_CACHE.clear()
logger.debug("Media upload cache cleared")
async def upload_media_chunked(
media_data: bytes,
token: str,
http_client: aiohttp.ClientSession | None = None,
filename: str = "media",
file_type: str = FILE_TYPE_FILE,
group_openid: str | None = None,
sandbox: bool = False,
chunk_size: int = _CHUNK_SIZE,
) -> str:
if len(media_data) <= chunk_size:
return await upload_media(
media_data,
token,
http_client=http_client,
filename=filename,
file_type=file_type,
group_openid=group_openid,
sandbox=sandbox,
)
import math
total_chunks = math.ceil(len(media_data) / chunk_size)
api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com"
headers = {"Authorization": f"QQBot {token}"}
content_type_map = {
FILE_TYPE_IMAGE: "image/png",
FILE_TYPE_VOICE: "audio/mpeg",
FILE_TYPE_VIDEO: "video/mp4",
FILE_TYPE_FILE: "application/octet-stream",
}
mime_type = content_type_map.get(file_type, content_type_map[FILE_TYPE_FILE])
async def _do_chunked(client: aiohttp.ClientSession) -> str:
init_url = f"{api_base}/v2/users/@me/files/chunked"
if group_openid:
init_url = f"{api_base}/v2/groups/{group_openid}/files/chunked"
init_payload = {
"filename": filename,
"file_type": int(file_type),
"total_size": len(media_data),
"chunk_size": chunk_size,
"total_chunks": total_chunks,
}
async with client.post(
init_url,
headers=headers,
json=init_payload,
) as resp:
if resp.status not in (200, 201):
raise DeliveryFailedError(f"Chunked upload init failed: HTTP {resp.status}")
init_data = await resp.json()
upload_id = init_data.get("upload_id", "")
if not upload_id:
raise DeliveryFailedError("Chunked upload: no upload_id returned")
for i in range(total_chunks):
start = i * chunk_size
end = min(start + chunk_size, len(media_data))
chunk = media_data[start:end]
chunk_url = f"{api_base}/v2/users/@me/files/chunked/{upload_id}"
if group_openid:
chunk_url = f"{api_base}/v2/groups/{group_openid}/files/chunked/{upload_id}"
form = aiohttp.FormData()
form.add_field("chunk", chunk, filename=f"{filename}.chunk{i}", content_type=mime_type)
form.add_field("chunk_index", str(i))
async with client.post(chunk_url, headers=headers, data=form) as resp:
if resp.status not in (200, 201):
raise DeliveryFailedError(f"Chunked upload part {i + 1}/{total_chunks} failed: HTTP {resp.status}")
complete_url = f"{api_base}/v2/users/@me/files/chunked/{upload_id}/complete"
if group_openid:
complete_url = f"{api_base}/v2/groups/{group_openid}/files/chunked/{upload_id}/complete"
async with client.post(complete_url, headers=headers) as resp:
if resp.status != 200:
raise DeliveryFailedError(f"Chunked upload complete failed: HTTP {resp.status}")
result = await resp.json()
return result.get("file_uuid", "") or result.get("file_info", "")
if http_client:
return await _do_chunked(http_client)
else:
async with aiohttp.ClientSession() as session:
return await _do_chunked(session)
async def upload_media_from_url(
url: str,
token: str,
http_client: aiohttp.ClientSession | None = None,
filename: str = "media",
file_type: str = FILE_TYPE_FILE,
group_openid: str | None = None,
sandbox: bool = False,
) -> str:
async def _do_url_upload(client: aiohttp.ClientSession) -> str:
api_base = "https://sandbox.api.sgroup.qq.com" if sandbox else "https://api.sgroup.qq.com"
headers = {"Authorization": f"QQBot {token}"}
endpoint = f"{api_base}/v2/users/@me/files/url"
if group_openid:
endpoint = f"{api_base}/v2/groups/{group_openid}/files/url"
payload = {
"url": url,
"file_type": int(file_type),
}
async with client.post(endpoint, headers=headers, json=payload) as resp:
if resp.status not in (200, 201):
raise DeliveryFailedError(f"URL upload failed: HTTP {resp.status}")
result = await resp.json()
return result.get("file_uuid", "") or result.get("file_info", "")
if http_client:
return await _do_url_upload(http_client)
else:
async with aiohttp.ClientSession() as session:
return await _do_url_upload(session)

View File

@ -0,0 +1,223 @@
from __future__ import annotations
import asyncio
import logging
import random
import time
from collections.abc import Callable
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class AccountConfig:
account_id: str
app_id: str
app_secret: str
label: str = ""
weight: int = 1
priority: int = 0
group_ids: list[str] = field(default_factory=list)
user_ids: list[str] = field(default_factory=list)
cooldown_s: float = 30.0
_fail_count: int = 0
_last_fail: float = 0.0
_last_used: float = 0.0
@property
def is_cooling_down(self) -> bool:
if self._last_fail <= 0:
return False
return time.time() - self._last_fail < self.cooldown_s
def record_success(self) -> None:
self._fail_count = 0
self._last_fail = 0.0
self._last_used = time.time()
def record_failure(self) -> None:
self._fail_count += 1
self._last_fail = time.time()
def matches_chat(self, group_id: str = "", user_id: str = "") -> bool:
if self.group_ids or self.user_ids:
if group_id and self.group_ids and group_id not in self.group_ids:
return False
if user_id and self.user_ids and user_id not in self.user_ids:
return False
return True
@dataclass
class AccountRouteResult:
account: AccountConfig
account_id: str
resolved: bool = True
reason: str = ""
class MultiAccountManager:
def __init__(
self,
accounts: list[AccountConfig] | None = None,
default_rotation_strategy: str = "weighted_round_robin",
):
self._accounts: dict[str, AccountConfig] = {}
self._rotation_index = 0
self._lock = asyncio.Lock()
self._strategy = default_rotation_strategy
self._route_fn: Callable | None = None
if accounts:
for acc in accounts:
self._accounts[acc.account_id] = acc
@classmethod
def from_config(cls, config: dict | None) -> MultiAccountManager:
if not config:
return cls()
accounts_cfg = config.get("accounts", [])
if not accounts_cfg:
app_id = config.get("app_id", "")
app_secret = config.get("app_secret", "")
if app_id and app_secret:
acc = AccountConfig(
account_id="default",
app_id=app_id,
app_secret=app_secret,
label="Default",
)
return cls(accounts=[acc])
return cls()
accounts = []
for ac in accounts_cfg:
accounts.append(AccountConfig(
account_id=ac.get("account_id", str(random.randint(1000, 9999))),
app_id=ac.get("app_id", ""),
app_secret=ac.get("app_secret", ""),
label=ac.get("label", ""),
weight=ac.get("weight", 1),
priority=ac.get("priority", 0),
group_ids=ac.get("group_ids", []),
user_ids=ac.get("user_ids", []),
cooldown_s=ac.get("cooldown_s", 30.0),
))
return cls(accounts=accounts)
@property
def account_count(self) -> int:
return len(self._accounts)
def get_account(self, account_id: str) -> AccountConfig | None:
return self._accounts.get(account_id)
async def route(
self,
group_id: str = "",
user_id: str = "",
strategy: str | None = None,
) -> AccountRouteResult:
async with self._lock:
strategy = strategy or self._strategy
if self._route_fn is not None:
result = self._route_fn(self._accounts, group_id, user_id)
if result:
return result
candidates = [
acc
for acc in self._accounts.values()
if acc.matches_chat(group_id, user_id) and not acc.is_cooling_down
]
if not candidates:
all_accounts = [
acc
for acc in self._accounts.values()
if acc.matches_chat(group_id, user_id)
]
if all_accounts:
acc = all_accounts[0]
return AccountRouteResult(
account=acc,
account_id=acc.account_id,
reason="all cooling down, picked first",
)
return AccountRouteResult(
account=AccountConfig(account_id="", app_id="", app_secret=""),
account_id="",
resolved=False,
reason="no matching accounts",
)
if strategy == "weighted_random":
weights = [acc.weight for acc in candidates]
total = sum(weights)
if total <= 0:
acc = candidates[0]
else:
r = random.uniform(0, total)
agg = 0
acc = candidates[0]
for candidate in candidates:
agg += candidate.weight
if r <= agg:
acc = candidate
break
elif strategy == "least_used":
acc = min(candidates, key=lambda a: a._last_used)
elif strategy == "priority":
candidates.sort(key=lambda a: (-a.priority, a._fail_count))
acc = candidates[0]
else:
idx = self._rotation_index % len(candidates)
acc = candidates[idx]
self._rotation_index += 1
acc.record_success()
return AccountRouteResult(
account=acc,
account_id=acc.account_id,
)
def set_route_fn(self, fn: Callable | None) -> None:
self._route_fn = fn
async def mark_failure(self, account_id: str) -> None:
async with self._lock:
acc = self._accounts.get(account_id)
if acc:
acc.record_failure()
logger.warning(
"MultiAccount: account %s failed (count=%d)",
account_id,
acc._fail_count,
)
async def mark_success(self, account_id: str) -> None:
async with self._lock:
acc = self._accounts.get(account_id)
if acc:
acc.record_success()
async def all_cooling_down(self) -> bool:
async with self._lock:
return all(acc.is_cooling_down for acc in self._accounts.values()) if self._accounts else False
def list_accounts(self) -> list[dict]:
return [
{
"account_id": acc.account_id,
"label": acc.label,
"weight": acc.weight,
"priority": acc.priority,
"is_cooling_down": acc.is_cooling_down,
"fail_count": acc._fail_count,
}
for acc in self._accounts.values()
]

View File

@ -0,0 +1,148 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
import aiohttp
from yuxi.channels.models import HealthStatus
from yuxi.utils.logging_config import logger
@dataclass
class EndpointCheck:
name: str
url: str
status: str = "unknown"
latency_ms: float = 0
error: str = ""
http_status: int = 0
@dataclass
class AggregatedHealth:
status: str = "healthy"
overall_latency_ms: float = 0
checks: list[EndpointCheck] = field(default_factory=list)
ws_latency_ms: float | None = None
@property
def all_healthy(self) -> bool:
return all(c.status == "healthy" for c in self.checks)
async def health_check_dsm(
api_base: str,
token: str,
http_client: aiohttp.ClientSession | None = None,
sandbox: bool = False,
ws_connected: bool = False,
) -> HealthStatus:
headers = {"Authorization": f"QQBot {token}"}
timeout = aiohttp.ClientTimeout(total=10)
async def _check(session: aiohttp.ClientSession) -> HealthStatus:
start = time.monotonic()
async with session.get(f"{api_base}/gateway", headers=headers) as resp:
latency_ms = (time.monotonic() - start) * 1000
if resp.status == 200:
return HealthStatus(
status="healthy",
latency_ms=latency_ms,
metadata={
"sandbox": sandbox,
"ws_connected": ws_connected,
},
)
elif resp.status == 401:
return HealthStatus(
status="unhealthy",
last_error="Token expired or invalid",
metadata={
"sandbox": sandbox,
"auth_status": "failed",
},
)
else:
return HealthStatus(
status="degraded",
latency_ms=latency_ms,
last_error=f"Gateway returned {resp.status}",
)
if http_client:
try:
return await _check(http_client)
except Exception as e:
logger.warning(f"[QQBot] Health check failed: {e}")
return HealthStatus(status="unhealthy", last_error=str(e), metadata={"sandbox": sandbox})
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
return await _check(session)
except Exception as e:
logger.warning(f"[QQBot] Health check failed: {e}")
return HealthStatus(status="unhealthy", last_error=str(e), metadata={"sandbox": sandbox})
async def health_check_multi_endpoint(
api_base: str,
token: str,
http_client: aiohttp.ClientSession | None = None,
ws_connected: bool = False,
ws_latency_ms: float | None = None,
) -> AggregatedHealth:
headers = {"Authorization": f"QQBot {token}"}
timeout = aiohttp.ClientTimeout(total=10)
endpoints = [
EndpointCheck(name="gateway", url=f"{api_base}/gateway"),
EndpointCheck(name="bot_info", url=f"{api_base}/v2/users/@me"),
]
async def _check(session: aiohttp.ClientSession) -> AggregatedHealth:
for ep in endpoints:
try:
start = time.monotonic()
async with session.get(ep.url, headers=headers) as resp:
ep.latency_ms = (time.monotonic() - start) * 1000
ep.http_status = resp.status
if resp.status == 200:
ep.status = "healthy"
elif resp.status == 401:
ep.status = "unhealthy"
ep.error = "Authentication failed"
elif resp.status >= 500:
ep.status = "degraded"
ep.error = f"Server error ({resp.status})"
else:
ep.status = "degraded"
ep.error = f"Unexpected status ({resp.status})"
except TimeoutError:
ep.status = "degraded"
ep.error = "Timeout"
except Exception as e:
ep.status = "unhealthy"
ep.error = str(e)
overall = "healthy"
if any(ep.status == "unhealthy" for ep in endpoints):
overall = "unhealthy"
elif any(ep.status == "degraded" for ep in endpoints):
overall = "degraded"
healthy_checks = [ep for ep in endpoints if ep.status == "healthy"]
avg_latency = sum(ep.latency_ms for ep in healthy_checks) / len(healthy_checks) if healthy_checks else 0
return AggregatedHealth(
status=overall,
overall_latency_ms=avg_latency,
checks=endpoints,
ws_latency_ms=ws_latency_ms,
)
if http_client:
return await _check(http_client)
async with aiohttp.ClientSession(timeout=timeout) as session:
return await _check(session)

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class TokenBucket:
rate: float
burst: int
_tokens: float = field(default=0, init=False)
_last_refill: float = field(default_factory=time.monotonic, init=False)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
def __post_init__(self):
self._tokens = float(self.burst)
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self._tokens + elapsed * self.rate, float(self.burst))
self._last_refill = now
async def acquire(self, tokens: float = 1.0) -> None:
while True:
async with self._lock:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return
wait = (tokens - self._tokens) / self.rate if self.rate > 0 else 0.1
await asyncio.sleep(max(wait, 0.01))
def try_acquire(self, tokens: float = 1.0) -> bool:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return True
return False
@dataclass
class RouteRateLimiter:
defaults: dict[str, tuple[float, int]] = field(
default_factory=lambda: {
"send_message": (5.0, 10),
"send_media": (1.0, 3),
"upload_media": (0.5, 2),
"default": (10.0, 20),
}
)
_buckets: dict[str, TokenBucket] = field(default_factory=dict, init=False)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
async def acquire(self, route: str = "default", tokens: float = 1.0) -> None:
bucket = await self._get_or_create_bucket(route)
await bucket.acquire(tokens)
def try_acquire(self, route: str = "default", tokens: float = 1.0) -> bool:
bucket = self._get_or_create_bucket_sync(route)
return bucket.try_acquire(tokens)
async def _get_or_create_bucket(self, route: str) -> TokenBucket:
async with self._lock:
if route not in self._buckets:
rate, burst = self.defaults.get(route, self.defaults["default"])
self._buckets[route] = TokenBucket(rate=rate, burst=burst)
return self._buckets[route]
def _get_or_create_bucket_sync(self, route: str) -> TokenBucket:
if route not in self._buckets:
rate, burst = self.defaults.get(route, self.defaults["default"])
self._buckets[route] = TokenBucket(rate=rate, burst=burst)
return self._buckets[route]
def get_stats(self) -> dict[str, dict]:
return {
route: {"tokens": bucket._tokens, "rate": bucket.rate, "burst": bucket.burst}
for route, bucket in self._buckets.items()
}

View File

@ -0,0 +1,322 @@
from __future__ import annotations
import asyncio
import logging
import random
import time
from enum import Enum, auto
from collections.abc import Callable, Awaitable
from yuxi.channels.adapters.qqbot.constants import ECode, SERVER_CLOSE_CODE_MAP
logger = logging.getLogger(__name__)
_RAPID_DISCONNECT_THRESHOLD_S = 5.0
_RAPID_DISCONNECT_MAX_WARNINGS = 3
class ReconnectState(Enum):
DISCONNECTED = auto()
CONNECTING = auto()
CONNECTED = auto()
RECONNECTING = auto()
IDENTIFYING = auto()
RESUMING = auto()
BACKOFF = auto()
FROZEN = auto()
class CloseCodeCategory(Enum):
ABNORMAL = auto()
RECOVERABLE = auto()
FATAL = auto()
SERVER_SIDE = auto()
SERVER_ERROR = auto()
RATE_LIMITED = auto()
class ServerErrorCategory(Enum):
OVERLOAD = auto()
MAINTENANCE = auto()
NETWORK = auto()
INTERNAL = auto()
UNAVAILABLE = auto()
TIMEOUT = auto()
UNKNOWN = auto()
_RECOVERABLE_CODES: set[int] = {
ECode.UNKNOWN_ERROR,
ECode.UNKNOWN_OPCODE,
ECode.DECODE_ERROR,
ECode.NOT_AUTHENTICATED,
ECode.AUTHENTICATION_FAILED,
ECode.RATE_LIMITED,
4009,
ECode.INVALID_INTENT,
ECode.INVALID_SHARD,
}
_FATAL_CODES: set[int] = {
ECode.INVALID_API_VERSION,
ECode.INVALID_SEQ,
4013,
ECode.INVALID_SHARD_COUNT,
ECode.BOT_REMOVED,
ECode.ACCOUNT_BANNED,
}
_ABNORMAL_CODES: set[int] = {4000, 4008, 4011}
_RATELIMIT_CODES: set[int] = {ECode.RATE_LIMITED, 4009}
_SERVER_ERROR_CODES: set[int] = set(range(4900, 4914))
_SERVER_OVERLOAD_CODES: set[int] = {4901, 4904, 4912}
_SERVER_MAINTENANCE_CODES: set[int] = {4902, 4905}
_SERVER_NETWORK_CODES: set[int] = {4903, 4906, 4907}
_SERVER_UNAVAILABLE_CODES: set[int] = {4908, 4909}
_SERVER_TIMEOUT_CODES: set[int] = {4913}
_SERVER_INTERNAL_CODES: set[int] = {4900, 4910, 4911}
def classify_server_error_category(code: int) -> ServerErrorCategory:
if code in _SERVER_OVERLOAD_CODES:
return ServerErrorCategory.OVERLOAD
if code in _SERVER_MAINTENANCE_CODES:
return ServerErrorCategory.MAINTENANCE
if code in _SERVER_NETWORK_CODES:
return ServerErrorCategory.NETWORK
if code in _SERVER_UNAVAILABLE_CODES:
return ServerErrorCategory.UNAVAILABLE
if code in _SERVER_TIMEOUT_CODES:
return ServerErrorCategory.TIMEOUT
if code in _SERVER_INTERNAL_CODES:
return ServerErrorCategory.INTERNAL
return ServerErrorCategory.UNKNOWN
def get_server_error_name(code: int) -> str:
return SERVER_CLOSE_CODE_MAP.get(code, f"server_error_{code}")
def classify_close_code(code: int | None) -> CloseCodeCategory:
if code is None:
return CloseCodeCategory.ABNORMAL
if code in _RATELIMIT_CODES:
return CloseCodeCategory.RATE_LIMITED
if code in _FATAL_CODES:
return CloseCodeCategory.FATAL
if code in _RECOVERABLE_CODES:
return CloseCodeCategory.RECOVERABLE
if code in _ABNORMAL_CODES:
return CloseCodeCategory.ABNORMAL
if code in _SERVER_ERROR_CODES:
return CloseCodeCategory.SERVER_ERROR
return CloseCodeCategory.SERVER_SIDE if 4000 <= code < 5000 else CloseCodeCategory.ABNORMAL
class QQBotReconnectManager:
def __init__(
self,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: float = 0.3,
max_retries: int = 10,
resume_timeout: float = 15.0,
) -> None:
self._state = ReconnectState.DISCONNECTED
self._base_delay = base_delay
self._max_delay = max_delay
self._jitter = jitter
self._max_retries = max_retries
self._resume_timeout = resume_timeout
self._retry_count = 0
self._session_id: str | None = None
self._last_seq: int | None = None
self._seq_reset_lock = asyncio.Lock()
self._state_listeners: list[Callable[[ReconnectState, ReconnectState], Awaitable[None]]] = []
self._last_connect_time: float = 0.0
self._rapid_disconnect_count: int = 0
self._last_disconnect_code: int | None = None
self._last_disconnect_time: float = 0.0
@property
def state(self) -> ReconnectState:
return self._state
@property
def session_id(self) -> str | None:
return self._session_id
@property
def last_seq(self) -> int | None:
return self._last_seq
def add_state_listener(self, listener: Callable[[ReconnectState, ReconnectState], Awaitable[None]]) -> None:
self._state_listeners.append(listener)
async def _notify_state_change(self, old: ReconnectState, new: ReconnectState) -> None:
for listener in self._state_listeners:
try:
await listener(old, new)
except Exception:
logger.exception("ReconnectManager state listener error")
async def transition(self, new: ReconnectState) -> None:
old = self._state
if old == new:
return
self._state = new
logger.info("ReconnectManager: %s -> %s", old.name, new.name)
await self._notify_state_change(old, new)
def on_identify_success(self, session_id: str) -> None:
self._session_id = session_id
self._retry_count = 0
self._last_connect_time = time.monotonic()
def mark_connected(self) -> None:
self._last_connect_time = time.monotonic()
self._rapid_disconnect_count = 0
async def record_seq(self, seq: int) -> None:
async with self._seq_reset_lock:
self._last_seq = seq
def seq_reset(self) -> None:
self._last_seq = None
def on_hello(self) -> None:
pass
def should_resume(self) -> bool:
return self._session_id is not None and self._last_seq is not None
async def on_disconnect(self, code: int | None) -> None:
now = time.monotonic()
self._last_disconnect_code = code
self._last_disconnect_time = now
category = classify_close_code(code)
self._check_rapid_disconnect(code, category, now)
if category == CloseCodeCategory.FATAL:
logger.error("ReconnectManager: fatal close code %s, freezing", code)
await self.transition(ReconnectState.FROZEN)
return
if category == CloseCodeCategory.SERVER_ERROR:
error_name = get_server_error_name(code) if code else "unknown"
sub_category = classify_server_error_category(code) if code else ServerErrorCategory.UNKNOWN
logger.warning(
"ReconnectManager: server error code=%s name=%s category=%s",
code,
error_name,
sub_category.name,
)
self._retry_count += 1
if self._retry_count >= self._max_retries:
logger.error("ReconnectManager: server error retries exhausted, freezing")
await self.transition(ReconnectState.FROZEN)
return
delay = self._calc_delay_for_server_error(sub_category)
logger.warning(
"ReconnectManager: server error close code %s (%s), backing off %.1fs (retry %d/%d)",
code,
error_name,
delay,
self._retry_count,
self._max_retries,
)
self.seq_reset()
self._session_id = None
await self.transition(ReconnectState.BACKOFF)
await asyncio.sleep(delay)
await self.transition(ReconnectState.IDENTIFYING)
return
if category == CloseCodeCategory.RATE_LIMITED:
self._retry_count += 1
if self._retry_count > 3:
logger.error("ReconnectManager: rate-limited retries exhausted, freezing")
await self.transition(ReconnectState.FROZEN)
return
self.seq_reset()
if self._retry_count >= self._max_retries:
logger.error("ReconnectManager: max retries (%d) exhausted", self._max_retries)
await self.transition(ReconnectState.FROZEN)
return
self._retry_count += 1
delay = self._calc_delay()
logger.info("ReconnectManager: backing off %.1fs (retry %d/%d)", delay, self._retry_count, self._max_retries)
await self.transition(ReconnectState.BACKOFF)
await asyncio.sleep(delay)
if self.should_resume():
await self.transition(ReconnectState.RESUMING)
else:
self.seq_reset()
await self.transition(ReconnectState.IDENTIFYING)
def _calc_delay(self) -> float:
raw = min(self._base_delay * (2 ** (self._retry_count - 1)), self._max_delay)
jittered = raw * (1 + random.uniform(-self._jitter, self._jitter))
return max(0.5, min(jittered, self._max_delay))
def _calc_delay_for_server_error(self, sub_category: ServerErrorCategory) -> float:
base = self._calc_delay()
if sub_category == ServerErrorCategory.OVERLOAD:
return base * 3.0
if sub_category == ServerErrorCategory.MAINTENANCE:
return base * 5.0
if sub_category == ServerErrorCategory.NETWORK:
return base * 1.5
if sub_category == ServerErrorCategory.TIMEOUT:
return base * 1.5
return base * 2.0
def _check_rapid_disconnect(self, code: int | None, category: CloseCodeCategory, now: float) -> None:
if category == CloseCodeCategory.FATAL:
return
if self._last_connect_time == 0:
return
elapsed = now - self._last_connect_time
if elapsed > _RAPID_DISCONNECT_THRESHOLD_S:
self._rapid_disconnect_count = 0
return
self._rapid_disconnect_count += 1
if self._rapid_disconnect_count >= _RAPID_DISCONNECT_MAX_WARNINGS:
logger.error(
"ReconnectManager: RAPID DISCONNECT LOOP DETECTED - "
"%d disconnects within %.1fs threshold, last code=%s category=%s elapsed=%.2fs",
self._rapid_disconnect_count,
_RAPID_DISCONNECT_THRESHOLD_S,
code,
category.name,
elapsed,
)
else:
logger.warning(
"ReconnectManager: rapid disconnect #%d/%d within %.1fs threshold, code=%s category=%s elapsed=%.2fs",
self._rapid_disconnect_count,
_RAPID_DISCONNECT_MAX_WARNINGS,
_RAPID_DISCONNECT_THRESHOLD_S,
code,
category.name,
elapsed,
)
async def reset(self) -> None:
self._retry_count = 0
self._session_id = None
self._last_seq = None
self._last_connect_time = 0.0
self._rapid_disconnect_count = 0
self._last_disconnect_code = None
self._last_disconnect_time = 0.0
await self.transition(ReconnectState.DISCONNECTED)

View File

@ -0,0 +1,5 @@
from .types import RefItem, RefCategory
from .store import RefIndex
from .format import format_ref_item
__all__ = ["RefItem", "RefCategory", "RefIndex", "format_ref_item"]

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from .types import RefItem
def format_ref_item(ref: RefItem, style: str = "inline") -> str:
if style == "inline":
return _format_inline(ref)
if style == "block":
return _format_block(ref)
if style == "markdown":
return _format_markdown(ref)
return _format_brief(ref)
def _format_inline(ref: RefItem) -> str:
label = ref.label or ref.value
return f"[{ref.category.value}:{label}]"
def _format_brief(ref: RefItem) -> str:
return f"Ref({ref.ref_id}): {ref.category.value}='{ref.value}'"
def _format_block(ref: RefItem) -> str:
lines = [
f"[Ref: {ref.ref_id}]",
f" 类别: {ref.category.value}",
f" 值: {ref.value}",
]
if ref.label:
lines.append(f" 标签: {ref.label}")
if ref.source_msg_id:
lines.append(f" 来源消息: {ref.source_msg_id}")
if ref.target_msg_id:
lines.append(f" 目标消息: {ref.target_msg_id}")
return "\n".join(lines)
def _format_markdown(ref: RefItem) -> str:
label = ref.label or ref.value
source_link = ""
if ref.source_msg_id:
source_link = f" → `{ref.source_msg_id}`"
return f"- **`[{ref.category.value}]`** {label}{source_link}"

View File

@ -0,0 +1,156 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from .types import RefItem, RefCategory
logger = logging.getLogger(__name__)
class RefIndex:
def __init__(
self,
store_dir: str | None = None,
max_entries: int = 10000,
ttl_s: float = 86400.0,
):
self._store_dir = store_dir or os.path.join(os.path.dirname(__file__), "..", "ref_data")
self._max_entries = max_entries
self._ttl_s = ttl_s
self._refs: dict[str, RefItem] = {}
self._by_source: dict[str, list[str]] = {}
self._by_chat: dict[str, list[str]] = {}
self._lock = asyncio.Lock()
self._load()
def _load(self) -> None:
path = os.path.join(self._store_dir, "ref_index.json")
try:
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
data = json.load(f)
for item in data:
ref = RefItem.from_dict(item)
self._add_indexes(ref)
logger.info("RefIndex: loaded %d refs from %s", len(self._refs), path)
except (OSError, json.JSONDecodeError):
logger.exception("RefIndex: failed to load")
async def _save(self) -> None:
os.makedirs(self._store_dir, exist_ok=True)
path = os.path.join(self._store_dir, "ref_index.json")
tmp = path + ".tmp"
data = [r.to_dict() for r in self._refs.values()]
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
except OSError:
logger.exception("RefIndex: failed to save")
def _add_indexes(self, ref: RefItem) -> None:
self._refs[ref.ref_id] = ref
if ref.source_msg_id:
self._by_source.setdefault(ref.source_msg_id, []).append(ref.ref_id)
if ref.chat_id:
self._by_chat.setdefault(ref.chat_id, []).append(ref.ref_id)
async def add(
self,
value: str,
category: RefCategory,
label: str = "",
source_msg_id: str = "",
target_msg_id: str = "",
chat_id: str = "",
user_id: str = "",
extra: dict | None = None,
) -> RefItem:
async with self._lock:
self._gc()
ref = RefItem(
ref_id=str(uuid.uuid4())[:8],
category=category,
value=value,
label=label,
source_msg_id=source_msg_id,
target_msg_id=target_msg_id,
chat_id=chat_id,
user_id=user_id,
extra=extra or {},
)
self._add_indexes(ref)
await self._save()
return ref
async def get(self, ref_id: str) -> RefItem | None:
async with self._lock:
return self._refs.get(ref_id)
async def get_by_source(self, source_msg_id: str) -> list[RefItem]:
async with self._lock:
ref_ids = self._by_source.get(source_msg_id, [])
return [self._refs[rid] for rid in ref_ids if rid in self._refs]
async def get_by_chat(self, chat_id: str, limit: int = 100) -> list[RefItem]:
async with self._lock:
ref_ids = self._by_chat.get(chat_id, [])
if limit:
ref_ids = ref_ids[-limit:]
return [self._refs[rid] for rid in ref_ids if rid in self._refs]
async def remove(self, ref_id: str) -> bool:
async with self._lock:
ref = self._refs.pop(ref_id, None)
if ref is None:
return False
if ref.source_msg_id and ref.source_msg_id in self._by_source:
self._by_source[ref.source_msg_id] = [
rid for rid in self._by_source[ref.source_msg_id] if rid != ref_id
]
if ref.chat_id and ref.chat_id in self._by_chat:
self._by_chat[ref.chat_id] = [
rid for rid in self._by_chat[ref.chat_id] if rid != ref_id
]
await self._save()
return True
def _gc(self) -> None:
now = time.time()
stale = [rid for rid, ref in self._refs.items() if now - ref.created_at > self._ttl_s]
for rid in stale:
ref = self._refs.pop(rid, None)
if ref:
if ref.source_msg_id in self._by_source:
self._by_source[ref.source_msg_id] = [
r for r in self._by_source[ref.source_msg_id] if r != rid
]
if ref.chat_id in self._by_chat:
self._by_chat[ref.chat_id] = [
r for r in self._by_chat[ref.chat_id] if r != rid
]
over = len(self._refs) - self._max_entries
if over > 0:
oldest = sorted(self._refs.values(), key=lambda r: r.created_at)[:over]
for ref in oldest:
self._refs.pop(ref.ref_id, None)
if ref.source_msg_id in self._by_source:
self._by_source[ref.source_msg_id] = [
r for r in self._by_source[ref.source_msg_id] if r != ref.ref_id
]
if ref.chat_id in self._by_chat:
self._by_chat[ref.chat_id] = [
r for r in self._by_chat[ref.chat_id] if r != ref.ref_id
]

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
class RefCategory(Enum):
MSG = "msg"
MEDIA = "media"
EMOJI = "emoji"
STICKER = "sticker"
ATTACHMENT = "attachment"
FILE = "file"
CUSTOM = "custom"
@dataclass
class RefItem:
ref_id: str
category: RefCategory
value: str
label: str = ""
source_msg_id: str = ""
target_msg_id: str = ""
chat_id: str = ""
user_id: str = ""
extra: dict = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
@property
def is_stale(self) -> bool:
return time.time() - self.created_at > 86400
def to_dict(self) -> dict:
return {
"ref_id": self.ref_id,
"category": self.category.value,
"value": self.value,
"label": self.label,
"source_msg_id": self.source_msg_id,
"target_msg_id": self.target_msg_id,
"chat_id": self.chat_id,
"user_id": self.user_id,
"extra": self.extra,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data: dict) -> RefItem:
return cls(
ref_id=data.get("ref_id", ""),
category=RefCategory(data.get("category", "msg")),
value=data.get("value", ""),
label=data.get("label", ""),
source_msg_id=data.get("source_msg_id", ""),
target_msg_id=data.get("target_msg_id", ""),
chat_id=data.get("chat_id", ""),
user_id=data.get("user_id", ""),
extra=data.get("extra", {}),
created_at=data.get("created_at", 0),
)

View File

@ -0,0 +1,84 @@
from __future__ import annotations
import logging
from enum import Enum, auto
from typing import Any
from yuxi.channels.adapters.qqbot.send import render_reply_payload
from yuxi.channels.pipeline.context import PipelineContext
logger = logging.getLogger(__name__)
class ReplyMode(Enum):
DIRECT = auto()
STREAMING = auto()
MARKDOWN = auto()
FALLBACK = auto()
class ReplyDispatcher:
def __init__(self, adapter: Any) -> None:
self._adapter = adapter
async def reply(self, ctx: PipelineContext, text: str) -> Any:
if ctx.chat_type == "interaction":
return await self._reply_interaction(ctx, text)
streaming_ready = self._check_streaming_ready(ctx)
if streaming_ready:
return await self._reply_streaming(ctx)
if len(text) > 2000:
return await self._reply_markdown(ctx, text)
return await self._reply_direct(ctx, text)
def _check_streaming_ready(self, ctx: PipelineContext) -> bool:
if ctx.chat_type != "dm":
return False
adapter = self._adapter
c2c_ctrl = getattr(adapter, "_c2c_streaming", None)
return c2c_ctrl is not None
async def _reply_direct(self, ctx: PipelineContext, text: str) -> Any:
if ctx.chat_type == "group":
return await self._adapter.send_group_message(ctx.chat_id, text, msg_id=ctx.msg_id)
return await self._adapter.send_dm_message(ctx.chat_id, text, msg_id=ctx.msg_id)
async def _reply_streaming(self, ctx: PipelineContext) -> Any:
c2c_ctrl = self._adapter._c2c_streaming
if c2c_ctrl is None:
return None
msg_id = ctx.metadata.get("stream_msg_id", "")
if not msg_id:
msg_id = ctx.msg_id
return await c2c_ctrl.stream(
chat_id=ctx.chat_id,
msg_id=msg_id,
content_generator=self._adapter._stream_content(ctx),
event_id=ctx.metadata.get("event_id", ""),
)
async def _reply_markdown(self, ctx: PipelineContext, text: str) -> Any:
chunks = self._adapter._markdown_chunker.chunk(text)
results = []
for i, chunk in enumerate(chunks):
payload = render_reply_payload(
chunk, msg_type=2, msg_id=ctx.msg_id, chunk_index=i, total_chunks=len(chunks)
)
if ctx.chat_type == "group":
result = await self._adapter.send_group_message(
ctx.chat_id, content="", payload=payload, msg_id=ctx.msg_id
)
else:
result = await self._adapter.send_dm_message(
ctx.chat_id, content="", payload=payload, msg_id=ctx.msg_id
)
results.append(result)
return results
async def _reply_interaction(self, ctx: PipelineContext, text: str) -> Any:
return await self._adapter._put_interaction(ctx.metadata.get("interaction_id", ""), ctx.content)

View File

@ -0,0 +1,147 @@
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class RetryTask:
task_id: str
chat_id: str
payload: dict
attempt: int = 0
max_attempts: int = 5
created_at: float = field(default_factory=time.time)
next_retry_at: float = field(default_factory=time.time)
last_error: str = ""
base_delay: float = 1.0
max_delay: float = 120.0
class MessageRetryQueue:
def __init__(self, max_concurrent: int = 3, poll_interval: float = 1.0):
self._queue: list[RetryTask] = []
self._dead_letter: list[RetryTask] = []
self._lock = asyncio.Lock()
self._max_concurrent = max_concurrent
self._poll_interval = poll_interval
self._running = False
self._worker_task: asyncio.Task | None = None
self._send_cb: Callable[[str, dict], Awaitable[bool]] | None = None
self._active_tasks: set[str] = set()
@property
def pending_count(self) -> int:
return len(self._queue)
@property
def dead_count(self) -> int:
return len(self._dead_letter)
def set_send_callback(self, callback: Callable[[str, dict], Awaitable[bool]]) -> None:
self._send_cb = callback
async def enqueue(self, task: RetryTask) -> None:
async with self._lock:
if task.task_id in self._active_tasks:
return
self._queue.append(task)
self._queue.sort(key=lambda t: t.next_retry_at)
async def start(self) -> None:
if self._running:
return
self._running = True
self._worker_task = asyncio.create_task(self._worker_loop())
logger.info("MessageRetryQueue: worker started")
async def stop(self) -> None:
self._running = False
if self._worker_task:
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
self._worker_task = None
logger.info("MessageRetryQueue: worker stopped, pending=%d dead=%d", len(self._queue), len(self._dead_letter))
async def _worker_loop(self) -> None:
while self._running:
task = None
async with self._lock:
for t in self._queue:
if t.task_id in self._active_tasks:
continue
if time.time() >= t.next_retry_at:
task = t
self._active_tasks.add(t.task_id)
break
if task is None:
await asyncio.sleep(self._poll_interval)
continue
if len(self._active_tasks) >= self._max_concurrent:
async with self._lock:
self._active_tasks.discard(task.task_id)
await asyncio.sleep(self._poll_interval)
continue
try:
success = await self._process_task(task)
async with self._lock:
self._active_tasks.discard(task.task_id)
if success:
self._queue.remove(task)
elif task.attempt >= task.max_attempts:
self._queue.remove(task)
self._dead_letter.append(task)
logger.warning(
"MessageRetryQueue: task %s exhausted retries (chat=%s)",
task.task_id,
task.chat_id,
)
except Exception:
async with self._lock:
self._active_tasks.discard(task.task_id)
await asyncio.sleep(self._poll_interval)
async def _process_task(self, task: RetryTask) -> bool:
if self._send_cb is None:
return False
try:
task.attempt += 1
success = await self._send_cb(task.chat_id, task.payload)
if success:
logger.debug("MessageRetryQueue: task %s succeeded on attempt %d", task.task_id, task.attempt)
return True
task.last_error = "send_failed"
except Exception as e:
task.last_error = str(e)
delay = min(task.base_delay * (2 ** (task.attempt - 1)), task.max_delay)
task.next_retry_at = time.time() + delay
logger.info(
"MessageRetryQueue: task %s retry %d/%d, next in %.1fs",
task.task_id,
task.attempt,
task.max_attempts,
delay,
)
return False
def get_dead_letter_tasks(self) -> list[RetryTask]:
return list(self._dead_letter)
def clear_dead_letter(self) -> int:
count = len(self._dead_letter)
self._dead_letter.clear()
return count

View File

@ -0,0 +1,196 @@
from __future__ import annotations
import logging
from yuxi.channels.models import ChannelMessage
from yuxi.channels.policy.security_policy import (
AccessResult,
BaseSecurityPolicy,
DmPolicy,
GroupPolicy,
RejectReason,
WildcardAllowlistMatcher,
)
logger = logging.getLogger(__name__)
class QQBotSecurityPolicy(BaseSecurityPolicy):
def __init__(self, config: dict):
normalized = self._normalize_config_ids(config)
super().__init__(normalized)
self._paired_users_matcher = WildcardAllowlistMatcher.from_config(
self._format_ids(config.get("paired_users", []))
)
self._group_require_mention = config.get("group_require_mention", True)
self._pairing_enabled = config.get("pairing_enabled", config.get("enable_pairing", False))
@staticmethod
def _normalize_config_ids(config: dict) -> dict:
normalized = dict(config)
for key in ("allowFrom", "allow_from"):
if key in normalized:
normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key])
for key in ("groupAllowFrom", "group_allow_from"):
if key in normalized:
normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key])
return normalized
def check_dm_access(self, sender_id: str) -> AccessResult:
if self._dm_policy == DmPolicy.DISABLED:
return AccessResult(False, RejectReason.DM_DISABLED)
if self._dm_policy == DmPolicy.OPEN:
return AccessResult(True, RejectReason.DM_OPEN_PASS)
if self._dm_policy == DmPolicy.PAIRING:
if not self._pairing_enabled:
return AccessResult(True, RejectReason.DM_OPEN_PASS)
if self._paired_users_matcher.match(sender_id):
return AccessResult(True, RejectReason.DM_ALLOWLISTED)
return AccessResult(False, RejectReason.DM_PAIRING_REQUIRED, f"sender '{sender_id}' not paired")
if self._dm_policy == DmPolicy.ALLOWLIST:
if self._dm_matcher.match(sender_id):
return AccessResult(True, RejectReason.DM_ALLOWLISTED)
return AccessResult(False, RejectReason.DM_NOT_ALLOWLISTED, f"sender '{sender_id}' not in allowFrom")
return AccessResult(True, None)
def check_group_access(self, group_id: str) -> AccessResult:
if self._group_policy == GroupPolicy.DISABLED:
return AccessResult(False, RejectReason.GROUP_DISABLED)
if self._group_policy == GroupPolicy.OPEN:
return AccessResult(True, RejectReason.GROUP_OPEN_PASS)
if self._group_policy == GroupPolicy.ALLOWLIST:
if self._group_matcher.match(group_id):
return AccessResult(True, RejectReason.GROUP_ALLOWLISTED)
return AccessResult(False, RejectReason.GROUP_NOT_ALLOWLISTED, f"group '{group_id}' not in groupAllowFrom")
return AccessResult(False, RejectReason.GROUP_DISABLED)
def check_mention_required(
self,
chat_id: str,
msg: ChannelMessage,
bot_names: list[str] | None = None,
) -> bool:
if not self._group_require_mention:
return True
groups_config = self._config.get("groups", {})
chat_cfg = groups_config.get(chat_id, {})
require_mention = chat_cfg.get("require_mention", True)
if not require_mention:
return True
if msg.mentions and msg.mentions.is_bot_mentioned:
return True
content = msg.content or ""
for name in bot_names or []:
if f"@{name}" in content:
return True
return False
def _resolve_sender_id(self, event_data: dict) -> str:
author = event_data.get("author", {})
return author.get("id", author.get("member_openid", ""))
def _resolve_group_id(self, event_data: dict) -> str:
return event_data.get("group_openid", event_data.get("group_id", event_data.get("guild_id", "")))
@staticmethod
def _format_ids(raw_ids: list[str]) -> list[str]:
result: list[str] = []
for raw in raw_ids:
raw = raw.strip()
if raw.startswith("qq:"):
result.append(raw[3:])
else:
result.append(raw)
return result
def verify_webhook_ed25519(headers: dict, body: bytes, bot_secret: str) -> bool:
sig = headers.get("x-signature-ed25519", "")
timestamp_str = headers.get("x-signature-timestamp", "")
if not sig or not timestamp_str:
return False
try:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
if len(bot_secret) != 64:
logger.warning("[QQBot] Invalid bot_secret length, expected 64 hex chars")
return False
seed = bytes.fromhex(bot_secret)
private_key = Ed25519PrivateKey.from_private_bytes(seed)
public_key = private_key.public_key()
message = timestamp_str.encode() + body
public_key.verify(bytes.fromhex(sig), message)
return True
except InvalidSignature:
return False
except Exception:
logger.exception("[QQBot] Ed25519 verification error")
return False
__all__ = [
"QQBotSecurityPolicy",
"verify_webhook_ed25519",
"check_dm_policy",
"check_group_policy",
"check_mention_required",
"AccessResult",
"RejectReason",
"BaseSecurityPolicy",
"DmPolicy",
"GroupPolicy",
"WildcardAllowlistMatcher",
]
async def check_dm_policy(user_id: str, config: dict) -> bool:
normalized_config = _normalize_config(config)
policy = QQBotSecurityPolicy(normalized_config)
clean_id = user_id.strip().removeprefix("qq:")
result = policy.check_dm_access(clean_id)
return result.allowed
def _normalize_config(config: dict) -> dict:
normalized = dict(config)
for key in ("allowFrom", "allow_from"):
if key in normalized:
normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key])
for key in ("groupAllowFrom", "group_allow_from"):
if key in normalized:
normalized[key] = QQBotSecurityPolicy._format_ids(normalized[key])
return normalized
async def check_group_policy(chat_id: str, user_id: str, config: dict) -> bool:
normalized_config = _normalize_config(config)
policy = QQBotSecurityPolicy(normalized_config)
group_id = chat_id.replace("group_", "").replace("dm_", "")
result = policy.check_group_access(group_id)
if not result.allowed:
group_allow = config.get("group_allow_from", [])
if f"qq:{user_id}" in group_allow:
return True
groups_config = config.get("groups", {})
chat_cfg = groups_config.get(chat_id, {})
per_group_allow = chat_cfg.get("allow_from", [])
if f"qq:{user_id}" in per_group_allow:
return True
return result.allowed
async def check_mention_required(
chat_id: str,
msg,
config: dict,
bot_names: list[str] | None = None,
) -> bool:
policy = QQBotSecurityPolicy(config)
return policy.check_mention_required(chat_id, msg, bot_names)

View File

@ -0,0 +1,170 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import aiohttp
from yuxi.channels.exceptions import DeliveryFailedError
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
@dataclass
class MessageSeqManager:
next_seq: int = 1
_passive_seq: int = 0
_active_seq: int = 1
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
MAX_SEQ: int = 2**31 - 1
async def acquire_active(self) -> int:
async with self._lock:
if self._active_seq > self.MAX_SEQ:
self._active_seq = 1
seq = self._active_seq
self._active_seq += 1
return seq
async def acquire_passive(self) -> int:
async with self._lock:
if self._passive_seq > self.MAX_SEQ:
self._passive_seq = 0
seq = self._passive_seq
self._passive_seq -= 1
return seq
def reset(self) -> None:
self._active_seq = 1
self._passive_seq = 0
self.next_seq = 1
def snapshot(self) -> dict:
return {
"active_seq": self._active_seq,
"passive_seq": self._passive_seq,
}
def restore(self, snapshot: dict) -> None:
self._active_seq = snapshot.get("active_seq", 1)
self._passive_seq = snapshot.get("passive_seq", 0)
self.next_seq = self._active_seq
async def send_with_retry(
http_client: aiohttp.ClientSession,
token: str,
api_base: str,
payload: dict,
chat_id: str,
config: dict | None = None,
token_refresh_cb: Callable[[], Awaitable[str]] | None = None,
on_sent: Callable[[DeliveryResult], Awaitable[None]] | None = None,
) -> DeliveryResult:
cfg = config or {}
max_retries = cfg.get("retry", {}).get("attempts", 3)
min_delay = cfg.get("retry", {}).get("min_delay_ms", 400) / 1000
max_delay = cfg.get("retry", {}).get("max_delay_ms", 30000) / 1000
current_token = token
token_refreshed = False
last_error = None
url = _resolve_send_url(api_base, chat_id)
for attempt in range(max_retries):
try:
headers = {
"Authorization": f"QQBot {current_token}",
"Content-Type": "application/json",
}
async with http_client.post(url, json=payload, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
result = DeliveryResult(
success=True,
message_id=data.get("id") or data.get("message_id"),
)
if on_sent:
await on_sent(result)
return result
elif resp.status == 429:
retry_after = int(resp.headers.get("Retry-After", "30"))
logger.warning(f"[QQBot] Rate limited, retry after {retry_after}s")
await asyncio.sleep(retry_after)
continue
elif resp.status in (401, 403):
if resp.status == 401 and token_refresh_cb and not token_refreshed:
logger.warning("[QQBot] 401 received, refreshing token and retrying")
try:
current_token = await token_refresh_cb()
token_refreshed = True
continue
except Exception as e:
logger.error(f"[QQBot] Token refresh after 401 failed: {e}")
error_body = await resp.text()
result = DeliveryResult(success=False, error=f"Auth failed ({resp.status}): {error_body}")
if on_sent:
await on_sent(result)
raise DeliveryFailedError(f"Auth failed ({resp.status}): {error_body}")
elif 400 <= resp.status < 500:
error_body = await resp.text()
result = DeliveryResult(success=False, error=f"Client error ({resp.status}): {error_body}")
if on_sent:
await on_sent(result)
raise DeliveryFailedError(f"Client error ({resp.status}): {error_body}")
else:
error_body = await resp.text()
last_error = DeliveryFailedError(f"Server error ({resp.status}): {error_body}")
except DeliveryFailedError:
raise
except Exception as e:
last_error = DeliveryFailedError(str(e))
if attempt < max_retries - 1:
delay = min(min_delay * (2**attempt), max_delay)
await asyncio.sleep(delay)
result = DeliveryResult(
success=False,
error=str(last_error) if last_error else "Max retries exceeded",
)
if on_sent:
await on_sent(result)
return result
def _resolve_send_url(api_base: str, chat_id: str) -> str:
if chat_id.startswith(GROUP_CHAT_PREFIX):
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
return f"{api_base}/v2/groups/{group_openid}/messages"
elif chat_id.startswith(DM_CHAT_PREFIX):
openid = chat_id.replace(DM_CHAT_PREFIX, "")
return f"{api_base}/v2/users/{openid}/messages"
elif chat_id:
return f"{api_base}/v2/channels/{chat_id}/messages"
return f"{api_base}/v2/users/@me/messages"
def render_reply_payload(
content: str,
msg_type: int = 0,
msg_id: str = "",
chunk_index: int = 0,
total_chunks: int = 1,
) -> dict:
payload: dict = {
"content": content,
"msg_type": msg_type,
}
if msg_id:
payload["msg_id"] = msg_id
if total_chunks > 1:
payload["chunk_index"] = chunk_index
payload["total_chunks"] = total_chunks
return payload

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from yuxi.channels.models import ChannelIdentity
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
def resolve_thread_key(identity: ChannelIdentity) -> str:
chat_id = identity.channel_chat_id
if chat_id.startswith(GROUP_CHAT_PREFIX):
return f"qqbot:group:{chat_id}"
elif chat_id.startswith(DM_CHAT_PREFIX):
return f"qqbot:direct:{chat_id}"
return f"qqbot:guild:{chat_id}"
def resolve_agent_route(
identity: ChannelIdentity,
default_agent_id: str = "default",
groups_config: dict | None = None,
guild_channels_config: dict | None = None,
) -> str:
chat_id = identity.channel_chat_id
if chat_id.startswith(GROUP_CHAT_PREFIX):
groups = groups_config or {}
chat_cfg = groups.get(chat_id, {})
agent_id = chat_cfg.get("agent_id", default_agent_id)
return f"agent:{agent_id}:qqbot:group:{chat_id}"
elif chat_id.startswith(DM_CHAT_PREFIX):
return f"agent:{default_agent_id}:qqbot:direct:{chat_id}"
else:
guild_channels = guild_channels_config or {}
channel_cfg = guild_channels.get(chat_id, {})
agent_id = channel_cfg.get("agent_id", default_agent_id)
return f"agent:{agent_id}:qqbot:guild:{chat_id}"
def resolve_chat_type(identity: ChannelIdentity) -> str:
chat_id = identity.channel_chat_id
if chat_id.startswith(GROUP_CHAT_PREFIX):
return "group"
elif chat_id.startswith(DM_CHAT_PREFIX):
return "direct"
return "guild_channel"

View File

@ -0,0 +1,121 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
DEFAULT_SESSION_DIR = os.path.join(tempfile.gettempdir(), "yuxi_qqbot_sessions")
@dataclass
class SessionRecord:
session_id: str = ""
last_seq: int | None = None
last_heartbeat: float = 0
identify_at: float = 0
shard_id: int = 0
shard_count: int = 1
metadata: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"last_seq": self.last_seq,
"last_heartbeat": self.last_heartbeat,
"identify_at": self.identify_at,
"shard_id": self.shard_id,
"shard_count": self.shard_count,
"metadata": self.metadata,
"saved_at": time.time(),
}
@classmethod
def from_dict(cls, data: dict) -> SessionRecord:
return cls(
session_id=data.get("session_id", ""),
last_seq=data.get("last_seq"),
last_heartbeat=data.get("last_heartbeat", 0),
identify_at=data.get("identify_at", 0),
shard_id=data.get("shard_id", 0),
shard_count=data.get("shard_count", 1),
metadata=data.get("metadata", {}),
)
class SessionStore:
def __init__(self, app_id: str, store_dir: str | None = None):
self._app_id = app_id
self._store_dir = store_dir or DEFAULT_SESSION_DIR
self._store_path = os.path.join(self._store_dir, f"{app_id}_session.json")
def save(self, record: SessionRecord) -> bool:
try:
os.makedirs(self._store_dir, exist_ok=True)
data = record.to_dict()
tmp_path = self._store_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, self._store_path)
logger.debug("SessionStore: saved session_id=%s seq=%s", record.session_id, record.last_seq)
return True
except OSError:
logger.exception("SessionStore: failed to save session")
return False
def load(self) -> SessionRecord | None:
try:
if not os.path.exists(self._store_path):
return None
with open(self._store_path, encoding="utf-8") as f:
data = json.load(f)
record = SessionRecord.from_dict(data)
logger.info("SessionStore: loaded session_id=%s seq=%s", record.session_id, record.last_seq)
return record
except (OSError, json.JSONDecodeError, KeyError):
logger.exception("SessionStore: failed to load session")
return None
def clear(self) -> bool:
try:
for suffix in ("", ".tmp"):
path = self._store_path + suffix
if os.path.exists(path):
os.remove(path)
logger.info("SessionStore: cleared session for app_id=%s", self._app_id[:6] + "...")
return True
except OSError:
logger.exception("SessionStore: failed to clear session")
return False
@staticmethod
def cleanup_expired(store_dir: str | None = None, max_age_s: float = 86400 * 7) -> int:
directory = store_dir or DEFAULT_SESSION_DIR
if not os.path.exists(directory):
return 0
removed = 0
now = time.time()
try:
for filename in os.listdir(directory):
if not filename.endswith("_session.json"):
continue
filepath = os.path.join(directory, filename)
try:
stat = os.stat(filepath)
if now - stat.st_mtime > max_age_s:
os.remove(filepath)
removed += 1
except OSError:
pass
except OSError:
logger.exception("SessionStore: cleanup failed")
return removed

View File

@ -0,0 +1,211 @@
from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from enum import Enum, auto
logger = logging.getLogger(__name__)
class WizardStep(Enum):
WELCOME = auto()
APP_CREDENTIALS = auto()
INTENTS = auto()
PERMISSIONS = auto()
WEBHOOK_URL = auto()
TEST_CONNECT = auto()
CONFIRM = auto()
FINISH = auto()
@dataclass
class WizardState:
step: WizardStep = WizardStep.WELCOME
app_id: str = ""
app_secret: str = ""
bot_token: str = ""
intents: list[str] = None
webhook_url: str = ""
verify_result: dict | None = None
started_at: float = 0.0
def __post_init__(self):
if self.intents is None:
self.intents = []
if self.started_at == 0.0:
self.started_at = time.time()
def to_dict(self) -> dict:
return {
"step": self.step.name,
"app_id": self.app_id,
"intents": self.intents,
"webhook_url": self.webhook_url,
}
@classmethod
def from_dict(cls, data: dict) -> WizardState:
return cls(
step=WizardStep[data.get("step", "WELCOME")],
app_id=data.get("app_id", ""),
app_secret="",
intents=data.get("intents", []),
webhook_url=data.get("webhook_url", ""),
)
_WIZARD_WELCOME = """
=== QQ Bot 安装向导 ===
该向导将帮助你完成 QQ Bot 的初始配置
请按以下步骤操作
1. 前往 QQ 开放平台 (https://q.qq.com) 创建机器人应用
2. 获取 App ID App Secret
3. 配置机器人的 Intents意图
4. 配置 Webhook 地址
输入 /setup start 开始配置
""".strip()
_SETUP_GUIDE = """
配置说明
- App ID: QQ 开放平台应用管理页面获取
- App Secret: 开发设置中生成请注意保管
- Intents: 机器人需要订阅的事件类型至少需要
- PUBLIC_GUILD_MESSAGES (群聊消息)
- DIRECT_MESSAGE (私信消息)
- GUILD_MEMBERS (频道成员)
- INTERACTION (交互事件)
- Webhook URL: 接收 QQ 推送事件的回调地址
""".strip()
def validate_app_id(app_id: str) -> bool:
return bool(re.match(r"^\d{10,20}$", app_id))
def validate_app_secret(secret: str) -> bool:
return len(secret) >= 32
def validate_webhook_url(url: str) -> bool:
return bool(re.match(r"^https?://", url))
_DEFAULT_INTENTS = [
(0, "GUILDS", "频道事件"),
(1, "GUILD_MEMBERS", "频道成员事件"),
(12, "DIRECT_MESSAGE", "私信事件"),
(25, "INTERACTION", "交互事件"),
(26, "AUDIO_ACTION", "音频事件"),
(27, "PUBLIC_GUILD_MESSAGES", "公域消息事件"),
(28, "GROUP_AND_C2C_EVENT", "群聊和私聊事件"),
]
def get_default_intents() -> list[int]:
return [intent[0] for intent in _DEFAULT_INTENTS]
def get_intent_descriptions() -> dict[int, tuple[str, str]]:
return {intent[0]: (intent[1], intent[2]) for intent in _DEFAULT_INTENTS}
async def test_connection(app_id: str, app_secret: str) -> dict:
import aiohttp
result = {"success": False, "error": "", "bot_info": {}}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.sgroup.qq.com/oauth2/token",
json={"app_id": app_id, "app_secret": app_secret},
) as resp:
if resp.status != 200:
result["error"] = f"获取 Token 失败: HTTP {resp.status}"
return result
data = await resp.json()
token = data.get("access_token", "")
if not token:
result["error"] = "Token 为空,请检查 App ID 和 App Secret"
return result
async with session.get(
"https://api.sgroup.qq.com/users/@me",
headers={"Authorization": f"QQBot {token}"},
) as resp:
if resp.status == 200:
user_data = await resp.json()
result["bot_info"] = {
"id": user_data.get("id", ""),
"username": user_data.get("username", ""),
"avatar": user_data.get("avatar", ""),
}
async with session.get(
"https://api.sgroup.qq.com/gateway/bot",
headers={"Authorization": f"QQBot {token}"},
) as resp:
if resp.status == 200:
gw_data = await resp.json()
result["gateway_url"] = gw_data.get("url", "")
result["success"] = True
except Exception as e:
result["error"] = str(e)
return result
async def setup_from_env() -> dict:
env_config = {
"app_id": os.environ.get("QQBOT_APP_ID", ""),
"app_secret": os.environ.get("QQBOT_CLIENT_SECRET", ""),
"bot_token": os.environ.get("QQBOT_BOT_TOKEN", ""),
"intents": os.environ.get("QQBOT_INTENTS", ""),
"webhook_url": os.environ.get("QQBOT_WEBHOOK_URL", ""),
}
if not env_config["app_id"] or not env_config["app_secret"]:
return {"success": False, "error": "环境变量未配置。请设置 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET"}
if not validate_app_id(env_config["app_id"]):
return {"success": False, "error": f"App ID 格式无效: {env_config['app_id']}"}
if not validate_app_secret(env_config["app_secret"]):
return {"success": False, "error": "App Secret 长度不足(需要至少 32 字符)"}
result = await test_connection(env_config["app_id"], env_config["app_secret"])
return result
async def generate_config_yaml(app_id: str, app_secret: str, intents: list[int] | None = None) -> str:
intent_values = intents or get_default_intents()
lines = [
"# QQ Bot 配置文件",
f"qqbot_app_id: {app_id}",
"# qqbot_client_secret: 请通过环境变量 QQBOT_CLIENT_SECRET 设置",
f"qqbot_intents: {json.dumps(intent_values)}",
"",
"# 推荐通过环境变量配置敏感信息",
"# export QQBOT_APP_ID={app_id}",
"# export QQBOT_CLIENT_SECRET=your_secret_here",
"",
"# Intents 说明:",
]
for intent_id, (name, desc) in get_intent_descriptions().items():
mark = "" if intent_id in intent_values else ""
lines.append(f"# {mark} {intent_id}: {name} ({desc})")
return "\n".join(lines)

View File

@ -0,0 +1,260 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass, field
from typing import Any
from yuxi.channels.adapters.qqbot.c2c_stream import (
C2CStreamingController,
FlushController,
FlushStrategy,
)
logger = logging.getLogger(__name__)
class ParagraphChunker:
def __init__(self, flush_per_paragraph: bool = True, min_chunk_size: int = 10):
self.flush_per_paragraph = flush_per_paragraph
self.min_chunk_size = min_chunk_size
self._buffer: list[str] = []
def feed(self, text: str) -> list[str]:
results: list[str] = []
self._buffer.append(text)
if not self.flush_per_paragraph:
return results
accumulated = "".join(self._buffer)
if "\n\n" in accumulated:
paragraphs = accumulated.split("\n\n")
if len(paragraphs) > 1:
for para in paragraphs[:-1]:
if len(para.strip()) >= self.min_chunk_size:
results.append(para + "\n\n")
self._buffer = [paragraphs[-1]]
return results
def flush(self) -> str:
if not self._buffer:
return ""
result = "".join(self._buffer)
self._buffer = []
return result
async def stream_content(
content_generator: AsyncGenerator[str, None],
c2c_ctrl: C2CStreamingController | None,
chunker: ParagraphChunker | None = None,
) -> AsyncGenerator[str, None]:
if chunker is None:
chunker = ParagraphChunker()
try:
async for chunk in content_generator:
if not chunk:
continue
paragraphs = chunker.feed(chunk)
for para in paragraphs:
yield para
if c2c_ctrl is not None:
c2c_batches = c2c_ctrl.flush_controller.feed(chunk)
for batch in c2c_batches:
pass
await asyncio.sleep(0)
remaining = chunker.flush()
if remaining:
yield remaining
except asyncio.CancelledError:
logger.debug("Streaming cancelled")
remaining = chunker.flush()
if remaining:
yield remaining
except Exception:
logger.exception("Streaming error")
remaining = chunker.flush()
if remaining:
yield remaining
__all__ = [
"ParagraphChunker",
"stream_content",
"send_blocks_stream",
"C2CStreamingController",
"FlushController",
"FlushStrategy",
"MediaAwareStreamer",
"StreamMediaContext",
]
@dataclass
class StreamMediaContext:
stream_active: bool = True
media_queue: list[tuple[str, str]] = field(default_factory=list)
interrupt_count: int = 0
def interrupt(self) -> None:
self.stream_active = False
self.interrupt_count += 1
def restore(self) -> None:
self.stream_active = True
class MediaAwareStreamer:
def __init__(
self,
send_text_fn: Callable[..., Any],
send_media_fn: Callable[..., Any],
chat_id: str = "",
max_interrupts: int = 10,
):
self._send_text_fn = send_text_fn
self._send_media_fn = send_media_fn
self._chat_id = chat_id
self._max_interrupts = max_interrupts
self._context = StreamMediaContext()
from .media_tags import parse_media_tags
self._parse_media_tags = parse_media_tags
@property
def context(self) -> StreamMediaContext:
return self._context
async def feed(self, chunk: str) -> None:
from .media_tags import has_media_tags
if not has_media_tags(chunk):
if self._context.stream_active:
await self._send_text_fn(self._chat_id, chunk)
return
if self._context.interrupt_count >= self._max_interrupts:
clean = self._parse_media_tags(chunk).text
if clean:
await self._send_text_fn(self._chat_id, clean)
return
parsed = self._parse_media_tags(chunk)
if parsed.text:
self._context.interrupt()
await self._send_text_fn(self._chat_id, parsed.text)
self._context.restore()
for item in parsed.media_items:
try:
await self._send_media_fn(
self._chat_id,
media_type=item.media_type,
reference=item.reference,
is_url=item.is_url,
)
except Exception:
logger.exception("MediaAwareStreamer: failed to send media %s", item)
async def flush(self) -> None:
pass
async def stream_with_media_handling(
content_generator: AsyncGenerator[str, None],
send_text_fn: Callable[..., Any],
send_media_fn: Callable[..., Any],
chat_id: str = "",
c2c_ctrl: C2CStreamingController | None = None,
chunker: ParagraphChunker | None = None,
max_interrupts: int = 10,
) -> AsyncGenerator[str, None]:
if chunker is None:
chunker = ParagraphChunker()
media_streamer = MediaAwareStreamer(
send_text_fn=send_text_fn,
send_media_fn=send_media_fn,
chat_id=chat_id,
max_interrupts=max_interrupts,
)
try:
async for chunk in content_generator:
if not chunk:
continue
await media_streamer.feed(chunk)
paragraphs = chunker.feed(chunk)
for para in paragraphs:
yield para
if c2c_ctrl is not None:
c2c_batches = c2c_ctrl.flush_controller.feed(chunk)
for _batch in c2c_batches:
pass
await asyncio.sleep(0)
remaining = chunker.flush()
if remaining:
yield remaining
except asyncio.CancelledError:
logger.debug("Streaming cancelled")
remaining = chunker.flush()
if remaining:
yield remaining
except Exception:
logger.exception("Streaming error")
remaining = chunker.flush()
if remaining:
yield remaining
async def send_blocks_stream(
chat_id: str,
text: str,
send_fn,
channel_id: str = "qqbot",
channel_type=None,
chunk_size: int = 1,
parallelism: int = 1,
) -> None:
from yuxi.channels.models import (
ChannelIdentity,
ChannelResponse,
ChannelType,
DeliveryResult,
)
ct = channel_type or ChannelType.QQ_BOT
identity = ChannelIdentity(
channel_id=channel_id,
channel_type=ct,
channel_user_id="",
channel_chat_id=chat_id,
)
paragraphs = text.split("\n\n")
for para in paragraphs:
if not para.strip():
continue
response = ChannelResponse(identity=identity, content=para)
try:
result = await send_fn(response)
if isinstance(result, DeliveryResult) and not result.success:
logger.warning("send_blocks_stream: failed to send para: %s", result.error)
except Exception:
logger.exception("send_blocks_stream: error sending paragraph")

View File

@ -0,0 +1,117 @@
from __future__ import annotations
import asyncio
import time
import aiohttp
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.utils.logging_config import logger
class QQBotTokenManager:
def __init__(
self,
app_id: str,
app_secret: str,
sandbox: bool = False,
http_client: aiohttp.ClientSession | None = None,
):
self.app_id = app_id
self.app_secret = app_secret
self.sandbox = sandbox
self._http_client = http_client
self._access_token: str | None = None
self._expires_at: float | None = None
self._token_lock = asyncio.Lock()
self._refresh_in_progress: asyncio.Event | None = None
self._refresh_task: asyncio.Task | None = None
self._refresh_interval: float = 60.0
@property
def api_base(self) -> str:
if self.sandbox:
return "https://sandbox.api.sgroup.qq.com"
return "https://api.sgroup.qq.com"
async def get_token(self) -> str:
async with self._token_lock:
if self._is_expired():
await self._do_refresh()
return self._access_token
async def force_refresh(self) -> str:
async with self._token_lock:
await self._do_refresh()
return self._access_token
async def _do_refresh(self) -> None:
if self._refresh_in_progress is not None:
await self._refresh_in_progress.wait()
return
self._refresh_in_progress = asyncio.Event()
try:
await self._refresh()
self._refresh_in_progress.set()
except Exception:
self._refresh_in_progress.set()
raise
finally:
self._refresh_in_progress = None
async def _refresh(self) -> None:
client = self._http_client or aiohttp.ClientSession()
try:
async with client.post(
f"{self.api_base}/oauth2/token",
json={
"app_id": self.app_id,
"app_secret": self.app_secret,
},
) as resp:
if resp.status != 200:
raise ChannelAuthenticationError(f"Token refresh failed: HTTP {resp.status}")
data = await resp.json()
self._access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
self._expires_at = time.monotonic() + expires_in
logger.info(f"[QQBot] Token refreshed, expires in {expires_in}s (app_id={self.app_id[:6]}...)")
finally:
if not self._http_client:
await client.close()
def start_background_refresh(self) -> None:
if self._refresh_task is not None and not self._refresh_task.done():
return
self._refresh_task = asyncio.create_task(self._background_refresh_loop())
logger.debug(f"[QQBot] Background token refresh started (interval={self._refresh_interval}s)")
def stop_background_refresh(self) -> None:
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
self._refresh_task = None
logger.debug("[QQBot] Background token refresh stopped")
async def _background_refresh_loop(self) -> None:
while True:
try:
await asyncio.sleep(self._refresh_interval)
async with self._token_lock:
if not self._is_expired():
continue
try:
await self._do_refresh()
except Exception as e:
logger.warning(f"[QQBot] Background token refresh failed (will retry): {e}")
except asyncio.CancelledError:
logger.debug("[QQBot] Background token refresh cancelled")
break
except Exception as e:
logger.error(f"[QQBot] Background token refresh loop error: {e}")
await asyncio.sleep(5)
def _is_expired(self) -> bool:
if self._access_token is None or self._expires_at is None:
return True
return time.monotonic() > self._expires_at - 300

View File

@ -0,0 +1,4 @@
from .channel import qqbot_channel_api
from .remind import qqbot_remind
__all__ = ["qqbot_channel_api", "qqbot_remind"]

View File

@ -0,0 +1,211 @@
from __future__ import annotations
import logging
import aiohttp
from pydantic import BaseModel, Field
from yuxi.agents.toolkits.registry import tool
logger = logging.getLogger(__name__)
class ChannelApiInput(BaseModel):
action: str = Field(
description="操作类型: list_channels(子频道列表), channel_info(频道信息), "
"create_channel(创建子频道), update_channel(修改子频道), delete_channel(删除子频道), "
"channel_permissions(权限信息), members(成员列表), announcements(公告列表)",
)
guild_id: str = Field(description="频道 ID")
channel_id: str = Field(default="", description="子频道 ID操作特定子频道时需要")
params: str = Field(
default="{}",
description="JSON 格式的额外参数,如创建/修改频道时的 name/type/position 等",
)
_CHANNEL_API_GUIDE = """
使用前请确保 QQ Bot 已配置 app_id app_secret
该工具允许 Agent 通过 QQ 开放平台 API 管理频道包括
- 查看子频道列表
- 创建/修改/删除子频道
- 查看频道成员
- 管理公告
所有操作自动处理 Token 鉴权无需手动管理凭证
""".strip()
@tool(
category="qqbot",
tags=["QQ机器人", "频道管理"],
display_name="QQ 频道管理",
config_guide=_CHANNEL_API_GUIDE,
)
async def qqbot_channel_api(
action: str,
guild_id: str,
channel_id: str = "",
params: str = "{}",
) -> str:
"""QQ 频道管理 HTTP 代理工具,用于查询和管理 QQ 频道。
支持操作类型
- list_channels: 获取子频道列表
- channel_info: 获取子频道详情
- create_channel: 创建子频道需要 params 中包含 name, type, position
- update_channel: 修改子频道需要 params 中包含要修改的字段
- delete_channel: 删除子频道
- channel_permissions: 获取子频道权限
- members: 获取频道成员列表
- announcements: 获取公告列表
Args:
action: 操作类型
guild_id: 频道 ID
channel_id: 子频道 ID操作特定子频道时需要
params: JSON 格式的额外参数字符串
Returns:
操作结果描述
"""
import json
import os
app_id = os.environ.get("QQBOT_APP_ID", "")
app_secret = os.environ.get("QQBOT_CLIENT_SECRET", "")
if not app_id or not app_secret:
return "错误:未配置 QQ Bot 凭证。请设置 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET 环境变量。"
parsed_params = {}
try:
parsed_params = json.loads(params)
except json.JSONDecodeError:
return "错误params 参数不是有效的 JSON 格式。"
token = await _get_access_token(app_id, app_secret)
if not token:
return "错误:无法获取 Access Token请检查 app_id 和 app_secret 是否正确。"
api_base = "https://api.sgroup.qq.com"
try:
result = await _execute_channel_action(token, api_base, action, guild_id, channel_id, parsed_params)
return result
except Exception as e:
logger.exception("qqbot_channel_api error: action=%s guild_id=%s", action, guild_id)
return f"频道 API 调用失败: {e}"
async def _get_access_token(app_id: str, app_secret: str) -> str:
try:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.sgroup.qq.com/oauth2/token",
json={"app_id": app_id, "app_secret": app_secret},
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("access_token", "")
logger.warning("Token API returned status %d", resp.status)
return ""
except Exception:
logger.exception("Failed to get access token")
return ""
async def _execute_channel_action(
token: str,
api_base: str,
action: str,
guild_id: str,
channel_id: str,
params: dict,
) -> str:
import json
headers = {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
if action == "list_channels":
async with session.get(f"{api_base}/guilds/{guild_id}/channels", headers=headers) as resp:
if resp.status != 200:
return f"获取子频道列表失败: HTTP {resp.status}"
data = await resp.json()
channels = data if isinstance(data, list) else data.get("channels", data)
return json.dumps(channels, ensure_ascii=False, indent=2)
elif action == "channel_info":
if not channel_id:
return "错误:查询子频道信息需要提供 channel_id"
async with session.get(f"{api_base}/channels/{channel_id}", headers=headers) as resp:
if resp.status != 200:
return f"获取子频道信息失败: HTTP {resp.status}"
data = await resp.json()
return json.dumps(data, ensure_ascii=False, indent=2)
elif action == "create_channel":
body = {
"name": params.get("name", "新频道"),
"type": params.get("type", 0),
"sub_type": params.get("sub_type", 0),
"position": params.get("position", 0),
"parent_id": params.get("parent_id", "0"),
"private_type": params.get("private_type", 0),
}
async with session.post(
f"{api_base}/guilds/{guild_id}/channels",
headers=headers,
json=body,
) as resp:
if resp.status not in (200, 201):
return f"创建子频道失败: HTTP {resp.status}"
data = await resp.json()
return f"子频道创建成功: {json.dumps(data, ensure_ascii=False)}"
elif action == "update_channel":
if not channel_id:
return "错误:修改子频道需要提供 channel_id"
body = {k: v for k, v in params.items() if v is not None}
async with session.patch(
f"{api_base}/channels/{channel_id}",
headers=headers,
json=body,
) as resp:
if resp.status != 200:
return f"修改子频道失败: HTTP {resp.status}"
data = await resp.json()
return f"子频道修改成功: {json.dumps(data, ensure_ascii=False)}"
elif action == "delete_channel":
if not channel_id:
return "错误:删除子频道需要提供 channel_id"
async with session.delete(f"{api_base}/channels/{channel_id}", headers=headers) as resp:
if resp.status != 200:
return f"删除子频道失败: HTTP {resp.status}"
return f"子频道 {channel_id} 已删除"
elif action == "members":
limit = params.get("limit", 100)
after = params.get("after", "0")
url = f"{api_base}/guilds/{guild_id}/members?limit={limit}&after={after}"
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return f"获取成员列表失败: HTTP {resp.status}"
data = await resp.json()
return json.dumps(data, ensure_ascii=False, indent=2)
elif action == "announcements":
async with session.get(f"{api_base}/guilds/{guild_id}/announces", headers=headers) as resp:
if resp.status != 200:
return f"获取公告列表失败: HTTP {resp.status}"
data = await resp.json()
return json.dumps(data, ensure_ascii=False, indent=2)
else:
return f"不支持的操作类型: {action}。支持的操作: list_channels, channel_info, create_channel, update_channel, delete_channel, channel_permissions, members, announcements"

View File

@ -0,0 +1,287 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from datetime import datetime
from pydantic import BaseModel, Field
from yuxi.agents.toolkits.registry import tool
logger = logging.getLogger(__name__)
DEFAULT_REMIND_DIR = os.path.join(os.path.dirname(__file__), "..", "remind_data")
_CRON_PATTERN = re.compile(
r"^(\*|[0-5]?\d)\s+(\*|[01]?\d|2[0-3])\s+(\*|[012]?\d|3[01])\s+(\*|1[012]?|[1-9])\s+(\*|[0-7])$"
)
_RELATIVE_TIME_PATTERN = re.compile(r"(\d+)\s*(秒|分钟|小时|天|周|s|min|h|d|w)", re.IGNORECASE)
class RemindInput(BaseModel):
action: str = Field(
description="操作类型: add(添加提醒), list(列出提醒), remove(删除提醒)",
)
message: str = Field(default="", description="提醒内容add 操作时需要)")
time_spec: str = Field(
default="",
description="时间规格,支持: 相对时间(如 '30分钟', '1小时', '2天')、"
"cron 表达式(如 '* 9 * * *' 每天9点)、"
"绝对时间 ISO格式(如 '2026-05-12T14:30:00')",
)
remind_id: str = Field(default="", description="提醒 IDremove 操作时需要)")
chat_id: str = Field(default="", description="目标聊天 ID发送提醒的目标位置")
_REMIND_TOOL_GUIDE = """
使用前请确保 QQ Bot 已配置 app_id app_secret
该工具允许 Agent 创建和管理 QQ Bot 的定时提醒支持
- 创建提醒相对时间 / cron / 绝对时间
- 列出所有活跃提醒
- 删除指定提醒
提醒到期时会通过 QQ Bot 发送消息到指定的聊天目标
""".strip()
@dataclass
class Reminder:
remind_id: str
message: str
trigger_at: float
chat_id: str
created_at: float = field(default_factory=time.time)
cron_expr: str = ""
def to_dict(self) -> dict:
return {
"remind_id": self.remind_id,
"message": self.message,
"trigger_at": self.trigger_at,
"chat_id": self.chat_id,
"created_at": self.created_at,
"cron_expr": self.cron_expr,
}
@classmethod
def from_dict(cls, data: dict) -> Reminder:
return cls(
remind_id=data["remind_id"],
message=data["message"],
trigger_at=data["trigger_at"],
chat_id=data.get("chat_id", ""),
created_at=data.get("created_at", time.time()),
cron_expr=data.get("cron_expr", ""),
)
class RemindStore:
def __init__(self, store_dir: str | None = None):
self._store_dir = store_dir or DEFAULT_REMIND_DIR
self._store_path = os.path.join(self._store_dir, "reminders.json")
self._reminders: dict[str, Reminder] = {}
self._lock = asyncio.Lock()
self._load()
def _load(self) -> None:
try:
if os.path.exists(self._store_path):
with open(self._store_path, encoding="utf-8") as f:
data = json.load(f)
for item in data:
r = Reminder.from_dict(item)
self._reminders[r.remind_id] = r
logger.info("RemindStore: loaded %d reminders", len(self._reminders))
except (OSError, json.JSONDecodeError):
logger.exception("RemindStore: failed to load")
async def _save(self) -> None:
try:
os.makedirs(self._store_dir, exist_ok=True)
data = [r.to_dict() for r in self._reminders.values()]
tmp_path = self._store_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, self._store_path)
except OSError:
logger.exception("RemindStore: failed to save")
async def add(self, message: str, trigger_at: float, chat_id: str, cron_expr: str = "") -> Reminder:
async with self._lock:
import uuid
r = Reminder(
remind_id=str(uuid.uuid4())[:8],
message=message,
trigger_at=trigger_at,
chat_id=chat_id,
cron_expr=cron_expr,
)
self._reminders[r.remind_id] = r
await self._save()
return r
async def list_all(self) -> list[Reminder]:
async with self._lock:
self._cleanup_expired()
return list(self._reminders.values())
async def remove(self, remind_id: str) -> bool:
async with self._lock:
if remind_id in self._reminders:
del self._reminders[remind_id]
await self._save()
return True
return False
def _cleanup_expired(self) -> None:
now = time.time()
expired = [rid for rid, r in self._reminders.items() if r.trigger_at < now - 3600]
for rid in expired:
del self._reminders[rid]
async def get_due_reminders(self) -> list[Reminder]:
async with self._lock:
now = time.time()
due = [r for r in self._reminders.values() if r.trigger_at <= now]
return due
_remind_store: RemindStore | None = None
def _get_store() -> RemindStore:
global _remind_store
if _remind_store is None:
_remind_store = RemindStore()
return _remind_store
def parse_time_spec(time_spec: str) -> tuple[float, str]:
"""解析时间规格,返回 (trigger_at, cron_expr)"""
now = time.time()
if not time_spec:
return now + 3600, ""
relative_match = _RELATIVE_TIME_PATTERN.fullmatch(time_spec.strip())
if relative_match:
value = int(relative_match.group(1))
unit = relative_match.group(2).lower()
multipliers = {
"": 1,
"s": 1,
"分钟": 60,
"min": 60,
"小时": 3600,
"h": 3600,
"": 86400,
"d": 86400,
"": 604800,
"w": 604800,
}
return now + value * multipliers.get(unit, 60), ""
if _CRON_PATTERN.match(time_spec.strip()):
return now + 60, time_spec.strip()
try:
dt = datetime.fromisoformat(time_spec)
return dt.timestamp(), ""
except (ValueError, TypeError):
pass
try:
seconds = int(time_spec)
return now + seconds, ""
except ValueError:
pass
return now + 3600, ""
@tool(
category="qqbot",
tags=["QQ机器人", "定时提醒"],
display_name="QQ 定时提醒",
config_guide=_REMIND_TOOL_GUIDE,
)
async def qqbot_remind(
action: str,
message: str = "",
time_spec: str = "",
remind_id: str = "",
chat_id: str = "",
) -> str:
"""QQ Bot 定时提醒工具,用于创建和管理定时提醒消息。
支持操作类型
- add: 添加提醒需要 message time_spec
- list: 列出所有活跃提醒
- remove: 删除指定提醒需要 remind_id
时间规格支持
- 相对时间: '30分钟', '1小时', '2天', '1周'
- cron 表达式: '* 9 * * *' (每天9点)
- 绝对时间 ISO: '2026-05-12T14:30:00'
Args:
action: 操作类型 (add/list/remove)
message: 提醒内容
time_spec: 时间规格
remind_id: 提醒 ID删除时需要
chat_id: 目标聊天 ID
Returns:
操作结果描述
"""
store = _get_store()
if action == "add":
if not message:
return "错误:添加提醒需要提供 message提醒内容"
trigger_at, cron_expr = parse_time_spec(time_spec)
if not chat_id:
chat_id = os.environ.get("QQBOT_DEFAULT_CHAT_ID", "")
r = await store.add(message, trigger_at, chat_id, cron_expr)
dt = datetime.fromtimestamp(trigger_at)
result = f"提醒已创建 (ID: {r.remind_id})\n内容: {message}\n触发时间: {dt.strftime('%Y-%m-%d %H:%M:%S')}"
if cron_expr:
result += f"\nCron: {cron_expr}"
if chat_id:
result += f"\n目标: {chat_id}"
return result
elif action == "list":
reminders = await store.list_all()
if not reminders:
return "当前没有活跃的提醒。"
lines = ["当前活跃的提醒:"]
for r in reminders:
dt = datetime.fromtimestamp(r.trigger_at)
status = "已过期" if r.trigger_at < time.time() else "待触发"
lines.append(f" [{r.remind_id}] {r.message} - {dt.strftime('%Y-%m-%d %H:%M:%S')} ({status})")
return "\n".join(lines)
elif action == "remove":
if not remind_id:
return "错误:删除提醒需要提供 remind_id。"
removed = await store.remove(remind_id)
if removed:
return f"提醒 {remind_id} 已删除。"
return f"未找到提醒 {remind_id}"
else:
return "不支持的操作类型。支持的操作: add, list, remove"

View File

@ -0,0 +1,177 @@
from __future__ import annotations
import aiohttp
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
from .media_upload import (
FILE_TYPE_VOICE,
build_media_payload,
upload_media,
validate_media_size,
)
async def send_voice(
voice_data: bytes,
chat_id: str,
token: str,
http_client: aiohttp.ClientSession,
api_base: str,
filename: str = "voice.mp3",
max_size_mb: int = 100,
) -> DeliveryResult:
validate_media_size(voice_data, max_size_mb=max_size_mb, label="voice")
group_openid = None
if chat_id.startswith(GROUP_CHAT_PREFIX):
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
try:
file_id = await upload_media(
voice_data,
token,
http_client=http_client,
filename=filename,
file_type=FILE_TYPE_VOICE,
group_openid=group_openid,
)
except Exception as e:
return DeliveryResult(success=False, error=f"Voice upload failed: {e}")
payload = build_media_payload(chat_id, file_id, msg_type=7)
url = _resolve_media_send_url(api_base, chat_id)
headers = {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
}
try:
async with http_client.post(url, json=payload, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
return DeliveryResult(
success=True,
message_id=data.get("id") or data.get("message_id"),
)
return DeliveryResult(success=False, error=f"Voice send failed: HTTP {resp.status}")
except Exception as e:
logger.error(f"[QQBot] Voice send error: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_video(
video_data: bytes,
chat_id: str,
token: str,
http_client: aiohttp.ClientSession,
api_base: str,
filename: str = "video.mp4",
max_size_mb: int = 100,
) -> DeliveryResult:
from .media_upload import FILE_TYPE_VIDEO
validate_media_size(video_data, max_size_mb=max_size_mb, label="video")
group_openid = None
if chat_id.startswith(GROUP_CHAT_PREFIX):
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
try:
file_id = await upload_media(
video_data,
token,
http_client=http_client,
filename=filename,
file_type=FILE_TYPE_VIDEO,
group_openid=group_openid,
)
except Exception as e:
return DeliveryResult(success=False, error=f"Video upload failed: {e}")
payload = build_media_payload(chat_id, file_id, msg_type=7)
url = _resolve_media_send_url(api_base, chat_id)
headers = {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
}
try:
async with http_client.post(url, json=payload, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
return DeliveryResult(
success=True,
message_id=data.get("id") or data.get("message_id"),
)
return DeliveryResult(success=False, error=f"Video send failed: HTTP {resp.status}")
except Exception as e:
logger.error(f"[QQBot] Video send error: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_file(
file_data: bytes,
chat_id: str,
token: str,
http_client: aiohttp.ClientSession,
api_base: str,
filename: str = "file.bin",
max_size_mb: int = 100,
) -> DeliveryResult:
from .media_upload import FILE_TYPE_FILE
validate_media_size(file_data, max_size_mb=max_size_mb, label="file")
group_openid = None
if chat_id.startswith(GROUP_CHAT_PREFIX):
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
try:
file_id = await upload_media(
file_data,
token,
http_client=http_client,
filename=filename,
file_type=FILE_TYPE_FILE,
group_openid=group_openid,
)
except Exception as e:
return DeliveryResult(success=False, error=f"File upload failed: {e}")
payload = build_media_payload(chat_id, file_id, msg_type=7)
url = _resolve_media_send_url(api_base, chat_id)
headers = {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
}
try:
async with http_client.post(url, json=payload, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
return DeliveryResult(
success=True,
message_id=data.get("id") or data.get("message_id"),
)
return DeliveryResult(success=False, error=f"File send failed: HTTP {resp.status}")
except Exception as e:
logger.error(f"[QQBot] File send error: {e}")
return DeliveryResult(success=False, error=str(e))
def _resolve_media_send_url(api_base: str, chat_id: str) -> str:
if chat_id.startswith(GROUP_CHAT_PREFIX):
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
return f"{api_base}/v2/groups/{group_openid}/messages"
elif chat_id.startswith(DM_CHAT_PREFIX):
openid = chat_id.replace(DM_CHAT_PREFIX, "")
return f"{api_base}/v2/users/{openid}/messages"
elif chat_id:
return f"{api_base}/v2/channels/{chat_id}/messages"
return f"{api_base}/v2/users/@me/messages"