feat(qqbot): 实现QQ机器人适配器完整功能模块
新增QQ Bot适配器完整代码栈,包含: 1. 基础适配器入口与工具类封装 2. 会话管理、重试队列与流量控制 3. 命令系统与内置指令(ping/help/status等) 4. 富媒体消息处理与格式转换 5. 引用存储与审批管理 6. 凭证备份与会话持久化 7. 健康检查与交互回调系统
This commit is contained in:
parent
a1f8288d20
commit
552aef767c
3
backend/package/yuxi/channels/adapters/qqbot/__init__.py
Normal file
3
backend/package/yuxi/channels/adapters/qqbot/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from yuxi.channels.adapters.qqbot.adapter import QQBotAdapter
|
||||
|
||||
__all__ = ["QQBotAdapter"]
|
||||
1388
backend/package/yuxi/channels/adapters/qqbot/adapter.py
Normal file
1388
backend/package/yuxi/channels/adapters/qqbot/adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
||||
from .manager import ExecApprovalManager, ApprovalRequest, ApprovalStatus
|
||||
|
||||
__all__ = ["ExecApprovalManager", "ApprovalRequest", "ApprovalStatus"]
|
||||
248
backend/package/yuxi/channels/adapters/qqbot/approval/manager.py
Normal file
248
backend/package/yuxi/channels/adapters/qqbot/approval/manager.py
Normal 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)
|
||||
@ -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}
|
||||
412
backend/package/yuxi/channels/adapters/qqbot/audio.py
Normal file
412
backend/package/yuxi/channels/adapters/qqbot/audio.py
Normal 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()
|
||||
343
backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py
Normal file
343
backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py
Normal 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)
|
||||
@ -0,0 +1,4 @@
|
||||
from .framework import CommandRegistry, CommandContext, CommandResult
|
||||
from .builtin import register_builtin_commands
|
||||
|
||||
__all__ = ["CommandRegistry", "CommandContext", "CommandResult", "register_builtin_commands"]
|
||||
102
backend/package/yuxi/channels/adapters/qqbot/commands/builtin.py
Normal file
102
backend/package/yuxi/channels/adapters/qqbot/commands/builtin.py
Normal 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
|
||||
@ -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
|
||||
105
backend/package/yuxi/channels/adapters/qqbot/constants.py
Normal file
105
backend/package/yuxi/channels/adapters/qqbot/constants.py
Normal 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
|
||||
@ -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
|
||||
250
backend/package/yuxi/channels/adapters/qqbot/format.py
Normal file
250
backend/package/yuxi/channels/adapters/qqbot/format.py
Normal 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
|
||||
69
backend/package/yuxi/channels/adapters/qqbot/group_buffer.py
Normal file
69
backend/package/yuxi/channels/adapters/qqbot/group_buffer.py
Normal 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)
|
||||
384
backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py
Normal file
384
backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py
Normal 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
|
||||
185
backend/package/yuxi/channels/adapters/qqbot/interaction.py
Normal file
185
backend/package/yuxi/channels/adapters/qqbot/interaction.py
Normal 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)
|
||||
180
backend/package/yuxi/channels/adapters/qqbot/known_users.py
Normal file
180
backend/package/yuxi/channels/adapters/qqbot/known_users.py
Normal 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]
|
||||
114
backend/package/yuxi/channels/adapters/qqbot/media_tags.py
Normal file
114
backend/package/yuxi/channels/adapters/qqbot/media_tags.py
Normal 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
|
||||
324
backend/package/yuxi/channels/adapters/qqbot/media_upload.py
Normal file
324
backend/package/yuxi/channels/adapters/qqbot/media_upload.py
Normal 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)
|
||||
223
backend/package/yuxi/channels/adapters/qqbot/multi_account.py
Normal file
223
backend/package/yuxi/channels/adapters/qqbot/multi_account.py
Normal 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()
|
||||
]
|
||||
148
backend/package/yuxi/channels/adapters/qqbot/probe.py
Normal file
148
backend/package/yuxi/channels/adapters/qqbot/probe.py
Normal 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)
|
||||
87
backend/package/yuxi/channels/adapters/qqbot/rate_limiter.py
Normal file
87
backend/package/yuxi/channels/adapters/qqbot/rate_limiter.py
Normal 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()
|
||||
}
|
||||
322
backend/package/yuxi/channels/adapters/qqbot/reconnect.py
Normal file
322
backend/package/yuxi/channels/adapters/qqbot/reconnect.py
Normal 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)
|
||||
@ -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"]
|
||||
45
backend/package/yuxi/channels/adapters/qqbot/ref/format.py
Normal file
45
backend/package/yuxi/channels/adapters/qqbot/ref/format.py
Normal 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}"
|
||||
156
backend/package/yuxi/channels/adapters/qqbot/ref/store.py
Normal file
156
backend/package/yuxi/channels/adapters/qqbot/ref/store.py
Normal 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
|
||||
]
|
||||
62
backend/package/yuxi/channels/adapters/qqbot/ref/types.py
Normal file
62
backend/package/yuxi/channels/adapters/qqbot/ref/types.py
Normal 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),
|
||||
)
|
||||
@ -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)
|
||||
147
backend/package/yuxi/channels/adapters/qqbot/retry_queue.py
Normal file
147
backend/package/yuxi/channels/adapters/qqbot/retry_queue.py
Normal 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
|
||||
196
backend/package/yuxi/channels/adapters/qqbot/security.py
Normal file
196
backend/package/yuxi/channels/adapters/qqbot/security.py
Normal 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)
|
||||
170
backend/package/yuxi/channels/adapters/qqbot/send.py
Normal file
170
backend/package/yuxi/channels/adapters/qqbot/send.py
Normal 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
|
||||
45
backend/package/yuxi/channels/adapters/qqbot/session.py
Normal file
45
backend/package/yuxi/channels/adapters/qqbot/session.py
Normal 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"
|
||||
121
backend/package/yuxi/channels/adapters/qqbot/session_store.py
Normal file
121
backend/package/yuxi/channels/adapters/qqbot/session_store.py
Normal 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
|
||||
211
backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py
Normal file
211
backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py
Normal 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)
|
||||
260
backend/package/yuxi/channels/adapters/qqbot/streaming.py
Normal file
260
backend/package/yuxi/channels/adapters/qqbot/streaming.py
Normal 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")
|
||||
117
backend/package/yuxi/channels/adapters/qqbot/token.py
Normal file
117
backend/package/yuxi/channels/adapters/qqbot/token.py
Normal 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
|
||||
@ -0,0 +1,4 @@
|
||||
from .channel import qqbot_channel_api
|
||||
from .remind import qqbot_remind
|
||||
|
||||
__all__ = ["qqbot_channel_api", "qqbot_remind"]
|
||||
211
backend/package/yuxi/channels/adapters/qqbot/tools/channel.py
Normal file
211
backend/package/yuxi/channels/adapters/qqbot/tools/channel.py
Normal 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"
|
||||
287
backend/package/yuxi/channels/adapters/qqbot/tools/remind.py
Normal file
287
backend/package/yuxi/channels/adapters/qqbot/tools/remind.py
Normal 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="提醒 ID(remove 操作时需要)")
|
||||
chat_id: str = Field(default="", description="目标聊天 ID(发送提醒的目标位置)")
|
||||
|
||||
|
||||
_REMIND_TOOL_GUIDE = """
|
||||
使用前请确保 QQ Bot 已配置 app_id 和 app_secret。
|
||||
|
||||
该工具允许 Agent 创建和管理 QQ Bot 的定时提醒,支持:
|
||||
- 创建提醒(相对时间 / cron / 绝对时间)
|
||||
- 列出所有活跃提醒
|
||||
- 删除指定提醒
|
||||
|
||||
提醒到期时会通过 QQ Bot 发送消息到指定的聊天目标。
|
||||
""".strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reminder:
|
||||
remind_id: str
|
||||
message: str
|
||||
trigger_at: float
|
||||
chat_id: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
cron_expr: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"remind_id": self.remind_id,
|
||||
"message": self.message,
|
||||
"trigger_at": self.trigger_at,
|
||||
"chat_id": self.chat_id,
|
||||
"created_at": self.created_at,
|
||||
"cron_expr": self.cron_expr,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> Reminder:
|
||||
return cls(
|
||||
remind_id=data["remind_id"],
|
||||
message=data["message"],
|
||||
trigger_at=data["trigger_at"],
|
||||
chat_id=data.get("chat_id", ""),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
cron_expr=data.get("cron_expr", ""),
|
||||
)
|
||||
|
||||
|
||||
class RemindStore:
|
||||
def __init__(self, store_dir: str | None = None):
|
||||
self._store_dir = store_dir or DEFAULT_REMIND_DIR
|
||||
self._store_path = os.path.join(self._store_dir, "reminders.json")
|
||||
self._reminders: dict[str, Reminder] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if os.path.exists(self._store_path):
|
||||
with open(self._store_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for item in data:
|
||||
r = Reminder.from_dict(item)
|
||||
self._reminders[r.remind_id] = r
|
||||
logger.info("RemindStore: loaded %d reminders", len(self._reminders))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception("RemindStore: failed to load")
|
||||
|
||||
async def _save(self) -> None:
|
||||
try:
|
||||
os.makedirs(self._store_dir, exist_ok=True)
|
||||
data = [r.to_dict() for r in self._reminders.values()]
|
||||
tmp_path = self._store_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp_path, self._store_path)
|
||||
except OSError:
|
||||
logger.exception("RemindStore: failed to save")
|
||||
|
||||
async def add(self, message: str, trigger_at: float, chat_id: str, cron_expr: str = "") -> Reminder:
|
||||
async with self._lock:
|
||||
import uuid
|
||||
|
||||
r = Reminder(
|
||||
remind_id=str(uuid.uuid4())[:8],
|
||||
message=message,
|
||||
trigger_at=trigger_at,
|
||||
chat_id=chat_id,
|
||||
cron_expr=cron_expr,
|
||||
)
|
||||
self._reminders[r.remind_id] = r
|
||||
await self._save()
|
||||
return r
|
||||
|
||||
async def list_all(self) -> list[Reminder]:
|
||||
async with self._lock:
|
||||
self._cleanup_expired()
|
||||
return list(self._reminders.values())
|
||||
|
||||
async def remove(self, remind_id: str) -> bool:
|
||||
async with self._lock:
|
||||
if remind_id in self._reminders:
|
||||
del self._reminders[remind_id]
|
||||
await self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cleanup_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [rid for rid, r in self._reminders.items() if r.trigger_at < now - 3600]
|
||||
for rid in expired:
|
||||
del self._reminders[rid]
|
||||
|
||||
async def get_due_reminders(self) -> list[Reminder]:
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
due = [r for r in self._reminders.values() if r.trigger_at <= now]
|
||||
return due
|
||||
|
||||
|
||||
_remind_store: RemindStore | None = None
|
||||
|
||||
|
||||
def _get_store() -> RemindStore:
|
||||
global _remind_store
|
||||
if _remind_store is None:
|
||||
_remind_store = RemindStore()
|
||||
return _remind_store
|
||||
|
||||
|
||||
def parse_time_spec(time_spec: str) -> tuple[float, str]:
|
||||
"""解析时间规格,返回 (trigger_at, cron_expr)"""
|
||||
now = time.time()
|
||||
|
||||
if not time_spec:
|
||||
return now + 3600, ""
|
||||
|
||||
relative_match = _RELATIVE_TIME_PATTERN.fullmatch(time_spec.strip())
|
||||
if relative_match:
|
||||
value = int(relative_match.group(1))
|
||||
unit = relative_match.group(2).lower()
|
||||
multipliers = {
|
||||
"秒": 1,
|
||||
"s": 1,
|
||||
"分钟": 60,
|
||||
"min": 60,
|
||||
"小时": 3600,
|
||||
"h": 3600,
|
||||
"天": 86400,
|
||||
"d": 86400,
|
||||
"周": 604800,
|
||||
"w": 604800,
|
||||
}
|
||||
return now + value * multipliers.get(unit, 60), ""
|
||||
|
||||
if _CRON_PATTERN.match(time_spec.strip()):
|
||||
return now + 60, time_spec.strip()
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(time_spec)
|
||||
return dt.timestamp(), ""
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
seconds = int(time_spec)
|
||||
return now + seconds, ""
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return now + 3600, ""
|
||||
|
||||
|
||||
@tool(
|
||||
category="qqbot",
|
||||
tags=["QQ机器人", "定时提醒"],
|
||||
display_name="QQ 定时提醒",
|
||||
config_guide=_REMIND_TOOL_GUIDE,
|
||||
)
|
||||
async def qqbot_remind(
|
||||
action: str,
|
||||
message: str = "",
|
||||
time_spec: str = "",
|
||||
remind_id: str = "",
|
||||
chat_id: str = "",
|
||||
) -> str:
|
||||
"""QQ Bot 定时提醒工具,用于创建和管理定时提醒消息。
|
||||
|
||||
支持操作类型:
|
||||
- add: 添加提醒(需要 message 和 time_spec)
|
||||
- list: 列出所有活跃提醒
|
||||
- remove: 删除指定提醒(需要 remind_id)
|
||||
|
||||
时间规格支持:
|
||||
- 相对时间: '30分钟', '1小时', '2天', '1周'
|
||||
- cron 表达式: '* 9 * * *' (每天9点)
|
||||
- 绝对时间 ISO: '2026-05-12T14:30:00'
|
||||
|
||||
Args:
|
||||
action: 操作类型 (add/list/remove)
|
||||
message: 提醒内容
|
||||
time_spec: 时间规格
|
||||
remind_id: 提醒 ID(删除时需要)
|
||||
chat_id: 目标聊天 ID
|
||||
|
||||
Returns:
|
||||
操作结果描述
|
||||
"""
|
||||
store = _get_store()
|
||||
|
||||
if action == "add":
|
||||
if not message:
|
||||
return "错误:添加提醒需要提供 message(提醒内容)。"
|
||||
|
||||
trigger_at, cron_expr = parse_time_spec(time_spec)
|
||||
if not chat_id:
|
||||
chat_id = os.environ.get("QQBOT_DEFAULT_CHAT_ID", "")
|
||||
|
||||
r = await store.add(message, trigger_at, chat_id, cron_expr)
|
||||
|
||||
dt = datetime.fromtimestamp(trigger_at)
|
||||
result = f"提醒已创建 (ID: {r.remind_id})\n内容: {message}\n触发时间: {dt.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if cron_expr:
|
||||
result += f"\nCron: {cron_expr}"
|
||||
if chat_id:
|
||||
result += f"\n目标: {chat_id}"
|
||||
return result
|
||||
|
||||
elif action == "list":
|
||||
reminders = await store.list_all()
|
||||
if not reminders:
|
||||
return "当前没有活跃的提醒。"
|
||||
|
||||
lines = ["当前活跃的提醒:"]
|
||||
for r in reminders:
|
||||
dt = datetime.fromtimestamp(r.trigger_at)
|
||||
status = "已过期" if r.trigger_at < time.time() else "待触发"
|
||||
lines.append(f" [{r.remind_id}] {r.message} - {dt.strftime('%Y-%m-%d %H:%M:%S')} ({status})")
|
||||
return "\n".join(lines)
|
||||
|
||||
elif action == "remove":
|
||||
if not remind_id:
|
||||
return "错误:删除提醒需要提供 remind_id。"
|
||||
|
||||
removed = await store.remove(remind_id)
|
||||
if removed:
|
||||
return f"提醒 {remind_id} 已删除。"
|
||||
return f"未找到提醒 {remind_id}。"
|
||||
|
||||
else:
|
||||
return "不支持的操作类型。支持的操作: add, list, remove"
|
||||
177
backend/package/yuxi/channels/adapters/qqbot/voice_send.py
Normal file
177
backend/package/yuxi/channels/adapters/qqbot/voice_send.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user