@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15
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/README.md +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +32 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/nextclaw-sdk/account-id.ts +31 -0
- package/src/nextclaw-sdk/compat.ts +8 -0
- package/src/nextclaw-sdk/core-channel.ts +296 -0
- package/src/nextclaw-sdk/core-pairing.ts +224 -0
- package/src/nextclaw-sdk/core.ts +26 -0
- package/src/nextclaw-sdk/dedupe.ts +246 -0
- package/src/nextclaw-sdk/feishu.ts +77 -0
- package/src/nextclaw-sdk/history.ts +127 -0
- package/src/nextclaw-sdk/network-body.ts +245 -0
- package/src/nextclaw-sdk/network-fetch.ts +129 -0
- package/src/nextclaw-sdk/network-webhook.ts +182 -0
- package/src/nextclaw-sdk/network.ts +13 -0
- package/src/nextclaw-sdk/runtime-store.ts +26 -0
- package/src/nextclaw-sdk/secrets-config.ts +109 -0
- package/src/nextclaw-sdk/secrets-core.ts +170 -0
- package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
- package/src/nextclaw-sdk/secrets.ts +18 -0
- package/src/nextclaw-sdk/types.ts +300 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
package/src/post.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
2
|
+
|
|
3
|
+
const FALLBACK_POST_TEXT = "[Rich text message]";
|
|
4
|
+
const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g;
|
|
5
|
+
|
|
6
|
+
type PostParseResult = {
|
|
7
|
+
textContent: string;
|
|
8
|
+
imageKeys: string[];
|
|
9
|
+
mediaKeys: Array<{ fileKey: string; fileName?: string }>;
|
|
10
|
+
mentionedOpenIds: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type PostPayload = {
|
|
14
|
+
title: string;
|
|
15
|
+
content: unknown[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toStringOrEmpty(value: unknown): string {
|
|
23
|
+
return typeof value === "string" ? value : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeMarkdownText(text: string): string {
|
|
27
|
+
return text.replace(MARKDOWN_SPECIAL_CHARS, "\\$1");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toBoolean(value: unknown): boolean {
|
|
31
|
+
return value === true || value === 1 || value === "true";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isStyleEnabled(style: Record<string, unknown> | undefined, key: string): boolean {
|
|
35
|
+
if (!style) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return toBoolean(style[key]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wrapInlineCode(text: string): string {
|
|
42
|
+
const maxRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
|
43
|
+
const fence = "`".repeat(maxRun + 1);
|
|
44
|
+
const needsPadding = text.startsWith("`") || text.endsWith("`");
|
|
45
|
+
const body = needsPadding ? ` ${text} ` : text;
|
|
46
|
+
return `${fence}${body}${fence}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sanitizeFenceLanguage(language: string): string {
|
|
50
|
+
return language.trim().replace(/[^A-Za-z0-9_+#.-]/g, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderTextElement(element: Record<string, unknown>): string {
|
|
54
|
+
const text = toStringOrEmpty(element.text);
|
|
55
|
+
const style = isRecord(element.style) ? element.style : undefined;
|
|
56
|
+
|
|
57
|
+
if (isStyleEnabled(style, "code")) {
|
|
58
|
+
return wrapInlineCode(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let rendered = escapeMarkdownText(text);
|
|
62
|
+
if (!rendered) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isStyleEnabled(style, "bold")) {
|
|
67
|
+
rendered = `**${rendered}**`;
|
|
68
|
+
}
|
|
69
|
+
if (isStyleEnabled(style, "italic")) {
|
|
70
|
+
rendered = `*${rendered}*`;
|
|
71
|
+
}
|
|
72
|
+
if (isStyleEnabled(style, "underline")) {
|
|
73
|
+
rendered = `<u>${rendered}</u>`;
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
isStyleEnabled(style, "strikethrough") ||
|
|
77
|
+
isStyleEnabled(style, "line_through") ||
|
|
78
|
+
isStyleEnabled(style, "lineThrough")
|
|
79
|
+
) {
|
|
80
|
+
rendered = `~~${rendered}~~`;
|
|
81
|
+
}
|
|
82
|
+
return rendered;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderLinkElement(element: Record<string, unknown>): string {
|
|
86
|
+
const href = toStringOrEmpty(element.href).trim();
|
|
87
|
+
const rawText = toStringOrEmpty(element.text);
|
|
88
|
+
const text = rawText || href;
|
|
89
|
+
if (!text) {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
if (!href) {
|
|
93
|
+
return escapeMarkdownText(text);
|
|
94
|
+
}
|
|
95
|
+
return `[${escapeMarkdownText(text)}](${href})`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderMentionElement(element: Record<string, unknown>): string {
|
|
99
|
+
const mention =
|
|
100
|
+
toStringOrEmpty(element.user_name) ||
|
|
101
|
+
toStringOrEmpty(element.user_id) ||
|
|
102
|
+
toStringOrEmpty(element.open_id);
|
|
103
|
+
if (!mention) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
return `@${escapeMarkdownText(mention)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderEmotionElement(element: Record<string, unknown>): string {
|
|
110
|
+
const text =
|
|
111
|
+
toStringOrEmpty(element.emoji) ||
|
|
112
|
+
toStringOrEmpty(element.text) ||
|
|
113
|
+
toStringOrEmpty(element.emoji_type);
|
|
114
|
+
return escapeMarkdownText(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderCodeBlockElement(element: Record<string, unknown>): string {
|
|
118
|
+
const language = sanitizeFenceLanguage(
|
|
119
|
+
toStringOrEmpty(element.language) || toStringOrEmpty(element.lang),
|
|
120
|
+
);
|
|
121
|
+
const code = (toStringOrEmpty(element.text) || toStringOrEmpty(element.content)).replace(
|
|
122
|
+
/\r\n/g,
|
|
123
|
+
"\n",
|
|
124
|
+
);
|
|
125
|
+
const trailingNewline = code.endsWith("\n") ? "" : "\n";
|
|
126
|
+
return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderElement(
|
|
130
|
+
element: unknown,
|
|
131
|
+
imageKeys: string[],
|
|
132
|
+
mediaKeys: Array<{ fileKey: string; fileName?: string }>,
|
|
133
|
+
mentionedOpenIds: string[],
|
|
134
|
+
): string {
|
|
135
|
+
if (!isRecord(element)) {
|
|
136
|
+
return escapeMarkdownText(toStringOrEmpty(element));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tag = toStringOrEmpty(element.tag).toLowerCase();
|
|
140
|
+
switch (tag) {
|
|
141
|
+
case "text":
|
|
142
|
+
return renderTextElement(element);
|
|
143
|
+
case "a":
|
|
144
|
+
return renderLinkElement(element);
|
|
145
|
+
case "at":
|
|
146
|
+
{
|
|
147
|
+
const mentioned = toStringOrEmpty(element.open_id) || toStringOrEmpty(element.user_id);
|
|
148
|
+
const normalizedMention = normalizeFeishuExternalKey(mentioned);
|
|
149
|
+
if (normalizedMention) {
|
|
150
|
+
mentionedOpenIds.push(normalizedMention);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return renderMentionElement(element);
|
|
154
|
+
case "img": {
|
|
155
|
+
const imageKey = normalizeFeishuExternalKey(toStringOrEmpty(element.image_key));
|
|
156
|
+
if (imageKey) {
|
|
157
|
+
imageKeys.push(imageKey);
|
|
158
|
+
}
|
|
159
|
+
return "![image]";
|
|
160
|
+
}
|
|
161
|
+
case "media": {
|
|
162
|
+
const fileKey = normalizeFeishuExternalKey(toStringOrEmpty(element.file_key));
|
|
163
|
+
if (fileKey) {
|
|
164
|
+
const fileName = toStringOrEmpty(element.file_name) || undefined;
|
|
165
|
+
mediaKeys.push({ fileKey, fileName });
|
|
166
|
+
}
|
|
167
|
+
return "[media]";
|
|
168
|
+
}
|
|
169
|
+
case "emotion":
|
|
170
|
+
return renderEmotionElement(element);
|
|
171
|
+
case "br":
|
|
172
|
+
return "\n";
|
|
173
|
+
case "hr":
|
|
174
|
+
return "\n\n---\n\n";
|
|
175
|
+
case "code": {
|
|
176
|
+
const code = toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
|
|
177
|
+
return code ? wrapInlineCode(code) : "";
|
|
178
|
+
}
|
|
179
|
+
case "code_block":
|
|
180
|
+
case "pre":
|
|
181
|
+
return renderCodeBlockElement(element);
|
|
182
|
+
default:
|
|
183
|
+
return escapeMarkdownText(toStringOrEmpty(element.text));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toPostPayload(candidate: unknown): PostPayload | null {
|
|
188
|
+
if (!isRecord(candidate) || !Array.isArray(candidate.content)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
title: toStringOrEmpty(candidate.title),
|
|
193
|
+
content: candidate.content,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveLocalePayload(candidate: unknown): PostPayload | null {
|
|
198
|
+
const direct = toPostPayload(candidate);
|
|
199
|
+
if (direct) {
|
|
200
|
+
return direct;
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(candidate)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
for (const value of Object.values(candidate)) {
|
|
206
|
+
const localePayload = toPostPayload(value);
|
|
207
|
+
if (localePayload) {
|
|
208
|
+
return localePayload;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolvePostPayload(parsed: unknown): PostPayload | null {
|
|
215
|
+
const direct = toPostPayload(parsed);
|
|
216
|
+
if (direct) {
|
|
217
|
+
return direct;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!isRecord(parsed)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const wrappedPost = resolveLocalePayload(parsed.post);
|
|
225
|
+
if (wrappedPost) {
|
|
226
|
+
return wrappedPost;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return resolveLocalePayload(parsed);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function parsePostContent(content: string): PostParseResult {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(content);
|
|
235
|
+
const payload = resolvePostPayload(parsed);
|
|
236
|
+
if (!payload) {
|
|
237
|
+
return {
|
|
238
|
+
textContent: FALLBACK_POST_TEXT,
|
|
239
|
+
imageKeys: [],
|
|
240
|
+
mediaKeys: [],
|
|
241
|
+
mentionedOpenIds: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const imageKeys: string[] = [];
|
|
246
|
+
const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
|
|
247
|
+
const mentionedOpenIds: string[] = [];
|
|
248
|
+
const paragraphs: string[] = [];
|
|
249
|
+
|
|
250
|
+
for (const paragraph of payload.content) {
|
|
251
|
+
if (!Array.isArray(paragraph)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let renderedParagraph = "";
|
|
255
|
+
for (const element of paragraph) {
|
|
256
|
+
renderedParagraph += renderElement(element, imageKeys, mediaKeys, mentionedOpenIds);
|
|
257
|
+
}
|
|
258
|
+
paragraphs.push(renderedParagraph);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const title = escapeMarkdownText(payload.title.trim());
|
|
262
|
+
const body = paragraphs.join("\n").trim();
|
|
263
|
+
const textContent = [title, body].filter(Boolean).join("\n\n").trim();
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
textContent: textContent || FALLBACK_POST_TEXT,
|
|
267
|
+
imageKeys,
|
|
268
|
+
mediaKeys,
|
|
269
|
+
mentionedOpenIds,
|
|
270
|
+
};
|
|
271
|
+
} catch {
|
|
272
|
+
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
|
|
5
|
+
vi.mock("./client.js", () => ({
|
|
6
|
+
createFeishuClient: createFeishuClientMock,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
|
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
|
+
|
|
27
|
+
function makeRequestFn(response: Record<string, unknown>) {
|
|
28
|
+
return vi.fn().mockResolvedValue(response);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setupClient(response: Record<string, unknown>) {
|
|
32
|
+
const requestFn = makeRequestFn(response);
|
|
33
|
+
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
|
34
|
+
return requestFn;
|
|
35
|
+
}
|
|
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
|
+
|
|
95
|
+
describe("probeFeishu", () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
clearProbeCache();
|
|
98
|
+
vi.restoreAllMocks();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
clearProbeCache();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns error when credentials are missing", async () => {
|
|
106
|
+
const result = await probeFeishu();
|
|
107
|
+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns error when appId is missing", async () => {
|
|
111
|
+
const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret
|
|
112
|
+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns error when appSecret is missing", async () => {
|
|
116
|
+
const result = await probeFeishu({ appId: "cli_123" } as never);
|
|
117
|
+
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns bot info on successful probe", async () => {
|
|
121
|
+
const requestFn = setupSuccessClient();
|
|
122
|
+
|
|
123
|
+
await expectDefaultSuccessResult();
|
|
124
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("passes the probe timeout to the Feishu request", async () => {
|
|
128
|
+
const requestFn = setupSuccessClient();
|
|
129
|
+
|
|
130
|
+
await probeFeishu(DEFAULT_CREDS);
|
|
131
|
+
|
|
132
|
+
expect(requestFn).toHaveBeenCalledWith(
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
method: "GET",
|
|
135
|
+
url: "/open-apis/bot/v3/info",
|
|
136
|
+
timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns timeout error when request exceeds timeout", async () => {
|
|
142
|
+
await withFakeTimers(async () => {
|
|
143
|
+
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
144
|
+
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
|
145
|
+
|
|
146
|
+
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
|
147
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
148
|
+
const result = await promise;
|
|
149
|
+
|
|
150
|
+
expect(result).toMatchObject({ ok: false, error: "probe timed out after 1000ms" });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns aborted when abort signal is already aborted", async () => {
|
|
155
|
+
createFeishuClientMock.mockClear();
|
|
156
|
+
const abortController = new AbortController();
|
|
157
|
+
abortController.abort();
|
|
158
|
+
|
|
159
|
+
const result = await probeFeishu(
|
|
160
|
+
{ appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret
|
|
161
|
+
{ abortSignal: abortController.signal },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
|
165
|
+
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it("returns cached result on subsequent calls within TTL", async () => {
|
|
168
|
+
const requestFn = setupSuccessClient();
|
|
169
|
+
|
|
170
|
+
const { first, second } = await readSequentialDefaultProbePair();
|
|
171
|
+
|
|
172
|
+
expect(first).toEqual(second);
|
|
173
|
+
// Only one API call should have been made
|
|
174
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("makes a fresh API call after cache expires", async () => {
|
|
178
|
+
await withFakeTimers(async () => {
|
|
179
|
+
const requestFn = setupSuccessClient();
|
|
180
|
+
|
|
181
|
+
await expectFreshDefaultProbeAfter(requestFn, () => {
|
|
182
|
+
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("caches failed probe results (API error) for the error TTL", async () => {
|
|
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
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("caches thrown request errors for the error TTL", async () => {
|
|
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
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("caches per account independently", async () => {
|
|
208
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
209
|
+
|
|
210
|
+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
|
|
211
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
212
|
+
|
|
213
|
+
// Different appId should trigger a new API call
|
|
214
|
+
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret
|
|
215
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
216
|
+
|
|
217
|
+
// Same appId + appSecret as first call should return cached
|
|
218
|
+
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
|
|
219
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("does not share cache between accounts with same appId but different appSecret", async () => {
|
|
223
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
224
|
+
|
|
225
|
+
// First account with appId + secret A
|
|
226
|
+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
|
|
227
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
228
|
+
|
|
229
|
+
// Second account with same appId but different secret (e.g. after rotation)
|
|
230
|
+
// must NOT reuse the cached result
|
|
231
|
+
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret
|
|
232
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("uses accountId for cache key when available", async () => {
|
|
236
|
+
const requestFn = setupClient(BOT1_RESPONSE);
|
|
237
|
+
|
|
238
|
+
// Two accounts with same appId+appSecret but different accountIds are cached separately
|
|
239
|
+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
240
|
+
expect(requestFn).toHaveBeenCalledTimes(1);
|
|
241
|
+
|
|
242
|
+
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
243
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
244
|
+
|
|
245
|
+
// Same accountId should return cached
|
|
246
|
+
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
|
|
247
|
+
expect(requestFn).toHaveBeenCalledTimes(2);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("clearProbeCache forces fresh API call", async () => {
|
|
251
|
+
const requestFn = setupSuccessClient();
|
|
252
|
+
|
|
253
|
+
await expectFreshDefaultProbeAfter(requestFn, () => {
|
|
254
|
+
clearProbeCache();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("handles response.data.bot fallback path", async () => {
|
|
259
|
+
setupClient({
|
|
260
|
+
code: 0,
|
|
261
|
+
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await expectDefaultSuccessResult(DEFAULT_CREDS, {
|
|
265
|
+
...DEFAULT_SUCCESS_RESULT,
|
|
266
|
+
botName: "DataBot",
|
|
267
|
+
botOpenId: "ou_data",
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { raceWithTimeoutAndAbort } from "./async.js";
|
|
2
|
+
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
|
3
|
+
import type { FeishuProbeResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** Cache probe results to reduce repeated health-check calls.
|
|
6
|
+
* Gateway health checks call probeFeishu() every minute; without caching this
|
|
7
|
+
* burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
|
|
8
|
+
* Successful bot info is effectively static, while failures are cached briefly
|
|
9
|
+
* to avoid hammering the API during transient outages. */
|
|
10
|
+
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
|
11
|
+
const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
12
|
+
const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
|
|
13
|
+
const MAX_PROBE_CACHE_SIZE = 64;
|
|
14
|
+
export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
|
|
15
|
+
export type ProbeFeishuOptions = {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
abortSignal?: AbortSignal;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type FeishuBotInfoResponse = {
|
|
21
|
+
code: number;
|
|
22
|
+
msg?: string;
|
|
23
|
+
bot?: { bot_name?: string; open_id?: string };
|
|
24
|
+
data?: { bot?: { bot_name?: string; open_id?: string } };
|
|
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
|
+
|
|
42
|
+
export async function probeFeishu(
|
|
43
|
+
creds?: FeishuClientCredentials,
|
|
44
|
+
options: ProbeFeishuOptions = {},
|
|
45
|
+
): Promise<FeishuProbeResult> {
|
|
46
|
+
if (!creds?.appId || !creds?.appSecret) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: "missing credentials (appId, appSecret)",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (options.abortSignal?.aborted) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
appId: creds.appId,
|
|
56
|
+
error: "probe aborted",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? FEISHU_PROBE_REQUEST_TIMEOUT_MS;
|
|
61
|
+
|
|
62
|
+
// Return cached result if still valid.
|
|
63
|
+
// Use accountId when available; otherwise include appSecret prefix so two
|
|
64
|
+
// accounts sharing the same appId (e.g. after secret rotation) don't
|
|
65
|
+
// pollute each other's cache entry.
|
|
66
|
+
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
|
|
67
|
+
const cached = probeCache.get(cacheKey);
|
|
68
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
69
|
+
return cached.result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const client = createFeishuClient(creds);
|
|
74
|
+
// Use bot/v3/info API to get bot information
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
|
|
76
|
+
const responseResult = await raceWithTimeoutAndAbort<FeishuBotInfoResponse>(
|
|
77
|
+
(client as any).request({
|
|
78
|
+
method: "GET",
|
|
79
|
+
url: "/open-apis/bot/v3/info",
|
|
80
|
+
data: {},
|
|
81
|
+
timeout: timeoutMs,
|
|
82
|
+
}) as Promise<FeishuBotInfoResponse>,
|
|
83
|
+
{
|
|
84
|
+
timeoutMs,
|
|
85
|
+
abortSignal: options.abortSignal,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (responseResult.status === "aborted") {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
appId: creds.appId,
|
|
93
|
+
error: "probe aborted",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (responseResult.status === "timeout") {
|
|
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
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const response = responseResult.value;
|
|
109
|
+
if (options.abortSignal?.aborted) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
appId: creds.appId,
|
|
113
|
+
error: "probe aborted",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (response.code !== 0) {
|
|
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
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bot = response.bot || response.data?.bot;
|
|
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
|
+
);
|
|
140
|
+
} catch (err) {
|
|
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
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Clear the probe cache (for testing). */
|
|
154
|
+
export function clearProbeCache(): void {
|
|
155
|
+
probeCache.clear();
|
|
156
|
+
}
|