@openclaw/feishu 2026.2.25 → 2026.3.2

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 (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
@@ -3,11 +3,19 @@
3
3
  */
4
4
 
5
5
  import type { Client } from "@larksuiteoapi/node-sdk";
6
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
6
7
  import type { FeishuDomain } from "./types.js";
7
8
 
8
9
  type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
9
10
  type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
10
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
+
11
19
  // Token cache (keyed by domain + appId)
12
20
  const tokenCache = new Map<string, { token: string; expiresAt: number }>();
13
21
 
@@ -21,6 +29,20 @@ function resolveApiBase(domain?: FeishuDomain): string {
21
29
  return "https://open.feishu.cn/open-apis";
22
30
  }
23
31
 
32
+ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
33
+ if (domain === "lark") {
34
+ return ["open.larksuite.com"];
35
+ }
36
+ if (domain && domain !== "feishu" && domain.startsWith("http")) {
37
+ try {
38
+ return [new URL(domain).hostname];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+ return ["open.feishu.cn"];
44
+ }
45
+
24
46
  async function getToken(creds: Credentials): Promise<string> {
25
47
  const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
26
48
  const cached = tokenCache.get(key);
@@ -28,17 +50,23 @@ async function getToken(creds: Credentials): Promise<string> {
28
50
  return cached.token;
29
51
  }
30
52
 
31
- const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
32
- method: "POST",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
53
+ const { response, release } = await fetchWithSsrFGuard({
54
+ url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
55
+ init: {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
59
+ },
60
+ policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
61
+ auditContext: "feishu.streaming-card.token",
35
62
  });
36
- const data = (await res.json()) as {
63
+ const data = (await response.json()) as {
37
64
  code: number;
38
65
  msg: string;
39
66
  tenant_access_token?: string;
40
67
  expire?: number;
41
68
  };
69
+ await release();
42
70
  if (data.code !== 0 || !data.tenant_access_token) {
43
71
  throw new Error(`Token error: ${data.msg}`);
44
72
  }
@@ -78,13 +106,19 @@ export class FeishuStreamingSession {
78
106
  async start(
79
107
  receiveId: string,
80
108
  receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
109
+ options?: {
110
+ replyToMessageId?: string;
111
+ replyInThread?: boolean;
112
+ rootId?: string;
113
+ header?: StreamingCardHeader;
114
+ },
81
115
  ): Promise<void> {
82
116
  if (this.state) {
83
117
  return;
84
118
  }
85
119
 
86
120
  const apiBase = resolveApiBase(this.creds.domain);
87
- const cardJson = {
121
+ const cardJson: Record<string, unknown> = {
88
122
  schema: "2.0",
89
123
  config: {
90
124
  streaming_mode: true,
@@ -95,35 +129,71 @@ export class FeishuStreamingSession {
95
129
  elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
96
130
  },
97
131
  };
132
+ if (options?.header) {
133
+ cardJson.header = {
134
+ title: { tag: "plain_text", content: options.header.title },
135
+ template: options.header.template ?? "blue",
136
+ };
137
+ }
98
138
 
99
139
  // Create card entity
100
- const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
101
- method: "POST",
102
- headers: {
103
- Authorization: `Bearer ${await getToken(this.creds)}`,
104
- "Content-Type": "application/json",
140
+ const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
141
+ url: `${apiBase}/cardkit/v1/cards`,
142
+ init: {
143
+ method: "POST",
144
+ headers: {
145
+ Authorization: `Bearer ${await getToken(this.creds)}`,
146
+ "Content-Type": "application/json",
147
+ },
148
+ body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
105
149
  },
106
- body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
150
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
151
+ auditContext: "feishu.streaming-card.create",
107
152
  });
108
153
  const createData = (await createRes.json()) as {
109
154
  code: number;
110
155
  msg: string;
111
156
  data?: { card_id: string };
112
157
  };
158
+ await releaseCreate();
113
159
  if (createData.code !== 0 || !createData.data?.card_id) {
114
160
  throw new Error(`Create card failed: ${createData.msg}`);
115
161
  }
116
162
  const cardId = createData.data.card_id;
163
+ const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
117
164
 
118
- // Send card message
119
- const sendRes = await this.client.im.message.create({
120
- params: { receive_id_type: receiveIdType },
121
- data: {
165
+ // Topic-group replies require root_id routing. Prefer create+root_id when available.
166
+ let sendRes;
167
+ if (options?.rootId) {
168
+ const createData = {
122
169
  receive_id: receiveId,
123
170
  msg_type: "interactive",
124
- content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
125
- },
126
- });
171
+ content: cardContent,
172
+ root_id: options.rootId,
173
+ };
174
+ sendRes = await this.client.im.message.create({
175
+ params: { receive_id_type: receiveIdType },
176
+ data: createData,
177
+ });
178
+ } else if (options?.replyToMessageId) {
179
+ sendRes = await this.client.im.message.reply({
180
+ path: { message_id: options.replyToMessageId },
181
+ data: {
182
+ msg_type: "interactive",
183
+ content: cardContent,
184
+ ...(options.replyInThread ? { reply_in_thread: true } : {}),
185
+ },
186
+ });
187
+ } else {
188
+ sendRes = await this.client.im.message.create({
189
+ params: { receive_id_type: receiveIdType },
190
+ data: {
191
+ receive_id: receiveId,
192
+ msg_type: "interactive",
193
+ content: cardContent,
194
+ },
195
+ });
196
+ }
127
197
  if (sendRes.code !== 0 || !sendRes.data?.message_id) {
128
198
  throw new Error(`Send card failed: ${sendRes.msg}`);
129
199
  }
@@ -138,18 +208,27 @@ export class FeishuStreamingSession {
138
208
  }
139
209
  const apiBase = resolveApiBase(this.creds.domain);
140
210
  this.state.sequence += 1;
141
- await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
142
- method: "PUT",
143
- headers: {
144
- Authorization: `Bearer ${await getToken(this.creds)}`,
145
- "Content-Type": "application/json",
211
+ await fetchWithSsrFGuard({
212
+ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
213
+ init: {
214
+ method: "PUT",
215
+ headers: {
216
+ Authorization: `Bearer ${await getToken(this.creds)}`,
217
+ "Content-Type": "application/json",
218
+ },
219
+ body: JSON.stringify({
220
+ content: text,
221
+ sequence: this.state.sequence,
222
+ uuid: `s_${this.state.cardId}_${this.state.sequence}`,
223
+ }),
146
224
  },
147
- body: JSON.stringify({
148
- content: text,
149
- sequence: this.state.sequence,
150
- uuid: `s_${this.state.cardId}_${this.state.sequence}`,
151
- }),
152
- }).catch((error) => onError?.(error));
225
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
226
+ auditContext: "feishu.streaming-card.update",
227
+ })
228
+ .then(async ({ release }) => {
229
+ await release();
230
+ })
231
+ .catch((error) => onError?.(error));
153
232
  }
154
233
 
155
234
  async update(text: string): Promise<void> {
@@ -194,20 +273,29 @@ export class FeishuStreamingSession {
194
273
 
195
274
  // Close streaming mode
196
275
  this.state.sequence += 1;
197
- await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
198
- method: "PATCH",
199
- headers: {
200
- Authorization: `Bearer ${await getToken(this.creds)}`,
201
- "Content-Type": "application/json; charset=utf-8",
202
- },
203
- body: JSON.stringify({
204
- settings: JSON.stringify({
205
- config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
276
+ await fetchWithSsrFGuard({
277
+ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
278
+ init: {
279
+ method: "PATCH",
280
+ headers: {
281
+ Authorization: `Bearer ${await getToken(this.creds)}`,
282
+ "Content-Type": "application/json; charset=utf-8",
283
+ },
284
+ body: JSON.stringify({
285
+ settings: JSON.stringify({
286
+ config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
287
+ }),
288
+ sequence: this.state.sequence,
289
+ uuid: `c_${this.state.cardId}_${this.state.sequence}`,
206
290
  }),
207
- sequence: this.state.sequence,
208
- uuid: `c_${this.state.cardId}_${this.state.sequence}`,
209
- }),
210
- }).catch((e) => this.log?.(`Close failed: ${String(e)}`));
291
+ },
292
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
293
+ auditContext: "feishu.streaming-card.close",
294
+ })
295
+ .then(async ({ release }) => {
296
+ await release();
297
+ })
298
+ .catch((e) => this.log?.(`Close failed: ${String(e)}`));
211
299
 
212
300
  this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
213
301
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { resolveReceiveIdType } from "./targets.js";
2
+ import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
3
3
 
4
4
  describe("resolveReceiveIdType", () => {
5
5
  it("resolves chat IDs by oc_ prefix", () => {
@@ -13,4 +13,58 @@ describe("resolveReceiveIdType", () => {
13
13
  it("defaults unprefixed IDs to user_id", () => {
14
14
  expect(resolveReceiveIdType("u_123")).toBe("user_id");
15
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
+ });
16
70
  });
package/src/targets.ts CHANGED
@@ -4,6 +4,10 @@ const CHAT_ID_PREFIX = "oc_";
4
4
  const OPEN_ID_PREFIX = "ou_";
5
5
  const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
6
6
 
7
+ function stripProviderPrefix(raw: string): string {
8
+ return raw.replace(/^(feishu|lark):/i, "").trim();
9
+ }
10
+
7
11
  export function detectIdType(id: string): FeishuIdType | null {
8
12
  const trimmed = id.trim();
9
13
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
@@ -24,18 +28,28 @@ export function normalizeFeishuTarget(raw: string): string | null {
24
28
  return null;
25
29
  }
26
30
 
27
- const lowered = trimmed.toLowerCase();
31
+ const withoutProvider = stripProviderPrefix(trimmed);
32
+ const lowered = withoutProvider.toLowerCase();
28
33
  if (lowered.startsWith("chat:")) {
29
- return trimmed.slice("chat:".length).trim() || null;
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;
30
41
  }
31
42
  if (lowered.startsWith("user:")) {
32
- return trimmed.slice("user:".length).trim() || null;
43
+ return withoutProvider.slice("user:".length).trim() || null;
44
+ }
45
+ if (lowered.startsWith("dm:")) {
46
+ return withoutProvider.slice("dm:".length).trim() || null;
33
47
  }
34
48
  if (lowered.startsWith("open_id:")) {
35
- return trimmed.slice("open_id:".length).trim() || null;
49
+ return withoutProvider.slice("open_id:".length).trim() || null;
36
50
  }
37
51
 
38
- return trimmed;
52
+ return withoutProvider;
39
53
  }
40
54
 
41
55
  export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
@@ -51,6 +65,17 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
51
65
 
52
66
  export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
53
67
  const trimmed = id.trim();
68
+ const lowered = trimmed.toLowerCase();
69
+ if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
70
+ return "chat_id";
71
+ }
72
+ if (lowered.startsWith("open_id:")) {
73
+ return "open_id";
74
+ }
75
+ if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
76
+ const normalized = trimmed.replace(/^(user|dm):/i, "").trim();
77
+ return normalized.startsWith(OPEN_ID_PREFIX) ? "open_id" : "user_id";
78
+ }
54
79
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
55
80
  return "chat_id";
56
81
  }
@@ -61,11 +86,11 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
61
86
  }
62
87
 
63
88
  export function looksLikeFeishuId(raw: string): boolean {
64
- const trimmed = raw.trim();
89
+ const trimmed = stripProviderPrefix(raw.trim());
65
90
  if (!trimmed) {
66
91
  return false;
67
92
  }
68
- if (/^(chat|user|open_id):/i.test(trimmed)) {
93
+ if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
69
94
  return true;
70
95
  }
71
96
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
@@ -0,0 +1,129 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
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",
39
+ tools: params.toolsA,
40
+ },
41
+ b: {
42
+ appId: "app-b",
43
+ appSecret: "sec-b",
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
+ });
@@ -0,0 +1,70 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import { resolveFeishuAccount } from "./accounts.js";
4
+ import { createFeishuClient } from "./client.js";
5
+ import { resolveToolsConfig } from "./tools-config.js";
6
+ import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
7
+
8
+ type AccountAwareParams = { accountId?: string };
9
+
10
+ function normalizeOptionalAccountId(value: string | undefined): string | undefined {
11
+ const trimmed = value?.trim();
12
+ return trimmed ? trimmed : undefined;
13
+ }
14
+
15
+ function readConfiguredDefaultAccountId(config: OpenClawPluginApi["config"]): string | undefined {
16
+ const value = (config?.channels?.feishu as { defaultAccount?: unknown } | undefined)
17
+ ?.defaultAccount;
18
+ if (typeof value !== "string") {
19
+ return undefined;
20
+ }
21
+ return normalizeOptionalAccountId(value);
22
+ }
23
+
24
+ export function resolveFeishuToolAccount(params: {
25
+ api: Pick<OpenClawPluginApi, "config">;
26
+ executeParams?: AccountAwareParams;
27
+ defaultAccountId?: string;
28
+ }): ResolvedFeishuAccount {
29
+ if (!params.api.config) {
30
+ throw new Error("Feishu config unavailable");
31
+ }
32
+ return resolveFeishuAccount({
33
+ cfg: params.api.config,
34
+ accountId:
35
+ normalizeOptionalAccountId(params.executeParams?.accountId) ??
36
+ readConfiguredDefaultAccountId(params.api.config) ??
37
+ normalizeOptionalAccountId(params.defaultAccountId),
38
+ });
39
+ }
40
+
41
+ export function createFeishuToolClient(params: {
42
+ api: Pick<OpenClawPluginApi, "config">;
43
+ executeParams?: AccountAwareParams;
44
+ defaultAccountId?: string;
45
+ }): Lark.Client {
46
+ return createFeishuClient(resolveFeishuToolAccount(params));
47
+ }
48
+
49
+ export function resolveAnyEnabledFeishuToolsConfig(
50
+ accounts: ResolvedFeishuAccount[],
51
+ ): Required<FeishuToolsConfig> {
52
+ const merged: Required<FeishuToolsConfig> = {
53
+ doc: false,
54
+ chat: false,
55
+ wiki: false,
56
+ drive: false,
57
+ perm: false,
58
+ scopes: false,
59
+ };
60
+ for (const account of accounts) {
61
+ const cfg = resolveToolsConfig(account.config.tools);
62
+ merged.doc = merged.doc || cfg.doc;
63
+ merged.chat = merged.chat || cfg.chat;
64
+ merged.wiki = merged.wiki || cfg.wiki;
65
+ merged.drive = merged.drive || cfg.drive;
66
+ merged.perm = merged.perm || cfg.perm;
67
+ merged.scopes = merged.scopes || cfg.scopes;
68
+ }
69
+ return merged;
70
+ }
@@ -0,0 +1,76 @@
1
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+
3
+ type ToolContextLike = {
4
+ agentAccountId?: string;
5
+ };
6
+
7
+ type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined;
8
+
9
+ export type ToolLike = {
10
+ name: string;
11
+ execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
12
+ };
13
+
14
+ type RegisteredTool = {
15
+ tool: AnyAgentTool | ToolFactoryLike;
16
+ opts?: { name?: string };
17
+ };
18
+
19
+ function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] {
20
+ if (!value) return [];
21
+ return Array.isArray(value) ? value : [value];
22
+ }
23
+
24
+ function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
25
+ const candidate = tool as Partial<ToolLike>;
26
+ const name = candidate.name ?? fallbackName;
27
+ const execute = candidate.execute;
28
+ if (!name || typeof execute !== "function") {
29
+ throw new Error(`Resolved tool is missing required fields (name=${String(name)})`);
30
+ }
31
+ return {
32
+ name,
33
+ execute: (toolCallId, params) => execute(toolCallId, params),
34
+ };
35
+ }
36
+
37
+ export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) {
38
+ const registered: RegisteredTool[] = [];
39
+
40
+ const api: Pick<OpenClawPluginApi, "config" | "logger" | "registerTool"> = {
41
+ config: cfg,
42
+ logger: {
43
+ info: () => {},
44
+ warn: () => {},
45
+ error: () => {},
46
+ debug: () => {},
47
+ },
48
+ registerTool: (tool, opts) => {
49
+ registered.push({ tool, opts });
50
+ },
51
+ };
52
+
53
+ const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => {
54
+ for (const entry of registered) {
55
+ if (entry.opts?.name === name && typeof entry.tool !== "function") {
56
+ return asToolLike(entry.tool, name);
57
+ }
58
+
59
+ if (typeof entry.tool === "function") {
60
+ const builtTools = toToolList(entry.tool(ctx));
61
+ const hit = builtTools.find((tool) => (tool as { name?: string }).name === name);
62
+ if (hit) {
63
+ return asToolLike(hit, name);
64
+ }
65
+ } else if ((entry.tool as { name?: string }).name === name) {
66
+ return asToolLike(entry.tool, name);
67
+ }
68
+ }
69
+ throw new Error(`Tool not registered: ${name}`);
70
+ };
71
+
72
+ return {
73
+ api: api as OpenClawPluginApi,
74
+ resolveTool,
75
+ };
76
+ }