ForcePilot/backend/package/yuxi/channels/adapters/feishu/stream.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

235 lines
7.9 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 json
import time
from dataclasses import dataclass
from typing import Literal
from yuxi.utils.logging_config import logger
try:
import lark_oapi
HAS_LARK_SDK = True
except ImportError:
HAS_LARK_SDK = False
lark_oapi = None # type: ignore
STREAM_UPDATE_MIN_INTERVAL_S = 0.16
STREAM_SIGNIFICANT_INCREMENT = 18
STREAM_NATURAL_BOUNDARIES = ("\n", "", "", "", "", "", ",", ". ", "! ", "? ", "; ", ": ")
STREAM_PLACEHOLDER_TEXT = "⏳ Thinking..."
STREAM_START_BACKOFF_S = 60
CHAR_PRINT_INTERVAL_S = 0.05
CHAR_PRINT_STEP = 1
StreamSendMode = Literal["create", "reply", "root_create"]
def merge_streaming_text(existing: str, incoming: str) -> str:
if not existing:
return incoming
if not incoming:
return existing
if incoming.startswith(existing):
return incoming
if existing.startswith(incoming):
return existing
for i in range(len(incoming) - 1, 0, -1):
prefix = incoming[:i]
if existing.endswith(prefix):
return existing + incoming[i:]
overlap_start = max(0, len(existing) - len(incoming))
for j in range(1, len(incoming)):
suffix = incoming[j:]
if existing.startswith(suffix):
return incoming[:j] + existing
return existing + incoming
@dataclass
class FeishuStreamingSession:
"""管理单个流式卡片的完整生命周期"""
_client: object
_chat_id: str
_msg_id: str | None = None
_reply_to_msg_id: str | None = None
_root_id: str | None = None
_send_mode: StreamSendMode = "create"
def __post_init__(self):
self._card_id: str | None = None
self._message_id: str | None = self._msg_id
self._accumulated = ""
self._last_update = 0.0
self._closed = False
async def start(self) -> str:
if not HAS_LARK_SDK:
return ""
try:
placeholder_card = {
"header": {"title": {"tag": "plain_text", "content": STREAM_PLACEHOLDER_TEXT}, "template": "blue"},
"elements": [{"tag": "markdown", "content": ""}],
}
create_card_req = (
lark_oapi.api.cardkit.v1.CreateCardRequest.builder()
.request_body(lark_oapi.api.cardkit.v1.CreateCardRequestBody.builder().card(placeholder_card).build())
.build()
)
create_resp = await self._client.cardkit.v1.card.create(create_card_req)
if not create_resp.success():
logger.warning(f"[FeishuStream] Failed to create card: {create_resp.msg}")
return ""
self._card_id = create_resp.data.get("card_id", "")
msg_content = json.dumps(
{"config": {"streaming_mode": True}, "card_id": self._card_id},
ensure_ascii=False,
)
chat_type = "private" if self._chat_id.startswith("dm_") else "group"
if self._send_mode == "reply" and self._reply_to_msg_id:
msg_req = _make_reply_message(self._reply_to_msg_id, msg_content)
elif self._send_mode == "root_create":
msg_req = _make_interactive_message(self._chat_id, chat_type, msg_content)
if self._reply_to_msg_id:
msg_req.root_id = self._reply_to_msg_id
else:
msg_req = _make_interactive_message(self._chat_id, chat_type, msg_content)
if self._root_id:
msg_req.root_id = self._root_id
msg_resp = await self._client.im.message.create(msg_req)
if msg_resp.success():
self._message_id = msg_resp.data.get("message_id", "")
logger.info(f"[FeishuStream] Card sent, card_id={self._card_id}, message_id={self._message_id}")
return self._card_id
logger.warning(f"[FeishuStream] Failed to send card message: {msg_resp.msg}")
return ""
except Exception as e:
logger.error(f"[FeishuStream] start error: {e}")
return ""
async def update(self, full_text: str) -> None:
if not self._card_id or self._closed:
return
now = time.monotonic()
merged = merge_streaming_text(self._accumulated, full_text)
elapsed = now - self._last_update
increment = len(merged) - len(self._accumulated)
skip = (
elapsed < STREAM_UPDATE_MIN_INTERVAL_S
and increment < STREAM_SIGNIFICANT_INCREMENT
and not any(full_text.endswith(b) for b in STREAM_NATURAL_BOUNDARIES)
)
if skip:
self._accumulated = merged
return
self._accumulated = merged
self._last_update = now
try:
update_req = (
lark_oapi.api.cardkit.v1.UpdateCardContentRequest.builder()
.card_id(self._card_id)
.request_body(
lark_oapi.api.cardkit.v1.UpdateCardContentRequestBody.builder()
.elements([{"tag": "markdown", "content": merged}])
.build()
)
.build()
)
resp = await self._client.cardkit.v1.card.update_content(update_req)
if not resp.success():
logger.debug(f"[FeishuStream] Update failed: {resp.msg}")
except Exception as e:
logger.debug(f"[FeishuStream] Update error: {e}")
async def close(self) -> None:
if self._closed or not self._card_id:
return
self._closed = True
try:
close_req = (
lark_oapi.api.cardkit.v1.UpdateCardSettingsRequest.builder()
.card_id(self._card_id)
.request_body(
lark_oapi.api.cardkit.v1.UpdateCardSettingsRequestBody.builder().streaming_mode(False).build()
)
.build()
)
resp = await self._client.cardkit.v1.card.update_settings(close_req)
if resp.success():
logger.info(f"[FeishuStream] Card closed, card_id={self._card_id}")
except Exception as e:
logger.warning(f"[FeishuStream] Close error: {e}")
def _make_interactive_message(receive_id: str, chat_type: str, content_json: str) -> Any:
receive_id_type = "open_id" if chat_type == "private" else "chat_id"
return (
lark_oapi.api.im.v1.CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
lark_oapi.api.im.v1.CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("interactive")
.content(content_json)
.build()
)
.build()
)
def _make_reply_message(reply_to_msg_id: str, content_json: str) -> Any:
return (
lark_oapi.api.im.v1.ReplyMessageRequest.builder()
.message_id(reply_to_msg_id)
.request_body(
lark_oapi.api.im.v1.ReplyMessageRequestBody.builder().msg_type("interactive").content(content_json).build()
)
.build()
)
class CharacterStreamingSession:
def __init__(
self,
session: FeishuStreamingSession,
*,
interval_s: float = CHAR_PRINT_INTERVAL_S,
step: int = CHAR_PRINT_STEP,
):
self._session = session
self._interval_s = interval_s
self._step = step
self._displayed_len = 0
async def update(self, full_text: str) -> None:
while self._displayed_len < len(full_text):
next_len = min(self._displayed_len + self._step, len(full_text))
display_text = full_text[:next_len]
self._displayed_len = next_len
await self._session.update(display_text)
if next_len < len(full_text):
await asyncio.sleep(self._interval_s)
async def close(self) -> None:
await self._session.close()