@openclaw/feishu 2026.3.1 → 2026.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +268 -11
- package/src/accounts.ts +101 -14
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +9 -1
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +945 -77
- package/src/bot.ts +492 -165
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +72 -68
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +221 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +33 -6
- package/src/config-schema.ts +18 -10
- package/src/dedup.ts +47 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/doc-schema.ts +16 -22
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +10 -16
- package/src/docx.test.ts +41 -189
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +164 -14
- package/src/media.ts +44 -10
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +284 -25
- package/src/monitor.reaction.test.ts +395 -46
- package/src/monitor.startup.test.ts +25 -8
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +88 -9
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +13 -11
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +213 -106
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +54 -36
- package/src/probe.ts +57 -37
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +216 -0
- package/src/reply-dispatcher.ts +89 -22
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +7 -3
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +25 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +11 -4
- package/src/typing.ts +1 -1
- package/src/wiki.ts +15 -19
|
@@ -26,6 +26,23 @@ vi.mock("./typing.js", () => ({
|
|
|
26
26
|
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
27
|
}));
|
|
28
28
|
vi.mock("./streaming-card.js", () => ({
|
|
29
|
+
mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => {
|
|
30
|
+
const previous = typeof previousText === "string" ? previousText : "";
|
|
31
|
+
const next = typeof nextText === "string" ? nextText : "";
|
|
32
|
+
if (!next) {
|
|
33
|
+
return previous;
|
|
34
|
+
}
|
|
35
|
+
if (!previous || next === previous) {
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
if (next.startsWith(previous)) {
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
if (previous.startsWith(next)) {
|
|
42
|
+
return previous;
|
|
43
|
+
}
|
|
44
|
+
return `${previous}${next}`;
|
|
45
|
+
},
|
|
29
46
|
FeishuStreamingSession: class {
|
|
30
47
|
active = false;
|
|
31
48
|
start = vi.fn(async () => {
|
|
@@ -89,6 +106,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
89
106
|
});
|
|
90
107
|
});
|
|
91
108
|
|
|
109
|
+
function setupNonStreamingAutoDispatcher() {
|
|
110
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
111
|
+
accountId: "main",
|
|
112
|
+
appId: "app_id",
|
|
113
|
+
appSecret: "app_secret",
|
|
114
|
+
domain: "feishu",
|
|
115
|
+
config: {
|
|
116
|
+
renderMode: "auto",
|
|
117
|
+
streaming: false,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
createFeishuReplyDispatcher({
|
|
122
|
+
cfg: {} as never,
|
|
123
|
+
agentId: "agent",
|
|
124
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
125
|
+
chatId: "oc_chat",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
it("skips typing indicator when account typingIndicator is disabled", async () => {
|
|
93
132
|
resolveFeishuAccountMock.mockReturnValue({
|
|
94
133
|
accountId: "main",
|
|
@@ -185,6 +224,34 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
185
224
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
186
225
|
});
|
|
187
226
|
|
|
227
|
+
it("suppresses internal block payload delivery", async () => {
|
|
228
|
+
createFeishuReplyDispatcher({
|
|
229
|
+
cfg: {} as never,
|
|
230
|
+
agentId: "agent",
|
|
231
|
+
runtime: {} as never,
|
|
232
|
+
chatId: "oc_chat",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
236
|
+
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
|
237
|
+
|
|
238
|
+
expect(streamingInstances).toHaveLength(0);
|
|
239
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
240
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
241
|
+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => {
|
|
245
|
+
const result = createFeishuReplyDispatcher({
|
|
246
|
+
cfg: {} as never,
|
|
247
|
+
agentId: "agent",
|
|
248
|
+
runtime: {} as never,
|
|
249
|
+
chatId: "oc_chat",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
|
|
253
|
+
});
|
|
254
|
+
|
|
188
255
|
it("uses streaming session for auto mode markdown payloads", async () => {
|
|
189
256
|
createFeishuReplyDispatcher({
|
|
190
257
|
cfg: {} as never,
|
|
@@ -209,6 +276,131 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
209
276
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
210
277
|
});
|
|
211
278
|
|
|
279
|
+
it("closes streaming with block text when final reply is missing", async () => {
|
|
280
|
+
createFeishuReplyDispatcher({
|
|
281
|
+
cfg: {} as never,
|
|
282
|
+
agentId: "agent",
|
|
283
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
284
|
+
chatId: "oc_chat",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
288
|
+
await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
|
|
289
|
+
await options.onIdle?.();
|
|
290
|
+
|
|
291
|
+
expect(streamingInstances).toHaveLength(1);
|
|
292
|
+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("delivers distinct final payloads after streaming close", async () => {
|
|
298
|
+
createFeishuReplyDispatcher({
|
|
299
|
+
cfg: {} as never,
|
|
300
|
+
agentId: "agent",
|
|
301
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
302
|
+
chatId: "oc_chat",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
306
|
+
await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
|
|
307
|
+
await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
|
|
308
|
+
|
|
309
|
+
expect(streamingInstances).toHaveLength(2);
|
|
310
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
311
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
|
|
312
|
+
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
|
|
313
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
|
|
314
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
315
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("skips exact duplicate final text after streaming close", async () => {
|
|
319
|
+
createFeishuReplyDispatcher({
|
|
320
|
+
cfg: {} as never,
|
|
321
|
+
agentId: "agent",
|
|
322
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
323
|
+
chatId: "oc_chat",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
327
|
+
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
328
|
+
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
329
|
+
|
|
330
|
+
expect(streamingInstances).toHaveLength(1);
|
|
331
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
|
|
333
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
334
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it("suppresses duplicate final text while still sending media", async () => {
|
|
337
|
+
const options = setupNonStreamingAutoDispatcher();
|
|
338
|
+
await options.deliver({ text: "plain final" }, { kind: "final" });
|
|
339
|
+
await options.deliver(
|
|
340
|
+
{ text: "plain final", mediaUrl: "https://example.com/a.png" },
|
|
341
|
+
{ kind: "final" },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
text: "plain final",
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
351
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
352
|
+
expect.objectContaining({
|
|
353
|
+
mediaUrl: "https://example.com/a.png",
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("keeps distinct non-streaming final payloads", async () => {
|
|
359
|
+
const options = setupNonStreamingAutoDispatcher();
|
|
360
|
+
await options.deliver({ text: "notice header" }, { kind: "final" });
|
|
361
|
+
await options.deliver({ text: "actual answer body" }, { kind: "final" });
|
|
362
|
+
|
|
363
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
|
|
364
|
+
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
|
|
365
|
+
1,
|
|
366
|
+
expect.objectContaining({ text: "notice header" }),
|
|
367
|
+
);
|
|
368
|
+
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
|
|
369
|
+
2,
|
|
370
|
+
expect.objectContaining({ text: "actual answer body" }),
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("treats block updates as delta chunks", async () => {
|
|
375
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
376
|
+
accountId: "main",
|
|
377
|
+
appId: "app_id",
|
|
378
|
+
appSecret: "app_secret",
|
|
379
|
+
domain: "feishu",
|
|
380
|
+
config: {
|
|
381
|
+
renderMode: "card",
|
|
382
|
+
streaming: true,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = createFeishuReplyDispatcher({
|
|
387
|
+
cfg: {} as never,
|
|
388
|
+
agentId: "agent",
|
|
389
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
390
|
+
chatId: "oc_chat",
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
394
|
+
await options.onReplyStart?.();
|
|
395
|
+
await result.replyOptions.onPartialReply?.({ text: "hello" });
|
|
396
|
+
await options.deliver({ text: "lo world" }, { kind: "block" });
|
|
397
|
+
await options.onIdle?.();
|
|
398
|
+
|
|
399
|
+
expect(streamingInstances).toHaveLength(1);
|
|
400
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
401
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
|
|
402
|
+
});
|
|
403
|
+
|
|
212
404
|
it("sends media-only payloads as attachments", async () => {
|
|
213
405
|
createFeishuReplyDispatcher({
|
|
214
406
|
cfg: {} as never,
|
|
@@ -352,6 +544,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
352
544
|
});
|
|
353
545
|
});
|
|
354
546
|
|
|
547
|
+
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
|
548
|
+
createFeishuReplyDispatcher({
|
|
549
|
+
cfg: {} as never,
|
|
550
|
+
agentId: "agent",
|
|
551
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
552
|
+
chatId: "oc_chat",
|
|
553
|
+
replyToMessageId: "om_msg",
|
|
554
|
+
replyInThread: false,
|
|
555
|
+
threadReply: true,
|
|
556
|
+
rootId: "om_root_topic",
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
560
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
561
|
+
|
|
562
|
+
expect(streamingInstances).toHaveLength(0);
|
|
563
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
564
|
+
expect.objectContaining({
|
|
565
|
+
replyToMessageId: "om_msg",
|
|
566
|
+
replyInThread: true,
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
355
571
|
it("passes replyInThread to media attachments", async () => {
|
|
356
572
|
createFeishuReplyDispatcher({
|
|
357
573
|
cfg: {} as never,
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ClawdbotConfig,
|
|
6
6
|
type ReplyPayload,
|
|
7
7
|
type RuntimeEnv,
|
|
8
|
-
} from "openclaw/plugin-sdk";
|
|
8
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
9
9
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
10
10
|
import { createFeishuClient } from "./client.js";
|
|
11
11
|
import { sendMediaFeishu } from "./media.js";
|
|
@@ -13,7 +13,7 @@ import type { MentionTarget } from "./mention.js";
|
|
|
13
13
|
import { buildMentionedCardContent } from "./mention.js";
|
|
14
14
|
import { getFeishuRuntime } from "./runtime.js";
|
|
15
15
|
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
|
16
|
-
import { FeishuStreamingSession } from "./streaming-card.js";
|
|
16
|
+
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
|
17
17
|
import { resolveReceiveIdType } from "./targets.js";
|
|
18
18
|
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
|
19
19
|
|
|
@@ -45,6 +45,8 @@ export type CreateFeishuReplyDispatcherParams = {
|
|
|
45
45
|
/** When true, preserve typing indicator on reply target but send messages without reply metadata */
|
|
46
46
|
skipReplyToInMessages?: boolean;
|
|
47
47
|
replyInThread?: boolean;
|
|
48
|
+
/** True when inbound message is already inside a thread/topic context */
|
|
49
|
+
threadReply?: boolean;
|
|
48
50
|
rootId?: string;
|
|
49
51
|
mentionTargets?: MentionTarget[];
|
|
50
52
|
accountId?: string;
|
|
@@ -62,11 +64,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
62
64
|
replyToMessageId,
|
|
63
65
|
skipReplyToInMessages,
|
|
64
66
|
replyInThread,
|
|
67
|
+
threadReply,
|
|
65
68
|
rootId,
|
|
66
69
|
mentionTargets,
|
|
67
70
|
accountId,
|
|
68
71
|
} = params;
|
|
69
72
|
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
|
73
|
+
const threadReplyMode = threadReply === true;
|
|
74
|
+
const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
|
|
70
75
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
71
76
|
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
|
72
77
|
|
|
@@ -89,6 +94,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
89
94
|
) {
|
|
90
95
|
return;
|
|
91
96
|
}
|
|
97
|
+
// Feishu reactions persist until explicitly removed, so skip keepalive
|
|
98
|
+
// re-adds when a reaction already exists. Re-adding the same emoji
|
|
99
|
+
// triggers a new push notification for every call (#28660).
|
|
100
|
+
if (typingState?.reactionId) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
92
103
|
typingState = await addTypingIndicator({
|
|
93
104
|
cfg,
|
|
94
105
|
messageId: replyToMessageId,
|
|
@@ -125,13 +136,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
125
136
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
126
137
|
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
|
127
138
|
const renderMode = account.config?.renderMode ?? "auto";
|
|
128
|
-
|
|
139
|
+
// Card streaming may miss thread affinity in topic contexts; use direct replies there.
|
|
140
|
+
const streamingEnabled =
|
|
141
|
+
!threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
|
|
129
142
|
|
|
130
143
|
let streaming: FeishuStreamingSession | null = null;
|
|
131
144
|
let streamText = "";
|
|
132
145
|
let lastPartial = "";
|
|
146
|
+
const deliveredFinalTexts = new Set<string>();
|
|
133
147
|
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
134
148
|
let streamingStartPromise: Promise<void> | null = null;
|
|
149
|
+
type StreamTextUpdateMode = "snapshot" | "delta";
|
|
150
|
+
|
|
151
|
+
const queueStreamingUpdate = (
|
|
152
|
+
nextText: string,
|
|
153
|
+
options?: {
|
|
154
|
+
dedupeWithLastPartial?: boolean;
|
|
155
|
+
mode?: StreamTextUpdateMode;
|
|
156
|
+
},
|
|
157
|
+
) => {
|
|
158
|
+
if (!nextText) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (options?.dedupeWithLastPartial && nextText === lastPartial) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (options?.dedupeWithLastPartial) {
|
|
165
|
+
lastPartial = nextText;
|
|
166
|
+
}
|
|
167
|
+
const mode = options?.mode ?? "snapshot";
|
|
168
|
+
streamText =
|
|
169
|
+
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
|
|
170
|
+
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
171
|
+
if (streamingStartPromise) {
|
|
172
|
+
await streamingStartPromise;
|
|
173
|
+
}
|
|
174
|
+
if (streaming?.isActive()) {
|
|
175
|
+
await streaming.update(streamText);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
};
|
|
135
179
|
|
|
136
180
|
const startStreaming = () => {
|
|
137
181
|
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
@@ -152,7 +196,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
152
196
|
try {
|
|
153
197
|
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
|
154
198
|
replyToMessageId,
|
|
155
|
-
replyInThread,
|
|
199
|
+
replyInThread: effectiveReplyInThread,
|
|
156
200
|
rootId,
|
|
157
201
|
});
|
|
158
202
|
} catch (error) {
|
|
@@ -186,6 +230,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
186
230
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
187
231
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
188
232
|
onReplyStart: () => {
|
|
233
|
+
deliveredFinalTexts.clear();
|
|
189
234
|
if (streamingEnabled && renderMode === "card") {
|
|
190
235
|
startStreaming();
|
|
191
236
|
}
|
|
@@ -201,15 +246,30 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
201
246
|
: [];
|
|
202
247
|
const hasText = Boolean(text.trim());
|
|
203
248
|
const hasMedia = mediaList.length > 0;
|
|
249
|
+
const skipTextForDuplicateFinal =
|
|
250
|
+
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
251
|
+
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
|
204
252
|
|
|
205
|
-
if (!
|
|
253
|
+
if (!shouldDeliverText && !hasMedia) {
|
|
206
254
|
return;
|
|
207
255
|
}
|
|
208
256
|
|
|
209
|
-
if (
|
|
257
|
+
if (shouldDeliverText) {
|
|
210
258
|
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
211
259
|
|
|
212
|
-
if (
|
|
260
|
+
if (info?.kind === "block") {
|
|
261
|
+
// Drop internal block chunks unless we can safely consume them as
|
|
262
|
+
// streaming-card fallback content.
|
|
263
|
+
if (!(streamingEnabled && useCard)) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
startStreaming();
|
|
267
|
+
if (streamingStartPromise) {
|
|
268
|
+
await streamingStartPromise;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (info?.kind === "final" && streamingEnabled && useCard) {
|
|
213
273
|
startStreaming();
|
|
214
274
|
if (streamingStartPromise) {
|
|
215
275
|
await streamingStartPromise;
|
|
@@ -217,9 +277,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
217
277
|
}
|
|
218
278
|
|
|
219
279
|
if (streaming?.isActive()) {
|
|
280
|
+
if (info?.kind === "block") {
|
|
281
|
+
// Some runtimes emit block payloads without onPartial/final callbacks.
|
|
282
|
+
// Mirror block text into streamText so onIdle close still sends content.
|
|
283
|
+
queueStreamingUpdate(text, { mode: "delta" });
|
|
284
|
+
}
|
|
220
285
|
if (info?.kind === "final") {
|
|
221
|
-
streamText = text;
|
|
286
|
+
streamText = mergeStreamingText(streamText, text);
|
|
222
287
|
await closeStreaming();
|
|
288
|
+
deliveredFinalTexts.add(text);
|
|
223
289
|
}
|
|
224
290
|
// Send media even when streaming handled the text
|
|
225
291
|
if (hasMedia) {
|
|
@@ -229,7 +295,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
229
295
|
to: chatId,
|
|
230
296
|
mediaUrl,
|
|
231
297
|
replyToMessageId: sendReplyToMessageId,
|
|
232
|
-
replyInThread,
|
|
298
|
+
replyInThread: effectiveReplyInThread,
|
|
233
299
|
accountId,
|
|
234
300
|
});
|
|
235
301
|
}
|
|
@@ -249,12 +315,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
249
315
|
to: chatId,
|
|
250
316
|
text: chunk,
|
|
251
317
|
replyToMessageId: sendReplyToMessageId,
|
|
252
|
-
replyInThread,
|
|
318
|
+
replyInThread: effectiveReplyInThread,
|
|
253
319
|
mentions: first ? mentionTargets : undefined,
|
|
254
320
|
accountId,
|
|
255
321
|
});
|
|
256
322
|
first = false;
|
|
257
323
|
}
|
|
324
|
+
if (info?.kind === "final") {
|
|
325
|
+
deliveredFinalTexts.add(text);
|
|
326
|
+
}
|
|
258
327
|
} else {
|
|
259
328
|
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
260
329
|
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
@@ -267,12 +336,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
267
336
|
to: chatId,
|
|
268
337
|
text: chunk,
|
|
269
338
|
replyToMessageId: sendReplyToMessageId,
|
|
270
|
-
replyInThread,
|
|
339
|
+
replyInThread: effectiveReplyInThread,
|
|
271
340
|
mentions: first ? mentionTargets : undefined,
|
|
272
341
|
accountId,
|
|
273
342
|
});
|
|
274
343
|
first = false;
|
|
275
344
|
}
|
|
345
|
+
if (info?.kind === "final") {
|
|
346
|
+
deliveredFinalTexts.add(text);
|
|
347
|
+
}
|
|
276
348
|
}
|
|
277
349
|
}
|
|
278
350
|
|
|
@@ -283,7 +355,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
283
355
|
to: chatId,
|
|
284
356
|
mediaUrl,
|
|
285
357
|
replyToMessageId: sendReplyToMessageId,
|
|
286
|
-
replyInThread,
|
|
358
|
+
replyInThread: effectiveReplyInThread,
|
|
287
359
|
accountId,
|
|
288
360
|
});
|
|
289
361
|
}
|
|
@@ -310,20 +382,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
310
382
|
replyOptions: {
|
|
311
383
|
...replyOptions,
|
|
312
384
|
onModelSelected: prefixContext.onModelSelected,
|
|
385
|
+
disableBlockStreaming: true,
|
|
313
386
|
onPartialReply: streamingEnabled
|
|
314
387
|
? (payload: ReplyPayload) => {
|
|
315
|
-
if (!payload.text
|
|
388
|
+
if (!payload.text) {
|
|
316
389
|
return;
|
|
317
390
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (streamingStartPromise) {
|
|
322
|
-
await streamingStartPromise;
|
|
323
|
-
}
|
|
324
|
-
if (streaming?.isActive()) {
|
|
325
|
-
await streaming.update(streamText);
|
|
326
|
-
}
|
|
391
|
+
queueStreamingUpdate(payload.text, {
|
|
392
|
+
dedupeWithLastPartial: true,
|
|
393
|
+
mode: "snapshot",
|
|
327
394
|
});
|
|
328
395
|
}
|
|
329
396
|
: undefined,
|
package/src/runtime.ts
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSecretInputSchema,
|
|
3
|
+
hasConfiguredSecretInput,
|
|
4
|
+
normalizeResolvedSecretInputString,
|
|
5
|
+
normalizeSecretInputString,
|
|
6
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
buildSecretInputSchema,
|
|
10
|
+
hasConfiguredSecretInput,
|
|
11
|
+
normalizeResolvedSecretInputString,
|
|
12
|
+
normalizeSecretInputString,
|
|
13
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
2
|
+
|
|
3
|
+
type FeishuMessageClient = {
|
|
4
|
+
im: {
|
|
5
|
+
message: {
|
|
6
|
+
reply: (params: {
|
|
7
|
+
path: { message_id: string };
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
|
10
|
+
create: (params: {
|
|
11
|
+
params: { receive_id_type: string };
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function sendFeishuMessageWithOptionalReply(params: {
|
|
19
|
+
client: FeishuMessageClient;
|
|
20
|
+
receiveId: string;
|
|
21
|
+
receiveIdType: string;
|
|
22
|
+
content: string;
|
|
23
|
+
msgType: string;
|
|
24
|
+
replyToMessageId?: string;
|
|
25
|
+
replyInThread?: boolean;
|
|
26
|
+
sendErrorPrefix: string;
|
|
27
|
+
replyErrorPrefix: string;
|
|
28
|
+
fallbackSendErrorPrefix?: string;
|
|
29
|
+
shouldFallbackFromReply?: (response: { code?: number; msg?: string }) => boolean;
|
|
30
|
+
}): Promise<{ messageId: string; chatId: string }> {
|
|
31
|
+
const data = {
|
|
32
|
+
content: params.content,
|
|
33
|
+
msg_type: params.msgType,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (params.replyToMessageId) {
|
|
37
|
+
const response = await params.client.im.message.reply({
|
|
38
|
+
path: { message_id: params.replyToMessageId },
|
|
39
|
+
data: {
|
|
40
|
+
...data,
|
|
41
|
+
...(params.replyInThread ? { reply_in_thread: true } : {}),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (params.shouldFallbackFromReply?.(response)) {
|
|
45
|
+
const fallback = await params.client.im.message.create({
|
|
46
|
+
params: { receive_id_type: params.receiveIdType },
|
|
47
|
+
data: {
|
|
48
|
+
receive_id: params.receiveId,
|
|
49
|
+
...data,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assertFeishuMessageApiSuccess(
|
|
53
|
+
fallback,
|
|
54
|
+
params.fallbackSendErrorPrefix ?? params.sendErrorPrefix,
|
|
55
|
+
);
|
|
56
|
+
return toFeishuSendResult(fallback, params.receiveId);
|
|
57
|
+
}
|
|
58
|
+
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
|
|
59
|
+
return toFeishuSendResult(response, params.receiveId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const response = await params.client.im.message.create({
|
|
63
|
+
params: { receive_id_type: params.receiveIdType },
|
|
64
|
+
data: {
|
|
65
|
+
receive_id: params.receiveId,
|
|
66
|
+
...data,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
assertFeishuMessageApiSuccess(response, params.sendErrorPrefix);
|
|
70
|
+
return toFeishuSendResult(response, params.receiveId);
|
|
71
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
4
|
+
|
|
5
|
+
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("./accounts.js", () => ({
|
|
9
|
+
resolveFeishuAccount: resolveFeishuAccountMock,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./client.js", () => ({
|
|
13
|
+
createFeishuClient: createFeishuClientMock,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("resolveFeishuSendTarget", () => {
|
|
17
|
+
const cfg = {} as ClawdbotConfig;
|
|
18
|
+
const client = { id: "client" };
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resolveFeishuAccountMock.mockReset().mockReturnValue({
|
|
22
|
+
accountId: "default",
|
|
23
|
+
enabled: true,
|
|
24
|
+
configured: true,
|
|
25
|
+
});
|
|
26
|
+
createFeishuClientMock.mockReset().mockReturnValue(client);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => {
|
|
30
|
+
const result = resolveFeishuSendTarget({
|
|
31
|
+
cfg,
|
|
32
|
+
to: "feishu:group:group_room_alpha",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.receiveId).toBe("group_room_alpha");
|
|
36
|
+
expect(result.receiveIdType).toBe("chat_id");
|
|
37
|
+
expect(result.client).toBe(client);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("maps dm-prefixed open IDs to open_id", () => {
|
|
41
|
+
const result = resolveFeishuSendTarget({
|
|
42
|
+
cfg,
|
|
43
|
+
to: "lark:dm:ou_123",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.receiveId).toBe("ou_123");
|
|
47
|
+
expect(result.receiveIdType).toBe("open_id");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("maps dm-prefixed non-open IDs to user_id", () => {
|
|
51
|
+
const result = resolveFeishuSendTarget({
|
|
52
|
+
cfg,
|
|
53
|
+
to: " feishu:dm:user_123 ",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.receiveId).toBe("user_123");
|
|
57
|
+
expect(result.receiveIdType).toBe("user_id");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("throws when target account is not configured", () => {
|
|
61
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
62
|
+
accountId: "default",
|
|
63
|
+
enabled: true,
|
|
64
|
+
configured: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(() =>
|
|
68
|
+
resolveFeishuSendTarget({
|
|
69
|
+
cfg,
|
|
70
|
+
to: "feishu:group:oc_123",
|
|
71
|
+
}),
|
|
72
|
+
).toThrow('Feishu account "default" not configured');
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/send-target.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
3
|
import { createFeishuClient } from "./client.js";
|
|
4
4
|
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
@@ -8,18 +8,22 @@ export function resolveFeishuSendTarget(params: {
|
|
|
8
8
|
to: string;
|
|
9
9
|
accountId?: string;
|
|
10
10
|
}) {
|
|
11
|
+
const target = params.to.trim();
|
|
11
12
|
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
12
13
|
if (!account.configured) {
|
|
13
14
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
14
15
|
}
|
|
15
16
|
const client = createFeishuClient(account);
|
|
16
|
-
const receiveId = normalizeFeishuTarget(
|
|
17
|
+
const receiveId = normalizeFeishuTarget(target);
|
|
17
18
|
if (!receiveId) {
|
|
18
19
|
throw new Error(`Invalid Feishu target: ${params.to}`);
|
|
19
20
|
}
|
|
21
|
+
// Preserve explicit routing prefixes (chat/group/user/dm/open_id) when present.
|
|
22
|
+
// normalizeFeishuTarget strips these prefixes, so infer type from the raw target first.
|
|
23
|
+
const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
|
|
20
24
|
return {
|
|
21
25
|
client,
|
|
22
26
|
receiveId,
|
|
23
|
-
receiveIdType: resolveReceiveIdType(
|
|
27
|
+
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
|
|
24
28
|
};
|
|
25
29
|
}
|