ForcePilot/backend/package/yuxi/channels/adapters/dingding/stream.py
Kris 4347d3f937 chore: 批量优化多适配器代码与新增配置支持
本次提交包含多项改进:
1. 修复钉钉、WhatsApp、Telegram等适配器的线程动作映射名称
2. 为SynologyChat、iMessage、Urbit等多款适配器新增配置Schema
3. 优化日志输出格式,合并多行日志调用为单行
4. 修复指数退避计算中的空格问题
5. 为QQBot凭证备份模块添加弃用警告
6. 新增多款适配器的凭证持久化存储逻辑
7. 优化Matrix、Nostr、DingDing等适配器的状态存储实现
8. 完善Discord、Slack、Signal等适配器的动作注册逻辑
9. 优化WhatsApp桥接器的QR码获取逻辑
10. 修复IRC适配器的配置比对与重连逻辑
2026-05-14 02:06:59 +08:00

304 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from yuxi.channels.adapters.dingding.token import DingDingTokenManager
STREAM_UPDATE_MIN_INTERVAL_S = 0.16
STREAM_SIGNIFICANT_INCREMENT = 18
STREAM_NATURAL_BOUNDARIES = ("\n", "", "", "", "", "", ",", ". ", "! ", "? ", "; ", ": ")
STREAM_PLACEHOLDER_TEXT = "⏳ Thinking..."
MAX_CONSECUTIVE_FAILURES = 5
BACKOFF_BASE_S = 0.5
BACKOFF_MAX_S = 8.0
class DingDingStreamingSession:
def __init__(
self,
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
chat_id: str,
*,
config: dict[str, Any] | None = None,
http_client: httpx.AsyncClient | None = None,
):
self._token_manager = token_manager
self._open_conversation_id = open_conversation_id
self._robot_code = robot_code
self._chat_id = chat_id
self._config = config or {}
self._http_client = http_client
self._msg_id: str | None = None
self._accumulated = ""
self._last_update = 0.0
self._closed = False
self._consecutive_failures = 0
self._started = False
self._chat_type = "group" if chat_id.startswith("group_") else "direct"
stream_cfg = self._config.get("streaming", {})
if not isinstance(stream_cfg, dict):
stream_cfg = {}
self._stream_mode = stream_cfg.get("mode", "block")
block_cfg = stream_cfg.get("block", {})
if not isinstance(block_cfg, dict):
block_cfg = {}
self._update_interval_ms = block_cfg.get("update_interval_ms", 800)
self._coalesce_min_chars = block_cfg.get("coalesce_min_chars", 100)
async def start(self, initial_text: str = "") -> DeliveryResult:
if self._started:
return DeliveryResult(success=True, message_id=self._msg_id or "")
self._started = True
send_text = initial_text or STREAM_PLACEHOLDER_TEXT
try:
from .send import send_markdown
result = await send_markdown(
self._token_manager,
self._open_conversation_id,
self._robot_code,
"ForcePilot",
send_text,
chat_type=self._chat_type,
http_client=self._http_client,
)
if result.success:
self._msg_id = result.message_id
self._consecutive_failures = 0
else:
self._consecutive_failures += 1
return result
except Exception as e:
logger.error(f"[DingDingStream] start error: {e}")
self._consecutive_failures += 1
return DeliveryResult(success=False, error=str(e))
async def send_chunk(self, chunk: str, finished: bool = False) -> DeliveryResult:
if self._closed:
return DeliveryResult(success=False, error="Stream closed")
if not self._msg_id:
return await self.start(chunk)
if finished:
return await self._finish()
self._accumulated += chunk
return await self._maybe_update()
async def _finish(self) -> DeliveryResult:
self._closed = True
try:
from .send import send_markdown
result = await send_markdown(
self._token_manager,
self._open_conversation_id,
self._robot_code,
"ForcePilot",
self._accumulated,
chat_type=self._chat_type,
http_client=self._http_client,
)
if result.success:
self._consecutive_failures = 0
else:
self._consecutive_failures += 1
return result
except Exception as e:
logger.error(f"[DingDingStream] finish error: {e}")
self._consecutive_failures += 1
return DeliveryResult(success=False, error=str(e))
async def _maybe_update(self) -> DeliveryResult:
now = time.monotonic()
elapsed_ms = (now - self._last_update) * 1000
should_send = (
len(self._accumulated) >= self._coalesce_min_chars and elapsed_ms >= self._update_interval_ms
) or any(self._accumulated.endswith(b) for b in STREAM_NATURAL_BOUNDARIES)
if not should_send:
return DeliveryResult(success=True, message_id=self._msg_id)
self._last_update = now
try:
result = await self._edit_message(self._accumulated)
if result.success:
self._consecutive_failures = 0
else:
self._consecutive_failures += 1
logger.debug(f"[DingDingStream] edit failed (consecutive={self._consecutive_failures}): {result.error}")
return result
except Exception as e:
self._consecutive_failures += 1
logger.debug(f"[DingDingStream] edit error: {e}")
return DeliveryResult(success=False, error=str(e))
async def _edit_message(self, content: str) -> DeliveryResult:
if not self._token_manager:
return DeliveryResult(success=False, error="Not connected")
try:
token = await self._token_manager.get_token()
http_client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(15))
url = f"https://api.dingtalk.com/v1.0/robot/messages/{self._msg_id}"
headers = {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
import json
payload = {
"msgParam": json.dumps({"content": content}, ensure_ascii=False),
"msgKey": "sampleMarkdown",
"openConversationId": self._open_conversation_id,
"robotCode": self._robot_code,
}
resp = await http_client.put(url, json=payload, headers=headers)
if resp.status_code != 200:
return DeliveryResult(success=False, error=f"DingDing API HTTP {resp.status_code}")
data = resp.json()
return DeliveryResult(success=True, message_id=data.get("processQueryKey", self._msg_id))
except Exception as e:
logger.error(f"[DingDingStream] _edit_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
@property
def message_id(self) -> str | None:
return self._msg_id
@property
def accumulated_text(self) -> str:
return self._accumulated
@property
def is_closed(self) -> bool:
return self._closed
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
def should_degrade(self) -> bool:
return self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES
def backoff_delay(self) -> float:
if self._consecutive_failures == 0:
return 0.0
return min(BACKOFF_BASE_S * (2 ** (self._consecutive_failures - 1)), BACKOFF_MAX_S)
class DingDingStreamManager:
def __init__(self, adapter):
self._adapter = adapter
self._sessions: dict[str, DingDingStreamingSession] = {}
self._pending_block_text: dict[str, str] = {}
self._lock = asyncio.Lock()
async def send_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
config = getattr(self._adapter, "config", {})
stream_cfg = config.get("streaming", {})
if not isinstance(stream_cfg, dict):
stream_cfg = {}
stream_mode = stream_cfg.get("mode", "block")
if stream_mode == "partial":
return await self._send_partial(chat_id, msg_id, chunk, finished)
return await self._send_block(chat_id, msg_id, chunk, finished)
async def _send_partial(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
session = self._sessions.get(chat_id)
if session is None or session.is_closed:
adapter_config = getattr(self._adapter, "config", {})
accounts = adapter_config.get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conv_id = chat_id.replace("group_", "").replace("dm_", "")
token_mgr = getattr(self._adapter, "_token_manager", None)
http_client = await self._get_http_client()
session = DingDingStreamingSession(
token_mgr,
open_conv_id,
robot_code,
chat_id,
config=adapter_config,
http_client=http_client,
)
self._sessions[chat_id] = session
if session.should_degrade():
logger.warning(
f"[DingDingStream] Degrading partial stream for {chat_id} after {session.consecutive_failures} failures"
)
return await self._fallback_block_send(chat_id)
return await session.send_chunk(chunk, finished)
async def _send_block(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
async with self._lock:
if not finished:
self._pending_block_text[chat_id] = self._pending_block_text.get(chat_id, "") + chunk
return DeliveryResult(success=True, message_id=msg_id or "pending")
final_text = self._pending_block_text.pop(chat_id, chunk)
return await self._fallback_block_send(chat_id, final_text)
async def _fallback_block_send(self, chat_id: str, text: str = "") -> DeliveryResult:
if not text:
return DeliveryResult(success=True)
accounts = getattr(self._adapter, "config", {}).get("accounts", {})
robot_code = accounts.get("default", {}).get("robot_code", "")
open_conv_id = chat_id.replace("group_", "").replace("dm_", "")
chat_type = "group" if chat_id.startswith("group_") else "direct"
token_mgr = getattr(self._adapter, "_token_manager", None)
http_client = await self._get_http_client()
from .send import send_markdown
async with self._lock:
return await send_markdown(
token_mgr,
open_conv_id,
robot_code,
"ForcePilot",
text,
chat_type=chat_type,
http_client=http_client,
)
async def _get_http_client(self) -> httpx.AsyncClient:
if hasattr(self._adapter, "_get_http_client"):
return await self._adapter._get_http_client()
return httpx.AsyncClient(timeout=httpx.Timeout(15))
def close_session(self, chat_id: str) -> None:
self._sessions.pop(chat_id, None)
self._pending_block_text.pop(chat_id, None)
def cleanup(self) -> None:
self._sessions.clear()
self._pending_block_text.clear()