主要变更: 1. 修复速率限流器使用setdefault替代重复创建令牌桶 2. 重构交互注册表匹配逻辑,优化精确匹配查找 3. 重构去重缓存逻辑,移到适配器实例方法 4. 重构发送URL解析,增加合法性校验并拆分公共方法 5. 优化流式消息处理逻辑,简化flush_controller调用 6. 重构群聊类型判断代码,简化语法 7. 修复重连管理器对None类型关闭分类的处理 8. 新增消息缓存、线程模拟器、发送初始化模块 9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录 10. 新增配置提示与向导二维码绑定功能 11. 优化媒体上传逻辑,增加重试机制与缓存 12. 新增审批键盘模板构建函数 13. 重构消息格式处理,修正媒体发送字段与长度限制 14. 修复令牌过期时间计算,使用time.time替代monotonic 15. 新增群组激活缓冲区与用户追踪器增强功能 16. 修复换行符问题,统一文件结尾格式
257 lines
6.9 KiB
Python
257 lines
6.9 KiB
Python
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_ctrl.flush_controller.feed(chunk)
|
|
|
|
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_ctrl.flush_controller.feed(chunk)
|
|
|
|
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")
|