@openclaw/feishu 2026.3.1 → 2026.3.2
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/package.json +1 -1
- package/src/accounts.test.ts +74 -3
- package/src/accounts.ts +69 -10
- package/src/bot.checkBotMentioned.test.ts +1 -1
- package/src/bot.test.ts +390 -29
- package/src/bot.ts +131 -61
- package/src/channel.ts +20 -4
- package/src/client.test.ts +14 -0
- package/src/config-schema.test.ts +19 -0
- package/src/config-schema.ts +13 -9
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +16 -22
- package/src/docx.account-selection.test.ts +7 -13
- package/src/docx.test.ts +41 -189
- package/src/media.test.ts +104 -1
- package/src/media.ts +21 -1
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +266 -18
- package/src/monitor.reaction.test.ts +345 -2
- package/src/monitor.startup.test.ts +17 -1
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +84 -8
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.webhook-security.test.ts +26 -9
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/probe.test.ts +38 -20
- package/src/probe.ts +57 -37
- package/src/reply-dispatcher.test.ts +41 -0
- package/src/reply-dispatcher.ts +26 -7
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +21 -1
- package/src/types.ts +9 -1
package/src/monitor.account.ts
CHANGED
|
@@ -3,12 +3,26 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
|
|
3
3
|
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
4
4
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
5
|
import { raceWithTimeoutAndAbort } from "./async.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
handleFeishuMessage,
|
|
8
|
+
parseFeishuMessageEvent,
|
|
9
|
+
type FeishuMessageEvent,
|
|
10
|
+
type FeishuBotAddedEvent,
|
|
11
|
+
} from "./bot.js";
|
|
7
12
|
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
|
|
8
13
|
import { createEventDispatcher } from "./client.js";
|
|
14
|
+
import {
|
|
15
|
+
hasRecordedMessage,
|
|
16
|
+
hasRecordedMessagePersistent,
|
|
17
|
+
tryRecordMessage,
|
|
18
|
+
tryRecordMessagePersistent,
|
|
19
|
+
warmupDedupFromDisk,
|
|
20
|
+
} from "./dedup.js";
|
|
21
|
+
import { isMentionForwardRequest } from "./mention.js";
|
|
9
22
|
import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
|
|
10
23
|
import { botOpenIds } from "./monitor.state.js";
|
|
11
24
|
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
|
25
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
12
26
|
import { getMessageFeishu } from "./send.js";
|
|
13
27
|
import type { ResolvedFeishuAccount } from "./types.js";
|
|
14
28
|
|
|
@@ -17,7 +31,7 @@ const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
|
|
17
31
|
export type FeishuReactionCreatedEvent = {
|
|
18
32
|
message_id: string;
|
|
19
33
|
chat_id?: string;
|
|
20
|
-
chat_type?: "p2p" | "group";
|
|
34
|
+
chat_type?: "p2p" | "group" | "private";
|
|
21
35
|
reaction_type?: { emoji_type?: string };
|
|
22
36
|
operator_type?: string;
|
|
23
37
|
user_id?: { open_id?: string };
|
|
@@ -93,7 +107,8 @@ export async function resolveReactionSyntheticEvent(
|
|
|
93
107
|
|
|
94
108
|
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
|
|
95
109
|
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
|
|
96
|
-
const syntheticChatType: "p2p" | "group"
|
|
110
|
+
const syntheticChatType: "p2p" | "group" | "private" =
|
|
111
|
+
event.chat_type === "group" ? "group" : "p2p";
|
|
97
112
|
return {
|
|
98
113
|
sender: {
|
|
99
114
|
sender_id: { open_id: senderId },
|
|
@@ -119,33 +134,261 @@ type RegisterEventHandlersContext = {
|
|
|
119
134
|
fireAndForget?: boolean;
|
|
120
135
|
};
|
|
121
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Per-chat serial queue that ensures messages from the same chat are processed
|
|
139
|
+
* in arrival order while allowing different chats to run concurrently.
|
|
140
|
+
*/
|
|
141
|
+
function createChatQueue() {
|
|
142
|
+
const queues = new Map<string, Promise<void>>();
|
|
143
|
+
return (chatId: string, task: () => Promise<void>): Promise<void> => {
|
|
144
|
+
const prev = queues.get(chatId) ?? Promise.resolve();
|
|
145
|
+
const next = prev.then(task, task);
|
|
146
|
+
queues.set(chatId, next);
|
|
147
|
+
void next.finally(() => {
|
|
148
|
+
if (queues.get(chatId) === next) {
|
|
149
|
+
queues.delete(chatId);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return next;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mergeFeishuDebounceMentions(
|
|
157
|
+
entries: FeishuMessageEvent[],
|
|
158
|
+
): FeishuMessageEvent["message"]["mentions"] | undefined {
|
|
159
|
+
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
for (const mention of entry.message.mentions ?? []) {
|
|
162
|
+
const stableId =
|
|
163
|
+
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
|
|
164
|
+
const mentionName = mention.name?.trim();
|
|
165
|
+
const mentionKey = mention.key?.trim();
|
|
166
|
+
const fallback =
|
|
167
|
+
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
|
|
168
|
+
const key = stableId || fallback;
|
|
169
|
+
if (!key || merged.has(key)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
merged.set(key, mention);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (merged.size === 0) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
return Array.from(merged.values());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function dedupeFeishuDebounceEntriesByMessageId(
|
|
182
|
+
entries: FeishuMessageEvent[],
|
|
183
|
+
): FeishuMessageEvent[] {
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
const deduped: FeishuMessageEvent[] = [];
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
const messageId = entry.message.message_id?.trim();
|
|
188
|
+
if (!messageId) {
|
|
189
|
+
deduped.push(entry);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (seen.has(messageId)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
seen.add(messageId);
|
|
196
|
+
deduped.push(entry);
|
|
197
|
+
}
|
|
198
|
+
return deduped;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveFeishuDebounceMentions(params: {
|
|
202
|
+
entries: FeishuMessageEvent[];
|
|
203
|
+
botOpenId?: string;
|
|
204
|
+
}): FeishuMessageEvent["message"]["mentions"] | undefined {
|
|
205
|
+
const { entries, botOpenId } = params;
|
|
206
|
+
if (entries.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
210
|
+
const entry = entries[index];
|
|
211
|
+
if (isMentionForwardRequest(entry, botOpenId)) {
|
|
212
|
+
// Keep mention-forward semantics scoped to a single source message.
|
|
213
|
+
return mergeFeishuDebounceMentions([entry]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const merged = mergeFeishuDebounceMentions(entries);
|
|
217
|
+
if (!merged) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const normalizedBotOpenId = botOpenId?.trim();
|
|
221
|
+
if (!normalizedBotOpenId) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
const botMentions = merged.filter(
|
|
225
|
+
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
|
|
226
|
+
);
|
|
227
|
+
return botMentions.length > 0 ? botMentions : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
122
230
|
function registerEventHandlers(
|
|
123
231
|
eventDispatcher: Lark.EventDispatcher,
|
|
124
232
|
context: RegisterEventHandlersContext,
|
|
125
233
|
): void {
|
|
126
234
|
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
|
235
|
+
const core = getFeishuRuntime();
|
|
236
|
+
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
237
|
+
cfg,
|
|
238
|
+
channel: "feishu",
|
|
239
|
+
});
|
|
127
240
|
const log = runtime?.log ?? console.log;
|
|
128
241
|
const error = runtime?.error ?? console.error;
|
|
242
|
+
const enqueue = createChatQueue();
|
|
243
|
+
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
|
244
|
+
const chatId = event.message.chat_id?.trim() || "unknown";
|
|
245
|
+
const task = () =>
|
|
246
|
+
handleFeishuMessage({
|
|
247
|
+
cfg,
|
|
248
|
+
event,
|
|
249
|
+
botOpenId: botOpenIds.get(accountId),
|
|
250
|
+
runtime,
|
|
251
|
+
chatHistories,
|
|
252
|
+
accountId,
|
|
253
|
+
});
|
|
254
|
+
await enqueue(chatId, task);
|
|
255
|
+
};
|
|
256
|
+
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
|
|
257
|
+
const senderId =
|
|
258
|
+
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
|
|
259
|
+
return senderId || undefined;
|
|
260
|
+
};
|
|
261
|
+
const resolveDebounceText = (event: FeishuMessageEvent): string => {
|
|
262
|
+
const botOpenId = botOpenIds.get(accountId);
|
|
263
|
+
const parsed = parseFeishuMessageEvent(event, botOpenId);
|
|
264
|
+
return parsed.content.trim();
|
|
265
|
+
};
|
|
266
|
+
const recordSuppressedMessageIds = async (
|
|
267
|
+
entries: FeishuMessageEvent[],
|
|
268
|
+
dispatchMessageId?: string,
|
|
269
|
+
) => {
|
|
270
|
+
const keepMessageId = dispatchMessageId?.trim();
|
|
271
|
+
const suppressedIds = new Set(
|
|
272
|
+
entries
|
|
273
|
+
.map((entry) => entry.message.message_id?.trim())
|
|
274
|
+
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
|
|
275
|
+
);
|
|
276
|
+
if (suppressedIds.size === 0) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
for (const messageId of suppressedIds) {
|
|
280
|
+
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
|
|
281
|
+
tryRecordMessage(`${accountId}:${messageId}`);
|
|
282
|
+
try {
|
|
283
|
+
await tryRecordMessagePersistent(messageId, accountId, log);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
error(
|
|
286
|
+
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
|
|
292
|
+
const messageId = entry.message.message_id?.trim();
|
|
293
|
+
if (!messageId) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
const memoryKey = `${accountId}:${messageId}`;
|
|
297
|
+
if (hasRecordedMessage(memoryKey)) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
return hasRecordedMessagePersistent(messageId, accountId, log);
|
|
301
|
+
};
|
|
302
|
+
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
|
303
|
+
debounceMs: inboundDebounceMs,
|
|
304
|
+
buildKey: (event) => {
|
|
305
|
+
const chatId = event.message.chat_id?.trim();
|
|
306
|
+
const senderId = resolveSenderDebounceId(event);
|
|
307
|
+
if (!chatId || !senderId) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const rootId = event.message.root_id?.trim();
|
|
311
|
+
const threadKey = rootId ? `thread:${rootId}` : "chat";
|
|
312
|
+
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
|
|
313
|
+
},
|
|
314
|
+
shouldDebounce: (event) => {
|
|
315
|
+
if (event.message.message_type !== "text") {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const text = resolveDebounceText(event);
|
|
319
|
+
if (!text) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
return !core.channel.text.hasControlCommand(text, cfg);
|
|
323
|
+
},
|
|
324
|
+
onFlush: async (entries) => {
|
|
325
|
+
const last = entries.at(-1);
|
|
326
|
+
if (!last) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (entries.length === 1) {
|
|
330
|
+
await dispatchFeishuMessage(last);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
|
|
334
|
+
const freshEntries: FeishuMessageEvent[] = [];
|
|
335
|
+
for (const entry of dedupedEntries) {
|
|
336
|
+
if (!(await isMessageAlreadyProcessed(entry))) {
|
|
337
|
+
freshEntries.push(entry);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const dispatchEntry = freshEntries.at(-1);
|
|
341
|
+
if (!dispatchEntry) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
|
|
345
|
+
const combinedText = freshEntries
|
|
346
|
+
.map((entry) => resolveDebounceText(entry))
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
.join("\n");
|
|
349
|
+
const mergedMentions = resolveFeishuDebounceMentions({
|
|
350
|
+
entries: freshEntries,
|
|
351
|
+
botOpenId: botOpenIds.get(accountId),
|
|
352
|
+
});
|
|
353
|
+
if (!combinedText.trim()) {
|
|
354
|
+
await dispatchFeishuMessage({
|
|
355
|
+
...dispatchEntry,
|
|
356
|
+
message: {
|
|
357
|
+
...dispatchEntry.message,
|
|
358
|
+
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await dispatchFeishuMessage({
|
|
364
|
+
...dispatchEntry,
|
|
365
|
+
message: {
|
|
366
|
+
...dispatchEntry.message,
|
|
367
|
+
message_type: "text",
|
|
368
|
+
content: JSON.stringify({ text: combinedText }),
|
|
369
|
+
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
onError: (err) => {
|
|
374
|
+
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
129
377
|
|
|
130
378
|
eventDispatcher.register({
|
|
131
379
|
"im.message.receive_v1": async (data) => {
|
|
132
|
-
|
|
380
|
+
const processMessage = async () => {
|
|
133
381
|
const event = data as unknown as FeishuMessageEvent;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
chatHistories,
|
|
140
|
-
accountId,
|
|
382
|
+
await inboundDebouncer.enqueue(event);
|
|
383
|
+
};
|
|
384
|
+
if (fireAndForget) {
|
|
385
|
+
void processMessage().catch((err) => {
|
|
386
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
141
387
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
} else {
|
|
147
|
-
await promise;
|
|
148
|
-
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
await processMessage();
|
|
149
392
|
} catch (err) {
|
|
150
393
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
151
394
|
}
|
|
@@ -268,6 +511,11 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
|
|
268
511
|
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
|
269
512
|
}
|
|
270
513
|
|
|
514
|
+
const warmupCount = await warmupDedupFromDisk(accountId, log);
|
|
515
|
+
if (warmupCount > 0) {
|
|
516
|
+
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
|
517
|
+
}
|
|
518
|
+
|
|
271
519
|
const eventDispatcher = createEventDispatcher(account);
|
|
272
520
|
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
273
521
|
|
|
@@ -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";
|
|
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,100 @@ function makeReactionEvent(
|
|
|
16
51
|
};
|
|
17
52
|
}
|
|
18
53
|
|
|
54
|
+
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
|
55
|
+
|
|
56
|
+
function buildDebounceConfig(): ClawdbotConfig {
|
|
57
|
+
return {
|
|
58
|
+
messages: {
|
|
59
|
+
inbound: {
|
|
60
|
+
debounceMs: 0,
|
|
61
|
+
byChannel: {
|
|
62
|
+
feishu: 20,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
channels: {
|
|
67
|
+
feishu: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
} as ClawdbotConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildDebounceAccount(): ResolvedFeishuAccount {
|
|
75
|
+
return {
|
|
76
|
+
accountId: "default",
|
|
77
|
+
enabled: true,
|
|
78
|
+
configured: true,
|
|
79
|
+
appId: "cli_test",
|
|
80
|
+
appSecret: "secret_test",
|
|
81
|
+
domain: "feishu",
|
|
82
|
+
config: {
|
|
83
|
+
enabled: true,
|
|
84
|
+
connectionMode: "websocket",
|
|
85
|
+
},
|
|
86
|
+
} as ResolvedFeishuAccount;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createTextEvent(params: {
|
|
90
|
+
messageId: string;
|
|
91
|
+
text: string;
|
|
92
|
+
senderId?: string;
|
|
93
|
+
mentions?: FeishuMention[];
|
|
94
|
+
}): FeishuMessageEvent {
|
|
95
|
+
const senderId = params.senderId ?? "ou_sender";
|
|
96
|
+
return {
|
|
97
|
+
sender: {
|
|
98
|
+
sender_id: { open_id: senderId },
|
|
99
|
+
sender_type: "user",
|
|
100
|
+
},
|
|
101
|
+
message: {
|
|
102
|
+
message_id: params.messageId,
|
|
103
|
+
chat_id: "oc_group_1",
|
|
104
|
+
chat_type: "group",
|
|
105
|
+
message_type: "text",
|
|
106
|
+
content: JSON.stringify({ text: params.text }),
|
|
107
|
+
mentions: params.mentions,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
|
|
113
|
+
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
|
|
114
|
+
handlers = registered;
|
|
115
|
+
});
|
|
116
|
+
createEventDispatcherMock.mockReturnValue({ register });
|
|
117
|
+
|
|
118
|
+
await monitorSingleAccount({
|
|
119
|
+
cfg: buildDebounceConfig(),
|
|
120
|
+
account: buildDebounceAccount(),
|
|
121
|
+
runtime: {
|
|
122
|
+
log: vi.fn(),
|
|
123
|
+
error: vi.fn(),
|
|
124
|
+
exit: vi.fn(),
|
|
125
|
+
} as RuntimeEnv,
|
|
126
|
+
botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const onMessage = handlers["im.message.receive_v1"];
|
|
130
|
+
if (!onMessage) {
|
|
131
|
+
throw new Error("missing im.message.receive_v1 handler");
|
|
132
|
+
}
|
|
133
|
+
return onMessage;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|
137
|
+
const firstCall = handleFeishuMessageMock.mock.calls[0];
|
|
138
|
+
if (!firstCall) {
|
|
139
|
+
throw new Error("missing dispatch call");
|
|
140
|
+
}
|
|
141
|
+
const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
|
|
142
|
+
if (!firstParams?.event) {
|
|
143
|
+
throw new Error("missing dispatched event payload");
|
|
144
|
+
}
|
|
145
|
+
return firstParams.event;
|
|
146
|
+
}
|
|
147
|
+
|
|
19
148
|
describe("resolveReactionSyntheticEvent", () => {
|
|
20
149
|
it("filters app self-reactions", async () => {
|
|
21
150
|
const event = makeReactionEvent({ operator_type: "app" });
|
|
@@ -233,3 +362,217 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
233
362
|
);
|
|
234
363
|
});
|
|
235
364
|
});
|
|
365
|
+
|
|
366
|
+
describe("Feishu inbound debounce regressions", () => {
|
|
367
|
+
beforeEach(() => {
|
|
368
|
+
vi.useFakeTimers();
|
|
369
|
+
handlers = {};
|
|
370
|
+
handleFeishuMessageMock.mockClear();
|
|
371
|
+
setFeishuRuntime(
|
|
372
|
+
createPluginRuntimeMock({
|
|
373
|
+
channel: {
|
|
374
|
+
debounce: {
|
|
375
|
+
createInboundDebouncer,
|
|
376
|
+
resolveInboundDebounceMs,
|
|
377
|
+
},
|
|
378
|
+
text: {
|
|
379
|
+
hasControlCommand,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
afterEach(() => {
|
|
387
|
+
vi.useRealTimers();
|
|
388
|
+
vi.restoreAllMocks();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
|
|
392
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
393
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
394
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
395
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
396
|
+
const onMessage = await setupDebounceMonitor();
|
|
397
|
+
|
|
398
|
+
await onMessage(
|
|
399
|
+
createTextEvent({
|
|
400
|
+
messageId: "om_1",
|
|
401
|
+
text: "first",
|
|
402
|
+
mentions: [
|
|
403
|
+
{
|
|
404
|
+
key: "@_user_1",
|
|
405
|
+
id: { open_id: "ou_user_a" },
|
|
406
|
+
name: "user-a",
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
await Promise.resolve();
|
|
412
|
+
await Promise.resolve();
|
|
413
|
+
await onMessage(
|
|
414
|
+
createTextEvent({
|
|
415
|
+
messageId: "om_2",
|
|
416
|
+
text: "@bot second",
|
|
417
|
+
mentions: [
|
|
418
|
+
{
|
|
419
|
+
key: "@_user_1",
|
|
420
|
+
id: { open_id: "ou_bot" },
|
|
421
|
+
name: "bot",
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
await Promise.resolve();
|
|
427
|
+
await Promise.resolve();
|
|
428
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
429
|
+
|
|
430
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
431
|
+
const dispatched = getFirstDispatchedEvent();
|
|
432
|
+
const mergedMentions = dispatched.message.mentions ?? [];
|
|
433
|
+
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
434
|
+
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("does not synthesize mention-forward intent across separate messages", async () => {
|
|
438
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
439
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
440
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
441
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
442
|
+
const onMessage = await setupDebounceMonitor();
|
|
443
|
+
|
|
444
|
+
await onMessage(
|
|
445
|
+
createTextEvent({
|
|
446
|
+
messageId: "om_user_mention",
|
|
447
|
+
text: "@alice first",
|
|
448
|
+
mentions: [
|
|
449
|
+
{
|
|
450
|
+
key: "@_user_1",
|
|
451
|
+
id: { open_id: "ou_alice" },
|
|
452
|
+
name: "alice",
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
await Promise.resolve();
|
|
458
|
+
await Promise.resolve();
|
|
459
|
+
await onMessage(
|
|
460
|
+
createTextEvent({
|
|
461
|
+
messageId: "om_bot_mention",
|
|
462
|
+
text: "@bot second",
|
|
463
|
+
mentions: [
|
|
464
|
+
{
|
|
465
|
+
key: "@_user_1",
|
|
466
|
+
id: { open_id: "ou_bot" },
|
|
467
|
+
name: "bot",
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
await Promise.resolve();
|
|
473
|
+
await Promise.resolve();
|
|
474
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
475
|
+
|
|
476
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
477
|
+
const dispatched = getFirstDispatchedEvent();
|
|
478
|
+
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
479
|
+
expect(parsed.mentionedBot).toBe(true);
|
|
480
|
+
expect(parsed.mentionTargets).toBeUndefined();
|
|
481
|
+
const mergedMentions = dispatched.message.mentions ?? [];
|
|
482
|
+
expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
|
|
486
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
487
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
488
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
489
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
490
|
+
const onMessage = await setupDebounceMonitor();
|
|
491
|
+
|
|
492
|
+
await onMessage(
|
|
493
|
+
createTextEvent({
|
|
494
|
+
messageId: "om_bot_first",
|
|
495
|
+
text: "@bot first",
|
|
496
|
+
mentions: [
|
|
497
|
+
{
|
|
498
|
+
key: "@_user_1",
|
|
499
|
+
id: { open_id: "ou_bot" },
|
|
500
|
+
name: "bot",
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
await Promise.resolve();
|
|
506
|
+
await Promise.resolve();
|
|
507
|
+
await onMessage(
|
|
508
|
+
createTextEvent({
|
|
509
|
+
messageId: "om_plain_second",
|
|
510
|
+
text: "plain follow-up",
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
await Promise.resolve();
|
|
514
|
+
await Promise.resolve();
|
|
515
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
516
|
+
|
|
517
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
518
|
+
const dispatched = getFirstDispatchedEvent();
|
|
519
|
+
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
520
|
+
expect(parsed.mentionedBot).toBe(true);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("excludes previously processed retries from combined debounce text", async () => {
|
|
524
|
+
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
525
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
526
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
|
527
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
528
|
+
async (messageId) => messageId === "om_old",
|
|
529
|
+
);
|
|
530
|
+
const onMessage = await setupDebounceMonitor();
|
|
531
|
+
|
|
532
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
533
|
+
await Promise.resolve();
|
|
534
|
+
await Promise.resolve();
|
|
535
|
+
await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
|
|
536
|
+
await Promise.resolve();
|
|
537
|
+
await Promise.resolve();
|
|
538
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
539
|
+
await Promise.resolve();
|
|
540
|
+
await Promise.resolve();
|
|
541
|
+
await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
|
|
542
|
+
await Promise.resolve();
|
|
543
|
+
await Promise.resolve();
|
|
544
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
545
|
+
|
|
546
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
547
|
+
const dispatched = getFirstDispatchedEvent();
|
|
548
|
+
expect(dispatched.message.message_id).toBe("om_new_2");
|
|
549
|
+
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
550
|
+
expect(combined.text).toBe("first\nsecond");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
|
554
|
+
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
|
555
|
+
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
556
|
+
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
|
557
|
+
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
558
|
+
async (messageId) => messageId === "om_old",
|
|
559
|
+
);
|
|
560
|
+
const onMessage = await setupDebounceMonitor();
|
|
561
|
+
|
|
562
|
+
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
|
563
|
+
await Promise.resolve();
|
|
564
|
+
await Promise.resolve();
|
|
565
|
+
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
566
|
+
await Promise.resolve();
|
|
567
|
+
await Promise.resolve();
|
|
568
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
569
|
+
|
|
570
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
571
|
+
const dispatched = getFirstDispatchedEvent();
|
|
572
|
+
expect(dispatched.message.message_id).toBe("om_new");
|
|
573
|
+
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
574
|
+
expect(combined.text).toBe("fresh");
|
|
575
|
+
expect(recordSpy).toHaveBeenCalledWith("default:om_old");
|
|
576
|
+
expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
|
|
577
|
+
});
|
|
578
|
+
});
|