@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1
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/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +115 -22
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +798 -786
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +77 -25
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +76 -35
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +413 -87
- package/src/media.ts +488 -154
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +220 -313
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +194 -92
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +24 -36
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +297 -39
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +272 -0
- package/src/monitor.webhook-security.test.ts +125 -91
- package/src/monitor.webhook.test-helpers.ts +116 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +627 -53
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +122 -118
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +23 -60
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +721 -168
- package/src/reply-dispatcher.ts +422 -172
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +127 -42
- package/src/send.test.ts +386 -4
- package/src/send.ts +486 -164
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
|
|
4
1
|
import {
|
|
5
2
|
createInboundDebouncer,
|
|
6
3
|
resolveInboundDebounceMs,
|
|
7
|
-
} from "
|
|
8
|
-
import {
|
|
4
|
+
} from "openclaw/plugin-sdk/channel-inbound-debounce";
|
|
5
|
+
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
6
|
+
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
|
9
9
|
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
|
10
10
|
import * as dedup from "./dedup.js";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
monitorSingleAccount,
|
|
13
|
+
resolveReactionSyntheticEvent,
|
|
14
|
+
type FeishuReactionCreatedEvent,
|
|
15
|
+
} from "./monitor.account.js";
|
|
13
16
|
import { setFeishuRuntime } from "./runtime.js";
|
|
14
17
|
import type { ResolvedFeishuAccount } from "./types.js";
|
|
15
18
|
|
|
@@ -17,6 +20,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
|
|
|
17
20
|
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
|
18
21
|
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
19
22
|
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
23
|
+
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
|
|
20
24
|
|
|
21
25
|
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
|
22
26
|
|
|
@@ -37,6 +41,10 @@ vi.mock("./monitor.transport.js", () => ({
|
|
|
37
41
|
monitorWebhook: monitorWebhookMock,
|
|
38
42
|
}));
|
|
39
43
|
|
|
44
|
+
vi.mock("./thread-bindings.js", () => ({
|
|
45
|
+
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
|
|
46
|
+
}));
|
|
47
|
+
|
|
40
48
|
const cfg = {} as ClawdbotConfig;
|
|
41
49
|
|
|
42
50
|
function makeReactionEvent(
|
|
@@ -78,6 +86,25 @@ async function resolveReactionWithLookup(params: {
|
|
|
78
86
|
});
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
|
|
90
|
+
return await resolveReactionSyntheticEvent({
|
|
91
|
+
cfg: params?.cfg ?? cfg,
|
|
92
|
+
accountId: "default",
|
|
93
|
+
event: makeReactionEvent(),
|
|
94
|
+
botOpenId: "ou_bot",
|
|
95
|
+
fetchMessage: async () => ({
|
|
96
|
+
messageId: "om_msg1",
|
|
97
|
+
chatId: "oc_group",
|
|
98
|
+
chatType: "group",
|
|
99
|
+
senderOpenId: "ou_other",
|
|
100
|
+
senderType: "user",
|
|
101
|
+
content: "hello",
|
|
102
|
+
contentType: "text",
|
|
103
|
+
}),
|
|
104
|
+
...(params?.uuid ? { uuid: params.uuid } : {}),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
|
82
109
|
|
|
83
110
|
function buildDebounceConfig(): ClawdbotConfig {
|
|
@@ -148,11 +175,7 @@ async function setupDebounceMonitor(params?: {
|
|
|
148
175
|
await monitorSingleAccount({
|
|
149
176
|
cfg: buildDebounceConfig(),
|
|
150
177
|
account: buildDebounceAccount(),
|
|
151
|
-
runtime:
|
|
152
|
-
log: vi.fn(),
|
|
153
|
-
error: vi.fn(),
|
|
154
|
-
exit: vi.fn(),
|
|
155
|
-
} as RuntimeEnv,
|
|
178
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
156
179
|
botOpenIdSource: {
|
|
157
180
|
kind: "prefetched",
|
|
158
181
|
botOpenId: params?.botOpenId ?? "ou_bot",
|
|
@@ -179,11 +202,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|
|
179
202
|
return firstParams.event;
|
|
180
203
|
}
|
|
181
204
|
|
|
205
|
+
function expectSingleDispatchedEvent(): FeishuMessageEvent {
|
|
206
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
207
|
+
return getFirstDispatchedEvent();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
|
|
211
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
212
|
+
return {
|
|
213
|
+
dispatched,
|
|
214
|
+
parsed: parseFeishuMessageEvent(dispatched, botOpenId),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
182
218
|
function setDedupPassThroughMocks(): void {
|
|
183
|
-
vi.spyOn(dedup, "
|
|
184
|
-
vi.spyOn(dedup, "
|
|
185
|
-
vi.spyOn(dedup, "
|
|
186
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
219
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
220
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
221
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
|
|
@@ -194,6 +229,24 @@ function createMention(params: { openId: string; name: string; key?: string }):
|
|
|
194
229
|
};
|
|
195
230
|
}
|
|
196
231
|
|
|
232
|
+
function createFeishuMonitorRuntime(params?: {
|
|
233
|
+
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
|
|
234
|
+
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
|
|
235
|
+
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
|
|
236
|
+
}): PluginRuntime {
|
|
237
|
+
return {
|
|
238
|
+
channel: {
|
|
239
|
+
debounce: {
|
|
240
|
+
createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
|
|
241
|
+
resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
|
|
242
|
+
},
|
|
243
|
+
text: {
|
|
244
|
+
hasControlCommand: params?.hasControlCommand ?? hasControlCommand,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
} as unknown as PluginRuntime;
|
|
248
|
+
}
|
|
249
|
+
|
|
197
250
|
async function enqueueDebouncedMessage(
|
|
198
251
|
onMessage: (data: unknown) => Promise<void>,
|
|
199
252
|
event: FeishuMessageEvent,
|
|
@@ -203,6 +256,12 @@ async function enqueueDebouncedMessage(
|
|
|
203
256
|
await Promise.resolve();
|
|
204
257
|
}
|
|
205
258
|
|
|
259
|
+
function setStaleRetryMocks(messageId = "om_old") {
|
|
260
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
|
|
261
|
+
async (currentMessageId) => currentMessageId === messageId,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
206
265
|
describe("resolveReactionSyntheticEvent", () => {
|
|
207
266
|
it("filters app self-reactions", async () => {
|
|
208
267
|
const event = makeReactionEvent({ operator_type: "app" });
|
|
@@ -262,28 +321,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
262
321
|
});
|
|
263
322
|
|
|
264
323
|
it("filters reactions on non-bot messages", async () => {
|
|
265
|
-
const
|
|
266
|
-
const result = await resolveReactionSyntheticEvent({
|
|
267
|
-
cfg,
|
|
268
|
-
accountId: "default",
|
|
269
|
-
event,
|
|
270
|
-
botOpenId: "ou_bot",
|
|
271
|
-
fetchMessage: async () => ({
|
|
272
|
-
messageId: "om_msg1",
|
|
273
|
-
chatId: "oc_group",
|
|
274
|
-
chatType: "group",
|
|
275
|
-
senderOpenId: "ou_other",
|
|
276
|
-
senderType: "user",
|
|
277
|
-
content: "hello",
|
|
278
|
-
contentType: "text",
|
|
279
|
-
}),
|
|
280
|
-
});
|
|
324
|
+
const result = await resolveNonBotReaction();
|
|
281
325
|
expect(result).toBeNull();
|
|
282
326
|
});
|
|
283
327
|
|
|
284
328
|
it("allows non-bot reactions when reactionNotifications is all", async () => {
|
|
285
|
-
const
|
|
286
|
-
const result = await resolveReactionSyntheticEvent({
|
|
329
|
+
const result = await resolveNonBotReaction({
|
|
287
330
|
cfg: {
|
|
288
331
|
channels: {
|
|
289
332
|
feishu: {
|
|
@@ -291,18 +334,6 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
291
334
|
},
|
|
292
335
|
},
|
|
293
336
|
} as ClawdbotConfig,
|
|
294
|
-
accountId: "default",
|
|
295
|
-
event,
|
|
296
|
-
botOpenId: "ou_bot",
|
|
297
|
-
fetchMessage: async () => ({
|
|
298
|
-
messageId: "om_msg1",
|
|
299
|
-
chatId: "oc_group",
|
|
300
|
-
chatType: "group",
|
|
301
|
-
senderOpenId: "ou_other",
|
|
302
|
-
senderType: "user",
|
|
303
|
-
content: "hello",
|
|
304
|
-
contentType: "text",
|
|
305
|
-
}),
|
|
306
337
|
uuid: () => "fixed-uuid",
|
|
307
338
|
});
|
|
308
339
|
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
|
|
@@ -410,24 +441,68 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
410
441
|
});
|
|
411
442
|
});
|
|
412
443
|
|
|
444
|
+
describe("monitorSingleAccount lifecycle", () => {
|
|
445
|
+
beforeEach(() => {
|
|
446
|
+
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
|
|
447
|
+
stop: vi.fn(),
|
|
448
|
+
}));
|
|
449
|
+
createEventDispatcherMock.mockReset().mockReturnValue({
|
|
450
|
+
register: vi.fn(),
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("stops the Feishu thread binding manager when the monitor exits", async () => {
|
|
455
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
456
|
+
|
|
457
|
+
await monitorSingleAccount({
|
|
458
|
+
cfg: buildDebounceConfig(),
|
|
459
|
+
account: buildDebounceAccount(),
|
|
460
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
461
|
+
botOpenIdSource: {
|
|
462
|
+
kind: "prefetched",
|
|
463
|
+
botOpenId: "ou_bot",
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
|
468
|
+
| { stop: ReturnType<typeof vi.fn> }
|
|
469
|
+
| undefined;
|
|
470
|
+
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
|
|
474
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
475
|
+
createEventDispatcherMock.mockReturnValue({
|
|
476
|
+
get register() {
|
|
477
|
+
throw new Error("register failed");
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await expect(
|
|
482
|
+
monitorSingleAccount({
|
|
483
|
+
cfg: buildDebounceConfig(),
|
|
484
|
+
account: buildDebounceAccount(),
|
|
485
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
486
|
+
botOpenIdSource: {
|
|
487
|
+
kind: "prefetched",
|
|
488
|
+
botOpenId: "ou_bot",
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
).rejects.toThrow("register failed");
|
|
492
|
+
|
|
493
|
+
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
|
494
|
+
| { stop: ReturnType<typeof vi.fn> }
|
|
495
|
+
| undefined;
|
|
496
|
+
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
413
500
|
describe("Feishu inbound debounce regressions", () => {
|
|
414
501
|
beforeEach(() => {
|
|
415
502
|
vi.useFakeTimers();
|
|
416
503
|
handlers = {};
|
|
417
504
|
handleFeishuMessageMock.mockClear();
|
|
418
|
-
setFeishuRuntime(
|
|
419
|
-
createPluginRuntimeMock({
|
|
420
|
-
channel: {
|
|
421
|
-
debounce: {
|
|
422
|
-
createInboundDebouncer,
|
|
423
|
-
resolveInboundDebounceMs,
|
|
424
|
-
},
|
|
425
|
-
text: {
|
|
426
|
-
hasControlCommand,
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
}),
|
|
430
|
-
);
|
|
505
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
431
506
|
});
|
|
432
507
|
|
|
433
508
|
afterEach(() => {
|
|
@@ -457,18 +532,16 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
457
532
|
);
|
|
458
533
|
await vi.advanceTimersByTimeAsync(25);
|
|
459
534
|
|
|
460
|
-
|
|
461
|
-
const dispatched = getFirstDispatchedEvent();
|
|
535
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
462
536
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
463
537
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
464
538
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
|
465
539
|
});
|
|
466
540
|
|
|
467
541
|
it("passes prefetched botName through to handleFeishuMessage", async () => {
|
|
468
|
-
vi.spyOn(dedup, "
|
|
469
|
-
vi.spyOn(dedup, "
|
|
470
|
-
vi.spyOn(dedup, "
|
|
471
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
542
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
543
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
544
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
|
|
472
545
|
const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
|
|
473
546
|
|
|
474
547
|
await onMessage(
|
|
@@ -517,9 +590,7 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
517
590
|
);
|
|
518
591
|
await vi.advanceTimersByTimeAsync(25);
|
|
519
592
|
|
|
520
|
-
|
|
521
|
-
const dispatched = getFirstDispatchedEvent();
|
|
522
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
593
|
+
const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
|
|
523
594
|
expect(parsed.mentionedBot).toBe(true);
|
|
524
595
|
expect(parsed.mentionTargets).toBeUndefined();
|
|
525
596
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
@@ -547,19 +618,14 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
547
618
|
);
|
|
548
619
|
await vi.advanceTimersByTimeAsync(25);
|
|
549
620
|
|
|
550
|
-
|
|
551
|
-
const dispatched = getFirstDispatchedEvent();
|
|
552
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
621
|
+
const { parsed } = expectParsedFirstDispatchedEvent();
|
|
553
622
|
expect(parsed.mentionedBot).toBe(true);
|
|
554
623
|
});
|
|
555
624
|
|
|
556
625
|
it("excludes previously processed retries from combined debounce text", async () => {
|
|
557
|
-
vi.spyOn(dedup, "
|
|
558
|
-
vi.spyOn(dedup, "
|
|
559
|
-
|
|
560
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
561
|
-
async (messageId) => messageId === "om_old",
|
|
562
|
-
);
|
|
626
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
627
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
628
|
+
setStaleRetryMocks();
|
|
563
629
|
const onMessage = await setupDebounceMonitor();
|
|
564
630
|
|
|
565
631
|
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
@@ -576,36 +642,72 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
576
642
|
await Promise.resolve();
|
|
577
643
|
await vi.advanceTimersByTimeAsync(25);
|
|
578
644
|
|
|
579
|
-
|
|
580
|
-
const dispatched = getFirstDispatchedEvent();
|
|
645
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
581
646
|
expect(dispatched.message.message_id).toBe("om_new_2");
|
|
582
647
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
583
648
|
expect(combined.text).toBe("first\nsecond");
|
|
584
649
|
});
|
|
585
650
|
|
|
586
651
|
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
|
587
|
-
|
|
588
|
-
vi.spyOn(dedup, "
|
|
589
|
-
|
|
590
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
591
|
-
async (messageId) => messageId === "om_old",
|
|
592
|
-
);
|
|
652
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
653
|
+
const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
654
|
+
setStaleRetryMocks("om_old_latest_fresh");
|
|
593
655
|
const onMessage = await setupDebounceMonitor();
|
|
594
656
|
|
|
595
|
-
await onMessage(createTextEvent({ messageId: "
|
|
657
|
+
await onMessage(createTextEvent({ messageId: "om_new_latest_fresh", text: "fresh" }));
|
|
596
658
|
await Promise.resolve();
|
|
597
659
|
await Promise.resolve();
|
|
598
|
-
await onMessage(createTextEvent({ messageId: "
|
|
660
|
+
await onMessage(createTextEvent({ messageId: "om_old_latest_fresh", text: "stale" }));
|
|
599
661
|
await Promise.resolve();
|
|
600
662
|
await Promise.resolve();
|
|
601
663
|
await vi.advanceTimersByTimeAsync(25);
|
|
602
664
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
expect(dispatched.message.message_id).toBe("om_new");
|
|
665
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
666
|
+
expect(dispatched.message.message_id).toBe("om_new_latest_fresh");
|
|
606
667
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
607
668
|
expect(combined.text).toBe("fresh");
|
|
608
|
-
expect(recordSpy).toHaveBeenCalledWith("default
|
|
609
|
-
expect(recordSpy).not.toHaveBeenCalledWith(
|
|
669
|
+
expect(recordSpy).toHaveBeenCalledWith("om_old_latest_fresh", "default", expect.any(Function));
|
|
670
|
+
expect(recordSpy).not.toHaveBeenCalledWith(
|
|
671
|
+
"om_new_latest_fresh",
|
|
672
|
+
"default",
|
|
673
|
+
expect.any(Function),
|
|
674
|
+
);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("releases early event dedupe when debounced dispatch fails", async () => {
|
|
678
|
+
setDedupPassThroughMocks();
|
|
679
|
+
const enqueueMock = vi.fn();
|
|
680
|
+
setFeishuRuntime(
|
|
681
|
+
createFeishuMonitorRuntime({
|
|
682
|
+
createInboundDebouncer: <T>(params: { onError?: (err: unknown, items: T[]) => void }) => ({
|
|
683
|
+
enqueue: async (item: T) => {
|
|
684
|
+
enqueueMock(item);
|
|
685
|
+
params.onError?.(new Error("dispatch failed"), [item]);
|
|
686
|
+
},
|
|
687
|
+
flushKey: async () => {},
|
|
688
|
+
}),
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
const onMessage = await setupDebounceMonitor();
|
|
692
|
+
const event = createTextEvent({ messageId: "om_retryable", text: "hello" });
|
|
693
|
+
|
|
694
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
695
|
+
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
696
|
+
|
|
697
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
698
|
+
expect(enqueueMock).toHaveBeenCalledTimes(2);
|
|
699
|
+
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("drops duplicate inbound events before they re-enter the debounce pipeline", async () => {
|
|
703
|
+
const onMessage = await setupDebounceMonitor();
|
|
704
|
+
const event = createTextEvent({ messageId: "om_duplicate", text: "hello" });
|
|
705
|
+
|
|
706
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
707
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
708
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
709
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
710
|
+
|
|
711
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
610
712
|
});
|
|
611
713
|
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import "./lifecycle.test-support.js";
|
|
4
|
+
import {
|
|
5
|
+
getFeishuLifecycleTestMocks,
|
|
6
|
+
resetFeishuLifecycleTestMocks,
|
|
7
|
+
} from "./lifecycle.test-support.js";
|
|
8
|
+
import {
|
|
9
|
+
createFeishuLifecycleConfig,
|
|
10
|
+
createFeishuLifecycleReplyDispatcher,
|
|
11
|
+
createFeishuTextMessageEvent,
|
|
12
|
+
expectFeishuReplyDispatcherSentFinalReplyOnce,
|
|
13
|
+
expectFeishuReplyPipelineDedupedAcrossReplay,
|
|
14
|
+
expectFeishuReplyPipelineDedupedAfterPostSendFailure,
|
|
15
|
+
installFeishuLifecycleReplyRuntime,
|
|
16
|
+
mockFeishuReplyOnceDispatch,
|
|
17
|
+
restoreFeishuLifecycleStateDir,
|
|
18
|
+
setFeishuLifecycleStateDir,
|
|
19
|
+
setupFeishuMessageReceiveLifecycleHandler,
|
|
20
|
+
} from "./test-support/lifecycle-test-support.js";
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
createFeishuReplyDispatcherMock,
|
|
24
|
+
dispatchReplyFromConfigMock,
|
|
25
|
+
finalizeInboundContextMock,
|
|
26
|
+
resolveAgentRouteMock,
|
|
27
|
+
withReplyDispatcherMock,
|
|
28
|
+
} = getFeishuLifecycleTestMocks();
|
|
29
|
+
|
|
30
|
+
let lastRuntime = createRuntimeEnv();
|
|
31
|
+
let lifecycleCore: ReturnType<typeof installFeishuLifecycleReplyRuntime>;
|
|
32
|
+
const handleMessageMock = vi.fn();
|
|
33
|
+
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
34
|
+
const lifecycleConfig = createFeishuLifecycleConfig({
|
|
35
|
+
accountId: "acct-lifecycle",
|
|
36
|
+
appId: "cli_test",
|
|
37
|
+
appSecret: "secret_test",
|
|
38
|
+
accountConfig: {
|
|
39
|
+
groupPolicy: "open",
|
|
40
|
+
groups: {
|
|
41
|
+
oc_group_1: {
|
|
42
|
+
requireMention: false,
|
|
43
|
+
groupSessionScope: "group_topic_sender",
|
|
44
|
+
replyInThread: "enabled",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function setupLifecycleMonitor() {
|
|
51
|
+
lastRuntime = createRuntimeEnv();
|
|
52
|
+
return setupFeishuMessageReceiveLifecycleHandler({
|
|
53
|
+
runtime: lastRuntime,
|
|
54
|
+
core: lifecycleCore,
|
|
55
|
+
cfg: lifecycleConfig,
|
|
56
|
+
accountId: "acct-lifecycle",
|
|
57
|
+
handleMessage: handleMessageMock,
|
|
58
|
+
resolveDebounceText: ({ event }) => {
|
|
59
|
+
const parsed = JSON.parse(event.message.content) as { text?: string };
|
|
60
|
+
return parsed.text ?? "";
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("Feishu reply-once lifecycle", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.useRealTimers();
|
|
68
|
+
resetFeishuLifecycleTestMocks();
|
|
69
|
+
handleMessageMock.mockReset();
|
|
70
|
+
lastRuntime = createRuntimeEnv();
|
|
71
|
+
setFeishuLifecycleStateDir("openclaw-feishu-lifecycle");
|
|
72
|
+
|
|
73
|
+
createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
|
|
74
|
+
|
|
75
|
+
resolveAgentRouteMock.mockReturnValue({
|
|
76
|
+
agentId: "main",
|
|
77
|
+
channel: "feishu",
|
|
78
|
+
accountId: "acct-lifecycle",
|
|
79
|
+
sessionKey: "agent:main:feishu:group:oc_group_1",
|
|
80
|
+
mainSessionKey: "agent:main:main",
|
|
81
|
+
matchedBy: "default",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
mockFeishuReplyOnceDispatch({
|
|
85
|
+
dispatchReplyFromConfigMock,
|
|
86
|
+
replyText: "reply once",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
|
|
90
|
+
handleMessageMock.mockImplementation(async ({ event }) => {
|
|
91
|
+
const reply = createFeishuReplyDispatcherMock({
|
|
92
|
+
accountId: "acct-lifecycle",
|
|
93
|
+
chatId: event.message.chat_id,
|
|
94
|
+
replyToMessageId: event.message.root_id ?? event.message.message_id,
|
|
95
|
+
replyInThread: true,
|
|
96
|
+
rootId: event.message.root_id,
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await withReplyDispatcherMock({
|
|
100
|
+
dispatcher: reply.dispatcher,
|
|
101
|
+
onSettled: () => reply.markDispatchIdle(),
|
|
102
|
+
run: () =>
|
|
103
|
+
dispatchReplyFromConfigMock({
|
|
104
|
+
ctx: {
|
|
105
|
+
AccountId: "acct-lifecycle",
|
|
106
|
+
MessageSid: event.message.message_id,
|
|
107
|
+
},
|
|
108
|
+
dispatcher: reply.dispatcher,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
lastRuntime?.error(`feishu[acct-lifecycle]: failed to dispatch message: ${String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
lifecycleCore = installFeishuLifecycleReplyRuntime({
|
|
117
|
+
resolveAgentRouteMock,
|
|
118
|
+
finalizeInboundContextMock,
|
|
119
|
+
dispatchReplyFromConfigMock,
|
|
120
|
+
withReplyDispatcherMock,
|
|
121
|
+
storePath: "/tmp/feishu-lifecycle-sessions.json",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
restoreFeishuLifecycleStateDir(originalStateDir);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("routes a topic-bound inbound event and emits one reply across duplicate replay", async () => {
|
|
131
|
+
const onMessage = await setupLifecycleMonitor();
|
|
132
|
+
const event = createFeishuTextMessageEvent({
|
|
133
|
+
messageId: "om_lifecycle_once",
|
|
134
|
+
chatId: "oc_group_1",
|
|
135
|
+
rootId: "om_root_topic_1",
|
|
136
|
+
threadId: "omt_topic_1",
|
|
137
|
+
text: "hello from topic",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await expectFeishuReplyPipelineDedupedAcrossReplay({
|
|
141
|
+
handler: onMessage,
|
|
142
|
+
event,
|
|
143
|
+
dispatchReplyFromConfigMock,
|
|
144
|
+
createFeishuReplyDispatcherMock,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(lastRuntime?.error).not.toHaveBeenCalled();
|
|
148
|
+
expect(handleMessageMock).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
accountId: "acct-lifecycle",
|
|
154
|
+
chatId: "oc_group_1",
|
|
155
|
+
replyToMessageId: "om_root_topic_1",
|
|
156
|
+
replyInThread: true,
|
|
157
|
+
rootId: "om_root_topic_1",
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does not duplicate delivery when the first attempt fails after sending the reply", async () => {
|
|
164
|
+
const onMessage = await setupLifecycleMonitor();
|
|
165
|
+
const event = createFeishuTextMessageEvent({
|
|
166
|
+
messageId: "om_lifecycle_retry",
|
|
167
|
+
chatId: "oc_group_1",
|
|
168
|
+
rootId: "om_root_topic_1",
|
|
169
|
+
threadId: "omt_topic_1",
|
|
170
|
+
text: "hello from topic",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
|
|
174
|
+
await dispatcher.sendFinalReply({ text: "reply once" });
|
|
175
|
+
throw new Error("post-send failure");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
|
|
179
|
+
handler: onMessage,
|
|
180
|
+
event,
|
|
181
|
+
dispatchReplyFromConfigMock,
|
|
182
|
+
runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(handleMessageMock).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
|
188
|
+
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
|
|
189
|
+
});
|
|
190
|
+
});
|