@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
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
|
|
3
|
+
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
4
|
+
import { requestDeviceAuthorization, pollDeviceToken } from "./device-flow.js";
|
|
5
|
+
import { feishuFetch } from "./feishu-fetch.js";
|
|
6
|
+
import { getTicket } from "./lark-ticket.js";
|
|
7
|
+
import { getStoredToken, setStoredToken, tokenStatus } from "./token-store.js";
|
|
8
|
+
import { resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
9
|
+
import { revokeUAT } from "./uat-client.js";
|
|
10
|
+
import { createUserToolClient } from "./user-tool-client.js";
|
|
11
|
+
import { jsonToolResult } from "./tool-result.js";
|
|
12
|
+
import { getAllKnownScopes } from "./tool-scopes.js";
|
|
13
|
+
|
|
14
|
+
const FeishuOAuthSchema = Type.Object({
|
|
15
|
+
action: Type.Union([
|
|
16
|
+
Type.Literal("authorize"),
|
|
17
|
+
Type.Literal("status"),
|
|
18
|
+
Type.Literal("revoke"),
|
|
19
|
+
]),
|
|
20
|
+
scope: Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
description: "可选,自定义 scope 空格串;不传时默认申请当前集成能力需要的全部高价值 scope。",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
wait_seconds: Type.Optional(
|
|
26
|
+
Type.Integer({
|
|
27
|
+
description: "authorize 后轮询等待秒数,默认 15,最大 60。",
|
|
28
|
+
minimum: 0,
|
|
29
|
+
maximum: 60,
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function json(data: unknown) {
|
|
35
|
+
return jsonToolResult(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function verifyTokenIdentity(
|
|
39
|
+
domain: "feishu" | "lark" | (string & {}),
|
|
40
|
+
accessToken: string,
|
|
41
|
+
expectedOpenId: string,
|
|
42
|
+
): Promise<{ valid: boolean; actualOpenId?: string }> {
|
|
43
|
+
const baseUrl = domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
|
|
44
|
+
const response = await feishuFetch(`${baseUrl}/open-apis/authen/v1/user_info`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
46
|
+
});
|
|
47
|
+
const data = (await response.json()) as {
|
|
48
|
+
code?: number;
|
|
49
|
+
data?: { open_id?: string };
|
|
50
|
+
};
|
|
51
|
+
const actualOpenId = data.data?.open_id;
|
|
52
|
+
return {
|
|
53
|
+
valid: data.code === 0 && actualOpenId === expectedOpenId,
|
|
54
|
+
actualOpenId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleOAuthStatus(params: {
|
|
59
|
+
appId: string;
|
|
60
|
+
senderOpenId: string;
|
|
61
|
+
}) {
|
|
62
|
+
const stored = await getStoredToken(params.appId, params.senderOpenId);
|
|
63
|
+
if (!stored) {
|
|
64
|
+
return json({
|
|
65
|
+
authorized: false,
|
|
66
|
+
user_open_id: params.senderOpenId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return json({
|
|
70
|
+
authorized: true,
|
|
71
|
+
user_open_id: params.senderOpenId,
|
|
72
|
+
scope: stored.scope,
|
|
73
|
+
token_status: tokenStatus(stored),
|
|
74
|
+
granted_at: new Date(stored.grantedAt).toISOString(),
|
|
75
|
+
expires_at: new Date(stored.expiresAt).toISOString(),
|
|
76
|
+
refresh_expires_at: new Date(stored.refreshExpiresAt).toISOString(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleOAuthRevoke(params: {
|
|
81
|
+
appId: string;
|
|
82
|
+
senderOpenId: string;
|
|
83
|
+
}) {
|
|
84
|
+
await revokeUAT(params.appId, params.senderOpenId);
|
|
85
|
+
return json({
|
|
86
|
+
success: true,
|
|
87
|
+
user_open_id: params.senderOpenId,
|
|
88
|
+
message: "当前用户的飞书 OAuth 授权已撤销。",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleOAuthAuthorize(params: {
|
|
93
|
+
account: ReturnType<typeof createUserToolClient>["account"];
|
|
94
|
+
senderOpenId: string;
|
|
95
|
+
scope?: string;
|
|
96
|
+
waitSeconds?: number;
|
|
97
|
+
}) {
|
|
98
|
+
const scope = params.scope?.trim() || getAllKnownScopes().join(" ");
|
|
99
|
+
const waitSeconds = Math.min(Math.max(params.waitSeconds ?? 15, 0), 60);
|
|
100
|
+
const deviceFlow = await requestDeviceAuthorization({
|
|
101
|
+
appId: params.account.appId,
|
|
102
|
+
appSecret: params.account.appSecret,
|
|
103
|
+
domain: params.account.domain,
|
|
104
|
+
scope,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => controller.abort(), waitSeconds * 1000);
|
|
109
|
+
try {
|
|
110
|
+
const result = await pollDeviceToken({
|
|
111
|
+
appId: params.account.appId,
|
|
112
|
+
appSecret: params.account.appSecret,
|
|
113
|
+
domain: params.account.domain,
|
|
114
|
+
deviceCode: deviceFlow.deviceCode,
|
|
115
|
+
interval: deviceFlow.interval,
|
|
116
|
+
expiresIn: deviceFlow.expiresIn,
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
return json({
|
|
122
|
+
authorized: false,
|
|
123
|
+
status: result.error,
|
|
124
|
+
message: result.message,
|
|
125
|
+
user_open_id: params.senderOpenId,
|
|
126
|
+
verification_uri: deviceFlow.verificationUri,
|
|
127
|
+
verification_uri_complete: deviceFlow.verificationUriComplete,
|
|
128
|
+
user_code: deviceFlow.userCode,
|
|
129
|
+
requested_scope: scope,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const verified = await verifyTokenIdentity(
|
|
134
|
+
params.account.domain,
|
|
135
|
+
result.token.accessToken,
|
|
136
|
+
params.senderOpenId,
|
|
137
|
+
);
|
|
138
|
+
if (!verified.valid) {
|
|
139
|
+
return json({
|
|
140
|
+
authorized: false,
|
|
141
|
+
status: "identity_mismatch",
|
|
142
|
+
message: "完成授权的飞书账号与当前消息发送者不一致,已拒绝写入令牌。",
|
|
143
|
+
expected_open_id: params.senderOpenId,
|
|
144
|
+
actual_open_id: verified.actualOpenId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
await setStoredToken({
|
|
150
|
+
userOpenId: params.senderOpenId,
|
|
151
|
+
appId: params.account.appId,
|
|
152
|
+
accessToken: result.token.accessToken,
|
|
153
|
+
refreshToken: result.token.refreshToken,
|
|
154
|
+
expiresAt: now + result.token.expiresIn * 1000,
|
|
155
|
+
refreshExpiresAt: now + result.token.refreshExpiresIn * 1000,
|
|
156
|
+
scope: result.token.scope,
|
|
157
|
+
grantedAt: now,
|
|
158
|
+
});
|
|
159
|
+
return json({
|
|
160
|
+
authorized: true,
|
|
161
|
+
user_open_id: params.senderOpenId,
|
|
162
|
+
scope: result.token.scope,
|
|
163
|
+
expires_at: new Date(now + result.token.expiresIn * 1000).toISOString(),
|
|
164
|
+
message: "飞书 OAuth 授权成功,后续工具将按当前用户身份执行。",
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!controller.signal.aborted) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return json({
|
|
171
|
+
authorized: false,
|
|
172
|
+
status: "authorization_pending",
|
|
173
|
+
message:
|
|
174
|
+
"授权请求已创建,但在等待窗口内尚未完成。请打开链接完成授权后重新调用 feishu_oauth status 或再次 authorize。",
|
|
175
|
+
user_open_id: params.senderOpenId,
|
|
176
|
+
verification_uri: deviceFlow.verificationUri,
|
|
177
|
+
verification_uri_complete: deviceFlow.verificationUriComplete,
|
|
178
|
+
user_code: deviceFlow.userCode,
|
|
179
|
+
requested_scope: scope,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function registerFeishuOAuthTool(api: OpenClawPluginApi) {
|
|
187
|
+
if (!api.config) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const accounts = listEnabledFeishuAccounts(api.config);
|
|
192
|
+
if (accounts.length === 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const toolsConfig = resolveAnyEnabledFeishuToolsConfig(accounts);
|
|
197
|
+
if (!toolsConfig.oauth) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
api.registerTool(
|
|
202
|
+
{
|
|
203
|
+
name: "feishu_oauth",
|
|
204
|
+
label: "Feishu OAuth",
|
|
205
|
+
description:
|
|
206
|
+
"管理当前消息发送者的飞书 OAuth 授权。支持 authorize/status/revoke,授权后 calendar/task/sheets/identity 工具即可按本人身份执行。",
|
|
207
|
+
parameters: FeishuOAuthSchema,
|
|
208
|
+
async execute(_toolCallId, params) {
|
|
209
|
+
const payload = params as {
|
|
210
|
+
action: "authorize" | "status" | "revoke";
|
|
211
|
+
scope?: string;
|
|
212
|
+
wait_seconds?: number;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const ticket = getTicket();
|
|
216
|
+
const senderOpenId = ticket?.senderOpenId;
|
|
217
|
+
if (!senderOpenId) {
|
|
218
|
+
return json({
|
|
219
|
+
error: "missing_sender_identity",
|
|
220
|
+
message: "当前不在飞书消息上下文中,无法按本人身份管理 OAuth。",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const client = createUserToolClient(api.config);
|
|
226
|
+
const account = client.account;
|
|
227
|
+
if (payload.action === "status") {
|
|
228
|
+
return handleOAuthStatus({ appId: account.appId, senderOpenId });
|
|
229
|
+
}
|
|
230
|
+
if (payload.action === "revoke") {
|
|
231
|
+
return handleOAuthRevoke({ appId: account.appId, senderOpenId });
|
|
232
|
+
}
|
|
233
|
+
return handleOAuthAuthorize({
|
|
234
|
+
account,
|
|
235
|
+
senderOpenId,
|
|
236
|
+
scope: payload.scope,
|
|
237
|
+
waitSeconds: payload.wait_seconds,
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return json({
|
|
241
|
+
error: error instanceof Error ? error.message : String(error),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{ name: "feishu_oauth" },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { feishuFetch } from "./feishu-fetch.js";
|
|
2
|
+
import { resolveDomainUrl } from "./domains.js";
|
|
3
|
+
import type { FeishuDomain } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export async function rawLarkRequest<T>(options: {
|
|
6
|
+
domain: FeishuDomain;
|
|
7
|
+
path: string;
|
|
8
|
+
method?: string;
|
|
9
|
+
body?: unknown;
|
|
10
|
+
query?: Record<string, string>;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
accessToken?: string;
|
|
13
|
+
}): Promise<T> {
|
|
14
|
+
const url = new URL(options.path, resolveDomainUrl(options.domain));
|
|
15
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
16
|
+
url.searchParams.set(key, value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const headers: Record<string, string> = {
|
|
20
|
+
...(options.headers ?? {}),
|
|
21
|
+
};
|
|
22
|
+
if (options.accessToken) {
|
|
23
|
+
headers.Authorization = `Bearer ${options.accessToken}`;
|
|
24
|
+
}
|
|
25
|
+
if (options.body !== undefined && !headers["Content-Type"]) {
|
|
26
|
+
headers["Content-Type"] = "application/json";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const response = await feishuFetch(url.toString(), {
|
|
30
|
+
method: options.method ?? "GET",
|
|
31
|
+
headers,
|
|
32
|
+
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
|
33
|
+
});
|
|
34
|
+
const data = (await response.json()) as { code?: number; msg?: string };
|
|
35
|
+
if (data.code !== undefined && data.code !== 0) {
|
|
36
|
+
const error = new Error(data.msg ?? `Feishu API error: code=${data.code}`) as Error & {
|
|
37
|
+
code?: number;
|
|
38
|
+
msg?: string;
|
|
39
|
+
};
|
|
40
|
+
error.code = data.code;
|
|
41
|
+
error.msg = data.msg;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return data as T;
|
|
45
|
+
}
|