@nextclaw/channel-plugin-feishu 0.2.16 → 0.2.18

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.
@@ -0,0 +1,172 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
3
+ import { normalizeTaskTime } from "./task-shared.js";
4
+ import { assertLarkOk, createToolContext, handleInvokeError, json, parseTimeToTimestampMs, registerTool, StringEnum } from "./user-tool-helpers.js";
5
+
6
+ const TaskSchema = Type.Union([
7
+ Type.Object({
8
+ action: Type.Literal("create"),
9
+ summary: Type.String(),
10
+ current_user_id: Type.Optional(Type.String()),
11
+ description: Type.Optional(Type.String()),
12
+ due: Type.Optional(Type.Object({ timestamp: Type.String(), is_all_day: Type.Optional(Type.Boolean()) })),
13
+ start: Type.Optional(Type.Object({ timestamp: Type.String(), is_all_day: Type.Optional(Type.Boolean()) })),
14
+ members: Type.Optional(Type.Array(Type.Object({ id: Type.String(), role: Type.Optional(StringEnum(["assignee", "follower"])) }))),
15
+ tasklists: Type.Optional(Type.Array(Type.Object({ tasklist_guid: Type.String(), section_guid: Type.Optional(Type.String()) }))),
16
+ repeat_rule: Type.Optional(Type.String()),
17
+ }),
18
+ Type.Object({ action: Type.Literal("get"), task_guid: Type.String() }),
19
+ Type.Object({ action: Type.Literal("list"), page_size: Type.Optional(Type.Number()), page_token: Type.Optional(Type.String()), completed: Type.Optional(Type.Boolean()) }),
20
+ Type.Object({
21
+ action: Type.Literal("patch"),
22
+ task_guid: Type.String(),
23
+ summary: Type.Optional(Type.String()),
24
+ description: Type.Optional(Type.String()),
25
+ due: Type.Optional(Type.Object({ timestamp: Type.String(), is_all_day: Type.Optional(Type.Boolean()) })),
26
+ start: Type.Optional(Type.Object({ timestamp: Type.String(), is_all_day: Type.Optional(Type.Boolean()) })),
27
+ completed_at: Type.Optional(Type.String()),
28
+ members: Type.Optional(Type.Array(Type.Object({ id: Type.String(), role: Type.Optional(StringEnum(["assignee", "follower"])) }))),
29
+ repeat_rule: Type.Optional(Type.String()),
30
+ }),
31
+ ]);
32
+
33
+ export function registerFeishuTaskTaskTool(api: OpenClawPluginApi) {
34
+ const { toolClient } = createToolContext(api, "feishu_task_task");
35
+ registerTool(api, {
36
+ name: "feishu_task_task",
37
+ label: "Feishu Task",
38
+ description: "按本人身份创建、查询、列出、更新飞书任务。",
39
+ parameters: TaskSchema,
40
+ async execute(_toolCallId, params) {
41
+ const payload = params as {
42
+ action: "create" | "get" | "list" | "patch";
43
+ task_guid?: string;
44
+ summary?: string;
45
+ current_user_id?: string;
46
+ description?: string;
47
+ due?: { timestamp?: string; is_all_day?: boolean };
48
+ start?: { timestamp?: string; is_all_day?: boolean };
49
+ members?: Array<{ id: string; role?: string }>;
50
+ tasklists?: Array<{ tasklist_guid: string; section_guid?: string }>;
51
+ repeat_rule?: string;
52
+ page_size?: number;
53
+ page_token?: string;
54
+ completed?: boolean;
55
+ completed_at?: string;
56
+ };
57
+ try {
58
+ const client = toolClient();
59
+ if (payload.action === "create") {
60
+ const members = [...(payload.members ?? [])];
61
+ if (payload.current_user_id && !members.some((member) => member.id === payload.current_user_id)) {
62
+ members.push({ id: payload.current_user_id, role: "follower" });
63
+ }
64
+ const response = await client.invoke(
65
+ "feishu_task_task.create",
66
+ (sdk, opts) =>
67
+ sdk.task.v2.task.create(
68
+ {
69
+ params: { user_id_type: "open_id" as never },
70
+ data: {
71
+ summary: payload.summary,
72
+ description: payload.description,
73
+ due: normalizeTaskTime(payload.due),
74
+ start: normalizeTaskTime(payload.start),
75
+ repeat_rule: payload.repeat_rule,
76
+ members: members.length
77
+ ? members.map((member) => ({
78
+ id: member.id,
79
+ type: "user",
80
+ role: member.role ?? "assignee",
81
+ }))
82
+ : undefined,
83
+ tasklists: payload.tasklists,
84
+ },
85
+ },
86
+ opts,
87
+ ),
88
+ { as: "user" },
89
+ );
90
+ assertLarkOk(response);
91
+ return json({ task: (response.data as { task?: unknown } | undefined)?.task });
92
+ }
93
+ if (payload.action === "get") {
94
+ const response = await client.invoke(
95
+ "feishu_task_task.get",
96
+ (sdk, opts) =>
97
+ sdk.task.v2.task.get(
98
+ { path: { task_guid: payload.task_guid! }, params: { user_id_type: "open_id" as never } },
99
+ opts,
100
+ ),
101
+ { as: "user" },
102
+ );
103
+ assertLarkOk(response);
104
+ return json({ task: (response.data as { task?: unknown } | undefined)?.task });
105
+ }
106
+ if (payload.action === "list") {
107
+ const response = await client.invoke(
108
+ "feishu_task_task.list",
109
+ (sdk, opts) =>
110
+ sdk.task.v2.task.list(
111
+ {
112
+ params: {
113
+ page_size: payload.page_size,
114
+ page_token: payload.page_token,
115
+ completed: payload.completed,
116
+ user_id_type: "open_id" as never,
117
+ },
118
+ },
119
+ opts,
120
+ ),
121
+ { as: "user" },
122
+ );
123
+ assertLarkOk(response);
124
+ return json({
125
+ tasks: (response.data as { items?: unknown[] } | undefined)?.items ?? [],
126
+ has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
127
+ page_token: (response.data as { page_token?: string } | undefined)?.page_token,
128
+ });
129
+ }
130
+
131
+ const patchData: Record<string, unknown> = {};
132
+ if (payload.summary) patchData.summary = payload.summary;
133
+ if (payload.description) patchData.description = payload.description;
134
+ if (payload.due) patchData.due = normalizeTaskTime(payload.due);
135
+ if (payload.start) patchData.start = normalizeTaskTime(payload.start);
136
+ if (payload.repeat_rule) patchData.repeat_rule = payload.repeat_rule;
137
+ if (payload.completed_at !== undefined) {
138
+ patchData.completed_at =
139
+ payload.completed_at === "0"
140
+ ? "0"
141
+ : /^\d+$/.test(payload.completed_at)
142
+ ? payload.completed_at
143
+ : parseTimeToTimestampMs(payload.completed_at);
144
+ }
145
+ if (payload.members) {
146
+ patchData.members = payload.members.map((member) => ({
147
+ id: member.id,
148
+ type: "user",
149
+ role: member.role ?? "assignee",
150
+ }));
151
+ }
152
+ const response = await client.invoke(
153
+ "feishu_task_task.patch",
154
+ (sdk, opts) =>
155
+ sdk.task.v2.task.patch(
156
+ {
157
+ path: { task_guid: payload.task_guid! },
158
+ params: { user_id_type: "open_id" as never },
159
+ data: patchData,
160
+ },
161
+ opts,
162
+ ),
163
+ { as: "user" },
164
+ );
165
+ assertLarkOk(response);
166
+ return json({ task: (response.data as { task?: unknown } | undefined)?.task });
167
+ } catch (error) {
168
+ return handleInvokeError(error, api);
169
+ }
170
+ },
171
+ }, { name: "feishu_task_task" });
172
+ }
@@ -0,0 +1,174 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
3
+ import { assertLarkOk, createToolContext, handleInvokeError, json, registerTool, StringEnum } from "./user-tool-helpers.js";
4
+
5
+ const TasklistSchema = Type.Union([
6
+ Type.Object({
7
+ action: Type.Literal("create"),
8
+ name: Type.String(),
9
+ members: Type.Optional(
10
+ Type.Array(
11
+ Type.Object({
12
+ id: Type.String(),
13
+ role: Type.Optional(StringEnum(["editor", "viewer"])),
14
+ }),
15
+ ),
16
+ ),
17
+ }),
18
+ Type.Object({ action: Type.Literal("get"), tasklist_guid: Type.String() }),
19
+ Type.Object({ action: Type.Literal("list"), page_size: Type.Optional(Type.Number()), page_token: Type.Optional(Type.String()) }),
20
+ Type.Object({ action: Type.Literal("tasks"), tasklist_guid: Type.String(), page_size: Type.Optional(Type.Number()), page_token: Type.Optional(Type.String()), completed: Type.Optional(Type.Boolean()) }),
21
+ Type.Object({ action: Type.Literal("patch"), tasklist_guid: Type.String(), name: Type.Optional(Type.String()) }),
22
+ Type.Object({
23
+ action: Type.Literal("add_members"),
24
+ tasklist_guid: Type.String(),
25
+ members: Type.Array(
26
+ Type.Object({
27
+ id: Type.String(),
28
+ role: Type.Optional(StringEnum(["editor", "viewer"])),
29
+ }),
30
+ ),
31
+ }),
32
+ ]);
33
+
34
+ export function registerFeishuTaskTasklistTool(api: OpenClawPluginApi) {
35
+ const { toolClient } = createToolContext(api, "feishu_task_tasklist");
36
+ registerTool(api, {
37
+ name: "feishu_task_tasklist",
38
+ label: "Feishu Tasklist",
39
+ description: "按本人身份创建、查询和管理飞书任务清单。",
40
+ parameters: TasklistSchema,
41
+ async execute(_toolCallId, params) {
42
+ const payload = params as {
43
+ action: "create" | "get" | "list" | "tasks" | "patch" | "add_members";
44
+ tasklist_guid?: string;
45
+ name?: string;
46
+ members?: Array<{ id: string; role?: string }>;
47
+ page_size?: number;
48
+ page_token?: string;
49
+ completed?: boolean;
50
+ };
51
+ try {
52
+ const client = toolClient();
53
+ if (payload.action === "create") {
54
+ const response = await client.invoke(
55
+ "feishu_task_tasklist.create",
56
+ (sdk, opts) =>
57
+ sdk.task.v2.tasklist.create(
58
+ {
59
+ params: { user_id_type: "open_id" as never },
60
+ data: {
61
+ name: payload.name,
62
+ members: payload.members?.map((member) => ({
63
+ id: member.id,
64
+ type: "user",
65
+ role: member.role ?? "editor",
66
+ })),
67
+ },
68
+ },
69
+ opts,
70
+ ),
71
+ { as: "user" },
72
+ );
73
+ assertLarkOk(response);
74
+ return json({ tasklist: (response.data as { tasklist?: unknown } | undefined)?.tasklist });
75
+ }
76
+ if (payload.action === "get") {
77
+ const response = await client.invoke(
78
+ "feishu_task_tasklist.get",
79
+ (sdk, opts) =>
80
+ sdk.task.v2.tasklist.get(
81
+ { path: { tasklist_guid: payload.tasklist_guid! }, params: { user_id_type: "open_id" as never } },
82
+ opts,
83
+ ),
84
+ { as: "user" },
85
+ );
86
+ assertLarkOk(response);
87
+ return json({ tasklist: (response.data as { tasklist?: unknown } | undefined)?.tasklist });
88
+ }
89
+ if (payload.action === "list") {
90
+ const response = await client.invoke(
91
+ "feishu_task_tasklist.list",
92
+ (sdk, opts) =>
93
+ sdk.task.v2.tasklist.list(
94
+ { params: { page_size: payload.page_size, page_token: payload.page_token, user_id_type: "open_id" as never } },
95
+ opts,
96
+ ),
97
+ { as: "user" },
98
+ );
99
+ assertLarkOk(response);
100
+ return json({
101
+ tasklists: (response.data as { items?: unknown[] } | undefined)?.items ?? [],
102
+ has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
103
+ page_token: (response.data as { page_token?: string } | undefined)?.page_token,
104
+ });
105
+ }
106
+ if (payload.action === "tasks") {
107
+ const response = await client.invoke(
108
+ "feishu_task_tasklist.tasks",
109
+ (sdk, opts) =>
110
+ sdk.task.v2.tasklist.tasks(
111
+ {
112
+ path: { tasklist_guid: payload.tasklist_guid! },
113
+ params: {
114
+ page_size: payload.page_size,
115
+ page_token: payload.page_token,
116
+ completed: payload.completed,
117
+ user_id_type: "open_id" as never,
118
+ },
119
+ },
120
+ opts,
121
+ ),
122
+ { as: "user" },
123
+ );
124
+ assertLarkOk(response);
125
+ return json({
126
+ tasks: (response.data as { items?: unknown[] } | undefined)?.items ?? [],
127
+ has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
128
+ page_token: (response.data as { page_token?: string } | undefined)?.page_token,
129
+ });
130
+ }
131
+ if (payload.action === "patch") {
132
+ const response = await client.invoke(
133
+ "feishu_task_tasklist.patch",
134
+ (sdk, opts) =>
135
+ sdk.task.v2.tasklist.patch(
136
+ {
137
+ path: { tasklist_guid: payload.tasklist_guid! },
138
+ params: { user_id_type: "open_id" as never },
139
+ data: { name: payload.name },
140
+ },
141
+ opts,
142
+ ),
143
+ { as: "user" },
144
+ );
145
+ assertLarkOk(response);
146
+ return json({ tasklist: (response.data as { tasklist?: unknown } | undefined)?.tasklist });
147
+ }
148
+ const response = await client.invoke(
149
+ "feishu_task_tasklist.add_members",
150
+ (sdk, opts) =>
151
+ sdk.task.v2.tasklist.addMembers(
152
+ {
153
+ path: { tasklist_guid: payload.tasklist_guid! },
154
+ params: { user_id_type: "open_id" as never },
155
+ data: {
156
+ members: (payload.members ?? []).map((member) => ({
157
+ id: member.id,
158
+ type: "user",
159
+ role: member.role ?? "editor",
160
+ })),
161
+ },
162
+ },
163
+ opts,
164
+ ),
165
+ { as: "user" },
166
+ );
167
+ assertLarkOk(response);
168
+ return json({ success: true, tasklist_guid: payload.tasklist_guid });
169
+ } catch (error) {
170
+ return handleInvokeError(error, api);
171
+ }
172
+ },
173
+ }, { name: "feishu_task_tasklist" });
174
+ }
package/src/task.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
2
+ import { listEnabledFeishuAccounts } from "./accounts.js";
3
+ import { resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
4
+ import { registerFeishuTaskCommentTool } from "./task-comment.js";
5
+ import { registerFeishuTaskSubtaskTool } from "./task-subtask.js";
6
+ import { registerFeishuTaskTaskTool } from "./task-task.js";
7
+ import { registerFeishuTaskTasklistTool } from "./task-tasklist.js";
8
+
9
+ export function registerFeishuTaskTools(api: OpenClawPluginApi) {
10
+ if (!api.config) return;
11
+ const accounts = listEnabledFeishuAccounts(api.config);
12
+ if (accounts.length === 0) return;
13
+ if (!resolveAnyEnabledFeishuToolsConfig(accounts).task) return;
14
+ registerFeishuTaskTaskTool(api);
15
+ registerFeishuTaskTasklistTool(api);
16
+ registerFeishuTaskCommentTool(api);
17
+ registerFeishuTaskSubtaskTool(api);
18
+ }
@@ -0,0 +1,211 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { mkdir, unlink, readFile, writeFile, chmod } from "node:fs/promises";
3
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFile = promisify(execFileCb);
9
+
10
+ export type StoredUAToken = {
11
+ userOpenId: string;
12
+ appId: string;
13
+ accessToken: string;
14
+ refreshToken: string;
15
+ expiresAt: number;
16
+ refreshExpiresAt: number;
17
+ scope: string;
18
+ grantedAt: number;
19
+ };
20
+
21
+ const KEYCHAIN_SERVICE = "nextclaw-feishu-uat";
22
+ const REFRESH_AHEAD_MS = 5 * 60 * 1000;
23
+
24
+ function accountKey(appId: string, userOpenId: string): string {
25
+ return `${appId}:${userOpenId}`;
26
+ }
27
+
28
+ export function maskToken(token: string): string {
29
+ if (token.length <= 8) {
30
+ return "****";
31
+ }
32
+ return `****${token.slice(-4)}`;
33
+ }
34
+
35
+ type KeychainBackend = {
36
+ get(service: string, account: string): Promise<string | null>;
37
+ set(service: string, account: string, data: string): Promise<void>;
38
+ remove(service: string, account: string): Promise<void>;
39
+ };
40
+
41
+ const darwinBackend: KeychainBackend = {
42
+ async get(service, account) {
43
+ try {
44
+ const { stdout } = await execFile("security", [
45
+ "find-generic-password",
46
+ "-s",
47
+ service,
48
+ "-a",
49
+ account,
50
+ "-w",
51
+ ]);
52
+ return stdout.trim() || null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ },
57
+
58
+ async set(service, account, data) {
59
+ try {
60
+ await execFile("security", ["delete-generic-password", "-s", service, "-a", account]);
61
+ } catch {
62
+ // no-op
63
+ }
64
+ await execFile("security", [
65
+ "add-generic-password",
66
+ "-s",
67
+ service,
68
+ "-a",
69
+ account,
70
+ "-w",
71
+ data,
72
+ ]);
73
+ },
74
+
75
+ async remove(service, account) {
76
+ try {
77
+ await execFile("security", ["delete-generic-password", "-s", service, "-a", account]);
78
+ } catch {
79
+ // no-op
80
+ }
81
+ },
82
+ };
83
+
84
+ const STORAGE_DIR = join(
85
+ process.platform === "win32"
86
+ ? process.env.LOCALAPPDATA ?? join(process.env.USERPROFILE ?? homedir(), "AppData", "Local")
87
+ : process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
88
+ KEYCHAIN_SERVICE,
89
+ );
90
+ const MASTER_KEY_PATH = join(STORAGE_DIR, "master.key");
91
+ const MASTER_KEY_BYTES = 32;
92
+ const IV_BYTES = 12;
93
+ const TAG_BYTES = 16;
94
+
95
+ function safeFileName(account: string): string {
96
+ return account.replace(/[^a-zA-Z0-9._-]/g, "_") + ".enc";
97
+ }
98
+
99
+ async function ensureStorageDir(): Promise<void> {
100
+ await mkdir(STORAGE_DIR, { recursive: true, mode: 0o700 });
101
+ }
102
+
103
+ async function getMasterKey(): Promise<Buffer> {
104
+ try {
105
+ const key = await readFile(MASTER_KEY_PATH);
106
+ if (key.length === MASTER_KEY_BYTES) {
107
+ return key;
108
+ }
109
+ } catch {
110
+ // ignore
111
+ }
112
+
113
+ await ensureStorageDir();
114
+ const key = randomBytes(MASTER_KEY_BYTES);
115
+ await writeFile(MASTER_KEY_PATH, key, { mode: 0o600 });
116
+ if (process.platform !== "win32") {
117
+ await chmod(MASTER_KEY_PATH, 0o600);
118
+ }
119
+ return key;
120
+ }
121
+
122
+ function encryptData(plaintext: string, key: Buffer): Buffer {
123
+ const iv = randomBytes(IV_BYTES);
124
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
125
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
126
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
127
+ }
128
+
129
+ function decryptData(data: Buffer, key: Buffer): string | null {
130
+ if (data.length < IV_BYTES + TAG_BYTES) {
131
+ return null;
132
+ }
133
+ try {
134
+ const iv = data.subarray(0, IV_BYTES);
135
+ const tag = data.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
136
+ const encrypted = data.subarray(IV_BYTES + TAG_BYTES);
137
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
138
+ decipher.setAuthTag(tag);
139
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ const encryptedFileBackend: KeychainBackend = {
146
+ async get(_service, account) {
147
+ try {
148
+ const key = await getMasterKey();
149
+ const data = await readFile(join(STORAGE_DIR, safeFileName(account)));
150
+ return decryptData(data, key);
151
+ } catch {
152
+ return null;
153
+ }
154
+ },
155
+
156
+ async set(_service, account, data) {
157
+ const key = await getMasterKey();
158
+ await ensureStorageDir();
159
+ const filePath = join(STORAGE_DIR, safeFileName(account));
160
+ const encrypted = encryptData(data, key);
161
+ await writeFile(filePath, encrypted, { mode: 0o600 });
162
+ if (process.platform !== "win32") {
163
+ await chmod(filePath, 0o600);
164
+ }
165
+ },
166
+
167
+ async remove(_service, account) {
168
+ try {
169
+ await unlink(join(STORAGE_DIR, safeFileName(account)));
170
+ } catch {
171
+ // no-op
172
+ }
173
+ },
174
+ };
175
+
176
+ const backend =
177
+ process.platform === "darwin"
178
+ ? darwinBackend
179
+ : encryptedFileBackend;
180
+
181
+ export async function getStoredToken(
182
+ appId: string,
183
+ userOpenId: string,
184
+ ): Promise<StoredUAToken | null> {
185
+ try {
186
+ const json = await backend.get(KEYCHAIN_SERVICE, accountKey(appId, userOpenId));
187
+ return json ? (JSON.parse(json) as StoredUAToken) : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ export async function setStoredToken(token: StoredUAToken): Promise<void> {
194
+ const payload = JSON.stringify(token);
195
+ await backend.set(KEYCHAIN_SERVICE, accountKey(token.appId, token.userOpenId), payload);
196
+ }
197
+
198
+ export async function removeStoredToken(appId: string, userOpenId: string): Promise<void> {
199
+ await backend.remove(KEYCHAIN_SERVICE, accountKey(appId, userOpenId));
200
+ }
201
+
202
+ export function tokenStatus(token: StoredUAToken): "valid" | "needs_refresh" | "expired" {
203
+ const now = Date.now();
204
+ if (now < token.expiresAt - REFRESH_AHEAD_MS) {
205
+ return "valid";
206
+ }
207
+ if (now < token.refreshExpiresAt) {
208
+ return "needs_refresh";
209
+ }
210
+ return "expired";
211
+ }
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
2
2
  import { beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import { registerFeishuBitableTools } from "./bitable.js";
4
4
  import { registerFeishuDriveTools } from "./drive.js";
5
+ import { withTicket } from "./lark-ticket.js";
5
6
  import { registerFeishuPermTools } from "./perm.js";
6
7
  import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
7
8
  import { registerFeishuWikiTools } from "./wiki.js";
@@ -126,4 +127,22 @@ describe("feishu tool account routing", () => {
126
127
  expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
127
128
  expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
128
129
  });
130
+
131
+ test("ticket account id overrides inherited tool account selection", async () => {
132
+ const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
133
+ registerFeishuBitableTools(api);
134
+
135
+ const tool = resolveTool("feishu_bitable_get_meta");
136
+ await withTicket(
137
+ {
138
+ accountId: "b",
139
+ chatId: "oc_test",
140
+ messageId: "om_test",
141
+ startTime: Date.now(),
142
+ },
143
+ () => tool.execute("call-ticket", { url: "invalid-url" }),
144
+ );
145
+
146
+ expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
147
+ });
129
148
  });
@@ -2,6 +2,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
3
3
  import { resolveFeishuAccount } from "./accounts.js";
4
4
  import { createFeishuClient } from "./client.js";
5
+ import { getTicket } from "./lark-ticket.js";
5
6
  import { resolveToolsConfig } from "./tools-config.js";
6
7
  import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
7
8
 
@@ -21,6 +22,10 @@ function readConfiguredDefaultAccountId(config: OpenClawPluginApi["config"]): st
21
22
  return normalizeOptionalAccountId(value);
22
23
  }
23
24
 
25
+ function readTicketAccountId(): string | undefined {
26
+ return normalizeOptionalAccountId(getTicket()?.accountId);
27
+ }
28
+
24
29
  export function resolveFeishuToolAccount(params: {
25
30
  api: Pick<OpenClawPluginApi, "config">;
26
31
  executeParams?: AccountAwareParams;
@@ -33,6 +38,7 @@ export function resolveFeishuToolAccount(params: {
33
38
  cfg: params.api.config,
34
39
  accountId:
35
40
  normalizeOptionalAccountId(params.executeParams?.accountId) ??
41
+ readTicketAccountId() ??
36
42
  readConfiguredDefaultAccountId(params.api.config) ??
37
43
  normalizeOptionalAccountId(params.defaultAccountId),
38
44
  });
@@ -56,6 +62,11 @@ export function resolveAnyEnabledFeishuToolsConfig(
56
62
  drive: false,
57
63
  perm: false,
58
64
  scopes: false,
65
+ calendar: false,
66
+ task: false,
67
+ sheets: false,
68
+ oauth: false,
69
+ identity: false,
59
70
  };
60
71
  for (const account of accounts) {
61
72
  const cfg = resolveToolsConfig(account.config.tools);
@@ -65,6 +76,11 @@ export function resolveAnyEnabledFeishuToolsConfig(
65
76
  merged.drive = merged.drive || cfg.drive;
66
77
  merged.perm = merged.perm || cfg.perm;
67
78
  merged.scopes = merged.scopes || cfg.scopes;
79
+ merged.calendar = merged.calendar || cfg.calendar;
80
+ merged.task = merged.task || cfg.task;
81
+ merged.sheets = merged.sheets || cfg.sheets;
82
+ merged.oauth = merged.oauth || cfg.oauth;
83
+ merged.identity = merged.identity || cfg.identity;
68
84
  }
69
85
  return merged;
70
86
  }