@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/probe.test.ts
CHANGED
|
@@ -8,6 +8,22 @@ vi.mock("./client.js", () => ({
|
|
|
8
8
|
|
|
9
9
|
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
|
10
10
|
|
|
11
|
+
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
|
|
12
|
+
const DEFAULT_SUCCESS_RESPONSE = {
|
|
13
|
+
code: 0,
|
|
14
|
+
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
15
|
+
} as const;
|
|
16
|
+
const DEFAULT_SUCCESS_RESULT = {
|
|
17
|
+
ok: true,
|
|
18
|
+
appId: "cli_123",
|
|
19
|
+
botName: "TestBot",
|
|
20
|
+
botOpenId: "ou_abc123",
|
|
21
|
+
} as const;
|
|
22
|
+
const BOT1_RESPONSE = {
|
|
23
|
+
code: 0,
|
|
24
|
+
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
11
27
|
function makeRequestFn(response: Record<string, unknown>) {
|
|
12
28
|
return vi.fn().mockResolvedValue(response);
|
|
13
29
|
}
|
|
@@ -18,6 +34,64 @@ function setupClient(response: Record<string, unknown>) {
|
|
|
18
34
|
return requestFn;
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
function setupSuccessClient() {
|
|
38
|
+
return setupClient(DEFAULT_SUCCESS_RESPONSE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function expectDefaultSuccessResult(
|
|
42
|
+
creds = DEFAULT_CREDS,
|
|
43
|
+
expected: Awaited<ReturnType<typeof probeFeishu>> = DEFAULT_SUCCESS_RESULT,
|
|
44
|
+
) {
|
|
45
|
+
const result = await probeFeishu(creds);
|
|
46
|
+
expect(result).toEqual(expected);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function withFakeTimers(run: () => Promise<void>) {
|
|
50
|
+
vi.useFakeTimers();
|
|
51
|
+
try {
|
|
52
|
+
await run();
|
|
53
|
+
} finally {
|
|
54
|
+
vi.useRealTimers();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function expectErrorResultCached(params: {
|
|
59
|
+
requestFn: ReturnType<typeof vi.fn>;
|
|
60
|
+
expectedError: string;
|
|
61
|
+
ttlMs: number;
|
|
62
|
+
}) {
|
|
63
|
+
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
|
|
64
|
+
|
|
65
|
+
const first = await probeFeishu(DEFAULT_CREDS);
|
|
66
|
+
const second = await probeFeishu(DEFAULT_CREDS);
|
|
67
|
+
expect(first).toMatchObject({ ok: false, error: params.expectedError });
|
|
68
|
+
expect(second).toMatchObject({ ok: false, error: params.expectedError });
|
|
69
|
+
expect(params.requestFn).toHaveBeenCalledTimes(1);
|
|
70
|
+
|
|
71
|
+
vi.advanceTimersByTime(params.ttlMs + 1);
|
|
72
|
+
|
|
73
|
+
await probeFeishu(DEFAULT_CREDS);
|
|
74
|
+
expect(params.requestFn).toHaveBeenCalledTimes(2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function expectFreshDefaultProbeAfter(
|
|
78
|
+
requestFn: ReturnType<typeof vi.fn>,
|
|
79
|
+
invalidate: () => void,
|
|
80
|
+
) {
|
|
81
|
+
await probeFeishu(DEFAULT_CREDS);
|
|
82
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
invalidate();
|
|
85
|
+
|
|
86
|
+
await probeFeishu(DEFAULT_CREDS);
|
|
87
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readSequentialDefaultProbePair() {
|
|
91
|
+
const first = await probeFeishu(DEFAULT_CREDS);
|
|
92
|
+
return { first, second: await probeFeishu(DEFAULT_CREDS) };
|
|
93
|
+
}
|
|
94
|
+
|
|
21
95
|
describe("probeFeishu", () => {
|
|
22
96
|
beforeEach(() => {
|
|
23
97
|
clearProbeCache();
|
|
@@ -44,28 +118,16 @@ describe("probeFeishu", () => {
|
|
|
44
118
|
});
|
|
45
119
|
|
|
46
120
|
it("returns bot info on successful probe", async () => {
|
|
47
|
-
const requestFn =
|
|
48
|
-
code: 0,
|
|
49
|
-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
50
|
-
});
|
|
121
|
+
const requestFn = setupSuccessClient();
|
|
51
122
|
|
|
52
|
-
|
|
53
|
-
expect(result).toEqual({
|
|
54
|
-
ok: true,
|
|
55
|
-
appId: "cli_123",
|
|
56
|
-
botName: "TestBot",
|
|
57
|
-
botOpenId: "ou_abc123",
|
|
58
|
-
});
|
|
123
|
+
await expectDefaultSuccessResult();
|
|
59
124
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
60
125
|
});
|
|
61
126
|
|
|
62
127
|
it("passes the probe timeout to the Feishu request", async () => {
|
|
63
|
-
const requestFn =
|
|
64
|
-
code: 0,
|
|
65
|
-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
66
|
-
});
|
|
128
|
+
const requestFn = setupSuccessClient();
|
|
67
129
|
|
|
68
|
-
await probeFeishu(
|
|
130
|
+
await probeFeishu(DEFAULT_CREDS);
|
|
69
131
|
|
|
70
132
|
expect(requestFn).toHaveBeenCalledWith(
|
|
71
133
|
expect.objectContaining({
|
|
@@ -77,19 +139,16 @@ describe("probeFeishu", () => {
|
|
|
77
139
|
});
|
|
78
140
|
|
|
79
141
|
it("returns timeout error when request exceeds timeout", async () => {
|
|
80
|
-
|
|
81
|
-
try {
|
|
142
|
+
await withFakeTimers(async () => {
|
|
82
143
|
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
83
144
|
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
|
84
145
|
|
|
85
|
-
const promise = probeFeishu(
|
|
146
|
+
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
|
86
147
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
87
148
|
const result = await promise;
|
|
88
149
|
|
|
89
150
|
expect(result).toMatchObject({ ok: false, error: "probe timed out after 1000ms" });
|
|
90
|
-
}
|
|
91
|
-
vi.useRealTimers();
|
|
92
|
-
}
|
|
151
|
+
});
|
|
93
152
|
});
|
|
94
153
|
|
|
95
154
|
it("returns aborted when abort signal is already aborted", async () => {
|
|
@@ -106,14 +165,9 @@ describe("probeFeishu", () => {
|
|
|
106
165
|
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
|
107
166
|
});
|
|
108
167
|
it("returns cached result on subsequent calls within TTL", async () => {
|
|
109
|
-
const requestFn =
|
|
110
|
-
code: 0,
|
|
111
|
-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
112
|
-
});
|
|
168
|
+
const requestFn = setupSuccessClient();
|
|
113
169
|
|
|
114
|
-
const
|
|
115
|
-
const first = await probeFeishu(creds);
|
|
116
|
-
const second = await probeFeishu(creds);
|
|
170
|
+
const { first, second } = await readSequentialDefaultProbePair();
|
|
117
171
|
|
|
118
172
|
expect(first).toEqual(second);
|
|
119
173
|
// Only one API call should have been made
|
|
@@ -121,76 +175,37 @@ describe("probeFeishu", () => {
|
|
|
121
175
|
});
|
|
122
176
|
|
|
123
177
|
it("makes a fresh API call after cache expires", async () => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const requestFn = setupClient({
|
|
127
|
-
code: 0,
|
|
128
|
-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
132
|
-
await probeFeishu(creds);
|
|
133
|
-
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
134
|
-
|
|
135
|
-
// Advance time past the success TTL
|
|
136
|
-
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
|
178
|
+
await withFakeTimers(async () => {
|
|
179
|
+
const requestFn = setupSuccessClient();
|
|
137
180
|
|
|
138
|
-
await
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
181
|
+
await expectFreshDefaultProbeAfter(requestFn, () => {
|
|
182
|
+
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
143
185
|
});
|
|
144
186
|
|
|
145
187
|
it("caches failed probe results (API error) for the error TTL", async () => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const second = await probeFeishu(creds);
|
|
154
|
-
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
|
|
155
|
-
expect(second).toMatchObject({ ok: false, error: "API error: token expired" });
|
|
156
|
-
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
157
|
-
|
|
158
|
-
vi.advanceTimersByTime(60 * 1000 + 1);
|
|
159
|
-
|
|
160
|
-
await probeFeishu(creds);
|
|
161
|
-
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
162
|
-
} finally {
|
|
163
|
-
vi.useRealTimers();
|
|
164
|
-
}
|
|
188
|
+
await withFakeTimers(async () => {
|
|
189
|
+
await expectErrorResultCached({
|
|
190
|
+
requestFn: makeRequestFn({ code: 99, msg: "token expired" }),
|
|
191
|
+
expectedError: "API error: token expired",
|
|
192
|
+
ttlMs: 60 * 1000,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
165
195
|
});
|
|
166
196
|
|
|
167
197
|
it("caches thrown request errors for the error TTL", async () => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const second = await probeFeishu(creds);
|
|
176
|
-
expect(first).toMatchObject({ ok: false, error: "network error" });
|
|
177
|
-
expect(second).toMatchObject({ ok: false, error: "network error" });
|
|
178
|
-
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
179
|
-
|
|
180
|
-
vi.advanceTimersByTime(60 * 1000 + 1);
|
|
181
|
-
|
|
182
|
-
await probeFeishu(creds);
|
|
183
|
-
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
184
|
-
} finally {
|
|
185
|
-
vi.useRealTimers();
|
|
186
|
-
}
|
|
198
|
+
await withFakeTimers(async () => {
|
|
199
|
+
await expectErrorResultCached({
|
|
200
|
+
requestFn: vi.fn().mockRejectedValue(new Error("network error")),
|
|
201
|
+
expectedError: "network error",
|
|
202
|
+
ttlMs: 60 * 1000,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
187
205
|
});
|
|
188
206
|
|
|
189
207
|
it("caches per account independently", async () => {
|
|
190
|
-
const requestFn = setupClient(
|
|
191
|
-
code: 0,
|
|
192
|
-
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
|
193
|
-
});
|
|
208
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
194
209
|
|
|
195
210
|
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
|
|
196
211
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
@@ -205,10 +220,7 @@ describe("probeFeishu", () => {
|
|
|
205
220
|
});
|
|
206
221
|
|
|
207
222
|
it("does not share cache between accounts with same appId but different appSecret", async () => {
|
|
208
|
-
const requestFn = setupClient(
|
|
209
|
-
code: 0,
|
|
210
|
-
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
|
211
|
-
});
|
|
223
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
212
224
|
|
|
213
225
|
// First account with appId + secret A
|
|
214
226
|
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
|
|
@@ -221,10 +233,7 @@ describe("probeFeishu", () => {
|
|
|
221
233
|
});
|
|
222
234
|
|
|
223
235
|
it("uses accountId for cache key when available", async () => {
|
|
224
|
-
const requestFn = setupClient(
|
|
225
|
-
code: 0,
|
|
226
|
-
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
|
227
|
-
});
|
|
236
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
228
237
|
|
|
229
238
|
// Two accounts with same appId+appSecret but different accountIds are cached separately
|
|
230
239
|
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
@@ -239,19 +248,11 @@ describe("probeFeishu", () => {
|
|
|
239
248
|
});
|
|
240
249
|
|
|
241
250
|
it("clearProbeCache forces fresh API call", async () => {
|
|
242
|
-
const requestFn =
|
|
243
|
-
code: 0,
|
|
244
|
-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
248
|
-
await probeFeishu(creds);
|
|
249
|
-
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
251
|
+
const requestFn = setupSuccessClient();
|
|
250
252
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
253
|
+
await expectFreshDefaultProbeAfter(requestFn, () => {
|
|
254
|
+
clearProbeCache();
|
|
255
|
+
});
|
|
255
256
|
});
|
|
256
257
|
|
|
257
258
|
it("handles response.data.bot fallback path", async () => {
|
|
@@ -260,10 +261,8 @@ describe("probeFeishu", () => {
|
|
|
260
261
|
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
|
|
261
262
|
});
|
|
262
263
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
ok: true,
|
|
266
|
-
appId: "cli_123",
|
|
264
|
+
await expectDefaultSuccessResult(DEFAULT_CREDS, {
|
|
265
|
+
...DEFAULT_SUCCESS_RESULT,
|
|
267
266
|
botName: "DataBot",
|
|
268
267
|
botOpenId: "ou_data",
|
|
269
268
|
});
|
package/src/reactions.ts
CHANGED
|
@@ -9,6 +9,20 @@ export type FeishuReaction = {
|
|
|
9
9
|
operatorId: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) {
|
|
13
|
+
const account = resolveFeishuAccount(params);
|
|
14
|
+
if (!account.configured) {
|
|
15
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
16
|
+
}
|
|
17
|
+
return createFeishuClient(account);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) {
|
|
21
|
+
if (response.code !== 0) {
|
|
22
|
+
throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
/**
|
|
13
27
|
* Add a reaction (emoji) to a message.
|
|
14
28
|
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
|
@@ -21,12 +35,7 @@ export async function addReactionFeishu(params: {
|
|
|
21
35
|
accountId?: string;
|
|
22
36
|
}): Promise<{ reactionId: string }> {
|
|
23
37
|
const { cfg, messageId, emojiType, accountId } = params;
|
|
24
|
-
const
|
|
25
|
-
if (!account.configured) {
|
|
26
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const client = createFeishuClient(account);
|
|
38
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
30
39
|
|
|
31
40
|
const response = (await client.im.messageReaction.create({
|
|
32
41
|
path: { message_id: messageId },
|
|
@@ -41,9 +50,7 @@ export async function addReactionFeishu(params: {
|
|
|
41
50
|
data?: { reaction_id?: string };
|
|
42
51
|
};
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
46
|
-
}
|
|
53
|
+
assertFeishuReactionApiSuccess(response, "add reaction");
|
|
47
54
|
|
|
48
55
|
const reactionId = response.data?.reaction_id;
|
|
49
56
|
if (!reactionId) {
|
|
@@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: {
|
|
|
63
70
|
accountId?: string;
|
|
64
71
|
}): Promise<void> {
|
|
65
72
|
const { cfg, messageId, reactionId, accountId } = params;
|
|
66
|
-
const
|
|
67
|
-
if (!account.configured) {
|
|
68
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const client = createFeishuClient(account);
|
|
73
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
72
74
|
|
|
73
75
|
const response = (await client.im.messageReaction.delete({
|
|
74
76
|
path: {
|
|
@@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: {
|
|
|
77
79
|
},
|
|
78
80
|
})) as { code?: number; msg?: string };
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
82
|
-
}
|
|
82
|
+
assertFeishuReactionApiSuccess(response, "remove reaction");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
@@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: {
|
|
|
92
92
|
accountId?: string;
|
|
93
93
|
}): Promise<FeishuReaction[]> {
|
|
94
94
|
const { cfg, messageId, emojiType, accountId } = params;
|
|
95
|
-
const
|
|
96
|
-
if (!account.configured) {
|
|
97
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const client = createFeishuClient(account);
|
|
95
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
101
96
|
|
|
102
97
|
const response = (await client.im.messageReaction.list({
|
|
103
98
|
path: { message_id: messageId },
|
|
@@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: {
|
|
|
115
110
|
};
|
|
116
111
|
};
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
|
120
|
-
}
|
|
113
|
+
assertFeishuReactionApiSuccess(response, "list reactions");
|
|
121
114
|
|
|
122
115
|
const items = response.data?.items ?? [];
|
|
123
116
|
return items.map((item) => ({
|