ForcePilot/backend/package/yuxi/channels/adapters/dingding/stream.py
Kris 9c01d480cc feat(dingding): 完整实现钉钉适配器功能模块
- 新增钉钉工具调用审计日志功能
- 新增参数校验装饰器与基础工具集(文档、审批、多维表格)
- 完善事件类型映射与会话ID生成逻辑
- 新增消息去重、访问策略匹配、配置校验与UI提示
- 新增酷应用卡片、目录管理、表情反应支持
- 优化消息发送逻辑,添加401重试机制
- 新增配置化的权限控制与流式输出支持
2026-05-13 16:06:10 +08:00

306 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}"
f" 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()