@openclaw/feishu 2026.3.2 → 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 +199 -13
- package/src/accounts.ts +45 -17
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +8 -0
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +516 -9
- package/src/bot.ts +366 -109
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +52 -64
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +207 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +14 -6
- package/src/config-schema.ts +5 -1
- package/src/dedup.ts +1 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +3 -3
- 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 +60 -13
- package/src/media.ts +23 -9
- package/src/monitor.account.ts +19 -8
- package/src/monitor.reaction.test.ts +111 -105
- package/src/monitor.startup.test.ts +11 -10
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.ts +4 -1
- package/src/monitor.test-mocks.ts +42 -9
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +8 -23
- package/src/onboarding.status.test.ts +1 -1
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +86 -71
- 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 +18 -18
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +175 -0
- package/src/reply-dispatcher.ts +69 -21
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +8 -14
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +1 -1
- package/src/send-target.ts +1 -1
- 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.ts +5 -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 +2 -3
- 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",
|
|
@@ -202,6 +241,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
202
241
|
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
|
203
242
|
});
|
|
204
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
|
+
|
|
205
255
|
it("uses streaming session for auto mode markdown payloads", async () => {
|
|
206
256
|
createFeishuReplyDispatcher({
|
|
207
257
|
cfg: {} as never,
|
|
@@ -226,6 +276,131 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
226
276
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
227
277
|
});
|
|
228
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
|
+
|
|
229
404
|
it("sends media-only payloads as attachments", async () => {
|
|
230
405
|
createFeishuReplyDispatcher({
|
|
231
406
|
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
|
|
|
@@ -143,8 +143,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
143
143
|
let streaming: FeishuStreamingSession | null = null;
|
|
144
144
|
let streamText = "";
|
|
145
145
|
let lastPartial = "";
|
|
146
|
+
const deliveredFinalTexts = new Set<string>();
|
|
146
147
|
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
147
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
|
+
};
|
|
148
179
|
|
|
149
180
|
const startStreaming = () => {
|
|
150
181
|
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
@@ -199,18 +230,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
199
230
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
200
231
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
201
232
|
onReplyStart: () => {
|
|
233
|
+
deliveredFinalTexts.clear();
|
|
202
234
|
if (streamingEnabled && renderMode === "card") {
|
|
203
235
|
startStreaming();
|
|
204
236
|
}
|
|
205
237
|
void typingCallbacks.onReplyStart?.();
|
|
206
238
|
},
|
|
207
239
|
deliver: async (payload: ReplyPayload, info) => {
|
|
208
|
-
// FIX: Filter out internal 'block' reasoning chunks immediately to prevent
|
|
209
|
-
// data leak and race conditions with streaming state initialization.
|
|
210
|
-
if (info?.kind === "block") {
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
240
|
const text = payload.text ?? "";
|
|
215
241
|
const mediaList =
|
|
216
242
|
payload.mediaUrls && payload.mediaUrls.length > 0
|
|
@@ -220,14 +246,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
220
246
|
: [];
|
|
221
247
|
const hasText = Boolean(text.trim());
|
|
222
248
|
const hasMedia = mediaList.length > 0;
|
|
249
|
+
const skipTextForDuplicateFinal =
|
|
250
|
+
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
251
|
+
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
|
223
252
|
|
|
224
|
-
if (!
|
|
253
|
+
if (!shouldDeliverText && !hasMedia) {
|
|
225
254
|
return;
|
|
226
255
|
}
|
|
227
256
|
|
|
228
|
-
if (
|
|
257
|
+
if (shouldDeliverText) {
|
|
229
258
|
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
230
259
|
|
|
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
|
+
|
|
231
272
|
if (info?.kind === "final" && streamingEnabled && useCard) {
|
|
232
273
|
startStreaming();
|
|
233
274
|
if (streamingStartPromise) {
|
|
@@ -236,9 +277,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
236
277
|
}
|
|
237
278
|
|
|
238
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
|
+
}
|
|
239
285
|
if (info?.kind === "final") {
|
|
240
|
-
streamText = text;
|
|
286
|
+
streamText = mergeStreamingText(streamText, text);
|
|
241
287
|
await closeStreaming();
|
|
288
|
+
deliveredFinalTexts.add(text);
|
|
242
289
|
}
|
|
243
290
|
// Send media even when streaming handled the text
|
|
244
291
|
if (hasMedia) {
|
|
@@ -274,6 +321,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
274
321
|
});
|
|
275
322
|
first = false;
|
|
276
323
|
}
|
|
324
|
+
if (info?.kind === "final") {
|
|
325
|
+
deliveredFinalTexts.add(text);
|
|
326
|
+
}
|
|
277
327
|
} else {
|
|
278
328
|
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
279
329
|
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
@@ -292,6 +342,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
292
342
|
});
|
|
293
343
|
first = false;
|
|
294
344
|
}
|
|
345
|
+
if (info?.kind === "final") {
|
|
346
|
+
deliveredFinalTexts.add(text);
|
|
347
|
+
}
|
|
295
348
|
}
|
|
296
349
|
}
|
|
297
350
|
|
|
@@ -329,20 +382,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
329
382
|
replyOptions: {
|
|
330
383
|
...replyOptions,
|
|
331
384
|
onModelSelected: prefixContext.onModelSelected,
|
|
385
|
+
disableBlockStreaming: true,
|
|
332
386
|
onPartialReply: streamingEnabled
|
|
333
387
|
? (payload: ReplyPayload) => {
|
|
334
|
-
if (!payload.text
|
|
388
|
+
if (!payload.text) {
|
|
335
389
|
return;
|
|
336
390
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (streamingStartPromise) {
|
|
341
|
-
await streamingStartPromise;
|
|
342
|
-
}
|
|
343
|
-
if (streaming?.isActive()) {
|
|
344
|
-
await streaming.update(streamText);
|
|
345
|
-
}
|
|
391
|
+
queueStreamingUpdate(payload.text, {
|
|
392
|
+
dedupeWithLastPartial: true,
|
|
393
|
+
mode: "snapshot",
|
|
346
394
|
});
|
|
347
395
|
}
|
|
348
396
|
: undefined,
|
package/src/runtime.ts
CHANGED
package/src/secret-input.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
buildSecretInputSchema,
|
|
2
3
|
hasConfiguredSecretInput,
|
|
3
4
|
normalizeResolvedSecretInputString,
|
|
4
5
|
normalizeSecretInputString,
|
|
5
|
-
} from "openclaw/plugin-sdk";
|
|
6
|
-
import { z } from "zod";
|
|
6
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
7
7
|
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
source: z.enum(["env", "file", "exec"]),
|
|
15
|
-
provider: z.string().min(1),
|
|
16
|
-
id: z.string().min(1),
|
|
17
|
-
}),
|
|
18
|
-
]);
|
|
19
|
-
}
|
|
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
|
+
}
|
package/src/send-target.test.ts
CHANGED
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";
|
|
@@ -102,4 +102,78 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
|
|
102
102
|
|
|
103
103
|
expect(createMock).not.toHaveBeenCalled();
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
it("falls back to create when reply throws a withdrawn SDK error", async () => {
|
|
107
|
+
const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
|
|
108
|
+
replyMock.mockRejectedValue(sdkError);
|
|
109
|
+
createMock.mockResolvedValue({
|
|
110
|
+
code: 0,
|
|
111
|
+
data: { message_id: "om_thrown_fallback" },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await sendMessageFeishu({
|
|
115
|
+
cfg: {} as never,
|
|
116
|
+
to: "user:ou_target",
|
|
117
|
+
text: "hello",
|
|
118
|
+
replyToMessageId: "om_parent",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(replyMock).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(createMock).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(result.messageId).toBe("om_thrown_fallback");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("falls back to create when card reply throws a not-found AxiosError", async () => {
|
|
127
|
+
const axiosError = Object.assign(new Error("Request failed"), {
|
|
128
|
+
response: { status: 200, data: { code: 231003, msg: "The message is not found" } },
|
|
129
|
+
});
|
|
130
|
+
replyMock.mockRejectedValue(axiosError);
|
|
131
|
+
createMock.mockResolvedValue({
|
|
132
|
+
code: 0,
|
|
133
|
+
data: { message_id: "om_axios_fallback" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await sendCardFeishu({
|
|
137
|
+
cfg: {} as never,
|
|
138
|
+
to: "user:ou_target",
|
|
139
|
+
card: { schema: "2.0" },
|
|
140
|
+
replyToMessageId: "om_parent",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(replyMock).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(createMock).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(result.messageId).toBe("om_axios_fallback");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("re-throws non-withdrawn thrown errors for text messages", async () => {
|
|
149
|
+
const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 });
|
|
150
|
+
replyMock.mockRejectedValue(sdkError);
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
sendMessageFeishu({
|
|
154
|
+
cfg: {} as never,
|
|
155
|
+
to: "user:ou_target",
|
|
156
|
+
text: "hello",
|
|
157
|
+
replyToMessageId: "om_parent",
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow("rate limited");
|
|
160
|
+
|
|
161
|
+
expect(createMock).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("re-throws non-withdrawn thrown errors for card messages", async () => {
|
|
165
|
+
const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 });
|
|
166
|
+
replyMock.mockRejectedValue(sdkError);
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
sendCardFeishu({
|
|
170
|
+
cfg: {} as never,
|
|
171
|
+
to: "user:ou_target",
|
|
172
|
+
card: { schema: "2.0" },
|
|
173
|
+
replyToMessageId: "om_parent",
|
|
174
|
+
}),
|
|
175
|
+
).rejects.toThrow("permission denied");
|
|
176
|
+
|
|
177
|
+
expect(createMock).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
105
179
|
});
|
package/src/send.test.ts
CHANGED