@openclaw/feishu 2026.3.11 → 2026.3.13
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 +40 -16
- package/src/accounts.ts +5 -1
- package/src/bot.ts +20 -11
- package/src/channel.ts +2 -2
- package/src/config-schema.test.ts +67 -16
- package/src/config-schema.ts +30 -9
- package/src/dedup.ts +103 -0
- package/src/media.test.ts +38 -61
- package/src/media.ts +64 -76
- package/src/monitor.account.ts +39 -22
- package/src/monitor.reaction.test.ts +134 -65
- package/src/monitor.startup.test.ts +16 -30
- package/src/monitor.transport.ts +104 -6
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +23 -92
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.ts +31 -0
- package/src/outbound.test.ts +11 -16
- package/src/probe.test.ts +112 -113
- package/src/reactions.ts +20 -27
- package/src/reply-dispatcher.test.ts +65 -143
- package/src/reply-dispatcher.ts +37 -40
- package/src/send.reply-fallback.test.ts +50 -40
- package/src/send.ts +95 -91
- package/src/types.ts +14 -0
package/src/media.ts
CHANGED
|
@@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = {
|
|
|
22
22
|
fileName?: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
|
|
26
|
+
account: ReturnType<typeof resolveFeishuAccount>;
|
|
27
|
+
client: ReturnType<typeof createFeishuClient>;
|
|
28
|
+
} {
|
|
29
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
30
|
+
if (!account.configured) {
|
|
31
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
account,
|
|
36
|
+
client: createFeishuClient({
|
|
37
|
+
...account,
|
|
38
|
+
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractFeishuUploadKey(
|
|
44
|
+
response: unknown,
|
|
45
|
+
params: {
|
|
46
|
+
key: "image_key" | "file_key";
|
|
47
|
+
errorPrefix: string;
|
|
48
|
+
},
|
|
49
|
+
): string {
|
|
50
|
+
// SDK v1.30+ returns data directly without code wrapper on success.
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
|
52
|
+
const responseAny = response as any;
|
|
53
|
+
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
|
54
|
+
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const key = responseAny[params.key] ?? responseAny.data?.[params.key];
|
|
58
|
+
if (!key) {
|
|
59
|
+
throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
|
|
60
|
+
}
|
|
61
|
+
return key;
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
async function readFeishuResponseBuffer(params: {
|
|
26
65
|
response: unknown;
|
|
27
66
|
tmpDirPrefix: string;
|
|
@@ -94,15 +133,7 @@ export async function downloadImageFeishu(params: {
|
|
|
94
133
|
if (!normalizedImageKey) {
|
|
95
134
|
throw new Error("Feishu image download failed: invalid image_key");
|
|
96
135
|
}
|
|
97
|
-
const
|
|
98
|
-
if (!account.configured) {
|
|
99
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const client = createFeishuClient({
|
|
103
|
-
...account,
|
|
104
|
-
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
|
105
|
-
});
|
|
136
|
+
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
|
106
137
|
|
|
107
138
|
const response = await client.im.image.get({
|
|
108
139
|
path: { image_key: normalizedImageKey },
|
|
@@ -132,15 +163,7 @@ export async function downloadMessageResourceFeishu(params: {
|
|
|
132
163
|
if (!normalizedFileKey) {
|
|
133
164
|
throw new Error("Feishu message resource download failed: invalid file_key");
|
|
134
165
|
}
|
|
135
|
-
const
|
|
136
|
-
if (!account.configured) {
|
|
137
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const client = createFeishuClient({
|
|
141
|
-
...account,
|
|
142
|
-
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
|
143
|
-
});
|
|
166
|
+
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
|
144
167
|
|
|
145
168
|
const response = await client.im.messageResource.get({
|
|
146
169
|
path: { message_id: messageId, file_key: normalizedFileKey },
|
|
@@ -179,15 +202,7 @@ export async function uploadImageFeishu(params: {
|
|
|
179
202
|
accountId?: string;
|
|
180
203
|
}): Promise<UploadImageResult> {
|
|
181
204
|
const { cfg, image, imageType = "message", accountId } = params;
|
|
182
|
-
const
|
|
183
|
-
if (!account.configured) {
|
|
184
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const client = createFeishuClient({
|
|
188
|
-
...account,
|
|
189
|
-
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
|
190
|
-
});
|
|
205
|
+
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
|
191
206
|
|
|
192
207
|
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
|
193
208
|
// Using Readable.from(buffer) causes issues with form-data library
|
|
@@ -202,38 +217,26 @@ export async function uploadImageFeishu(params: {
|
|
|
202
217
|
},
|
|
203
218
|
});
|
|
204
219
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
|
|
214
|
-
if (!imageKey) {
|
|
215
|
-
throw new Error("Feishu image upload failed: no image_key returned");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return { imageKey };
|
|
220
|
+
return {
|
|
221
|
+
imageKey: extractFeishuUploadKey(response, {
|
|
222
|
+
key: "image_key",
|
|
223
|
+
errorPrefix: "Feishu image upload failed",
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
219
226
|
}
|
|
220
227
|
|
|
221
228
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* the
|
|
225
|
-
*
|
|
226
|
-
*
|
|
229
|
+
* Sanitize a filename for safe use in Feishu multipart/form-data uploads.
|
|
230
|
+
* Strips control characters and multipart-injection vectors (CWE-93) while
|
|
231
|
+
* preserving the original UTF-8 display name (Chinese, emoji, etc.).
|
|
232
|
+
*
|
|
233
|
+
* Previous versions percent-encoded non-ASCII characters, but the Feishu
|
|
234
|
+
* `im.file.create` API uses `file_name` as a literal display name — it does
|
|
235
|
+
* NOT decode percent-encoding — so encoded filenames appeared as garbled text
|
|
236
|
+
* in chat (regression in v2026.3.2).
|
|
227
237
|
*/
|
|
228
238
|
export function sanitizeFileNameForUpload(fileName: string): string {
|
|
229
|
-
|
|
230
|
-
if (ASCII_ONLY.test(fileName)) {
|
|
231
|
-
return fileName;
|
|
232
|
-
}
|
|
233
|
-
return encodeURIComponent(fileName)
|
|
234
|
-
.replace(/'/g, "%27")
|
|
235
|
-
.replace(/\(/g, "%28")
|
|
236
|
-
.replace(/\)/g, "%29");
|
|
239
|
+
return fileName.replace(/[\x00-\x1F\x7F\r\n"\\]/g, "_");
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
/**
|
|
@@ -249,15 +252,7 @@ export async function uploadFileFeishu(params: {
|
|
|
249
252
|
accountId?: string;
|
|
250
253
|
}): Promise<UploadFileResult> {
|
|
251
254
|
const { cfg, file, fileName, fileType, duration, accountId } = params;
|
|
252
|
-
const
|
|
253
|
-
if (!account.configured) {
|
|
254
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const client = createFeishuClient({
|
|
258
|
-
...account,
|
|
259
|
-
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
|
260
|
-
});
|
|
255
|
+
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
|
261
256
|
|
|
262
257
|
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
|
263
258
|
// Using Readable.from(buffer) causes issues with form-data library
|
|
@@ -276,19 +271,12 @@ export async function uploadFileFeishu(params: {
|
|
|
276
271
|
},
|
|
277
272
|
});
|
|
278
273
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
|
|
287
|
-
if (!fileKey) {
|
|
288
|
-
throw new Error("Feishu file upload failed: no file_key returned");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return { fileKey };
|
|
274
|
+
return {
|
|
275
|
+
fileKey: extractFeishuUploadKey(response, {
|
|
276
|
+
key: "file_key",
|
|
277
|
+
errorPrefix: "Feishu file upload failed",
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
292
280
|
}
|
|
293
281
|
|
|
294
282
|
/**
|
package/src/monitor.account.ts
CHANGED
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
|
|
13
13
|
import { createEventDispatcher } from "./client.js";
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
hasProcessedFeishuMessage,
|
|
16
|
+
recordProcessedFeishuMessage,
|
|
17
|
+
releaseFeishuMessageProcessing,
|
|
18
|
+
tryBeginFeishuMessageProcessing,
|
|
19
19
|
warmupDedupFromDisk,
|
|
20
20
|
} from "./dedup.js";
|
|
21
21
|
import { isMentionForwardRequest } from "./mention.js";
|
|
@@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js";
|
|
|
24
24
|
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
|
25
25
|
import { getFeishuRuntime } from "./runtime.js";
|
|
26
26
|
import { getMessageFeishu } from "./send.js";
|
|
27
|
-
import type { ResolvedFeishuAccount } from "./types.js";
|
|
27
|
+
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
|
|
28
28
|
|
|
29
29
|
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
|
30
30
|
|
|
31
31
|
export type FeishuReactionCreatedEvent = {
|
|
32
32
|
message_id: string;
|
|
33
33
|
chat_id?: string;
|
|
34
|
-
chat_type?:
|
|
34
|
+
chat_type?: string;
|
|
35
35
|
reaction_type?: { emoji_type?: string };
|
|
36
36
|
operator_type?: string;
|
|
37
37
|
user_id?: { open_id?: string };
|
|
@@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent(
|
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
const fallbackChatType = reactedMsg.chatType;
|
|
109
|
+
const normalizedEventChatType = normalizeFeishuChatType(event.chat_type);
|
|
110
|
+
const resolvedChatType = normalizedEventChatType ?? fallbackChatType;
|
|
111
|
+
if (!resolvedChatType) {
|
|
112
|
+
logger?.(
|
|
113
|
+
`feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`,
|
|
114
|
+
);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
|
|
109
119
|
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
|
|
110
|
-
const syntheticChatType:
|
|
111
|
-
event.chat_type === "group" ? "group" : "p2p";
|
|
120
|
+
const syntheticChatType: FeishuChatType = resolvedChatType;
|
|
112
121
|
return {
|
|
113
122
|
sender: {
|
|
114
123
|
sender_id: { open_id: senderId },
|
|
@@ -126,6 +135,10 @@ export async function resolveReactionSyntheticEvent(
|
|
|
126
135
|
};
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
|
|
139
|
+
return value === "group" || value === "private" || value === "p2p" ? value : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
type RegisterEventHandlersContext = {
|
|
130
143
|
cfg: ClawdbotConfig;
|
|
131
144
|
accountId: string;
|
|
@@ -251,6 +264,7 @@ function registerEventHandlers(
|
|
|
251
264
|
runtime,
|
|
252
265
|
chatHistories,
|
|
253
266
|
accountId,
|
|
267
|
+
processingClaimHeld: true,
|
|
254
268
|
});
|
|
255
269
|
await enqueue(chatId, task);
|
|
256
270
|
};
|
|
@@ -278,10 +292,8 @@ function registerEventHandlers(
|
|
|
278
292
|
return;
|
|
279
293
|
}
|
|
280
294
|
for (const messageId of suppressedIds) {
|
|
281
|
-
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
|
|
282
|
-
tryRecordMessage(`${accountId}:${messageId}`);
|
|
283
295
|
try {
|
|
284
|
-
await
|
|
296
|
+
await recordProcessedFeishuMessage(messageId, accountId, log);
|
|
285
297
|
} catch (err) {
|
|
286
298
|
error(
|
|
287
299
|
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
|
@@ -290,15 +302,7 @@ function registerEventHandlers(
|
|
|
290
302
|
}
|
|
291
303
|
};
|
|
292
304
|
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
|
|
293
|
-
|
|
294
|
-
if (!messageId) {
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
const memoryKey = `${accountId}:${messageId}`;
|
|
298
|
-
if (hasRecordedMessage(memoryKey)) {
|
|
299
|
-
return true;
|
|
300
|
-
}
|
|
301
|
-
return hasRecordedMessagePersistent(messageId, accountId, log);
|
|
305
|
+
return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
|
|
302
306
|
};
|
|
303
307
|
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
|
304
308
|
debounceMs: inboundDebounceMs,
|
|
@@ -371,19 +375,28 @@ function registerEventHandlers(
|
|
|
371
375
|
},
|
|
372
376
|
});
|
|
373
377
|
},
|
|
374
|
-
onError: (err) => {
|
|
378
|
+
onError: (err, entries) => {
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
releaseFeishuMessageProcessing(entry.message.message_id, accountId);
|
|
381
|
+
}
|
|
375
382
|
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
|
376
383
|
},
|
|
377
384
|
});
|
|
378
385
|
|
|
379
386
|
eventDispatcher.register({
|
|
380
387
|
"im.message.receive_v1": async (data) => {
|
|
388
|
+
const event = data as unknown as FeishuMessageEvent;
|
|
389
|
+
const messageId = event.message?.message_id?.trim();
|
|
390
|
+
if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
|
|
391
|
+
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
381
394
|
const processMessage = async () => {
|
|
382
|
-
const event = data as unknown as FeishuMessageEvent;
|
|
383
395
|
await inboundDebouncer.enqueue(event);
|
|
384
396
|
};
|
|
385
397
|
if (fireAndForget) {
|
|
386
398
|
void processMessage().catch((err) => {
|
|
399
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
387
400
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
388
401
|
});
|
|
389
402
|
return;
|
|
@@ -391,6 +404,7 @@ function registerEventHandlers(
|
|
|
391
404
|
try {
|
|
392
405
|
await processMessage();
|
|
393
406
|
} catch (err) {
|
|
407
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
394
408
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
395
409
|
}
|
|
396
410
|
},
|
|
@@ -521,6 +535,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
|
|
521
535
|
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
|
|
522
536
|
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
|
523
537
|
}
|
|
538
|
+
if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
|
|
539
|
+
throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
|
|
540
|
+
}
|
|
524
541
|
|
|
525
542
|
const warmupCount = await warmupDedupFromDisk(accountId, log);
|
|
526
543
|
if (warmupCount > 0) {
|
|
@@ -51,10 +51,11 @@ function makeReactionEvent(
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function createFetchedReactionMessage(chatId: string) {
|
|
54
|
+
function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") {
|
|
55
55
|
return {
|
|
56
56
|
messageId: "om_msg1",
|
|
57
57
|
chatId,
|
|
58
|
+
chatType,
|
|
58
59
|
senderOpenId: "ou_bot",
|
|
59
60
|
content: "hello",
|
|
60
61
|
contentType: "text",
|
|
@@ -64,17 +65,38 @@ function createFetchedReactionMessage(chatId: string) {
|
|
|
64
65
|
async function resolveReactionWithLookup(params: {
|
|
65
66
|
event?: FeishuReactionCreatedEvent;
|
|
66
67
|
lookupChatId: string;
|
|
68
|
+
lookupChatType?: "p2p" | "group" | "private";
|
|
67
69
|
}) {
|
|
68
70
|
return await resolveReactionSyntheticEvent({
|
|
69
71
|
cfg,
|
|
70
72
|
accountId: "default",
|
|
71
73
|
event: params.event ?? makeReactionEvent(),
|
|
72
74
|
botOpenId: "ou_bot",
|
|
73
|
-
fetchMessage: async () =>
|
|
75
|
+
fetchMessage: async () =>
|
|
76
|
+
createFetchedReactionMessage(params.lookupChatId, params.lookupChatType),
|
|
74
77
|
uuid: () => "fixed-uuid",
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
|
|
82
|
+
return await resolveReactionSyntheticEvent({
|
|
83
|
+
cfg: params?.cfg ?? cfg,
|
|
84
|
+
accountId: "default",
|
|
85
|
+
event: makeReactionEvent(),
|
|
86
|
+
botOpenId: "ou_bot",
|
|
87
|
+
fetchMessage: async () => ({
|
|
88
|
+
messageId: "om_msg1",
|
|
89
|
+
chatId: "oc_group",
|
|
90
|
+
chatType: "group",
|
|
91
|
+
senderOpenId: "ou_other",
|
|
92
|
+
senderType: "user",
|
|
93
|
+
content: "hello",
|
|
94
|
+
contentType: "text",
|
|
95
|
+
}),
|
|
96
|
+
...(params?.uuid ? { uuid: params.uuid } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
|
79
101
|
|
|
80
102
|
function buildDebounceConfig(): ClawdbotConfig {
|
|
@@ -176,11 +198,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|
|
176
198
|
return firstParams.event;
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
function expectSingleDispatchedEvent(): FeishuMessageEvent {
|
|
202
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
203
|
+
return getFirstDispatchedEvent();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
|
|
207
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
208
|
+
return {
|
|
209
|
+
dispatched,
|
|
210
|
+
parsed: parseFeishuMessageEvent(dispatched, botOpenId),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
179
214
|
function setDedupPassThroughMocks(): void {
|
|
180
|
-
vi.spyOn(dedup, "
|
|
181
|
-
vi.spyOn(dedup, "
|
|
182
|
-
vi.spyOn(dedup, "
|
|
183
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
215
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
216
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
217
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
|
|
@@ -200,6 +234,12 @@ async function enqueueDebouncedMessage(
|
|
|
200
234
|
await Promise.resolve();
|
|
201
235
|
}
|
|
202
236
|
|
|
237
|
+
function setStaleRetryMocks(messageId = "om_old") {
|
|
238
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
|
|
239
|
+
async (currentMessageId) => currentMessageId === messageId,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
203
243
|
describe("resolveReactionSyntheticEvent", () => {
|
|
204
244
|
it("filters app self-reactions", async () => {
|
|
205
245
|
const event = makeReactionEvent({ operator_type: "app" });
|
|
@@ -259,27 +299,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
259
299
|
});
|
|
260
300
|
|
|
261
301
|
it("filters reactions on non-bot messages", async () => {
|
|
262
|
-
const
|
|
263
|
-
const result = await resolveReactionSyntheticEvent({
|
|
264
|
-
cfg,
|
|
265
|
-
accountId: "default",
|
|
266
|
-
event,
|
|
267
|
-
botOpenId: "ou_bot",
|
|
268
|
-
fetchMessage: async () => ({
|
|
269
|
-
messageId: "om_msg1",
|
|
270
|
-
chatId: "oc_group",
|
|
271
|
-
senderOpenId: "ou_other",
|
|
272
|
-
senderType: "user",
|
|
273
|
-
content: "hello",
|
|
274
|
-
contentType: "text",
|
|
275
|
-
}),
|
|
276
|
-
});
|
|
302
|
+
const result = await resolveNonBotReaction();
|
|
277
303
|
expect(result).toBeNull();
|
|
278
304
|
});
|
|
279
305
|
|
|
280
306
|
it("allows non-bot reactions when reactionNotifications is all", async () => {
|
|
281
|
-
const
|
|
282
|
-
const result = await resolveReactionSyntheticEvent({
|
|
307
|
+
const result = await resolveNonBotReaction({
|
|
283
308
|
cfg: {
|
|
284
309
|
channels: {
|
|
285
310
|
feishu: {
|
|
@@ -287,17 +312,6 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
287
312
|
},
|
|
288
313
|
},
|
|
289
314
|
} as ClawdbotConfig,
|
|
290
|
-
accountId: "default",
|
|
291
|
-
event,
|
|
292
|
-
botOpenId: "ou_bot",
|
|
293
|
-
fetchMessage: async () => ({
|
|
294
|
-
messageId: "om_msg1",
|
|
295
|
-
chatId: "oc_group",
|
|
296
|
-
senderOpenId: "ou_other",
|
|
297
|
-
senderType: "user",
|
|
298
|
-
content: "hello",
|
|
299
|
-
contentType: "text",
|
|
300
|
-
}),
|
|
301
315
|
uuid: () => "fixed-uuid",
|
|
302
316
|
});
|
|
303
317
|
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
|
|
@@ -348,21 +362,43 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
348
362
|
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
|
|
349
363
|
const result = await resolveReactionWithLookup({
|
|
350
364
|
lookupChatId: "oc_group_from_lookup",
|
|
365
|
+
lookupChatType: "group",
|
|
351
366
|
});
|
|
352
367
|
|
|
353
368
|
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
|
|
354
|
-
expect(result?.message.chat_type).toBe("
|
|
369
|
+
expect(result?.message.chat_type).toBe("group");
|
|
355
370
|
});
|
|
356
371
|
|
|
357
372
|
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
|
|
358
373
|
const result = await resolveReactionWithLookup({
|
|
359
374
|
lookupChatId: "",
|
|
375
|
+
lookupChatType: "p2p",
|
|
360
376
|
});
|
|
361
377
|
|
|
362
378
|
expect(result?.message.chat_id).toBe("p2p:ou_user1");
|
|
363
379
|
expect(result?.message.chat_type).toBe("p2p");
|
|
364
380
|
});
|
|
365
381
|
|
|
382
|
+
it("drops reactions without chat context when lookup does not provide chat_type", async () => {
|
|
383
|
+
const result = await resolveReactionWithLookup({
|
|
384
|
+
lookupChatId: "oc_group_from_lookup",
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(result).toBeNull();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("drops reactions when event chat_type is invalid and lookup cannot recover it", async () => {
|
|
391
|
+
const result = await resolveReactionWithLookup({
|
|
392
|
+
event: makeReactionEvent({
|
|
393
|
+
chat_id: "oc_group_from_event",
|
|
394
|
+
chat_type: "bogus" as "group",
|
|
395
|
+
}),
|
|
396
|
+
lookupChatId: "oc_group_from_lookup",
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(result).toBeNull();
|
|
400
|
+
});
|
|
401
|
+
|
|
366
402
|
it("logs and drops reactions when lookup throws", async () => {
|
|
367
403
|
const log = vi.fn();
|
|
368
404
|
const event = makeReactionEvent();
|
|
@@ -430,18 +466,16 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
430
466
|
);
|
|
431
467
|
await vi.advanceTimersByTimeAsync(25);
|
|
432
468
|
|
|
433
|
-
|
|
434
|
-
const dispatched = getFirstDispatchedEvent();
|
|
469
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
435
470
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
436
471
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
437
472
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
|
438
473
|
});
|
|
439
474
|
|
|
440
475
|
it("passes prefetched botName through to handleFeishuMessage", async () => {
|
|
441
|
-
vi.spyOn(dedup, "
|
|
442
|
-
vi.spyOn(dedup, "
|
|
443
|
-
vi.spyOn(dedup, "
|
|
444
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
476
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
477
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
478
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
|
|
445
479
|
const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
|
|
446
480
|
|
|
447
481
|
await onMessage(
|
|
@@ -490,9 +524,7 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
490
524
|
);
|
|
491
525
|
await vi.advanceTimersByTimeAsync(25);
|
|
492
526
|
|
|
493
|
-
|
|
494
|
-
const dispatched = getFirstDispatchedEvent();
|
|
495
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
527
|
+
const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
|
|
496
528
|
expect(parsed.mentionedBot).toBe(true);
|
|
497
529
|
expect(parsed.mentionTargets).toBeUndefined();
|
|
498
530
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
@@ -520,19 +552,14 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
520
552
|
);
|
|
521
553
|
await vi.advanceTimersByTimeAsync(25);
|
|
522
554
|
|
|
523
|
-
|
|
524
|
-
const dispatched = getFirstDispatchedEvent();
|
|
525
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
555
|
+
const { parsed } = expectParsedFirstDispatchedEvent();
|
|
526
556
|
expect(parsed.mentionedBot).toBe(true);
|
|
527
557
|
});
|
|
528
558
|
|
|
529
559
|
it("excludes previously processed retries from combined debounce text", async () => {
|
|
530
|
-
vi.spyOn(dedup, "
|
|
531
|
-
vi.spyOn(dedup, "
|
|
532
|
-
|
|
533
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
534
|
-
async (messageId) => messageId === "om_old",
|
|
535
|
-
);
|
|
560
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
561
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
562
|
+
setStaleRetryMocks();
|
|
536
563
|
const onMessage = await setupDebounceMonitor();
|
|
537
564
|
|
|
538
565
|
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
@@ -549,20 +576,16 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
549
576
|
await Promise.resolve();
|
|
550
577
|
await vi.advanceTimersByTimeAsync(25);
|
|
551
578
|
|
|
552
|
-
|
|
553
|
-
const dispatched = getFirstDispatchedEvent();
|
|
579
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
554
580
|
expect(dispatched.message.message_id).toBe("om_new_2");
|
|
555
581
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
556
582
|
expect(combined.text).toBe("first\nsecond");
|
|
557
583
|
});
|
|
558
584
|
|
|
559
585
|
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
|
560
|
-
|
|
561
|
-
vi.spyOn(dedup, "
|
|
562
|
-
|
|
563
|
-
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
|
564
|
-
async (messageId) => messageId === "om_old",
|
|
565
|
-
);
|
|
586
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
587
|
+
const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
588
|
+
setStaleRetryMocks();
|
|
566
589
|
const onMessage = await setupDebounceMonitor();
|
|
567
590
|
|
|
568
591
|
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
|
@@ -573,12 +596,58 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
573
596
|
await Promise.resolve();
|
|
574
597
|
await vi.advanceTimersByTimeAsync(25);
|
|
575
598
|
|
|
576
|
-
|
|
577
|
-
const dispatched = getFirstDispatchedEvent();
|
|
599
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
578
600
|
expect(dispatched.message.message_id).toBe("om_new");
|
|
579
601
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
580
602
|
expect(combined.text).toBe("fresh");
|
|
581
|
-
expect(recordSpy).toHaveBeenCalledWith("
|
|
582
|
-
expect(recordSpy).not.toHaveBeenCalledWith("
|
|
603
|
+
expect(recordSpy).toHaveBeenCalledWith("om_old", "default", expect.any(Function));
|
|
604
|
+
expect(recordSpy).not.toHaveBeenCalledWith("om_new", "default", expect.any(Function));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("releases early event dedupe when debounced dispatch fails", async () => {
|
|
608
|
+
setDedupPassThroughMocks();
|
|
609
|
+
const enqueueMock = vi.fn();
|
|
610
|
+
setFeishuRuntime(
|
|
611
|
+
createPluginRuntimeMock({
|
|
612
|
+
channel: {
|
|
613
|
+
debounce: {
|
|
614
|
+
createInboundDebouncer: <T>(params: {
|
|
615
|
+
onError?: (err: unknown, items: T[]) => void;
|
|
616
|
+
}) => ({
|
|
617
|
+
enqueue: async (item: T) => {
|
|
618
|
+
enqueueMock(item);
|
|
619
|
+
params.onError?.(new Error("dispatch failed"), [item]);
|
|
620
|
+
},
|
|
621
|
+
flushKey: async () => {},
|
|
622
|
+
}),
|
|
623
|
+
resolveInboundDebounceMs,
|
|
624
|
+
},
|
|
625
|
+
text: {
|
|
626
|
+
hasControlCommand,
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
const onMessage = await setupDebounceMonitor();
|
|
632
|
+
const event = createTextEvent({ messageId: "om_retryable", text: "hello" });
|
|
633
|
+
|
|
634
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
635
|
+
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
636
|
+
|
|
637
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
638
|
+
expect(enqueueMock).toHaveBeenCalledTimes(2);
|
|
639
|
+
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("drops duplicate inbound events before they re-enter the debounce pipeline", async () => {
|
|
643
|
+
const onMessage = await setupDebounceMonitor();
|
|
644
|
+
const event = createTextEvent({ messageId: "om_duplicate", text: "hello" });
|
|
645
|
+
|
|
646
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
647
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
648
|
+
await enqueueDebouncedMessage(onMessage, event);
|
|
649
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
650
|
+
|
|
651
|
+
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
|
583
652
|
});
|
|
584
653
|
});
|