@openclaw/feishu 2026.3.12 → 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 +21 -16
- package/src/bot.ts +20 -11
- package/src/config-schema.test.ts +14 -24
- package/src/dedup.ts +103 -0
- package/src/media.test.ts +38 -61
- package/src/media.ts +64 -76
- package/src/monitor.account.ts +19 -18
- package/src/monitor.reaction.test.ts +106 -64
- 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 +9 -97
- package/src/monitor.webhook.test-helpers.ts +98 -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 +91 -82
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";
|
|
@@ -264,6 +264,7 @@ function registerEventHandlers(
|
|
|
264
264
|
runtime,
|
|
265
265
|
chatHistories,
|
|
266
266
|
accountId,
|
|
267
|
+
processingClaimHeld: true,
|
|
267
268
|
});
|
|
268
269
|
await enqueue(chatId, task);
|
|
269
270
|
};
|
|
@@ -291,10 +292,8 @@ function registerEventHandlers(
|
|
|
291
292
|
return;
|
|
292
293
|
}
|
|
293
294
|
for (const messageId of suppressedIds) {
|
|
294
|
-
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
|
|
295
|
-
tryRecordMessage(`${accountId}:${messageId}`);
|
|
296
295
|
try {
|
|
297
|
-
await
|
|
296
|
+
await recordProcessedFeishuMessage(messageId, accountId, log);
|
|
298
297
|
} catch (err) {
|
|
299
298
|
error(
|
|
300
299
|
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
|
@@ -303,15 +302,7 @@ function registerEventHandlers(
|
|
|
303
302
|
}
|
|
304
303
|
};
|
|
305
304
|
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
|
|
306
|
-
|
|
307
|
-
if (!messageId) {
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
const memoryKey = `${accountId}:${messageId}`;
|
|
311
|
-
if (hasRecordedMessage(memoryKey)) {
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
return hasRecordedMessagePersistent(messageId, accountId, log);
|
|
305
|
+
return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
|
|
315
306
|
};
|
|
316
307
|
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
|
317
308
|
debounceMs: inboundDebounceMs,
|
|
@@ -384,19 +375,28 @@ function registerEventHandlers(
|
|
|
384
375
|
},
|
|
385
376
|
});
|
|
386
377
|
},
|
|
387
|
-
onError: (err) => {
|
|
378
|
+
onError: (err, entries) => {
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
releaseFeishuMessageProcessing(entry.message.message_id, accountId);
|
|
381
|
+
}
|
|
388
382
|
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
|
389
383
|
},
|
|
390
384
|
});
|
|
391
385
|
|
|
392
386
|
eventDispatcher.register({
|
|
393
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
|
+
}
|
|
394
394
|
const processMessage = async () => {
|
|
395
|
-
const event = data as unknown as FeishuMessageEvent;
|
|
396
395
|
await inboundDebouncer.enqueue(event);
|
|
397
396
|
};
|
|
398
397
|
if (fireAndForget) {
|
|
399
398
|
void processMessage().catch((err) => {
|
|
399
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
400
400
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
401
401
|
});
|
|
402
402
|
return;
|
|
@@ -404,6 +404,7 @@ function registerEventHandlers(
|
|
|
404
404
|
try {
|
|
405
405
|
await processMessage();
|
|
406
406
|
} catch (err) {
|
|
407
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
407
408
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
408
409
|
}
|
|
409
410
|
},
|
|
@@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
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
|
+
|
|
81
100
|
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
|
82
101
|
|
|
83
102
|
function buildDebounceConfig(): ClawdbotConfig {
|
|
@@ -179,11 +198,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|
|
179
198
|
return firstParams.event;
|
|
180
199
|
}
|
|
181
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
|
+
|
|
182
214
|
function setDedupPassThroughMocks(): void {
|
|
183
|
-
vi.spyOn(dedup, "
|
|
184
|
-
vi.spyOn(dedup, "
|
|
185
|
-
vi.spyOn(dedup, "
|
|
186
|
-
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);
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
|
|
@@ -203,6 +234,12 @@ async function enqueueDebouncedMessage(
|
|
|
203
234
|
await Promise.resolve();
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
function setStaleRetryMocks(messageId = "om_old") {
|
|
238
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
|
|
239
|
+
async (currentMessageId) => currentMessageId === messageId,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
206
243
|
describe("resolveReactionSyntheticEvent", () => {
|
|
207
244
|
it("filters app self-reactions", async () => {
|
|
208
245
|
const event = makeReactionEvent({ operator_type: "app" });
|
|
@@ -262,28 +299,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
262
299
|
});
|
|
263
300
|
|
|
264
301
|
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
|
-
});
|
|
302
|
+
const result = await resolveNonBotReaction();
|
|
281
303
|
expect(result).toBeNull();
|
|
282
304
|
});
|
|
283
305
|
|
|
284
306
|
it("allows non-bot reactions when reactionNotifications is all", async () => {
|
|
285
|
-
const
|
|
286
|
-
const result = await resolveReactionSyntheticEvent({
|
|
307
|
+
const result = await resolveNonBotReaction({
|
|
287
308
|
cfg: {
|
|
288
309
|
channels: {
|
|
289
310
|
feishu: {
|
|
@@ -291,18 +312,6 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
291
312
|
},
|
|
292
313
|
},
|
|
293
314
|
} 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
315
|
uuid: () => "fixed-uuid",
|
|
307
316
|
});
|
|
308
317
|
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
|
|
@@ -457,18 +466,16 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
457
466
|
);
|
|
458
467
|
await vi.advanceTimersByTimeAsync(25);
|
|
459
468
|
|
|
460
|
-
|
|
461
|
-
const dispatched = getFirstDispatchedEvent();
|
|
469
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
462
470
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
463
471
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
|
464
472
|
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
|
465
473
|
});
|
|
466
474
|
|
|
467
475
|
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);
|
|
476
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
477
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
478
|
+
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
|
|
472
479
|
const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
|
|
473
480
|
|
|
474
481
|
await onMessage(
|
|
@@ -517,9 +524,7 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
517
524
|
);
|
|
518
525
|
await vi.advanceTimersByTimeAsync(25);
|
|
519
526
|
|
|
520
|
-
|
|
521
|
-
const dispatched = getFirstDispatchedEvent();
|
|
522
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
527
|
+
const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
|
|
523
528
|
expect(parsed.mentionedBot).toBe(true);
|
|
524
529
|
expect(parsed.mentionTargets).toBeUndefined();
|
|
525
530
|
const mergedMentions = dispatched.message.mentions ?? [];
|
|
@@ -547,19 +552,14 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
547
552
|
);
|
|
548
553
|
await vi.advanceTimersByTimeAsync(25);
|
|
549
554
|
|
|
550
|
-
|
|
551
|
-
const dispatched = getFirstDispatchedEvent();
|
|
552
|
-
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
|
555
|
+
const { parsed } = expectParsedFirstDispatchedEvent();
|
|
553
556
|
expect(parsed.mentionedBot).toBe(true);
|
|
554
557
|
});
|
|
555
558
|
|
|
556
559
|
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
|
-
);
|
|
560
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
561
|
+
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
562
|
+
setStaleRetryMocks();
|
|
563
563
|
const onMessage = await setupDebounceMonitor();
|
|
564
564
|
|
|
565
565
|
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
|
@@ -576,20 +576,16 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
576
576
|
await Promise.resolve();
|
|
577
577
|
await vi.advanceTimersByTimeAsync(25);
|
|
578
578
|
|
|
579
|
-
|
|
580
|
-
const dispatched = getFirstDispatchedEvent();
|
|
579
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
581
580
|
expect(dispatched.message.message_id).toBe("om_new_2");
|
|
582
581
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
583
582
|
expect(combined.text).toBe("first\nsecond");
|
|
584
583
|
});
|
|
585
584
|
|
|
586
585
|
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
|
-
);
|
|
586
|
+
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
587
|
+
const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
588
|
+
setStaleRetryMocks();
|
|
593
589
|
const onMessage = await setupDebounceMonitor();
|
|
594
590
|
|
|
595
591
|
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
|
@@ -600,12 +596,58 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
600
596
|
await Promise.resolve();
|
|
601
597
|
await vi.advanceTimersByTimeAsync(25);
|
|
602
598
|
|
|
603
|
-
|
|
604
|
-
const dispatched = getFirstDispatchedEvent();
|
|
599
|
+
const dispatched = expectSingleDispatchedEvent();
|
|
605
600
|
expect(dispatched.message.message_id).toBe("om_new");
|
|
606
601
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
607
602
|
expect(combined.text).toBe("fresh");
|
|
608
|
-
expect(recordSpy).toHaveBeenCalledWith("
|
|
609
|
-
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);
|
|
610
652
|
});
|
|
611
653
|
});
|
|
@@ -3,33 +3,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
4
4
|
|
|
5
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
|
-
}));
|
|
26
6
|
|
|
27
7
|
vi.mock("./probe.js", () => ({
|
|
28
8
|
probeFeishu: probeFeishuMock,
|
|
29
9
|
}));
|
|
30
10
|
|
|
31
|
-
vi.mock("./client.js", () =>
|
|
32
|
-
|
|
11
|
+
vi.mock("./client.js", async () => {
|
|
12
|
+
const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js");
|
|
13
|
+
return createFeishuClientMockModule();
|
|
14
|
+
});
|
|
15
|
+
vi.mock("./runtime.js", async () => {
|
|
16
|
+
const { createFeishuRuntimeMockModule } = await import("./monitor.test-mocks.js");
|
|
17
|
+
return createFeishuRuntimeMockModule();
|
|
18
|
+
});
|
|
33
19
|
|
|
34
20
|
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
|
35
21
|
return {
|
|
@@ -52,6 +38,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
|
|
|
52
38
|
} as ClawdbotConfig;
|
|
53
39
|
}
|
|
54
40
|
|
|
41
|
+
async function waitForStartedAccount(started: string[], accountId: string) {
|
|
42
|
+
for (let i = 0; i < 10 && !started.includes(accountId); i += 1) {
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
55
47
|
afterEach(() => {
|
|
56
48
|
stopFeishuMonitor();
|
|
57
49
|
});
|
|
@@ -116,10 +108,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
116
108
|
});
|
|
117
109
|
|
|
118
110
|
try {
|
|
119
|
-
|
|
120
|
-
await Promise.resolve();
|
|
121
|
-
}
|
|
122
|
-
|
|
111
|
+
await waitForStartedAccount(started, "beta");
|
|
123
112
|
expect(started).toEqual(["alpha", "beta"]);
|
|
124
113
|
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
|
125
114
|
} finally {
|
|
@@ -153,10 +142,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
153
142
|
});
|
|
154
143
|
|
|
155
144
|
try {
|
|
156
|
-
|
|
157
|
-
await Promise.resolve();
|
|
158
|
-
}
|
|
159
|
-
|
|
145
|
+
await waitForStartedAccount(started, "beta");
|
|
160
146
|
expect(started).toEqual(["alpha", "beta"]);
|
|
161
147
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
162
148
|
expect.stringContaining("bot info probe timed out"),
|
package/src/monitor.transport.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
4
|
import {
|
|
4
5
|
applyBasicWebhookRequestGuards,
|
|
6
|
+
readJsonBodyWithLimit,
|
|
5
7
|
type RuntimeEnv,
|
|
6
8
|
installRequestBodyLimitGuard,
|
|
7
9
|
} from "openclaw/plugin-sdk/feishu";
|
|
@@ -26,6 +28,50 @@ export type MonitorTransportParams = {
|
|
|
26
28
|
eventDispatcher: Lark.EventDispatcher;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
function isFeishuWebhookPayload(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildFeishuWebhookEnvelope(
|
|
36
|
+
req: http.IncomingMessage,
|
|
37
|
+
payload: Record<string, unknown>,
|
|
38
|
+
): Record<string, unknown> {
|
|
39
|
+
return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isFeishuWebhookSignatureValid(params: {
|
|
43
|
+
headers: http.IncomingHttpHeaders;
|
|
44
|
+
payload: Record<string, unknown>;
|
|
45
|
+
encryptKey?: string;
|
|
46
|
+
}): boolean {
|
|
47
|
+
const encryptKey = params.encryptKey?.trim();
|
|
48
|
+
if (!encryptKey) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timestampHeader = params.headers["x-lark-request-timestamp"];
|
|
53
|
+
const nonceHeader = params.headers["x-lark-request-nonce"];
|
|
54
|
+
const signatureHeader = params.headers["x-lark-signature"];
|
|
55
|
+
const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
|
|
56
|
+
const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader;
|
|
57
|
+
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
|
58
|
+
if (!timestamp || !nonce || !signature) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const computedSignature = crypto
|
|
63
|
+
.createHash("sha256")
|
|
64
|
+
.update(timestamp + nonce + encryptKey + JSON.stringify(params.payload))
|
|
65
|
+
.digest("hex");
|
|
66
|
+
return computedSignature === signature;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
|
|
70
|
+
res.statusCode = statusCode;
|
|
71
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
72
|
+
res.end(body);
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
export async function monitorWebSocket({
|
|
30
76
|
account,
|
|
31
77
|
accountId,
|
|
@@ -88,7 +134,6 @@ export async function monitorWebhook({
|
|
|
88
134
|
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
|
|
89
135
|
|
|
90
136
|
const server = http.createServer();
|
|
91
|
-
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
|
|
92
137
|
|
|
93
138
|
server.on("request", (req, res) => {
|
|
94
139
|
res.on("finish", () => {
|
|
@@ -118,15 +163,68 @@ export async function monitorWebhook({
|
|
|
118
163
|
return;
|
|
119
164
|
}
|
|
120
165
|
|
|
121
|
-
void
|
|
122
|
-
|
|
166
|
+
void (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const bodyResult = await readJsonBodyWithLimit(req, {
|
|
169
|
+
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
|
170
|
+
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
|
171
|
+
});
|
|
172
|
+
if (guard.isTripped() || res.writableEnded) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!bodyResult.ok) {
|
|
176
|
+
if (bodyResult.code === "INVALID_JSON") {
|
|
177
|
+
respondText(res, 400, "Invalid JSON");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!isFeishuWebhookPayload(bodyResult.value)) {
|
|
182
|
+
respondText(res, 400, "Invalid JSON");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Lark's default adapter drops invalid signatures as an empty 200. Reject here instead.
|
|
187
|
+
if (
|
|
188
|
+
!isFeishuWebhookSignatureValid({
|
|
189
|
+
headers: req.headers,
|
|
190
|
+
payload: bodyResult.value,
|
|
191
|
+
encryptKey: account.encryptKey,
|
|
192
|
+
})
|
|
193
|
+
) {
|
|
194
|
+
respondText(res, 401, "Invalid signature");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, {
|
|
199
|
+
encryptKey: account.encryptKey ?? "",
|
|
200
|
+
});
|
|
201
|
+
if (isChallenge) {
|
|
202
|
+
res.statusCode = 200;
|
|
203
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
204
|
+
res.end(JSON.stringify(challenge));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const value = await eventDispatcher.invoke(
|
|
209
|
+
buildFeishuWebhookEnvelope(req, bodyResult.value),
|
|
210
|
+
{ needCheck: false },
|
|
211
|
+
);
|
|
212
|
+
if (!res.headersSent) {
|
|
213
|
+
res.statusCode = 200;
|
|
214
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
215
|
+
res.end(JSON.stringify(value));
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
123
218
|
if (!guard.isTripped()) {
|
|
124
219
|
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
|
|
220
|
+
if (!res.headersSent) {
|
|
221
|
+
respondText(res, 500, "Internal Server Error");
|
|
222
|
+
}
|
|
125
223
|
}
|
|
126
|
-
}
|
|
127
|
-
.finally(() => {
|
|
224
|
+
} finally {
|
|
128
225
|
guard.dispose();
|
|
129
|
-
}
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
130
228
|
});
|
|
131
229
|
|
|
132
230
|
httpServers.set(accountId, server);
|