新增 QQ Bot 渠道扩展,支持在 Yuxi 平台中集成 QQ 机器人渠道。 包含以下功能模块: - api_client: QQ API 客户端封装 - api_routes: API 路由管理 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - websocket: WebSocket 实时连接 - credentials: 凭证管理 - token: Token 管理 - outbound: 外发消息管理 - outbound_media: 媒体外发 - streaming: 流式消息处理 - streaming_media: 媒体流处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - pipeline: 消息管道 - pipeline_stages: 管道阶段 - commands: 指令处理 - commands_builtin: 内置指令 - interaction: 交互处理 - approval: 审批流程 - ark: ARK 消息 - audio: 音频处理 - media: 媒体资源 - media_chunked: 分块媒体 - media_tags: 媒体标签 - message_queue: 消息队列 - delivery: 消息送达确认 - reconnect: 重连机制 - typing_keepalive: 输入状态保活 - group_activation: 群激活 - group_gating: 群门控 - group_history: 群历史 - known_users: 已知用户 - ref_index: 引用索引 - tools: Agent 工具集成 - types: 类型定义
195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from yuxi.channel.extensions.qqbot.api_client import QQBotApiClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STREAM_THROTTLE_MS = 500
|
|
STREAM_MIN_THROTTLE_MS = 300
|
|
STREAM_LONG_INTERVAL_MS = 2000
|
|
STREAM_LONG_BATCH_WINDOW_MS = 300
|
|
|
|
|
|
class FlushController:
|
|
def __init__(self, throttle_ms: int = STREAM_THROTTLE_MS):
|
|
self._ready = False
|
|
self._flush_lock = asyncio.Lock()
|
|
self._needs_reflush = False
|
|
self._is_completed = False
|
|
self._last_update_time = 0.0
|
|
self._throttle_ms = throttle_ms
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
return self._ready
|
|
|
|
@ready.setter
|
|
def ready(self, value: bool) -> None:
|
|
self._ready = value
|
|
|
|
@property
|
|
def is_completed(self) -> bool:
|
|
return self._is_completed
|
|
|
|
@is_completed.setter
|
|
def is_completed(self, value: bool) -> None:
|
|
self._is_completed = value
|
|
|
|
@property
|
|
def needs_reflush(self) -> bool:
|
|
return self._needs_reflush
|
|
|
|
@needs_reflush.setter
|
|
def needs_reflush(self, value: bool) -> None:
|
|
self._needs_reflush = value
|
|
|
|
@property
|
|
def last_update_time(self) -> float:
|
|
return self._last_update_time
|
|
|
|
@last_update_time.setter
|
|
def last_update_time(self, value: float) -> None:
|
|
self._last_update_time = value
|
|
|
|
|
|
class C2CStreamingController:
|
|
def __init__(self, api_client: QQBotApiClient):
|
|
self._api_client = api_client
|
|
self._sessions: dict[str, FlushController] = {}
|
|
self._stream_ids: dict[str, str] = {}
|
|
self._pending_text: dict[str, str] = {}
|
|
self._flush_tasks: dict[str, asyncio.Task] = {}
|
|
self._msg_seqs: dict[str, int] = {}
|
|
|
|
async def start_streaming_session(self, openid: str) -> str:
|
|
if openid in self._stream_ids:
|
|
return self._stream_ids[openid]
|
|
|
|
data = await self._api_client.start_c2c_stream(openid)
|
|
stream_id = data.get("stream_id", data.get("id", ""))
|
|
self._stream_ids[openid] = stream_id
|
|
self._pending_text[openid] = ""
|
|
self._sessions[openid] = FlushController()
|
|
logger.debug("Streaming session started: openid=%s, stream_id=%s", openid, stream_id)
|
|
return stream_id
|
|
|
|
async def feed(self, openid: str, text: str) -> None:
|
|
if openid not in self._pending_text:
|
|
return
|
|
self._pending_text[openid] += text
|
|
|
|
controller = self._sessions.get(openid)
|
|
if controller:
|
|
controller.last_update_time = time.time()
|
|
controller.needs_reflush = True
|
|
|
|
await self._schedule_flush(openid)
|
|
|
|
async def flush(self, openid: str) -> None:
|
|
text = self._pending_text.get(openid, "")
|
|
stream_id = self._stream_ids.get(openid, "")
|
|
controller = self._sessions.get(openid)
|
|
|
|
if not text or not stream_id:
|
|
return
|
|
|
|
try:
|
|
if not controller or not controller.ready:
|
|
await self._api_client.send_c2c_stream_chunk(openid, stream_id, text)
|
|
if controller:
|
|
controller.ready = True
|
|
else:
|
|
await self._api_client.send_c2c_stream_chunk(openid, stream_id, text)
|
|
|
|
self._pending_text[openid] = ""
|
|
|
|
if controller:
|
|
controller.last_update_time = time.time()
|
|
controller.needs_reflush = False
|
|
except Exception as e:
|
|
logger.warning("Stream flush failed for openid=%s: %s", openid, e)
|
|
raise
|
|
|
|
async def complete(self, openid: str) -> None:
|
|
stream_id = self._stream_ids.pop(openid, "")
|
|
if not stream_id:
|
|
return
|
|
|
|
controller = self._sessions.pop(openid, None)
|
|
if controller:
|
|
controller.is_completed = True
|
|
|
|
pending = self._pending_text.pop(openid, "")
|
|
if pending:
|
|
try:
|
|
await self._api_client.send_c2c_stream_chunk(openid, stream_id, pending)
|
|
except Exception:
|
|
logger.exception("Final stream flush failed for openid=%s", openid)
|
|
|
|
await self._cancel_flush_task(openid)
|
|
|
|
try:
|
|
await self._api_client.complete_c2c_stream(openid, stream_id)
|
|
except Exception:
|
|
logger.exception("Stream completion failed for openid=%s", openid)
|
|
|
|
async def abort(self, openid: str, reason: str | None = None) -> None:
|
|
stream_id = self._stream_ids.pop(openid, "")
|
|
if not stream_id:
|
|
return
|
|
|
|
self._sessions.pop(openid, None)
|
|
self._pending_text.pop(openid, "")
|
|
await self._cancel_flush_task(openid)
|
|
|
|
try:
|
|
await self._api_client.abort_c2c_stream(openid, stream_id)
|
|
except Exception:
|
|
logger.exception("Stream abort failed for openid=%s", openid)
|
|
|
|
async def _schedule_flush(self, openid: str) -> None:
|
|
controller = self._sessions.get(openid)
|
|
if not controller or controller.is_completed:
|
|
return
|
|
|
|
await self._cancel_flush_task(openid)
|
|
|
|
delay_ms = controller._throttle_ms
|
|
now = time.time()
|
|
elapsed = (now - controller.last_update_time) * 1000
|
|
|
|
if elapsed > STREAM_LONG_INTERVAL_MS:
|
|
delay_ms = STREAM_LONG_BATCH_WINDOW_MS
|
|
else:
|
|
delay_ms = max(STREAM_MIN_THROTTLE_MS, delay_ms)
|
|
|
|
async def _delayed_flush():
|
|
await asyncio.sleep(delay_ms / 1000.0)
|
|
if openid in self._pending_text and controller and not controller.is_completed:
|
|
try:
|
|
async with controller._flush_lock:
|
|
await self.flush(openid)
|
|
except Exception:
|
|
pass
|
|
|
|
self._flush_tasks[openid] = asyncio.create_task(_delayed_flush())
|
|
|
|
async def _cancel_flush_task(self, openid: str) -> None:
|
|
task = self._flush_tasks.pop(openid, None)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
async def fallback_to_static(self, openid: str, text: str) -> bool:
|
|
await self.abort(openid)
|
|
if text:
|
|
await self._api_client.send_c2c_message(openid, text, msg_type=0)
|
|
return True |