Skip to content

Commit 6dc87e6

Browse files
authored
Merge 9cc80a0 into d481994
2 parents d481994 + 9cc80a0 commit 6dc87e6

5 files changed

Lines changed: 71 additions & 0 deletions

File tree

docs/channels/feishu.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,16 @@ Feishu/Lark supports streaming replies via interactive cards. When enabled, the
279279
feishu: {
280280
streaming: true, // enable streaming card output (default: true)
281281
blockStreaming: true, // opt into completed-block streaming
282+
streamingSearchFallback: false, // also send final plain text for Feishu search (default: false)
282283
},
283284
},
284285
}
285286
```
286287

287288
Set `streaming: false` to send the complete reply in one message. `blockStreaming` is off by default; enable it only when you want completed assistant blocks flushed before the final reply.
288289

290+
Feishu conversation search may only index the initial interactive card content, so completed streaming card text may not appear in "search conversation content" results. Set `streamingSearchFallback: true` to also send the final streamed reply as a plain text message after the card closes. This makes the content searchable, but users will see an additional final text message.
291+
289292
### Quota optimization
290293

291294
Reduce the number of Feishu/Lark API calls with two optional flags:
@@ -582,6 +585,7 @@ Full configuration: [Gateway configuration](/gateway/configuration)
582585
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
583586
| `channels.feishu.streaming` | Streaming card output | `true` |
584587
| `channels.feishu.blockStreaming` | Completed-block reply streaming | `false` |
588+
| `channels.feishu.streamingSearchFallback` | Send final streamed text as a searchable plain text fallback | `false` |
585589
| `channels.feishu.typingIndicator` | Send typing reactions | `true` |
586590
| `channels.feishu.resolveSenderNames` | Resolve sender display names | `true` |
587591
| `channels.feishu.tools.bitable` | Enable Bitable/Base tools | `true` |

extensions/feishu/openclaw.plugin.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
},
172172
"streaming": { "type": "boolean" },
173173
"blockStreaming": { "type": "boolean" },
174+
"streamingSearchFallback": { "type": "boolean" },
174175
"replyInThread": {
175176
"type": "string",
176177
"enum": ["disabled", "enabled"]
@@ -208,6 +209,7 @@
208209
},
209210
"streaming": { "type": "boolean" },
210211
"blockStreaming": { "type": "boolean" },
212+
"streamingSearchFallback": { "type": "boolean" },
211213
"replyInThread": {
212214
"type": "string",
213215
"enum": ["disabled", "enabled"]

extensions/feishu/src/config-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
7070
// for incremental text display with a "Thinking..." placeholder
7171
const StreamingModeSchema = z.boolean().optional();
7272
const BlockStreamingSchema = z.boolean().optional();
73+
const StreamingSearchFallbackSchema = z.boolean().optional();
7374

7475
const BlockStreamingCoalesceSchema = z
7576
.object({
@@ -199,6 +200,7 @@ const FeishuSharedConfigShape = {
199200
heartbeat: ChannelHeartbeatVisibilitySchema,
200201
renderMode: RenderModeSchema,
201202
streaming: StreamingModeSchema,
203+
streamingSearchFallback: StreamingSearchFallbackSchema,
202204
tools: FeishuToolsConfigSchema,
203205
actions: ChannelActionsSchema,
204206
replyInThread: ReplyInThreadSchema,

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,41 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
671671
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
672672
});
673673

674+
it("sends searchable plain-text fallback after streaming close when enabled", async () => {
675+
resolveFeishuAccountMock.mockReturnValue({
676+
accountId: "main",
677+
appId: "app_id",
678+
appSecret: "app_secret",
679+
domain: "feishu",
680+
config: {
681+
renderMode: "auto",
682+
streaming: true,
683+
streamingSearchFallback: true,
684+
},
685+
});
686+
687+
const { options } = createDispatcherHarness({
688+
replyToMessageId: "om_parent",
689+
replyInThread: true,
690+
});
691+
await options.deliver({ text: "```ts\nconst searchableKeyword = 1\n```" }, { kind: "final" });
692+
await options.onIdle?.();
693+
694+
expect(streamingInstances).toHaveLength(1);
695+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
696+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
697+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
698+
expect.objectContaining({
699+
cfg: {},
700+
to: "oc_chat",
701+
text: "```ts\nconst searchableKeyword = 1\n```",
702+
replyToMessageId: "om_parent",
703+
replyInThread: true,
704+
}),
705+
);
706+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
707+
});
708+
674709
it("closes streaming with block text when final reply is missing", async () => {
675710
const { options } = createDispatcherHarness({
676711
runtime: createRuntimeLogger(),

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
234234
const renderMode = account.config?.renderMode ?? "auto";
235235
const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
236236
const coreBlockStreamingEnabled = account.config?.blockStreaming === true;
237+
const streamingSearchFallbackEnabled = account.config?.streamingSearchFallback === true;
237238
const reasoningPreviewEnabled = streamingEnabled && params.allowReasoningPreview === true;
238239

239240
let streaming: FeishuStreamingSession | null = null;
@@ -422,6 +423,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
422423
streamingClosedForReply = true;
423424
}
424425
}
426+
if (contentVisible && streamingSearchFallbackEnabled && text.trim()) {
427+
await sendSearchableStreamingFallback(text);
428+
}
425429
}
426430
} finally {
427431
resetStreamingState();
@@ -483,6 +487,30 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
483487
}
484488
};
485489

490+
const sendSearchableStreamingFallback = async (text: string) => {
491+
try {
492+
await sendChunkedTextReply({
493+
text,
494+
useCard: false,
495+
sendChunk: async ({ chunk }) => {
496+
await sendMessageFeishu({
497+
cfg,
498+
to: chatId,
499+
text: chunk,
500+
replyToMessageId: sendReplyToMessageId,
501+
replyInThread: effectiveReplyInThread,
502+
allowTopLevelReplyFallback,
503+
accountId,
504+
});
505+
},
506+
});
507+
} catch (error) {
508+
params.runtime.error?.(
509+
`feishu[${account.accountId}]: streaming searchable text fallback failed: ${String(error)}`,
510+
);
511+
}
512+
};
513+
486514
const sendMediaReplies = async (payload: ReplyPayload, options?: { fallbackText?: string }) => {
487515
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
488516
let sentFallbackText = false;

0 commit comments

Comments
 (0)