@openclaw/feishu 2026.3.1 → 2026.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +268 -11
- package/src/accounts.ts +101 -14
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +9 -1
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +945 -77
- package/src/bot.ts +492 -165
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +72 -68
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +221 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +33 -6
- package/src/config-schema.ts +18 -10
- package/src/dedup.ts +47 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/doc-schema.ts +16 -22
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +10 -16
- package/src/docx.test.ts +41 -189
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +164 -14
- package/src/media.ts +44 -10
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +284 -25
- package/src/monitor.reaction.test.ts +395 -46
- package/src/monitor.startup.test.ts +25 -8
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +88 -9
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +13 -11
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +213 -106
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +54 -36
- package/src/probe.ts +57 -37
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +216 -0
- package/src/reply-dispatcher.ts +89 -22
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +7 -3
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +25 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +11 -4
- package/src/typing.ts +1 -1
- package/src/wiki.ts +15 -19
package/src/outbound.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
|
|
4
4
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
5
|
import { sendMediaFeishu } from "./media.js";
|
|
6
6
|
import { getFeishuRuntime } from "./runtime.js";
|
|
@@ -43,21 +43,37 @@ function shouldUseCard(text: string): boolean {
|
|
|
43
43
|
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function resolveReplyToMessageId(params: {
|
|
47
|
+
replyToId?: string | null;
|
|
48
|
+
threadId?: string | number | null;
|
|
49
|
+
}): string | undefined {
|
|
50
|
+
const replyToId = params.replyToId?.trim();
|
|
51
|
+
if (replyToId) {
|
|
52
|
+
return replyToId;
|
|
53
|
+
}
|
|
54
|
+
if (params.threadId == null) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const trimmed = String(params.threadId).trim();
|
|
58
|
+
return trimmed || undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
async function sendOutboundText(params: {
|
|
47
62
|
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
|
48
63
|
to: string;
|
|
49
64
|
text: string;
|
|
65
|
+
replyToMessageId?: string;
|
|
50
66
|
accountId?: string;
|
|
51
67
|
}) {
|
|
52
|
-
const { cfg, to, text, accountId } = params;
|
|
68
|
+
const { cfg, to, text, accountId, replyToMessageId } = params;
|
|
53
69
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
54
70
|
const renderMode = account.config?.renderMode ?? "auto";
|
|
55
71
|
|
|
56
72
|
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
|
|
57
|
-
return sendMarkdownCardFeishu({ cfg, to, text, accountId });
|
|
73
|
+
return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId });
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
return sendMessageFeishu({ cfg, to, text, accountId });
|
|
76
|
+
return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId });
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
export const feishuOutbound: ChannelOutboundAdapter = {
|
|
@@ -65,7 +81,8 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
65
81
|
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
66
82
|
chunkerMode: "markdown",
|
|
67
83
|
textChunkLimit: 4000,
|
|
68
|
-
sendText: async ({ cfg, to, text, accountId }) => {
|
|
84
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
|
85
|
+
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
|
69
86
|
// Scheme A compatibility shim:
|
|
70
87
|
// when upstream accidentally returns a local image path as plain text,
|
|
71
88
|
// auto-upload and send as Feishu image message instead of leaking path text.
|
|
@@ -77,6 +94,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
77
94
|
to,
|
|
78
95
|
mediaUrl: localImagePath,
|
|
79
96
|
accountId: accountId ?? undefined,
|
|
97
|
+
replyToMessageId,
|
|
80
98
|
});
|
|
81
99
|
return { channel: "feishu", ...result };
|
|
82
100
|
} catch (err) {
|
|
@@ -90,10 +108,21 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
90
108
|
to,
|
|
91
109
|
text,
|
|
92
110
|
accountId: accountId ?? undefined,
|
|
111
|
+
replyToMessageId,
|
|
93
112
|
});
|
|
94
113
|
return { channel: "feishu", ...result };
|
|
95
114
|
},
|
|
96
|
-
sendMedia: async ({
|
|
115
|
+
sendMedia: async ({
|
|
116
|
+
cfg,
|
|
117
|
+
to,
|
|
118
|
+
text,
|
|
119
|
+
mediaUrl,
|
|
120
|
+
accountId,
|
|
121
|
+
mediaLocalRoots,
|
|
122
|
+
replyToId,
|
|
123
|
+
threadId,
|
|
124
|
+
}) => {
|
|
125
|
+
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
|
97
126
|
// Send text first if provided
|
|
98
127
|
if (text?.trim()) {
|
|
99
128
|
await sendOutboundText({
|
|
@@ -101,6 +130,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
101
130
|
to,
|
|
102
131
|
text,
|
|
103
132
|
accountId: accountId ?? undefined,
|
|
133
|
+
replyToMessageId,
|
|
104
134
|
});
|
|
105
135
|
}
|
|
106
136
|
|
|
@@ -113,6 +143,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
113
143
|
mediaUrl,
|
|
114
144
|
accountId: accountId ?? undefined,
|
|
115
145
|
mediaLocalRoots,
|
|
146
|
+
replyToMessageId,
|
|
116
147
|
});
|
|
117
148
|
return { channel: "feishu", ...result };
|
|
118
149
|
} catch (err) {
|
|
@@ -125,6 +156,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
125
156
|
to,
|
|
126
157
|
text: fallbackText,
|
|
127
158
|
accountId: accountId ?? undefined,
|
|
159
|
+
replyToMessageId,
|
|
128
160
|
});
|
|
129
161
|
return { channel: "feishu", ...result };
|
|
130
162
|
}
|
|
@@ -136,6 +168,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|
|
136
168
|
to,
|
|
137
169
|
text: text ?? "",
|
|
138
170
|
accountId: accountId ?? undefined,
|
|
171
|
+
replyToMessageId,
|
|
139
172
|
});
|
|
140
173
|
return { channel: "feishu", ...result };
|
|
141
174
|
},
|
package/src/perm.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
|
3
3
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
4
4
|
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
|
5
5
|
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
12
|
-
details: data,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
6
|
+
import {
|
|
7
|
+
jsonToolResult,
|
|
8
|
+
toolExecutionErrorResult,
|
|
9
|
+
unknownToolActionResult,
|
|
10
|
+
} from "./tool-result.js";
|
|
15
11
|
|
|
16
12
|
type ListTokenType =
|
|
17
13
|
| "doc"
|
|
@@ -154,21 +150,21 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
|
|
154
150
|
});
|
|
155
151
|
switch (p.action) {
|
|
156
152
|
case "list":
|
|
157
|
-
return
|
|
153
|
+
return jsonToolResult(await listMembers(client, p.token, p.type));
|
|
158
154
|
case "add":
|
|
159
|
-
return
|
|
155
|
+
return jsonToolResult(
|
|
160
156
|
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
|
161
157
|
);
|
|
162
158
|
case "remove":
|
|
163
|
-
return
|
|
159
|
+
return jsonToolResult(
|
|
164
160
|
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
|
|
165
161
|
);
|
|
166
162
|
default:
|
|
167
163
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
|
168
|
-
return
|
|
164
|
+
return unknownToolActionResult((p as { action?: unknown }).action);
|
|
169
165
|
}
|
|
170
166
|
} catch (err) {
|
|
171
|
-
return
|
|
167
|
+
return toolExecutionErrorResult(err);
|
|
172
168
|
}
|
|
173
169
|
},
|
|
174
170
|
};
|
package/src/policy.test.ts
CHANGED
|
@@ -110,5 +110,45 @@ describe("feishu policy", () => {
|
|
|
110
110
|
}),
|
|
111
111
|
).toBe(true);
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
it("allows group when groupPolicy is 'open'", () => {
|
|
115
|
+
expect(
|
|
116
|
+
isFeishuGroupAllowed({
|
|
117
|
+
groupPolicy: "open",
|
|
118
|
+
allowFrom: [],
|
|
119
|
+
senderId: "oc_group_999",
|
|
120
|
+
}),
|
|
121
|
+
).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("treats 'allowall' as equivalent to 'open'", () => {
|
|
125
|
+
expect(
|
|
126
|
+
isFeishuGroupAllowed({
|
|
127
|
+
groupPolicy: "allowall",
|
|
128
|
+
allowFrom: [],
|
|
129
|
+
senderId: "oc_group_999",
|
|
130
|
+
}),
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects group when groupPolicy is 'disabled'", () => {
|
|
135
|
+
expect(
|
|
136
|
+
isFeishuGroupAllowed({
|
|
137
|
+
groupPolicy: "disabled",
|
|
138
|
+
allowFrom: ["oc_group_999"],
|
|
139
|
+
senderId: "oc_group_999",
|
|
140
|
+
}),
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
|
|
145
|
+
expect(
|
|
146
|
+
isFeishuGroupAllowed({
|
|
147
|
+
groupPolicy: "allowlist",
|
|
148
|
+
allowFrom: [],
|
|
149
|
+
senderId: "oc_group_999",
|
|
150
|
+
}),
|
|
151
|
+
).toBe(false);
|
|
152
|
+
});
|
|
113
153
|
});
|
|
114
154
|
});
|
package/src/policy.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type {
|
|
|
2
2
|
AllowlistMatch,
|
|
3
3
|
ChannelGroupContext,
|
|
4
4
|
GroupToolPolicyConfig,
|
|
5
|
-
} from "openclaw/plugin-sdk";
|
|
5
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
6
|
+
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/feishu";
|
|
6
7
|
import { normalizeFeishuTarget } from "./targets.js";
|
|
7
8
|
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
|
|
8
9
|
|
|
@@ -92,20 +93,18 @@ export function resolveFeishuGroupToolPolicy(
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export function isFeishuGroupAllowed(params: {
|
|
95
|
-
groupPolicy: "open" | "allowlist" | "disabled";
|
|
96
|
+
groupPolicy: "open" | "allowlist" | "disabled" | "allowall";
|
|
96
97
|
allowFrom: Array<string | number>;
|
|
97
98
|
senderId: string;
|
|
98
99
|
senderIds?: Array<string | null | undefined>;
|
|
99
100
|
senderName?: string | null;
|
|
100
101
|
}): boolean {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
return resolveFeishuAllowlistMatch(params).allowed;
|
|
102
|
+
return evaluateSenderGroupAccessForPolicy({
|
|
103
|
+
groupPolicy: params.groupPolicy === "allowall" ? "open" : params.groupPolicy,
|
|
104
|
+
groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
|
|
105
|
+
senderId: params.senderId,
|
|
106
|
+
isSenderAllowed: () => resolveFeishuAllowlistMatch(params).allowed,
|
|
107
|
+
}).allowed;
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
export function resolveFeishuReplyPolicy(params: {
|
package/src/probe.test.ts
CHANGED
|
@@ -34,7 +34,7 @@ describe("probeFeishu", () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
it("returns error when appId is missing", async () => {
|
|
37
|
-
const result = await probeFeishu({ appSecret: "secret" } as never);
|
|
37
|
+
const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret
|
|
38
38
|
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -49,7 +49,7 @@ describe("probeFeishu", () => {
|
|
|
49
49
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
|
52
|
+
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
53
53
|
expect(result).toEqual({
|
|
54
54
|
ok: true,
|
|
55
55
|
appId: "cli_123",
|
|
@@ -59,13 +59,13 @@ describe("probeFeishu", () => {
|
|
|
59
59
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it("
|
|
62
|
+
it("passes the probe timeout to the Feishu request", async () => {
|
|
63
63
|
const requestFn = setupClient({
|
|
64
64
|
code: 0,
|
|
65
65
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
|
68
|
+
await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
69
69
|
|
|
70
70
|
expect(requestFn).toHaveBeenCalledWith(
|
|
71
71
|
expect.objectContaining({
|
|
@@ -98,21 +98,20 @@ describe("probeFeishu", () => {
|
|
|
98
98
|
abortController.abort();
|
|
99
99
|
|
|
100
100
|
const result = await probeFeishu(
|
|
101
|
-
{ appId: "cli_123", appSecret: "secret" },
|
|
101
|
+
{ appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret
|
|
102
102
|
{ abortSignal: abortController.signal },
|
|
103
103
|
);
|
|
104
104
|
|
|
105
105
|
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
|
106
106
|
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
|
107
107
|
});
|
|
108
|
-
|
|
109
108
|
it("returns cached result on subsequent calls within TTL", async () => {
|
|
110
109
|
const requestFn = setupClient({
|
|
111
110
|
code: 0,
|
|
112
111
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
113
112
|
});
|
|
114
113
|
|
|
115
|
-
const creds = { appId: "cli_123", appSecret: "secret" };
|
|
114
|
+
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
116
115
|
const first = await probeFeishu(creds);
|
|
117
116
|
const second = await probeFeishu(creds);
|
|
118
117
|
|
|
@@ -129,11 +128,11 @@ describe("probeFeishu", () => {
|
|
|
129
128
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
130
129
|
});
|
|
131
130
|
|
|
132
|
-
const creds = { appId: "cli_123", appSecret: "secret" };
|
|
131
|
+
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
133
132
|
await probeFeishu(creds);
|
|
134
133
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
135
134
|
|
|
136
|
-
// Advance time past the
|
|
135
|
+
// Advance time past the success TTL
|
|
137
136
|
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
|
138
137
|
|
|
139
138
|
await probeFeishu(creds);
|
|
@@ -143,29 +142,48 @@ describe("probeFeishu", () => {
|
|
|
143
142
|
}
|
|
144
143
|
});
|
|
145
144
|
|
|
146
|
-
it("
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
it("caches failed probe results (API error) for the error TTL", async () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
|
|
149
|
+
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
152
|
+
const first = await probeFeishu(creds);
|
|
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);
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
vi.advanceTimersByTime(60 * 1000 + 1);
|
|
159
|
+
|
|
160
|
+
await probeFeishu(creds);
|
|
161
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
162
|
+
} finally {
|
|
163
|
+
vi.useRealTimers();
|
|
164
|
+
}
|
|
157
165
|
});
|
|
158
166
|
|
|
159
|
-
it("
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
it("caches thrown request errors for the error TTL", async () => {
|
|
168
|
+
vi.useFakeTimers();
|
|
169
|
+
try {
|
|
170
|
+
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
|
|
171
|
+
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
|
162
172
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
174
|
+
const first = await probeFeishu(creds);
|
|
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);
|
|
166
179
|
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
vi.advanceTimersByTime(60 * 1000 + 1);
|
|
181
|
+
|
|
182
|
+
await probeFeishu(creds);
|
|
183
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
184
|
+
} finally {
|
|
185
|
+
vi.useRealTimers();
|
|
186
|
+
}
|
|
169
187
|
});
|
|
170
188
|
|
|
171
189
|
it("caches per account independently", async () => {
|
|
@@ -174,15 +192,15 @@ describe("probeFeishu", () => {
|
|
|
174
192
|
bot: { bot_name: "Bot1", open_id: "ou_1" },
|
|
175
193
|
});
|
|
176
194
|
|
|
177
|
-
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
|
|
195
|
+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
|
|
178
196
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
179
197
|
|
|
180
198
|
// Different appId should trigger a new API call
|
|
181
|
-
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" });
|
|
199
|
+
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret
|
|
182
200
|
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
183
201
|
|
|
184
202
|
// Same appId + appSecret as first call should return cached
|
|
185
|
-
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
|
|
203
|
+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
|
|
186
204
|
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
187
205
|
});
|
|
188
206
|
|
|
@@ -193,12 +211,12 @@ describe("probeFeishu", () => {
|
|
|
193
211
|
});
|
|
194
212
|
|
|
195
213
|
// First account with appId + secret A
|
|
196
|
-
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" });
|
|
214
|
+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
|
|
197
215
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
198
216
|
|
|
199
217
|
// Second account with same appId but different secret (e.g. after rotation)
|
|
200
218
|
// must NOT reuse the cached result
|
|
201
|
-
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" });
|
|
219
|
+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret
|
|
202
220
|
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
203
221
|
});
|
|
204
222
|
|
|
@@ -209,14 +227,14 @@ describe("probeFeishu", () => {
|
|
|
209
227
|
});
|
|
210
228
|
|
|
211
229
|
// Two accounts with same appId+appSecret but different accountIds are cached separately
|
|
212
|
-
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
|
|
230
|
+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
213
231
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
214
232
|
|
|
215
|
-
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" });
|
|
233
|
+
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
216
234
|
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
217
235
|
|
|
218
236
|
// Same accountId should return cached
|
|
219
|
-
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
|
|
237
|
+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
220
238
|
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
221
239
|
});
|
|
222
240
|
|
|
@@ -226,7 +244,7 @@ describe("probeFeishu", () => {
|
|
|
226
244
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
|
227
245
|
});
|
|
228
246
|
|
|
229
|
-
const creds = { appId: "cli_123", appSecret: "secret" };
|
|
247
|
+
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
|
|
230
248
|
await probeFeishu(creds);
|
|
231
249
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
232
250
|
|
|
@@ -242,7 +260,7 @@ describe("probeFeishu", () => {
|
|
|
242
260
|
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
|
|
243
261
|
});
|
|
244
262
|
|
|
245
|
-
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
|
263
|
+
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
246
264
|
expect(result).toEqual({
|
|
247
265
|
ok: true,
|
|
248
266
|
appId: "cli_123",
|
package/src/probe.ts
CHANGED
|
@@ -2,15 +2,16 @@ import { raceWithTimeoutAndAbort } from "./async.js";
|
|
|
2
2
|
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
|
3
3
|
import type { FeishuProbeResult } from "./types.js";
|
|
4
4
|
|
|
5
|
-
/** Cache
|
|
5
|
+
/** Cache probe results to reduce repeated health-check calls.
|
|
6
6
|
* Gateway health checks call probeFeishu() every minute; without caching this
|
|
7
7
|
* burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
|
|
8
|
-
*
|
|
8
|
+
* Successful bot info is effectively static, while failures are cached briefly
|
|
9
|
+
* to avoid hammering the API during transient outages. */
|
|
9
10
|
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
|
10
|
-
const
|
|
11
|
+
const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
12
|
+
const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
|
|
11
13
|
const MAX_PROBE_CACHE_SIZE = 64;
|
|
12
14
|
export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
|
|
13
|
-
|
|
14
15
|
export type ProbeFeishuOptions = {
|
|
15
16
|
timeoutMs?: number;
|
|
16
17
|
abortSignal?: AbortSignal;
|
|
@@ -23,6 +24,21 @@ type FeishuBotInfoResponse = {
|
|
|
23
24
|
data?: { bot?: { bot_name?: string; open_id?: string } };
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
function setCachedProbeResult(
|
|
28
|
+
cacheKey: string,
|
|
29
|
+
result: FeishuProbeResult,
|
|
30
|
+
ttlMs: number,
|
|
31
|
+
): FeishuProbeResult {
|
|
32
|
+
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
|
33
|
+
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
|
34
|
+
const oldest = probeCache.keys().next().value;
|
|
35
|
+
if (oldest !== undefined) {
|
|
36
|
+
probeCache.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export async function probeFeishu(
|
|
27
43
|
creds?: FeishuClientCredentials,
|
|
28
44
|
options: ProbeFeishuOptions = {},
|
|
@@ -78,11 +94,15 @@ export async function probeFeishu(
|
|
|
78
94
|
};
|
|
79
95
|
}
|
|
80
96
|
if (responseResult.status === "timeout") {
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
return setCachedProbeResult(
|
|
98
|
+
cacheKey,
|
|
99
|
+
{
|
|
100
|
+
ok: false,
|
|
101
|
+
appId: creds.appId,
|
|
102
|
+
error: `probe timed out after ${timeoutMs}ms`,
|
|
103
|
+
},
|
|
104
|
+
PROBE_ERROR_TTL_MS,
|
|
105
|
+
);
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
const response = responseResult.value;
|
|
@@ -95,38 +115,38 @@ export async function probeFeishu(
|
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
if (response.code !== 0) {
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
return setCachedProbeResult(
|
|
119
|
+
cacheKey,
|
|
120
|
+
{
|
|
121
|
+
ok: false,
|
|
122
|
+
appId: creds.appId,
|
|
123
|
+
error: `API error: ${response.msg || `code ${response.code}`}`,
|
|
124
|
+
},
|
|
125
|
+
PROBE_ERROR_TTL_MS,
|
|
126
|
+
);
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
const bot = response.bot || response.data?.bot;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
|
117
|
-
const oldest = probeCache.keys().next().value;
|
|
118
|
-
if (oldest !== undefined) {
|
|
119
|
-
probeCache.delete(oldest);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return result;
|
|
130
|
+
return setCachedProbeResult(
|
|
131
|
+
cacheKey,
|
|
132
|
+
{
|
|
133
|
+
ok: true,
|
|
134
|
+
appId: creds.appId,
|
|
135
|
+
botName: bot?.bot_name,
|
|
136
|
+
botOpenId: bot?.open_id,
|
|
137
|
+
},
|
|
138
|
+
PROBE_SUCCESS_TTL_MS,
|
|
139
|
+
);
|
|
124
140
|
} catch (err) {
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
141
|
+
return setCachedProbeResult(
|
|
142
|
+
cacheKey,
|
|
143
|
+
{
|
|
144
|
+
ok: false,
|
|
145
|
+
appId: creds.appId,
|
|
146
|
+
error: err instanceof Error ? err.message : String(err),
|
|
147
|
+
},
|
|
148
|
+
PROBE_ERROR_TTL_MS,
|
|
149
|
+
);
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
|
package/src/reactions.ts
CHANGED