Skip to content

Commit ec21cb1

Browse files
camera-2018bread-ovOSoulter
authored
feat(lark): supports CardKit streaming output for feishu (#5777)
* feat(lark): 支持飞书 CardKit 流式输出 * refactor(lark): extract streaming fallback logic and deduplicate final text update * fix(lark): 修复流式输出竞态条件及增强健壮性 - 修复 sender loop 中 delta 快照竟态: await 期间 delta 被 generator 更新导致 last_sent 记录了未发送的值, 造成输出卡在最后一段 - send_streaming 入口增加 platform_meta 守卫, 未启用时直接回退 - _fallback_send_streaming 移除对已耗尽 generator 的 super() 调用, 改为内联父类副作用 (Metric.upload + _has_send_oper) - Metric.upload 统一改为 await, 确保指标上报在方法返回前完成 - 装饰器 support_streaming_message 改为 False, 与 meta() 动态配置对齐 - i18n hint 补充提示: 需在「AI 配置 → 其他配置」中开启流式输出 * chore(lark): 收口配置 * docs(lark): update streaming output instructions and client version requirements --------- Co-authored-by: bread-ovo <2570425204@qq.com> Co-authored-by: Soulter <905617992@qq.com>
1 parent 1d26b96 commit ec21cb1

6 files changed

Lines changed: 274 additions & 10 deletions

File tree

astrbot/core/platform/sources/lark/lark_adapter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535

3636
@register_platform_adapter(
37-
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
37+
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
3838
)
3939
class LarkPlatformAdapter(Platform):
4040
def __init__(
@@ -491,7 +491,7 @@ def meta(self) -> PlatformMetadata:
491491
name="lark",
492492
description="飞书机器人官方 API 适配器",
493493
id=cast(str, self.config.get("id")),
494-
support_streaming_message=False,
494+
support_streaming_message=True,
495495
)
496496

497497
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:

astrbot/core/platform/sources/lark/lark_event.py

Lines changed: 258 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import asyncio
12
import base64
23
import json
34
import os
45
import uuid
56
from io import BytesIO
67

78
import lark_oapi as lark
9+
from lark_oapi.api.cardkit.v1 import (
10+
ContentCardElementRequest,
11+
ContentCardElementRequestBody,
12+
CreateCardRequest,
13+
CreateCardRequestBody,
14+
SettingsCardRequest,
15+
SettingsCardRequestBody,
16+
)
817
from lark_oapi.api.im.v1 import (
918
CreateFileRequest,
1019
CreateFileRequestBody,
@@ -28,6 +37,7 @@
2837
convert_video_format,
2938
get_media_duration,
3039
)
40+
from astrbot.core.utils.metrics import Metric
3141

3242

3343
class LarkMessageEvent(AstrMessageEvent):
@@ -555,15 +565,257 @@ async def react(self, emoji: str) -> None:
555565
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
556566
return
557567

558-
async def send_streaming(self, generator, use_fallback: bool = False):
568+
async def _create_streaming_card(self) -> str | None:
569+
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
570+
if self.bot.cardkit is None:
571+
logger.error("[Lark] API Client cardkit 模块未初始化")
572+
return None
573+
574+
card_json = {
575+
"schema": "2.0",
576+
"header": {
577+
"title": {"content": "", "tag": "plain_text"},
578+
},
579+
"config": {
580+
"streaming_mode": True,
581+
"summary": {"content": ""},
582+
"streaming_config": {
583+
"print_frequency_ms": {"default": 50},
584+
"print_step": {"default": 2},
585+
"print_strategy": "fast",
586+
},
587+
},
588+
"body": {
589+
"elements": [
590+
{
591+
"tag": "markdown",
592+
"content": "",
593+
"element_id": "markdown_1",
594+
}
595+
]
596+
},
597+
}
598+
599+
request = (
600+
CreateCardRequest.builder()
601+
.request_body(
602+
CreateCardRequestBody.builder()
603+
.type("card_json")
604+
.data(json.dumps(card_json, ensure_ascii=False))
605+
.build()
606+
)
607+
.build()
608+
)
609+
610+
try:
611+
response = await self.bot.cardkit.v1.card.acreate(request)
612+
except Exception as e:
613+
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
614+
return None
615+
616+
if not response.success():
617+
logger.error(
618+
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
619+
)
620+
return None
621+
622+
if response.data is None or not response.data.card_id:
623+
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
624+
return None
625+
626+
card_id = response.data.card_id
627+
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
628+
return card_id
629+
630+
async def _send_card_message(
631+
self,
632+
card_id: str,
633+
reply_message_id: str | None = None,
634+
receive_id: str | None = None,
635+
receive_id_type: str | None = None,
636+
) -> bool:
637+
"""将卡片实体作为 interactive 消息发送。"""
638+
content = json.dumps(
639+
{"type": "card", "data": {"card_id": card_id}},
640+
ensure_ascii=False,
641+
)
642+
return await self._send_im_message(
643+
self.bot,
644+
content=content,
645+
msg_type="interactive",
646+
reply_message_id=reply_message_id,
647+
receive_id=receive_id,
648+
receive_id_type=receive_id_type,
649+
)
650+
651+
async def _update_streaming_text(
652+
self,
653+
card_id: str,
654+
content: str,
655+
sequence: int,
656+
) -> bool:
657+
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
658+
if self.bot.cardkit is None:
659+
logger.error("[Lark] API Client cardkit 模块未初始化")
660+
return False
661+
662+
request = (
663+
ContentCardElementRequest.builder()
664+
.card_id(card_id)
665+
.element_id("markdown_1")
666+
.request_body(
667+
ContentCardElementRequestBody.builder()
668+
.content(content)
669+
.sequence(sequence)
670+
.uuid(str(uuid.uuid4()))
671+
.build()
672+
)
673+
.build()
674+
)
675+
676+
try:
677+
response = await self.bot.cardkit.v1.card_element.acontent(request)
678+
except Exception as e:
679+
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
680+
return False
681+
682+
if not response.success():
683+
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
684+
return False
685+
686+
return True
687+
688+
async def _close_streaming_mode(
689+
self,
690+
card_id: str,
691+
sequence: int,
692+
) -> None:
693+
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
694+
if self.bot.cardkit is None:
695+
logger.error("[Lark] API Client cardkit 模块未初始化")
696+
return
697+
698+
settings_json = json.dumps(
699+
{"config": {"streaming_mode": False}},
700+
ensure_ascii=False,
701+
)
702+
703+
request = (
704+
SettingsCardRequest.builder()
705+
.card_id(card_id)
706+
.request_body(
707+
SettingsCardRequestBody.builder()
708+
.settings(settings_json)
709+
.sequence(sequence)
710+
.uuid(str(uuid.uuid4()))
711+
.build()
712+
)
713+
.build()
714+
)
715+
716+
try:
717+
response = await self.bot.cardkit.v1.card.asettings(request)
718+
except Exception as e:
719+
logger.error(f"[Lark] 关闭流式模式失败: {e}")
720+
return
721+
722+
if not response.success():
723+
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
724+
else:
725+
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
726+
727+
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
728+
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
559729
buffer = None
560730
async for chain in generator:
561731
if not buffer:
562732
buffer = chain
563733
else:
564734
buffer.chain.extend(chain.chain)
565-
if not buffer:
566-
return None
567-
buffer.squash_plain()
568-
await self.send(buffer)
569-
return await super().send_streaming(generator, use_fallback)
735+
736+
if buffer:
737+
buffer.squash_plain()
738+
await self.send(buffer)
739+
740+
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
741+
self._has_send_oper = True
742+
743+
async def send_streaming(self, generator, use_fallback: bool = False):
744+
"""使用 CardKit 流式卡片实现打字机效果。
745+
746+
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
747+
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
748+
发送频率由网络 RTT 自然限流。
749+
"""
750+
# Step 1: 创建流式卡片实体
751+
card_id = await self._create_streaming_card()
752+
if not card_id:
753+
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
754+
await self._fallback_send_streaming(generator, use_fallback)
755+
return
756+
757+
# Step 2: 发送卡片消息
758+
sent = await self._send_card_message(
759+
card_id,
760+
reply_message_id=self.message_obj.message_id,
761+
)
762+
if not sent:
763+
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
764+
await self._fallback_send_streaming(generator, use_fallback)
765+
return
766+
767+
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
768+
769+
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
770+
sequence = 0
771+
delta = ""
772+
last_sent = ""
773+
done = False
774+
text_changed = asyncio.Event()
775+
776+
async def _sender_loop() -> None:
777+
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
778+
nonlocal sequence, last_sent
779+
while not done:
780+
await text_changed.wait()
781+
text_changed.clear()
782+
snapshot = delta
783+
if snapshot and snapshot != last_sent:
784+
sequence += 1
785+
ok = await self._update_streaming_text(card_id, snapshot, sequence)
786+
if ok:
787+
last_sent = snapshot
788+
if delta != snapshot:
789+
text_changed.set()
790+
791+
sender_task = asyncio.create_task(_sender_loop())
792+
793+
try:
794+
async for chain in generator:
795+
if not isinstance(chain, MessageChain):
796+
continue
797+
798+
if chain.type == "break":
799+
# 飞书卡片不支持分段,忽略 break
800+
continue
801+
802+
for comp in chain.chain:
803+
if isinstance(comp, Plain):
804+
delta += comp.text
805+
text_changed.set()
806+
finally:
807+
done = True
808+
text_changed.set()
809+
await sender_task
810+
811+
# Step 4: 必要时补发最终文本 + 关闭流式模式
812+
if delta and delta != last_sent:
813+
sequence += 1
814+
await self._update_streaming_text(card_id, delta, sequence)
815+
816+
sequence += 1
817+
await self._close_streaming_mode(card_id, sequence)
818+
819+
# Step 5: 内联父类 send_streaming 的副作用
820+
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
821+
self._has_send_oper = True

dashboard/src/i18n/locales/en-US/features/config-metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,4 +1521,4 @@
15211521
"helpMiddle": "or",
15221522
"helpSuffix": "."
15231523
}
1524-
}
1524+
}

dashboard/src/i18n/locales/zh-CN/features/config-metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,4 +1524,4 @@
15241524
"helpMiddle": "",
15251525
"helpSuffix": ""
15261526
}
1527-
}
1527+
}

docs/en/platform/lark.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
Proactive message push: Supported.
1616

17+
Streaming output: Supported. You must enable the `Create and update cards (cardkit:card:write)` permission for your app in the Lark Developer Console.
18+
19+
The Lark client version must be >= 7.20. Lower versions only display the title and an upgrade prompt.
20+
1721
## Creating a Bot
1822

1923
Navigate to the [Developer Console](https://open.feishu.cn/app) and create a custom enterprise application.
@@ -88,6 +92,8 @@ Next, click on "Permission Management," click "Enable Permissions," and enter `i
8892

8993
Enter `im:resource:upload,im:resource` again to enable image upload permissions.
9094

95+
If you want to use streaming output, additionally enable `Create and update cards (cardkit:card:write)`.
96+
9197
The final set of permissions should look like this:
9298

9399
![Final Permissions](https://files.astrbot.app/docs/source/images/lark/image-11.png)

docs/zh/platform/lark.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
主动消息推送:支持。
1616

17+
流式输出:支持。需要在飞书开发者后台为应用开通 `创建与更新卡片(cardkit:card:write)` 权限。
18+
19+
飞书客户端版本需 >= 7.20。低版本客户端将只显示标题和升级提示。
20+
1721
## 创建机器人
1822

1923
前往 [开发者后台](https://open.feishu.cn/app) ,创建企业自建应用。
@@ -88,6 +92,8 @@
8892

8993
再次输入 `im:resource:upload,im:resource` 开通上传图片相关的权限。
9094

95+
如果需要使用流式输出,请额外开通 `创建与更新卡片(cardkit:card:write)` 权限。
96+
9197
最终开通的权限如下图:
9298

9399
![最终开通的权限](https://files.astrbot.app/docs/source/images/lark/image-11.png)

0 commit comments

Comments
 (0)