@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.
- 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 +33 -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 +431 -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 +174 -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,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
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
|
|
3
|
+
import { getAppGrantedScopes, invalidateAppScopeCache, missingScopes } from "./app-scope-checker.js";
|
|
4
|
+
import {
|
|
5
|
+
AppScopeMissingError,
|
|
6
|
+
LARK_ERROR,
|
|
7
|
+
NeedAuthorizationError,
|
|
8
|
+
UserAuthRequiredError,
|
|
9
|
+
UserScopeInsufficientError,
|
|
10
|
+
} from "./auth-errors.js";
|
|
11
|
+
import { createFeishuClient } from "./client.js";
|
|
12
|
+
import { getTicket } from "./lark-ticket.js";
|
|
13
|
+
import { rawLarkRequest } from "./raw-request.js";
|
|
14
|
+
import { getRequiredScopes, type ToolActionKey } from "./tool-scopes.js";
|
|
15
|
+
import { callWithUAT } from "./uat-client.js";
|
|
16
|
+
import { getStoredToken } from "./token-store.js";
|
|
17
|
+
import type { ClawdbotConfig } from "./nextclaw-sdk/feishu.js";
|
|
18
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
19
|
+
|
|
20
|
+
type LarkRequestOptions = ReturnType<typeof Lark.withUserAccessToken>;
|
|
21
|
+
type InvokeFn<T> = (sdk: Lark.Client, opts?: LarkRequestOptions, uat?: string) => Promise<T>;
|
|
22
|
+
|
|
23
|
+
function assertConfiguredAccount(account: ResolvedFeishuAccount): ResolvedFeishuAccount & {
|
|
24
|
+
appId: string;
|
|
25
|
+
appSecret: string;
|
|
26
|
+
configured: true;
|
|
27
|
+
} {
|
|
28
|
+
if (!account.enabled) {
|
|
29
|
+
throw new Error(`Feishu account "${account.accountId}" is disabled.`);
|
|
30
|
+
}
|
|
31
|
+
if (!account.configured || !account.appId || !account.appSecret) {
|
|
32
|
+
throw new Error(`Feishu account "${account.accountId}" is not configured.`);
|
|
33
|
+
}
|
|
34
|
+
return account as ResolvedFeishuAccount & { appId: string; appSecret: string; configured: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveConfiguredAccount(config: ClawdbotConfig, accountIndex = 0) {
|
|
38
|
+
const ticket = getTicket();
|
|
39
|
+
if (ticket?.accountId) {
|
|
40
|
+
return assertConfiguredAccount(resolveFeishuAccount({ cfg: config, accountId: ticket.accountId }));
|
|
41
|
+
}
|
|
42
|
+
const accounts = listEnabledFeishuAccounts(config);
|
|
43
|
+
if (accounts.length === 0) {
|
|
44
|
+
throw new Error("No enabled Feishu accounts configured.");
|
|
45
|
+
}
|
|
46
|
+
return assertConfiguredAccount(accounts[Math.min(accountIndex, accounts.length - 1)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class UserToolClient {
|
|
50
|
+
readonly account: ReturnType<typeof assertConfiguredAccount>;
|
|
51
|
+
readonly senderOpenId?: string;
|
|
52
|
+
readonly sdk: Lark.Client;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
readonly config: ClawdbotConfig,
|
|
56
|
+
accountIndex = 0,
|
|
57
|
+
) {
|
|
58
|
+
this.account = resolveConfiguredAccount(config, accountIndex);
|
|
59
|
+
this.senderOpenId = getTicket()?.senderOpenId;
|
|
60
|
+
this.sdk = createFeishuClient(this.account);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async invoke<T>(
|
|
64
|
+
toolAction: ToolActionKey,
|
|
65
|
+
fn: InvokeFn<T>,
|
|
66
|
+
options?: { as?: "user" | "tenant"; userOpenId?: string },
|
|
67
|
+
): Promise<T> {
|
|
68
|
+
const requiredScopes = getRequiredScopes(toolAction);
|
|
69
|
+
const tokenType = options?.as ?? "user";
|
|
70
|
+
const appScopeVerified = await this.verifyAppScopes(requiredScopes, tokenType, toolAction);
|
|
71
|
+
|
|
72
|
+
if (tokenType === "tenant") {
|
|
73
|
+
try {
|
|
74
|
+
return await fn(this.sdk);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.rethrowStructuredError(error, toolAction, requiredScopes);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const userOpenId = options?.userOpenId ?? this.senderOpenId;
|
|
82
|
+
if (!userOpenId) {
|
|
83
|
+
throw new UserAuthRequiredError("unknown", {
|
|
84
|
+
apiName: toolAction,
|
|
85
|
+
scopes: requiredScopes,
|
|
86
|
+
appScopeVerified,
|
|
87
|
+
appId: this.account.appId,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stored = await getStoredToken(this.account.appId, userOpenId);
|
|
92
|
+
if (!stored) {
|
|
93
|
+
throw new UserAuthRequiredError(userOpenId, {
|
|
94
|
+
apiName: toolAction,
|
|
95
|
+
scopes: requiredScopes,
|
|
96
|
+
appScopeVerified,
|
|
97
|
+
appId: this.account.appId,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (appScopeVerified && stored.scope && requiredScopes.length > 0) {
|
|
102
|
+
const granted = new Set(stored.scope.split(/\s+/).filter(Boolean));
|
|
103
|
+
const missingUserScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
|
104
|
+
if (missingUserScopes.length > 0) {
|
|
105
|
+
throw new UserAuthRequiredError(userOpenId, {
|
|
106
|
+
apiName: toolAction,
|
|
107
|
+
scopes: missingUserScopes,
|
|
108
|
+
appScopeVerified,
|
|
109
|
+
appId: this.account.appId,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
return await callWithUAT(
|
|
116
|
+
{
|
|
117
|
+
userOpenId,
|
|
118
|
+
appId: this.account.appId,
|
|
119
|
+
appSecret: this.account.appSecret,
|
|
120
|
+
domain: this.account.domain,
|
|
121
|
+
},
|
|
122
|
+
(accessToken) => fn(this.sdk, Lark.withUserAccessToken(accessToken), accessToken),
|
|
123
|
+
);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof NeedAuthorizationError) {
|
|
126
|
+
throw new UserAuthRequiredError(userOpenId, {
|
|
127
|
+
apiName: toolAction,
|
|
128
|
+
scopes: requiredScopes,
|
|
129
|
+
appScopeVerified,
|
|
130
|
+
appId: this.account.appId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
this.rethrowStructuredError(error, toolAction, requiredScopes, userOpenId);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async invokeByPath<T = unknown>(
|
|
139
|
+
toolAction: ToolActionKey,
|
|
140
|
+
path: string,
|
|
141
|
+
options?: {
|
|
142
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
143
|
+
body?: unknown;
|
|
144
|
+
query?: Record<string, string>;
|
|
145
|
+
headers?: Record<string, string>;
|
|
146
|
+
as?: "user" | "tenant";
|
|
147
|
+
userOpenId?: string;
|
|
148
|
+
},
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
return this.invoke(
|
|
151
|
+
toolAction,
|
|
152
|
+
async (_sdk, _opts, uat) =>
|
|
153
|
+
rawLarkRequest<T>({
|
|
154
|
+
domain: this.account.domain,
|
|
155
|
+
path,
|
|
156
|
+
method: options?.method,
|
|
157
|
+
body: options?.body,
|
|
158
|
+
query: options?.query,
|
|
159
|
+
headers: options?.headers,
|
|
160
|
+
accessToken: uat,
|
|
161
|
+
}),
|
|
162
|
+
options,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async verifyAppScopes(
|
|
167
|
+
requiredScopes: string[],
|
|
168
|
+
tokenType: "user" | "tenant",
|
|
169
|
+
toolAction: ToolActionKey,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
if (requiredScopes.length === 0) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const appGrantedScopes = await getAppGrantedScopes(this.sdk, this.account.appId, tokenType);
|
|
175
|
+
if (appGrantedScopes.length === 0) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const missingAppScopes = missingScopes(
|
|
179
|
+
appGrantedScopes,
|
|
180
|
+
tokenType === "user"
|
|
181
|
+
? [...new Set([...requiredScopes, "offline_access"])]
|
|
182
|
+
: requiredScopes,
|
|
183
|
+
);
|
|
184
|
+
if (missingAppScopes.length > 0) {
|
|
185
|
+
throw new AppScopeMissingError({
|
|
186
|
+
apiName: toolAction,
|
|
187
|
+
scopes: missingAppScopes,
|
|
188
|
+
appId: this.account.appId,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private rethrowStructuredError(
|
|
195
|
+
error: unknown,
|
|
196
|
+
toolAction: ToolActionKey,
|
|
197
|
+
requiredScopes: string[],
|
|
198
|
+
userOpenId?: string,
|
|
199
|
+
): void {
|
|
200
|
+
const code =
|
|
201
|
+
(error as { code?: number; response?: { data?: { code?: number } } }).code ??
|
|
202
|
+
(error as { response?: { data?: { code?: number } } }).response?.data?.code;
|
|
203
|
+
|
|
204
|
+
if (code === LARK_ERROR.APP_SCOPE_MISSING) {
|
|
205
|
+
invalidateAppScopeCache(this.account.appId);
|
|
206
|
+
throw new AppScopeMissingError({
|
|
207
|
+
apiName: toolAction,
|
|
208
|
+
scopes: requiredScopes,
|
|
209
|
+
appId: this.account.appId,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (code === LARK_ERROR.USER_SCOPE_INSUFFICIENT && userOpenId) {
|
|
214
|
+
throw new UserScopeInsufficientError(userOpenId, {
|
|
215
|
+
apiName: toolAction,
|
|
216
|
+
scopes: requiredScopes,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createUserToolClient(config: ClawdbotConfig, accountIndex = 0): UserToolClient {
|
|
223
|
+
return new UserToolClient(config, accountIndex);
|
|
224
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Type, type SchemaOptions, type TSchema } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
|
|
3
|
+
import { AppScopeMissingError, UserAuthRequiredError, UserScopeInsufficientError } from "./auth-errors.js";
|
|
4
|
+
import { openPlatformDomain } from "./domains.js";
|
|
5
|
+
import { formatLarkError } from "./user-tool-result.js";
|
|
6
|
+
import { getAllKnownScopes } from "./tool-scopes.js";
|
|
7
|
+
import { createUserToolClient } from "./user-tool-client.js";
|
|
8
|
+
import { jsonToolResult } from "./tool-result.js";
|
|
9
|
+
|
|
10
|
+
export function json(data: unknown) {
|
|
11
|
+
return jsonToolResult(data);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createToolContext(api: OpenClawPluginApi, toolName: string, accountIndex = 0) {
|
|
15
|
+
const logPrefix = `${toolName}:`;
|
|
16
|
+
const log = {
|
|
17
|
+
info: (message: string) => api.logger.info?.(`${logPrefix} ${message}`),
|
|
18
|
+
warn: (message: string) => api.logger.warn?.(`${logPrefix} ${message}`),
|
|
19
|
+
error: (message: string) => api.logger.error?.(`${logPrefix} ${message}`),
|
|
20
|
+
debug: (message: string) => api.logger.debug?.(`${logPrefix} ${message}`),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
toolClient: () => createUserToolClient(api.config, accountIndex),
|
|
25
|
+
log,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerTool(
|
|
30
|
+
api: OpenClawPluginApi,
|
|
31
|
+
tool: Parameters<OpenClawPluginApi["registerTool"]>[0],
|
|
32
|
+
opts?: Parameters<OpenClawPluginApi["registerTool"]>[1],
|
|
33
|
+
) {
|
|
34
|
+
api.registerTool(tool, opts);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertLarkOk(res: { code?: number; msg?: string }) {
|
|
38
|
+
if (!res.code || res.code === 0) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(res.msg ?? `Feishu API error (code=${res.code})`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function StringEnum<T extends string>(values: readonly T[], options?: SchemaOptions): TSchema {
|
|
45
|
+
return Type.Union(values.map((value) => Type.Literal(value)), options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseTimeToTimestamp(input: string): string | null {
|
|
49
|
+
const date = parseDateLike(input);
|
|
50
|
+
return date ? Math.floor(date.getTime() / 1000).toString() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseTimeToTimestampMs(input: string): string | null {
|
|
54
|
+
const date = parseDateLike(input);
|
|
55
|
+
return date ? date.getTime().toString() : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseTimeToRFC3339(input: string): string | null {
|
|
59
|
+
const trimmed = input.trim();
|
|
60
|
+
if (/[Zz]$|[+-]\d{2}:\d{2}$/.test(trimmed)) {
|
|
61
|
+
return new Date(trimmed).toString() === "Invalid Date" ? null : trimmed;
|
|
62
|
+
}
|
|
63
|
+
const normalized = trimmed.replace(" ", "T");
|
|
64
|
+
const match = normalized.match(
|
|
65
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/,
|
|
66
|
+
);
|
|
67
|
+
if (!match) {
|
|
68
|
+
const date = new Date(trimmed);
|
|
69
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
70
|
+
}
|
|
71
|
+
const [, y, m, d, hh, mm, ss] = match;
|
|
72
|
+
return `${y}-${m}-${d}T${hh}:${mm}:${ss ?? "00"}+08:00`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function unixTimestampToISO8601(raw: string | number | undefined): string | null {
|
|
76
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const value = Number(raw);
|
|
80
|
+
if (!Number.isFinite(value)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const ms = value > 1_000_000_000_000 ? value : value * 1000;
|
|
84
|
+
return new Date(ms).toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseDateLike(input: string): Date | null {
|
|
88
|
+
const trimmed = input.trim();
|
|
89
|
+
if (/[Zz]$|[+-]\d{2}:\d{2}$/.test(trimmed)) {
|
|
90
|
+
const date = new Date(trimmed);
|
|
91
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const normalized = trimmed.replace("T", " ");
|
|
95
|
+
const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
96
|
+
if (!match) {
|
|
97
|
+
const date = new Date(trimmed);
|
|
98
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
102
|
+
return new Date(
|
|
103
|
+
Date.UTC(
|
|
104
|
+
Number(year),
|
|
105
|
+
Number(month) - 1,
|
|
106
|
+
Number(day),
|
|
107
|
+
Number(hour) - 8,
|
|
108
|
+
Number(minute),
|
|
109
|
+
Number(second ?? "0"),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function handleInvokeError(error: unknown, _api: OpenClawPluginApi) {
|
|
115
|
+
if (error instanceof AppScopeMissingError) {
|
|
116
|
+
return json({
|
|
117
|
+
error: "app_scope_missing",
|
|
118
|
+
message: error.message,
|
|
119
|
+
missing_scopes: error.missingScopes,
|
|
120
|
+
app_id: error.appId,
|
|
121
|
+
open_platform: openPlatformDomain("feishu"),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (error instanceof UserAuthRequiredError) {
|
|
126
|
+
return json({
|
|
127
|
+
error: "need_user_authorization",
|
|
128
|
+
message: "当前用户尚未完成飞书 OAuth 授权,或授权范围不足。",
|
|
129
|
+
required_scopes: error.requiredScopes,
|
|
130
|
+
next_tool_call: {
|
|
131
|
+
tool: "feishu_oauth",
|
|
132
|
+
params: {
|
|
133
|
+
action: "authorize",
|
|
134
|
+
scope: error.requiredScopes.join(" "),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
default_scope_suggestion: getAllKnownScopes().join(" "),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (error instanceof UserScopeInsufficientError) {
|
|
142
|
+
return json({
|
|
143
|
+
error: "user_scope_insufficient",
|
|
144
|
+
message: "当前用户授权范围不足,请重新授权补齐缺失 scope。",
|
|
145
|
+
missing_scopes: error.missingScopes,
|
|
146
|
+
next_tool_call: {
|
|
147
|
+
tool: "feishu_oauth",
|
|
148
|
+
params: {
|
|
149
|
+
action: "authorize",
|
|
150
|
+
scope: error.missingScopes.join(" "),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return json({ error: formatLarkError(error) });
|
|
157
|
+
}
|