@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14

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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +33 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/onboarding.status.test.ts +25 -0
  62. package/src/onboarding.test.ts +143 -0
  63. package/src/onboarding.ts +489 -0
  64. package/src/outbound.test.ts +356 -0
  65. package/src/outbound.ts +176 -0
  66. package/src/perm-schema.ts +52 -0
  67. package/src/perm.ts +176 -0
  68. package/src/policy.test.ts +154 -0
  69. package/src/policy.ts +123 -0
  70. package/src/post.test.ts +105 -0
  71. package/src/post.ts +274 -0
  72. package/src/probe.test.ts +270 -0
  73. package/src/probe.ts +156 -0
  74. package/src/reactions.ts +153 -0
  75. package/src/reply-dispatcher.test.ts +513 -0
  76. package/src/reply-dispatcher.ts +397 -0
  77. package/src/runtime.ts +6 -0
  78. package/src/secret-input.ts +13 -0
  79. package/src/send-message.ts +71 -0
  80. package/src/send-result.ts +29 -0
  81. package/src/send-target.test.ts +74 -0
  82. package/src/send-target.ts +29 -0
  83. package/src/send.reply-fallback.test.ts +189 -0
  84. package/src/send.test.ts +168 -0
  85. package/src/send.ts +481 -0
  86. package/src/streaming-card.test.ts +54 -0
  87. package/src/streaming-card.ts +374 -0
  88. package/src/targets.test.ts +70 -0
  89. package/src/targets.ts +107 -0
  90. package/src/tool-account-routing.test.ts +129 -0
  91. package/src/tool-account.ts +70 -0
  92. package/src/tool-factory-test-harness.ts +76 -0
  93. package/src/tool-result.test.ts +32 -0
  94. package/src/tool-result.ts +14 -0
  95. package/src/tools-config.test.ts +21 -0
  96. package/src/tools-config.ts +22 -0
  97. package/src/types.ts +103 -0
  98. package/src/typing.test.ts +144 -0
  99. package/src/typing.ts +210 -0
  100. package/src/wiki-schema.ts +55 -0
  101. package/src/wiki.ts +233 -0
  102. package/index.js +0 -27
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Feishu Streaming Card - Card Kit streaming API for real-time text output
3
+ */
4
+
5
+ import type { Client } from "@larksuiteoapi/node-sdk";
6
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
7
+ import type { FeishuDomain } from "./types.js";
8
+
9
+ type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
10
+ type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
11
+
12
+ /** Optional header for streaming cards (title bar with color template) */
13
+ export type StreamingCardHeader = {
14
+ title: string;
15
+ /** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
16
+ template?: string;
17
+ };
18
+
19
+ type StreamingStartOptions = {
20
+ replyToMessageId?: string;
21
+ replyInThread?: boolean;
22
+ rootId?: string;
23
+ header?: StreamingCardHeader;
24
+ };
25
+
26
+ // Token cache (keyed by domain + appId)
27
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
28
+
29
+ function resolveApiBase(domain?: FeishuDomain): string {
30
+ if (domain === "lark") {
31
+ return "https://open.larksuite.com/open-apis";
32
+ }
33
+ if (domain && domain !== "feishu" && domain.startsWith("http")) {
34
+ return `${domain.replace(/\/+$/, "")}/open-apis`;
35
+ }
36
+ return "https://open.feishu.cn/open-apis";
37
+ }
38
+
39
+ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
40
+ if (domain === "lark") {
41
+ return ["open.larksuite.com"];
42
+ }
43
+ if (domain && domain !== "feishu" && domain.startsWith("http")) {
44
+ try {
45
+ return [new URL(domain).hostname];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+ return ["open.feishu.cn"];
51
+ }
52
+
53
+ async function getToken(creds: Credentials): Promise<string> {
54
+ const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
55
+ const cached = tokenCache.get(key);
56
+ if (cached && cached.expiresAt > Date.now() + 60000) {
57
+ return cached.token;
58
+ }
59
+
60
+ const { response, release } = await fetchWithSsrFGuard({
61
+ url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
62
+ init: {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
66
+ },
67
+ policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
68
+ auditContext: "feishu.streaming-card.token",
69
+ });
70
+ if (!response.ok) {
71
+ await release();
72
+ throw new Error(`Token request failed with HTTP ${response.status}`);
73
+ }
74
+ const data = (await response.json()) as {
75
+ code: number;
76
+ msg: string;
77
+ tenant_access_token?: string;
78
+ expire?: number;
79
+ };
80
+ await release();
81
+ if (data.code !== 0 || !data.tenant_access_token) {
82
+ throw new Error(`Token error: ${data.msg}`);
83
+ }
84
+ tokenCache.set(key, {
85
+ token: data.tenant_access_token,
86
+ expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
87
+ });
88
+ return data.tenant_access_token;
89
+ }
90
+
91
+ function truncateSummary(text: string, max = 50): string {
92
+ if (!text) {
93
+ return "";
94
+ }
95
+ const clean = text.replace(/\n/g, " ").trim();
96
+ return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
97
+ }
98
+
99
+ export function mergeStreamingText(
100
+ previousText: string | undefined,
101
+ nextText: string | undefined,
102
+ ): string {
103
+ const previous = typeof previousText === "string" ? previousText : "";
104
+ const next = typeof nextText === "string" ? nextText : "";
105
+ if (!next) {
106
+ return previous;
107
+ }
108
+ if (!previous || next === previous) {
109
+ return next;
110
+ }
111
+ if (next.startsWith(previous)) {
112
+ return next;
113
+ }
114
+ if (previous.startsWith(next)) {
115
+ return previous;
116
+ }
117
+ if (next.includes(previous)) {
118
+ return next;
119
+ }
120
+ if (previous.includes(next)) {
121
+ return previous;
122
+ }
123
+
124
+ // Merge partial overlaps, e.g. "这" + "这是" => "这是".
125
+ const maxOverlap = Math.min(previous.length, next.length);
126
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
127
+ if (previous.slice(-overlap) === next.slice(0, overlap)) {
128
+ return `${previous}${next.slice(overlap)}`;
129
+ }
130
+ }
131
+ // Fallback for fragmented partial chunks: append as-is to avoid losing tokens.
132
+ return `${previous}${next}`;
133
+ }
134
+
135
+ export function resolveStreamingCardSendMode(options?: StreamingStartOptions) {
136
+ if (options?.replyToMessageId) {
137
+ return "reply";
138
+ }
139
+ if (options?.rootId) {
140
+ return "root_create";
141
+ }
142
+ return "create";
143
+ }
144
+
145
+ /** Streaming card session manager */
146
+ export class FeishuStreamingSession {
147
+ private client: Client;
148
+ private creds: Credentials;
149
+ private state: CardState | null = null;
150
+ private queue: Promise<void> = Promise.resolve();
151
+ private closed = false;
152
+ private log?: (msg: string) => void;
153
+ private lastUpdateTime = 0;
154
+ private pendingText: string | null = null;
155
+ private updateThrottleMs = 100; // Throttle updates to max 10/sec
156
+
157
+ constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
158
+ this.client = client;
159
+ this.creds = creds;
160
+ this.log = log;
161
+ }
162
+
163
+ async start(
164
+ receiveId: string,
165
+ receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
166
+ options?: StreamingStartOptions,
167
+ ): Promise<void> {
168
+ if (this.state) {
169
+ return;
170
+ }
171
+
172
+ const apiBase = resolveApiBase(this.creds.domain);
173
+ const cardJson: Record<string, unknown> = {
174
+ schema: "2.0",
175
+ config: {
176
+ streaming_mode: true,
177
+ summary: { content: "[Generating...]" },
178
+ streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
179
+ },
180
+ body: {
181
+ elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
182
+ },
183
+ };
184
+ if (options?.header) {
185
+ cardJson.header = {
186
+ title: { tag: "plain_text", content: options.header.title },
187
+ template: options.header.template ?? "blue",
188
+ };
189
+ }
190
+
191
+ // Create card entity
192
+ const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
193
+ url: `${apiBase}/cardkit/v1/cards`,
194
+ init: {
195
+ method: "POST",
196
+ headers: {
197
+ Authorization: `Bearer ${await getToken(this.creds)}`,
198
+ "Content-Type": "application/json",
199
+ },
200
+ body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
201
+ },
202
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
203
+ auditContext: "feishu.streaming-card.create",
204
+ });
205
+ if (!createRes.ok) {
206
+ await releaseCreate();
207
+ throw new Error(`Create card request failed with HTTP ${createRes.status}`);
208
+ }
209
+ const createData = (await createRes.json()) as {
210
+ code: number;
211
+ msg: string;
212
+ data?: { card_id: string };
213
+ };
214
+ await releaseCreate();
215
+ if (createData.code !== 0 || !createData.data?.card_id) {
216
+ throw new Error(`Create card failed: ${createData.msg}`);
217
+ }
218
+ const cardId = createData.data.card_id;
219
+ const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
220
+
221
+ // Prefer message.reply when we have a reply target — reply_in_thread
222
+ // reliably routes streaming cards into Feishu topics, whereas
223
+ // message.create with root_id may silently ignore root_id for card
224
+ // references (card_id format).
225
+ let sendRes;
226
+ const sendOptions = options ?? {};
227
+ const sendMode = resolveStreamingCardSendMode(sendOptions);
228
+ if (sendMode === "reply") {
229
+ sendRes = await this.client.im.message.reply({
230
+ path: { message_id: sendOptions.replyToMessageId! },
231
+ data: {
232
+ msg_type: "interactive",
233
+ content: cardContent,
234
+ ...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
235
+ },
236
+ });
237
+ } else if (sendMode === "root_create") {
238
+ // root_id is undeclared in the SDK types but accepted at runtime
239
+ sendRes = await this.client.im.message.create({
240
+ params: { receive_id_type: receiveIdType },
241
+ data: Object.assign(
242
+ { receive_id: receiveId, msg_type: "interactive", content: cardContent },
243
+ { root_id: sendOptions.rootId },
244
+ ),
245
+ });
246
+ } else {
247
+ sendRes = await this.client.im.message.create({
248
+ params: { receive_id_type: receiveIdType },
249
+ data: {
250
+ receive_id: receiveId,
251
+ msg_type: "interactive",
252
+ content: cardContent,
253
+ },
254
+ });
255
+ }
256
+ if (sendRes.code !== 0 || !sendRes.data?.message_id) {
257
+ throw new Error(`Send card failed: ${sendRes.msg}`);
258
+ }
259
+
260
+ this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
261
+ this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
262
+ }
263
+
264
+ private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
265
+ if (!this.state) {
266
+ return;
267
+ }
268
+ const apiBase = resolveApiBase(this.creds.domain);
269
+ this.state.sequence += 1;
270
+ await fetchWithSsrFGuard({
271
+ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
272
+ init: {
273
+ method: "PUT",
274
+ headers: {
275
+ Authorization: `Bearer ${await getToken(this.creds)}`,
276
+ "Content-Type": "application/json",
277
+ },
278
+ body: JSON.stringify({
279
+ content: text,
280
+ sequence: this.state.sequence,
281
+ uuid: `s_${this.state.cardId}_${this.state.sequence}`,
282
+ }),
283
+ },
284
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
285
+ auditContext: "feishu.streaming-card.update",
286
+ })
287
+ .then(async ({ release }) => {
288
+ await release();
289
+ })
290
+ .catch((error) => onError?.(error));
291
+ }
292
+
293
+ async update(text: string): Promise<void> {
294
+ if (!this.state || this.closed) {
295
+ return;
296
+ }
297
+ const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
298
+ if (!mergedInput || mergedInput === this.state.currentText) {
299
+ return;
300
+ }
301
+
302
+ // Throttle: skip if updated recently, but remember pending text
303
+ const now = Date.now();
304
+ if (now - this.lastUpdateTime < this.updateThrottleMs) {
305
+ this.pendingText = mergedInput;
306
+ return;
307
+ }
308
+ this.pendingText = null;
309
+ this.lastUpdateTime = now;
310
+
311
+ this.queue = this.queue.then(async () => {
312
+ if (!this.state || this.closed) {
313
+ return;
314
+ }
315
+ const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
316
+ if (!mergedText || mergedText === this.state.currentText) {
317
+ return;
318
+ }
319
+ this.state.currentText = mergedText;
320
+ await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
321
+ });
322
+ await this.queue;
323
+ }
324
+
325
+ async close(finalText?: string): Promise<void> {
326
+ if (!this.state || this.closed) {
327
+ return;
328
+ }
329
+ this.closed = true;
330
+ await this.queue;
331
+
332
+ const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
333
+ const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
334
+ const apiBase = resolveApiBase(this.creds.domain);
335
+
336
+ // Only send final update if content differs from what's already displayed
337
+ if (text && text !== this.state.currentText) {
338
+ await this.updateCardContent(text);
339
+ this.state.currentText = text;
340
+ }
341
+
342
+ // Close streaming mode
343
+ this.state.sequence += 1;
344
+ await fetchWithSsrFGuard({
345
+ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
346
+ init: {
347
+ method: "PATCH",
348
+ headers: {
349
+ Authorization: `Bearer ${await getToken(this.creds)}`,
350
+ "Content-Type": "application/json; charset=utf-8",
351
+ },
352
+ body: JSON.stringify({
353
+ settings: JSON.stringify({
354
+ config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
355
+ }),
356
+ sequence: this.state.sequence,
357
+ uuid: `c_${this.state.cardId}_${this.state.sequence}`,
358
+ }),
359
+ },
360
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
361
+ auditContext: "feishu.streaming-card.close",
362
+ })
363
+ .then(async ({ release }) => {
364
+ await release();
365
+ })
366
+ .catch((e) => this.log?.(`Close failed: ${String(e)}`));
367
+
368
+ this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
369
+ }
370
+
371
+ isActive(): boolean {
372
+ return this.state !== null && !this.closed;
373
+ }
374
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
3
+
4
+ describe("resolveReceiveIdType", () => {
5
+ it("resolves chat IDs by oc_ prefix", () => {
6
+ expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
7
+ });
8
+
9
+ it("resolves open IDs by ou_ prefix", () => {
10
+ expect(resolveReceiveIdType("ou_123")).toBe("open_id");
11
+ });
12
+
13
+ it("defaults unprefixed IDs to user_id", () => {
14
+ expect(resolveReceiveIdType("u_123")).toBe("user_id");
15
+ });
16
+
17
+ it("treats explicit group targets as chat_id", () => {
18
+ expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
19
+ });
20
+
21
+ it("treats explicit channel targets as chat_id", () => {
22
+ expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
23
+ });
24
+
25
+ it("treats dm-prefixed open IDs as open_id", () => {
26
+ expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
27
+ });
28
+ });
29
+
30
+ describe("normalizeFeishuTarget", () => {
31
+ it("strips provider and user prefixes", () => {
32
+ expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
33
+ expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
34
+ });
35
+
36
+ it("strips provider and chat prefixes", () => {
37
+ expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
38
+ });
39
+
40
+ it("normalizes group/channel prefixes to chat ids", () => {
41
+ expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
42
+ expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
43
+ expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
44
+ expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
45
+ });
46
+
47
+ it("accepts provider-prefixed raw ids", () => {
48
+ expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
49
+ });
50
+
51
+ it("strips provider and dm prefixes", () => {
52
+ expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
53
+ });
54
+ });
55
+
56
+ describe("looksLikeFeishuId", () => {
57
+ it("accepts provider-prefixed user targets", () => {
58
+ expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
59
+ });
60
+
61
+ it("accepts provider-prefixed chat targets", () => {
62
+ expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
63
+ });
64
+
65
+ it("accepts group/channel targets", () => {
66
+ expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
67
+ expect(looksLikeFeishuId("group:oc_123")).toBe(true);
68
+ expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
69
+ });
70
+ });
package/src/targets.ts ADDED
@@ -0,0 +1,107 @@
1
+ import type { FeishuIdType } from "./types.js";
2
+
3
+ const CHAT_ID_PREFIX = "oc_";
4
+ const OPEN_ID_PREFIX = "ou_";
5
+ const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ function stripProviderPrefix(raw: string): string {
8
+ return raw.replace(/^(feishu|lark):/i, "").trim();
9
+ }
10
+
11
+ export function detectIdType(id: string): FeishuIdType | null {
12
+ const trimmed = id.trim();
13
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) {
14
+ return "chat_id";
15
+ }
16
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) {
17
+ return "open_id";
18
+ }
19
+ if (USER_ID_REGEX.test(trimmed)) {
20
+ return "user_id";
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export function normalizeFeishuTarget(raw: string): string | null {
26
+ const trimmed = raw.trim();
27
+ if (!trimmed) {
28
+ return null;
29
+ }
30
+
31
+ const withoutProvider = stripProviderPrefix(trimmed);
32
+ const lowered = withoutProvider.toLowerCase();
33
+ if (lowered.startsWith("chat:")) {
34
+ return withoutProvider.slice("chat:".length).trim() || null;
35
+ }
36
+ if (lowered.startsWith("group:")) {
37
+ return withoutProvider.slice("group:".length).trim() || null;
38
+ }
39
+ if (lowered.startsWith("channel:")) {
40
+ return withoutProvider.slice("channel:".length).trim() || null;
41
+ }
42
+ if (lowered.startsWith("user:")) {
43
+ return withoutProvider.slice("user:".length).trim() || null;
44
+ }
45
+ if (lowered.startsWith("dm:")) {
46
+ return withoutProvider.slice("dm:".length).trim() || null;
47
+ }
48
+ if (lowered.startsWith("open_id:")) {
49
+ return withoutProvider.slice("open_id:".length).trim() || null;
50
+ }
51
+
52
+ return withoutProvider;
53
+ }
54
+
55
+ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
56
+ const trimmed = id.trim();
57
+ if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
58
+ return `chat:${trimmed}`;
59
+ }
60
+ if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
61
+ return `user:${trimmed}`;
62
+ }
63
+ return trimmed;
64
+ }
65
+
66
+ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
67
+ const trimmed = id.trim();
68
+ const lowered = trimmed.toLowerCase();
69
+ if (
70
+ lowered.startsWith("chat:") ||
71
+ lowered.startsWith("group:") ||
72
+ lowered.startsWith("channel:")
73
+ ) {
74
+ return "chat_id";
75
+ }
76
+ if (lowered.startsWith("open_id:")) {
77
+ return "open_id";
78
+ }
79
+ if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
80
+ const normalized = trimmed.replace(/^(user|dm):/i, "").trim();
81
+ return normalized.startsWith(OPEN_ID_PREFIX) ? "open_id" : "user_id";
82
+ }
83
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) {
84
+ return "chat_id";
85
+ }
86
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) {
87
+ return "open_id";
88
+ }
89
+ return "user_id";
90
+ }
91
+
92
+ export function looksLikeFeishuId(raw: string): boolean {
93
+ const trimmed = stripProviderPrefix(raw.trim());
94
+ if (!trimmed) {
95
+ return false;
96
+ }
97
+ if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
98
+ return true;
99
+ }
100
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) {
101
+ return true;
102
+ }
103
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) {
104
+ return true;
105
+ }
106
+ return false;
107
+ }
@@ -0,0 +1,129 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { registerFeishuBitableTools } from "./bitable.js";
4
+ import { registerFeishuDriveTools } from "./drive.js";
5
+ import { registerFeishuPermTools } from "./perm.js";
6
+ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
7
+ import { registerFeishuWikiTools } from "./wiki.js";
8
+
9
+ const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
10
+ __appId: account?.appId,
11
+ }));
12
+
13
+ vi.mock("./client.js", () => ({
14
+ createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account),
15
+ }));
16
+
17
+ function createConfig(params: {
18
+ toolsA?: {
19
+ wiki?: boolean;
20
+ drive?: boolean;
21
+ perm?: boolean;
22
+ };
23
+ toolsB?: {
24
+ wiki?: boolean;
25
+ drive?: boolean;
26
+ perm?: boolean;
27
+ };
28
+ defaultAccount?: string;
29
+ }): OpenClawPluginApi["config"] {
30
+ return {
31
+ channels: {
32
+ feishu: {
33
+ enabled: true,
34
+ defaultAccount: params.defaultAccount,
35
+ accounts: {
36
+ a: {
37
+ appId: "app-a",
38
+ appSecret: "sec-a", // pragma: allowlist secret
39
+ tools: params.toolsA,
40
+ },
41
+ b: {
42
+ appId: "app-b",
43
+ appSecret: "sec-b", // pragma: allowlist secret
44
+ tools: params.toolsB,
45
+ },
46
+ },
47
+ },
48
+ },
49
+ } as OpenClawPluginApi["config"];
50
+ }
51
+
52
+ describe("feishu tool account routing", () => {
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ test("wiki tool registers when first account disables it and routes to agentAccountId", async () => {
58
+ const { api, resolveTool } = createToolFactoryHarness(
59
+ createConfig({
60
+ toolsA: { wiki: false },
61
+ toolsB: { wiki: true },
62
+ }),
63
+ );
64
+ registerFeishuWikiTools(api);
65
+
66
+ const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
67
+ await tool.execute("call", { action: "search" });
68
+
69
+ expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
70
+ });
71
+
72
+ test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
73
+ const { api, resolveTool } = createToolFactoryHarness(
74
+ createConfig({
75
+ defaultAccount: "b",
76
+ toolsA: { wiki: true },
77
+ toolsB: { wiki: true },
78
+ }),
79
+ );
80
+ registerFeishuWikiTools(api);
81
+
82
+ const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
83
+ await tool.execute("call", { action: "search" });
84
+
85
+ expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
86
+ });
87
+
88
+ test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
89
+ const { api, resolveTool } = createToolFactoryHarness(
90
+ createConfig({
91
+ toolsA: { drive: false },
92
+ toolsB: { drive: true },
93
+ }),
94
+ );
95
+ registerFeishuDriveTools(api);
96
+
97
+ const tool = resolveTool("feishu_drive", { agentAccountId: "b" });
98
+ await tool.execute("call", { action: "unknown_action" });
99
+
100
+ expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
101
+ });
102
+
103
+ test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
104
+ const { api, resolveTool } = createToolFactoryHarness(
105
+ createConfig({
106
+ toolsA: { perm: false },
107
+ toolsB: { perm: true },
108
+ }),
109
+ );
110
+ registerFeishuPermTools(api);
111
+
112
+ const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
113
+ await tool.execute("call", { action: "unknown_action" });
114
+
115
+ expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
116
+ });
117
+
118
+ test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => {
119
+ const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
120
+ registerFeishuBitableTools(api);
121
+
122
+ const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" });
123
+ await tool.execute("call-ctx", { url: "invalid-url" });
124
+ await tool.execute("call-override", { url: "invalid-url", accountId: "a" });
125
+
126
+ expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
127
+ expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
128
+ });
129
+ });