@nextclaw/channel-plugin-feishu 0.2.16 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +10 -0
- package/package.json +1 -1
- package/src/app-scope-checker.ts +75 -0
- package/src/auth-errors.ts +90 -0
- package/src/calendar-calendar.ts +72 -0
- package/src/calendar-event-attendee.ts +96 -0
- package/src/calendar-event.ts +236 -0
- package/src/calendar-freebusy.ts +58 -0
- package/src/calendar-shared.ts +32 -0
- package/src/calendar.ts +18 -0
- package/src/card-action.ts +19 -7
- package/src/config-schema.ts +5 -0
- package/src/device-flow.ts +188 -0
- package/src/domains.ts +19 -0
- package/src/feishu-fetch.ts +9 -0
- package/src/identity.ts +160 -0
- package/src/lark-ticket.ts +26 -0
- package/src/monitor.account.ts +48 -20
- package/src/oauth.ts +248 -0
- package/src/raw-request.ts +45 -0
- package/src/sheets.ts +374 -0
- package/src/task-comment.ts +95 -0
- package/src/task-shared.ts +10 -0
- package/src/task-subtask.ts +94 -0
- package/src/task-task.ts +172 -0
- package/src/task-tasklist.ts +154 -0
- package/src/task.ts +18 -0
- package/src/token-store.ts +211 -0
- package/src/tool-account-routing.test.ts +19 -0
- package/src/tool-account.ts +16 -0
- package/src/tool-scopes.ts +102 -0
- package/src/tools-config.test.ts +19 -0
- package/src/tools-config.ts +5 -0
- package/src/types.ts +5 -0
- package/src/uat-client.ts +159 -0
- package/src/user-tool-client.ts +224 -0
- package/src/user-tool-helpers.ts +157 -0
- package/src/user-tool-result.ts +22 -0
|
@@ -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
|
});
|
package/src/tool-account.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export type ToolActionKey =
|
|
2
|
+
| "feishu_get_user.default"
|
|
3
|
+
| "feishu_search_user.default"
|
|
4
|
+
| "feishu_calendar_calendar.list"
|
|
5
|
+
| "feishu_calendar_calendar.get"
|
|
6
|
+
| "feishu_calendar_calendar.primary"
|
|
7
|
+
| "feishu_calendar_event.create"
|
|
8
|
+
| "feishu_calendar_event.list"
|
|
9
|
+
| "feishu_calendar_event.get"
|
|
10
|
+
| "feishu_calendar_event.patch"
|
|
11
|
+
| "feishu_calendar_event.delete"
|
|
12
|
+
| "feishu_calendar_event.instance_view"
|
|
13
|
+
| "feishu_calendar_event_attendee.create"
|
|
14
|
+
| "feishu_calendar_event_attendee.list"
|
|
15
|
+
| "feishu_calendar_freebusy.list"
|
|
16
|
+
| "feishu_task_task.create"
|
|
17
|
+
| "feishu_task_task.get"
|
|
18
|
+
| "feishu_task_task.list"
|
|
19
|
+
| "feishu_task_task.patch"
|
|
20
|
+
| "feishu_task_tasklist.create"
|
|
21
|
+
| "feishu_task_tasklist.get"
|
|
22
|
+
| "feishu_task_tasklist.list"
|
|
23
|
+
| "feishu_task_tasklist.tasks"
|
|
24
|
+
| "feishu_task_tasklist.patch"
|
|
25
|
+
| "feishu_task_tasklist.add_members"
|
|
26
|
+
| "feishu_task_comment.create"
|
|
27
|
+
| "feishu_task_comment.get"
|
|
28
|
+
| "feishu_task_comment.list"
|
|
29
|
+
| "feishu_task_subtask.create"
|
|
30
|
+
| "feishu_task_subtask.list"
|
|
31
|
+
| "feishu_sheet.info"
|
|
32
|
+
| "feishu_sheet.read"
|
|
33
|
+
| "feishu_sheet.write"
|
|
34
|
+
| "feishu_sheet.append"
|
|
35
|
+
| "feishu_sheet.find"
|
|
36
|
+
| "feishu_sheet.create"
|
|
37
|
+
| "feishu_sheet.export";
|
|
38
|
+
|
|
39
|
+
export const TOOL_SCOPES: Record<ToolActionKey, string[]> = {
|
|
40
|
+
"feishu_get_user.default": ["contact:contact.base:readonly", "contact:user.base:readonly"],
|
|
41
|
+
"feishu_search_user.default": ["contact:user:search"],
|
|
42
|
+
"feishu_calendar_calendar.list": ["calendar:calendar:read"],
|
|
43
|
+
"feishu_calendar_calendar.get": ["calendar:calendar:read"],
|
|
44
|
+
"feishu_calendar_calendar.primary": ["calendar:calendar:read"],
|
|
45
|
+
"feishu_calendar_event.create": [
|
|
46
|
+
"calendar:calendar.event:create",
|
|
47
|
+
"calendar:calendar.event:update",
|
|
48
|
+
],
|
|
49
|
+
"feishu_calendar_event.list": ["calendar:calendar.event:read"],
|
|
50
|
+
"feishu_calendar_event.get": ["calendar:calendar.event:read"],
|
|
51
|
+
"feishu_calendar_event.patch": ["calendar:calendar.event:update"],
|
|
52
|
+
"feishu_calendar_event.delete": ["calendar:calendar.event:delete"],
|
|
53
|
+
"feishu_calendar_event.instance_view": ["calendar:calendar.event:read"],
|
|
54
|
+
"feishu_calendar_event_attendee.create": ["calendar:calendar.event:update"],
|
|
55
|
+
"feishu_calendar_event_attendee.list": ["calendar:calendar.event:read"],
|
|
56
|
+
"feishu_calendar_freebusy.list": ["calendar:calendar.free_busy:read"],
|
|
57
|
+
"feishu_task_task.create": ["task:task:write", "task:task:writeonly"],
|
|
58
|
+
"feishu_task_task.get": ["task:task:read", "task:task:write"],
|
|
59
|
+
"feishu_task_task.list": ["task:task:read", "task:task:write"],
|
|
60
|
+
"feishu_task_task.patch": ["task:task:write", "task:task:writeonly"],
|
|
61
|
+
"feishu_task_tasklist.create": ["task:tasklist:write"],
|
|
62
|
+
"feishu_task_tasklist.get": ["task:tasklist:read", "task:tasklist:write"],
|
|
63
|
+
"feishu_task_tasklist.list": ["task:tasklist:read", "task:tasklist:write"],
|
|
64
|
+
"feishu_task_tasklist.tasks": ["task:tasklist:read", "task:tasklist:write"],
|
|
65
|
+
"feishu_task_tasklist.patch": ["task:tasklist:write"],
|
|
66
|
+
"feishu_task_tasklist.add_members": ["task:tasklist:write"],
|
|
67
|
+
"feishu_task_comment.create": ["task:comment:write"],
|
|
68
|
+
"feishu_task_comment.get": ["task:comment:read", "task:comment:write"],
|
|
69
|
+
"feishu_task_comment.list": ["task:comment:read", "task:comment:write"],
|
|
70
|
+
"feishu_task_subtask.create": ["task:task:write"],
|
|
71
|
+
"feishu_task_subtask.list": ["task:task:read", "task:task:write"],
|
|
72
|
+
"feishu_sheet.info": ["sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"],
|
|
73
|
+
"feishu_sheet.read": ["sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"],
|
|
74
|
+
"feishu_sheet.write": [
|
|
75
|
+
"sheets:spreadsheet.meta:read",
|
|
76
|
+
"sheets:spreadsheet:read",
|
|
77
|
+
"sheets:spreadsheet:create",
|
|
78
|
+
"sheets:spreadsheet:write_only",
|
|
79
|
+
],
|
|
80
|
+
"feishu_sheet.append": [
|
|
81
|
+
"sheets:spreadsheet.meta:read",
|
|
82
|
+
"sheets:spreadsheet:read",
|
|
83
|
+
"sheets:spreadsheet:create",
|
|
84
|
+
"sheets:spreadsheet:write_only",
|
|
85
|
+
],
|
|
86
|
+
"feishu_sheet.find": ["sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"],
|
|
87
|
+
"feishu_sheet.create": [
|
|
88
|
+
"sheets:spreadsheet.meta:read",
|
|
89
|
+
"sheets:spreadsheet:read",
|
|
90
|
+
"sheets:spreadsheet:create",
|
|
91
|
+
"sheets:spreadsheet:write_only",
|
|
92
|
+
],
|
|
93
|
+
"feishu_sheet.export": ["docs:document:export"],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function getRequiredScopes(toolAction: ToolActionKey): string[] {
|
|
97
|
+
return TOOL_SCOPES[toolAction] ?? [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getAllKnownScopes(): string[] {
|
|
101
|
+
return Array.from(new Set(Object.values(TOOL_SCOPES).flat())).sort();
|
|
102
|
+
}
|
package/src/tools-config.test.ts
CHANGED
|
@@ -8,14 +8,33 @@ describe("feishu tools config", () => {
|
|
|
8
8
|
expect(resolved.chat).toBe(true);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
+
it("enables newly synced user tools by default", () => {
|
|
12
|
+
const resolved = resolveToolsConfig(undefined);
|
|
13
|
+
expect(resolved.calendar).toBe(true);
|
|
14
|
+
expect(resolved.task).toBe(true);
|
|
15
|
+
expect(resolved.sheets).toBe(true);
|
|
16
|
+
expect(resolved.oauth).toBe(true);
|
|
17
|
+
expect(resolved.identity).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
11
20
|
it("accepts tools.chat in config schema", () => {
|
|
12
21
|
const parsed = FeishuConfigSchema.parse({
|
|
13
22
|
enabled: true,
|
|
14
23
|
tools: {
|
|
15
24
|
chat: false,
|
|
25
|
+
calendar: false,
|
|
26
|
+
task: false,
|
|
27
|
+
sheets: false,
|
|
28
|
+
oauth: false,
|
|
29
|
+
identity: false,
|
|
16
30
|
},
|
|
17
31
|
});
|
|
18
32
|
|
|
19
33
|
expect(parsed.tools?.chat).toBe(false);
|
|
34
|
+
expect(parsed.tools?.calendar).toBe(false);
|
|
35
|
+
expect(parsed.tools?.task).toBe(false);
|
|
36
|
+
expect(parsed.tools?.sheets).toBe(false);
|
|
37
|
+
expect(parsed.tools?.oauth).toBe(false);
|
|
38
|
+
expect(parsed.tools?.identity).toBe(false);
|
|
20
39
|
});
|
|
21
40
|
});
|
package/src/tools-config.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -93,6 +93,11 @@ export type FeishuToolsConfig = {
|
|
|
93
93
|
drive?: boolean;
|
|
94
94
|
perm?: boolean;
|
|
95
95
|
scopes?: boolean;
|
|
96
|
+
calendar?: boolean;
|
|
97
|
+
task?: boolean;
|
|
98
|
+
sheets?: boolean;
|
|
99
|
+
oauth?: boolean;
|
|
100
|
+
identity?: boolean;
|
|
96
101
|
};
|
|
97
102
|
|
|
98
103
|
export type DynamicAgentCreationConfig = {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { NeedAuthorizationError, REFRESH_TOKEN_RETRYABLE, TOKEN_RETRY_CODES } from "./auth-errors.js";
|
|
2
|
+
import { resolveOAuthEndpoints } from "./device-flow.js";
|
|
3
|
+
import { feishuFetch } from "./feishu-fetch.js";
|
|
4
|
+
import {
|
|
5
|
+
getStoredToken,
|
|
6
|
+
removeStoredToken,
|
|
7
|
+
setStoredToken,
|
|
8
|
+
tokenStatus,
|
|
9
|
+
type StoredUAToken,
|
|
10
|
+
} from "./token-store.js";
|
|
11
|
+
import type { FeishuDomain } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export type UATCallOptions = {
|
|
14
|
+
userOpenId: string;
|
|
15
|
+
appId: string;
|
|
16
|
+
appSecret: string;
|
|
17
|
+
domain: FeishuDomain;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const refreshLocks = new Map<string, Promise<StoredUAToken | null>>();
|
|
21
|
+
|
|
22
|
+
async function doRefreshToken(
|
|
23
|
+
opts: UATCallOptions,
|
|
24
|
+
stored: StoredUAToken,
|
|
25
|
+
): Promise<StoredUAToken | null> {
|
|
26
|
+
if (Date.now() >= stored.refreshExpiresAt) {
|
|
27
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const endpoints = resolveOAuthEndpoints(opts.domain);
|
|
32
|
+
const requestBody = new URLSearchParams({
|
|
33
|
+
grant_type: "refresh_token",
|
|
34
|
+
refresh_token: stored.refreshToken,
|
|
35
|
+
client_id: opts.appId,
|
|
36
|
+
client_secret: opts.appSecret,
|
|
37
|
+
}).toString();
|
|
38
|
+
|
|
39
|
+
const callEndpoint = async () => {
|
|
40
|
+
const response = await feishuFetch(endpoints.token, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
43
|
+
body: requestBody,
|
|
44
|
+
});
|
|
45
|
+
return (await response.json()) as Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let data = await callEndpoint();
|
|
49
|
+
const code = typeof data.code === "number" ? data.code : undefined;
|
|
50
|
+
const error = typeof data.error === "string" ? data.error : undefined;
|
|
51
|
+
|
|
52
|
+
if ((code !== undefined && code !== 0) || error) {
|
|
53
|
+
if (code !== undefined && REFRESH_TOKEN_RETRYABLE.has(code)) {
|
|
54
|
+
data = await callEndpoint();
|
|
55
|
+
const retryCode = typeof data.code === "number" ? data.code : undefined;
|
|
56
|
+
const retryError = typeof data.error === "string" ? data.error : undefined;
|
|
57
|
+
if ((retryCode !== undefined && retryCode !== 0) || retryError) {
|
|
58
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!data.access_token) {
|
|
68
|
+
throw new Error("Token refresh returned no access_token");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const updated: StoredUAToken = {
|
|
73
|
+
userOpenId: stored.userOpenId,
|
|
74
|
+
appId: opts.appId,
|
|
75
|
+
accessToken: String(data.access_token),
|
|
76
|
+
refreshToken: String(data.refresh_token ?? stored.refreshToken),
|
|
77
|
+
expiresAt: now + Number(data.expires_in ?? 7200) * 1000,
|
|
78
|
+
refreshExpiresAt: data.refresh_token_expires_in
|
|
79
|
+
? now + Number(data.refresh_token_expires_in) * 1000
|
|
80
|
+
: stored.refreshExpiresAt,
|
|
81
|
+
scope: String(data.scope ?? stored.scope),
|
|
82
|
+
grantedAt: stored.grantedAt,
|
|
83
|
+
};
|
|
84
|
+
await setStoredToken(updated);
|
|
85
|
+
return updated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function refreshWithLock(
|
|
89
|
+
opts: UATCallOptions,
|
|
90
|
+
stored: StoredUAToken,
|
|
91
|
+
): Promise<StoredUAToken | null> {
|
|
92
|
+
const key = `${opts.appId}:${opts.userOpenId}`;
|
|
93
|
+
const existing = refreshLocks.get(key);
|
|
94
|
+
if (existing) {
|
|
95
|
+
await existing;
|
|
96
|
+
return getStoredToken(opts.appId, opts.userOpenId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const promise = doRefreshToken(opts, stored);
|
|
100
|
+
refreshLocks.set(key, promise);
|
|
101
|
+
try {
|
|
102
|
+
return await promise;
|
|
103
|
+
} finally {
|
|
104
|
+
refreshLocks.delete(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function getValidAccessToken(opts: UATCallOptions): Promise<string> {
|
|
109
|
+
const stored = await getStoredToken(opts.appId, opts.userOpenId);
|
|
110
|
+
if (!stored) {
|
|
111
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const status = tokenStatus(stored);
|
|
115
|
+
if (status === "valid") {
|
|
116
|
+
return stored.accessToken;
|
|
117
|
+
}
|
|
118
|
+
if (status === "needs_refresh") {
|
|
119
|
+
const refreshed = await refreshWithLock(opts, stored);
|
|
120
|
+
if (!refreshed) {
|
|
121
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
122
|
+
}
|
|
123
|
+
return refreshed.accessToken;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
127
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function callWithUAT<T>(
|
|
131
|
+
opts: UATCallOptions,
|
|
132
|
+
apiCall: (accessToken: string) => Promise<T>,
|
|
133
|
+
): Promise<T> {
|
|
134
|
+
const accessToken = await getValidAccessToken(opts);
|
|
135
|
+
try {
|
|
136
|
+
return await apiCall(accessToken);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const code =
|
|
139
|
+
(error as { code?: number; response?: { data?: { code?: number } } }).code ??
|
|
140
|
+
(error as { response?: { data?: { code?: number } } }).response?.data?.code;
|
|
141
|
+
if (!TOKEN_RETRY_CODES.has(Number(code))) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const stored = await getStoredToken(opts.appId, opts.userOpenId);
|
|
146
|
+
if (!stored) {
|
|
147
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
148
|
+
}
|
|
149
|
+
const refreshed = await refreshWithLock(opts, stored);
|
|
150
|
+
if (!refreshed) {
|
|
151
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
152
|
+
}
|
|
153
|
+
return apiCall(refreshed.accessToken);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function revokeUAT(appId: string, userOpenId: string): Promise<void> {
|
|
158
|
+
await removeStoredToken(appId, userOpenId);
|
|
159
|
+
}
|