新增飞书机器人适配器全套功能,包括: - 基础适配器入口与工具导出 - 消息格式化、卡片渲染、回复调度逻辑 - 会话ID生成、模型覆盖策略 - 消息发送缓存、顺序队列管理 - 飞书签名验证、加解密webhook请求 - 审批权限校验、机器人菜单事件处理 - 文档评论、钉消息、语音转码处理 - 静态/动态目录管理、子代理生命周期管理 - 各类工具集:聊天、云盘、文档、知识库API封装
235 lines
7.9 KiB
Python
235 lines
7.9 KiB
Python
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()
|