@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
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
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
|
+
import {
|
|
5
|
+
createInboundDebouncer,
|
|
6
|
+
resolveInboundDebounceMs,
|
|
7
|
+
} from "../../../src/auto-reply/inbound-debounce.js";
|
|
8
|
+
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
9
|
+
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
|
10
|
+
import * as dedup from "./dedup.js";
|
|
11
|
+
import { monitorSingleAccount } from "./monitor.account.js";
|
|
3
12
|
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
|
|
13
|
+
import { setFeishuRuntime } from "./runtime.js";
|
|
14
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
|
|
17
|
+
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
|
18
|
+
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
19
|
+
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
20
|
+
|
|
21
|
+
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
|
22
|
+
|
|
23
|
+
vi.mock("./client.js", () => ({
|
|
24
|
+
createEventDispatcher: createEventDispatcherMock,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("./bot.js", async () => {
|
|
28
|
+
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
|
|
29
|
+
return {
|
|
30
|
+
...actual,
|
|
31
|
+
handleFeishuMessage: handleFeishuMessageMock,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock("./monitor.transport.js", () => ({
|
|
36
|
+
monitorWebSocket: monitorWebSocketMock,
|
|
37
|
+
monitorWebhook: monitorWebhookMock,
|
|
38
|
+
}));
|
|
4
39
|
|
|
5
40
|
const cfg = {} as ClawdbotConfig;
|
|
6
41
|
|
|
@@ -16,6 +51,155 @@ function makeReactionEvent(
|
|
|
16
51
|
};
|
|
17
52
|
}
|
|
18
53
|
|
|
54
|
+
function createFetchedReactionMessage(chatId: string) {
|
|
55
|
+
return {
|
|
56
|
+
messageId: "om_msg1",
|
|
57
|
+
chatId,
|
|
58
|
+
senderOpenId: "ou_bot",
|
|
59
|
+
content: "hello",
|
|
60
|
+
contentType: "text",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveReactionWithLookup(params: {
|
|
65
|
+
event?: FeishuReactionCreatedEvent;
|
|
66
|
+
lookupChatId: string;
|
|
67
|
+
}) {
|
|
68
|
+
return await resolveReactionSyntheticEvent({
|
|
69
|
+
cfg,
|
|
70
|
+
accountId: "default",
|
|
71
|
+
event: params.event ?? makeReactionEvent(),
|
|
72
|
+
botOpenId: "ou_bot",
|
|
73
|
+
fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId),
|
|
74
|
+
uuid: () => "fixed-uuid",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
|
79
|
+
|
|
80
|
+
function buildDebounceConfig(): ClawdbotConfig {
|
|
81
|
+
return {
|
|
82
|
+
messages: {
|
|
83
|
+
inbound: {
|
|
84
|
+
debounceMs: 0,
|
|
85
|
+
byChannel: {
|
|
86
|
+
feishu: 20,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
channels: {
|
|
91
|
+
feishu: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
} as ClawdbotConfig;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildDebounceAccount(): ResolvedFeishuAccount {
|
|
99
|
+
return {
|
|
100
|
+
accountId: "default",
|
|
101
|
+
enabled: true,
|
|
102
|
+
configured: true,
|
|
103
|
+
appId: "cli_test",
|
|
104
|
+
appSecret: "secret_test", // pragma: allowlist secret
|
|
105
|
+
domain: "feishu",
|
|
106
|
+
config: {
|
|
107
|
+
enabled: true,
|
|
108
|
+
connectionMode: "websocket",
|
|
109
|
+
},
|
|
110
|
+
} as ResolvedFeishuAccount;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createTextEvent(params: {
|
|
114
|
+
messageId: string;
|
|
115
|
+
text: string;
|
|
116
|
+
senderId?: string;
|
|
117
|
+
mentions?: FeishuMention[];
|
|
118
|
+
}): FeishuMessageEvent {
|
|
119
|
+
const senderId = params.senderId ?? "ou_sender";
|
|
120
|
+
return {
|
|
121
|
+
sender: {
|
|
122
|
+
sender_id: { open_id: senderId },
|
|
123
|
+
sender_type: "user",
|
|
124
|
+
},
|
|
125
|
+
message: {
|
|
126
|
+
message_id: params.messageId,
|
|
127
|
+
chat_id: "oc_group_1",
|
|
128
|
+
chat_type: "group",
|
|
129
|
+
message_type: "text",
|
|
130
|
+
content: JSON.stringify({ text: params.text }),
|
|
131
|
+
mentions: params.mentions,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function setupDebounceMonitor(params?: {
|
|
137
|
+
botOpenId?: string;
|
|
138
|
+
botName?: string;
|
|
139
|
+
}): Promise<(data: unknown) => Promise<void>> {
|
|
140
|
+
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
|
|
141
|
+
handlers = registered;
|
|
142
|
+
});
|
|
143
|
+
createEventDispatcherMock.mockReturnValue({ register });
|
|
144
|
+
|
|
145
|
+
await monitorSingleAccount({
|
|
146
|
+
cfg: buildDebounceConfig(),
|
|
147
|
+
account: buildDebounceAccount(),
|
|
148
|
+
runtime: {
|
|
149
|
+
log: vi.fn(),
|
|
150
|
+
error: vi.fn(),
|
|
151
|
+
exit: vi.fn(),
|
|
152
|
+
} as RuntimeEnv,
|
|
153
|
+
botOpenIdSource: {
|
|
154
|
+
kind: "prefetched",
|
|
155
|
+
botOpenId: params?.botOpenId ?? "ou_bot",
|
|
156
|
+
botName: params?.botName,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const onMessage = handlers["im.message.receive_v1"];
|
|
161
|
+
if (!onMessage) {
|
|
162
|
+
throw new Error("missing im.message.receive_v1 handler");
|
|
163
|
+
}
|
|
164
|
+
return onMessage;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|
168
|
+
const firstCall = handleFeishuMessageMock.mock.calls[0];
|
|
169
|
+
if (!firstCall) {
|
|
170
|
+
throw new Error("missing dispatch call");
|
|
171
|
+
}
|
|
172
|
+
const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
|
|
173
|
+
if (!firstParams?.event) {
|
|
174
|
+
throw new Error("missing dispatched event payload");
|
|
175
|
+
}
|
|
176
|
+
return firstParams.event;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setDedupPassThroughMocks(): void {
|
|
180
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
181
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
182
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
183
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
|
|
187
|
+
return {
|
|
188
|
+
key: params.key ?? "@_user_1",
|
|
189
|
+
id: { open_id: params.openId },
|
|
190
|
+
name: params.name,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function enqueueDebouncedMessage(
|
|
195
|
+
onMessage: (data: unknown) => Promise<void>,
|
|
196
|
+
event: FeishuMessageEvent,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
await onMessage(event);
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
await Promise.resolve();
|
|
201
|
+
}
|
|
202
|
+
|
|
19
203
|
describe("resolveReactionSyntheticEvent", () => {
|
|
20
204
|
it("filters app self-reactions", async () => {
|
|
21
205
|
const event = makeReactionEvent({ operator_type: "app" });
|
|
@@ -136,23 +320,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
136
320
|
});
|
|
137
321
|
|
|
138
322
|
it("uses event chat context when provided", async () => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const result = await resolveReactionSyntheticEvent({
|
|
144
|
-
cfg,
|
|
145
|
-
accountId: "default",
|
|
146
|
-
event,
|
|
147
|
-
botOpenId: "ou_bot",
|
|
148
|
-
fetchMessage: async () => ({
|
|
149
|
-
messageId: "om_msg1",
|
|
150
|
-
chatId: "oc_group_from_lookup",
|
|
151
|
-
senderOpenId: "ou_bot",
|
|
152
|
-
content: "hello",
|
|
153
|
-
contentType: "text",
|
|
323
|
+
const result = await resolveReactionWithLookup({
|
|
324
|
+
event: makeReactionEvent({
|
|
325
|
+
chat_id: "oc_group_from_event",
|
|
326
|
+
chat_type: "group",
|
|
154
327
|
}),
|
|
155
|
-
|
|
328
|
+
lookupChatId: "oc_group_from_lookup",
|
|
156
329
|
});
|
|
157
330
|
|
|
158
331
|
expect(result).toEqual({
|
|
@@ -173,20 +346,8 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
173
346
|
});
|
|
174
347
|
|
|
175
348
|
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
cfg,
|
|
179
|
-
accountId: "default",
|
|
180
|
-
event,
|
|
181
|
-
botOpenId: "ou_bot",
|
|
182
|
-
fetchMessage: async () => ({
|
|
183
|
-
messageId: "om_msg1",
|
|
184
|
-
chatId: "oc_group_from_lookup",
|
|
185
|
-
senderOpenId: "ou_bot",
|
|
186
|
-
content: "hello",
|
|
187
|
-
contentType: "text",
|
|
188
|
-
}),
|
|
189
|
-
uuid: () => "fixed-uuid",
|
|
349
|
+
const result = await resolveReactionWithLookup({
|
|
350
|
+
lookupChatId: "oc_group_from_lookup",
|
|
190
351
|
});
|
|
191
352
|
|
|
192
353
|
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
|
|
@@ -194,20 +355,8 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
194
355
|
});
|
|
195
356
|
|
|
196
357
|
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
cfg,
|
|
200
|
-
accountId: "default",
|
|
201
|
-
event,
|
|
202
|
-
botOpenId: "ou_bot",
|
|
203
|
-
fetchMessage: async () => ({
|
|
204
|
-
messageId: "om_msg1",
|
|
205
|
-
chatId: "",
|
|
206
|
-
senderOpenId: "ou_bot",
|
|
207
|
-
content: "hello",
|
|
208
|
-
contentType: "text",
|
|
209
|
-
}),
|
|
210
|
-
uuid: () => "fixed-uuid",
|
|
358
|
+
const result = await resolveReactionWithLookup({
|
|
359
|
+
lookupChatId: "",
|
|
211
360
|
});
|
|
212
361
|
|
|
213
362
|
expect(result?.message.chat_id).toBe("p2p:ou_user1");
|
|
@@ -233,3 +382,203 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
233
382
|
);
|
|
234
383
|
});
|
|
235
384
|
});
|
|
385
|
+
|
|
386
|
+
describe("Feishu inbound debounce regressions", () => {
|
|
387
|
+
beforeEach(() => {
|
|
388
|
+
vi.useFakeTimers();
|
|
389
|
+
handlers = {};
|
|
390
|
+
handleFeishuMessageMock.mockClear();
|
|
391
|
+
setFeishuRuntime(
|
|
392
|
+
createPluginRuntimeMock({
|
|
393
|
+
channel: {
|
|
394
|
+
debounce: {
|
|
395
|
+
createInboundDebouncer,
|
|
396
|
+
resolveInboundDebounceMs,
|
|
397
|
+
},
|
|
398
|
+
text: {
|
|
399
|
+
hasControlCommand,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
afterEach(() => {
|
|
407
|
+
vi.useRealTimers();
|
|
408
|
+
vi.restoreAllMocks();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
|
|
412
|
+
setDedupPassThroughMocks();
|
|
413
|
+
const onMessage = await setupDebounceMonitor();
|
|
414
|
+
|
|
415
|
+
await enqueueDebouncedMessage(
|
|
416
|
+
onMessage,
|
|
417
|
+
createTextEvent({
|
|
418
|
+
messageId: "om_1",
|
|
419
|
+
text: "first",
|
|
420
|
+
mentions: [createMention({ openId: "ou_user_a", name: "user-a" })],
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
await enqueueDebouncedMessage(
|
|
424
|
+
onMessage,
|
|
425
|
+
createTextEvent({
|
|
426
|
+
messageId: "om_2",
|
|
427
|
+
text: "@bot second",
|
|
428
|
+
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
432
|
+
|
|
433
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
434
|
+
const dispatched = getFirstDispatchedEvent();
|
|
435
|
+
const mergedMentions = dispatched.message.mentions ?? [];
|
|
436
|
+
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
437
|
+
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("passes prefetched botName through to handleFeishuMessage", async () => {
|
|
441
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
442
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
443
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
444
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
445
|
+
const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
|
|
446
|
+
|
|
447
|
+
await onMessage(
|
|
448
|
+
createTextEvent({
|
|
449
|
+
messageId: "om_name_passthrough",
|
|
450
|
+
text: "@bot hello",
|
|
451
|
+
mentions: [
|
|
452
|
+
{
|
|
453
|
+
key: "@_user_1",
|
|
454
|
+
id: { open_id: "ou_bot" },
|
|
455
|
+
name: "OpenClaw Bot",
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
await Promise.resolve();
|
|
461
|
+
await Promise.resolve();
|
|
462
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
463
|
+
|
|
464
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
465
|
+
const firstParams = handleFeishuMessageMock.mock.calls[0]?.[0] as
|
|
466
|
+
| { botName?: string }
|
|
467
|
+
| undefined;
|
|
468
|
+
expect(firstParams?.botName).toBe("OpenClaw Bot");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("does not synthesize mention-forward intent across separate messages", async () => {
|
|
472
|
+
setDedupPassThroughMocks();
|
|
473
|
+
const onMessage = await setupDebounceMonitor();
|
|
474
|
+
|
|
475
|
+
await enqueueDebouncedMessage(
|
|
476
|
+
onMessage,
|
|
477
|
+
createTextEvent({
|
|
478
|
+
messageId: "om_user_mention",
|
|
479
|
+
text: "@alice first",
|
|
480
|
+
mentions: [createMention({ openId: "ou_alice", name: "alice" })],
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
await enqueueDebouncedMessage(
|
|
484
|
+
onMessage,
|
|
485
|
+
createTextEvent({
|
|
486
|
+
messageId: "om_bot_mention",
|
|
487
|
+
text: "@bot second",
|
|
488
|
+
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
492
|
+
|
|
493
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
494
|
+
const dispatched = getFirstDispatchedEvent();
|
|
495
|
+
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
496
|
+
expect(parsed.mentionedBot).toBe(true);
|
|
497
|
+
expect(parsed.mentionTargets).toBeUndefined();
|
|
498
|
+
const mergedMentions = dispatched.message.mentions ?? [];
|
|
499
|
+
expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
|
|
503
|
+
setDedupPassThroughMocks();
|
|
504
|
+
const onMessage = await setupDebounceMonitor();
|
|
505
|
+
|
|
506
|
+
await enqueueDebouncedMessage(
|
|
507
|
+
onMessage,
|
|
508
|
+
createTextEvent({
|
|
509
|
+
messageId: "om_bot_first",
|
|
510
|
+
text: "@bot first",
|
|
511
|
+
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
await enqueueDebouncedMessage(
|
|
515
|
+
onMessage,
|
|
516
|
+
createTextEvent({
|
|
517
|
+
messageId: "om_plain_second",
|
|
518
|
+
text: "plain follow-up",
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
522
|
+
|
|
523
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
524
|
+
const dispatched = getFirstDispatchedEvent();
|
|
525
|
+
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
526
|
+
expect(parsed.mentionedBot).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("excludes previously processed retries from combined debounce text", async () => {
|
|
530
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
531
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
532
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
|
533
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
534
|
+
async (messageId) => messageId === "om_old",
|
|
535
|
+
);
|
|
536
|
+
const onMessage = await setupDebounceMonitor();
|
|
537
|
+
|
|
538
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
539
|
+
await Promise.resolve();
|
|
540
|
+
await Promise.resolve();
|
|
541
|
+
await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
|
|
542
|
+
await Promise.resolve();
|
|
543
|
+
await Promise.resolve();
|
|
544
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
545
|
+
await Promise.resolve();
|
|
546
|
+
await Promise.resolve();
|
|
547
|
+
await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
|
|
548
|
+
await Promise.resolve();
|
|
549
|
+
await Promise.resolve();
|
|
550
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
551
|
+
|
|
552
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
553
|
+
const dispatched = getFirstDispatchedEvent();
|
|
554
|
+
expect(dispatched.message.message_id).toBe("om_new_2");
|
|
555
|
+
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
556
|
+
expect(combined.text).toBe("first\nsecond");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
|
560
|
+
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
561
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
562
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
|
563
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
564
|
+
async (messageId) => messageId === "om_old",
|
|
565
|
+
);
|
|
566
|
+
const onMessage = await setupDebounceMonitor();
|
|
567
|
+
|
|
568
|
+
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
|
569
|
+
await Promise.resolve();
|
|
570
|
+
await Promise.resolve();
|
|
571
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
572
|
+
await Promise.resolve();
|
|
573
|
+
await Promise.resolve();
|
|
574
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
575
|
+
|
|
576
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
577
|
+
const dispatched = getFirstDispatchedEvent();
|
|
578
|
+
expect(dispatched.message.message_id).toBe("om_new");
|
|
579
|
+
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
580
|
+
expect(combined.text).toBe("fresh");
|
|
581
|
+
expect(recordSpy).toHaveBeenCalledWith("default:om_old");
|
|
582
|
+
expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
|
|
583
|
+
});
|
|
584
|
+
});
|
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
3
4
|
|
|
4
5
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const feishuClientMockModule = vi.hoisted(() => ({
|
|
7
|
+
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
8
|
+
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
9
|
+
}));
|
|
10
|
+
const feishuRuntimeMockModule = vi.hoisted(() => ({
|
|
11
|
+
getFeishuRuntime: () => ({
|
|
12
|
+
channel: {
|
|
13
|
+
debounce: {
|
|
14
|
+
resolveInboundDebounceMs: () => 0,
|
|
15
|
+
createInboundDebouncer: () => ({
|
|
16
|
+
enqueue: async () => {},
|
|
17
|
+
flushKey: async () => {},
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
text: {
|
|
21
|
+
hasControlCommand: () => false,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
5
26
|
|
|
6
27
|
vi.mock("./probe.js", () => ({
|
|
7
28
|
probeFeishu: probeFeishuMock,
|
|
8
29
|
}));
|
|
9
30
|
|
|
10
|
-
vi.mock("./client.js", () =>
|
|
11
|
-
|
|
12
|
-
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
13
|
-
}));
|
|
14
|
-
|
|
15
|
-
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
31
|
+
vi.mock("./client.js", () => feishuClientMockModule);
|
|
32
|
+
vi.mock("./runtime.js", () => feishuRuntimeMockModule);
|
|
16
33
|
|
|
17
34
|
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
|
18
35
|
return {
|
|
@@ -25,7 +42,7 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
|
|
|
25
42
|
{
|
|
26
43
|
enabled: true,
|
|
27
44
|
appId: `cli_${accountId}`,
|
|
28
|
-
appSecret: `secret_${accountId}`,
|
|
45
|
+
appSecret: `secret_${accountId}`, // pragma: allowlist secret
|
|
29
46
|
connectionMode: "websocket",
|
|
30
47
|
},
|
|
31
48
|
]),
|
package/src/monitor.startup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { probeFeishu } from "./probe.js";
|
|
3
3
|
import type { ResolvedFeishuAccount } from "./types.js";
|
|
4
4
|
|
|
@@ -10,6 +10,11 @@ type FetchBotOpenIdOptions = {
|
|
|
10
10
|
timeoutMs?: number;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export type FeishuMonitorBotIdentity = {
|
|
14
|
+
botOpenId?: string;
|
|
15
|
+
botName?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
13
18
|
function isTimeoutErrorMessage(message: string | undefined): boolean {
|
|
14
19
|
return message?.toLowerCase().includes("timeout") || message?.toLowerCase().includes("timed out")
|
|
15
20
|
? true
|
|
@@ -20,12 +25,12 @@ function isAbortErrorMessage(message: string | undefined): boolean {
|
|
|
20
25
|
return message?.toLowerCase().includes("aborted") ?? false;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
export async function
|
|
28
|
+
export async function fetchBotIdentityForMonitor(
|
|
24
29
|
account: ResolvedFeishuAccount,
|
|
25
30
|
options: FetchBotOpenIdOptions = {},
|
|
26
|
-
): Promise<
|
|
31
|
+
): Promise<FeishuMonitorBotIdentity> {
|
|
27
32
|
if (options.abortSignal?.aborted) {
|
|
28
|
-
return
|
|
33
|
+
return {};
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const timeoutMs = options.timeoutMs ?? FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS;
|
|
@@ -34,11 +39,11 @@ export async function fetchBotOpenIdForMonitor(
|
|
|
34
39
|
abortSignal: options.abortSignal,
|
|
35
40
|
});
|
|
36
41
|
if (result.ok) {
|
|
37
|
-
return result.botOpenId;
|
|
42
|
+
return { botOpenId: result.botOpenId, botName: result.botName };
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) {
|
|
41
|
-
return
|
|
46
|
+
return {};
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
if (isTimeoutErrorMessage(result.error)) {
|
|
@@ -47,5 +52,13 @@ export async function fetchBotOpenIdForMonitor(
|
|
|
47
52
|
`feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
|
|
48
53
|
);
|
|
49
54
|
}
|
|
50
|
-
return
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function fetchBotOpenIdForMonitor(
|
|
59
|
+
account: ResolvedFeishuAccount,
|
|
60
|
+
options: FetchBotOpenIdOptions = {},
|
|
61
|
+
): Promise<string | undefined> {
|
|
62
|
+
const identity = await fetchBotIdentityForMonitor(account, options);
|
|
63
|
+
return identity.botOpenId;
|
|
51
64
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveFeishuWebhookAnomalyDefaultsForTest,
|
|
4
|
+
resolveFeishuWebhookRateLimitDefaultsForTest,
|
|
5
|
+
} from "./monitor.state.js";
|
|
6
|
+
|
|
7
|
+
describe("feishu monitor state defaults", () => {
|
|
8
|
+
it("falls back to hard defaults when sdk defaults are missing", () => {
|
|
9
|
+
expect(resolveFeishuWebhookRateLimitDefaultsForTest(undefined)).toEqual({
|
|
10
|
+
windowMs: 60_000,
|
|
11
|
+
maxRequests: 120,
|
|
12
|
+
maxTrackedKeys: 4_096,
|
|
13
|
+
});
|
|
14
|
+
expect(resolveFeishuWebhookAnomalyDefaultsForTest(undefined)).toEqual({
|
|
15
|
+
maxTrackedKeys: 4_096,
|
|
16
|
+
ttlMs: 21_600_000,
|
|
17
|
+
logEvery: 25,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps valid sdk values and repairs invalid fields", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveFeishuWebhookRateLimitDefaultsForTest({
|
|
24
|
+
windowMs: 45_000,
|
|
25
|
+
maxRequests: 0,
|
|
26
|
+
maxTrackedKeys: -1,
|
|
27
|
+
}),
|
|
28
|
+
).toEqual({
|
|
29
|
+
windowMs: 45_000,
|
|
30
|
+
maxRequests: 120,
|
|
31
|
+
maxTrackedKeys: 4_096,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
resolveFeishuWebhookAnomalyDefaultsForTest({
|
|
36
|
+
maxTrackedKeys: 2048,
|
|
37
|
+
ttlMs: Number.NaN,
|
|
38
|
+
logEvery: 10,
|
|
39
|
+
}),
|
|
40
|
+
).toEqual({
|
|
41
|
+
maxTrackedKeys: 2048,
|
|
42
|
+
ttlMs: 21_600_000,
|
|
43
|
+
logEvery: 10,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|