ForcePilot/backend/package/yuxi/channels/adapters/qqbot/c2c_stream.py
Kris 552aef767c feat(qqbot): 实现QQ机器人适配器完整功能模块
新增QQ Bot适配器完整代码栈,包含:
1. 基础适配器入口与工具类封装
2. 会话管理、重试队列与流量控制
3. 命令系统与内置指令(ping/help/status等)
4. 富媒体消息处理与格式转换
5. 引用存储与审批管理
6. 凭证备份与会话持久化
7. 健康检查与交互回调系统
2026-05-12 00:48:04 +08:00

344 lines
12 KiB
Python

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)