@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.
@@ -0,0 +1,32 @@
1
+ import { createToolContext, assertLarkOk, unixTimestampToISO8601 } from "./user-tool-helpers.js";
2
+
3
+ export function normalizeEventTimes<T extends Record<string, unknown> | undefined>(event: T): T {
4
+ if (!event) return event;
5
+ const typed = event as Record<string, unknown>;
6
+ const start = typed.start_time as { timestamp?: string | number } | undefined;
7
+ const end = typed.end_time as { timestamp?: string | number } | undefined;
8
+ return {
9
+ ...typed,
10
+ start_time_iso8601: unixTimestampToISO8601(start?.timestamp),
11
+ end_time_iso8601: unixTimestampToISO8601(end?.timestamp),
12
+ } as T;
13
+ }
14
+
15
+ export async function resolveCalendarId(
16
+ client: ReturnType<typeof createToolContext>["toolClient"] extends () => infer T ? T : never,
17
+ calendarId?: string,
18
+ ) {
19
+ if (calendarId) return calendarId;
20
+ const response = await client.invoke(
21
+ "feishu_calendar_calendar.primary",
22
+ (sdk, opts) => sdk.calendar.calendar.primary({}, opts),
23
+ { as: "user" },
24
+ );
25
+ assertLarkOk(response);
26
+ const calendars = (response.data as { calendars?: Array<{ calendar_id?: string }> } | undefined)?.calendars;
27
+ const primaryId = calendars?.[0]?.calendar_id;
28
+ if (!primaryId) {
29
+ throw new Error("No primary calendar found for current user.");
30
+ }
31
+ return primaryId;
32
+ }
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
2
+ import { listEnabledFeishuAccounts } from "./accounts.js";
3
+ import { registerFeishuCalendarCalendarTool } from "./calendar-calendar.js";
4
+ import { registerFeishuCalendarEventTool } from "./calendar-event.js";
5
+ import { registerFeishuCalendarEventAttendeeTool } from "./calendar-event-attendee.js";
6
+ import { registerFeishuCalendarFreebusyTool } from "./calendar-freebusy.js";
7
+ import { resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
8
+
9
+ export function registerFeishuCalendarTools(api: OpenClawPluginApi) {
10
+ if (!api.config) return;
11
+ const accounts = listEnabledFeishuAccounts(api.config);
12
+ if (accounts.length === 0) return;
13
+ if (!resolveAnyEnabledFeishuToolsConfig(accounts).calendar) return;
14
+ registerFeishuCalendarCalendarTool(api);
15
+ registerFeishuCalendarEventTool(api);
16
+ registerFeishuCalendarEventAttendeeTool(api);
17
+ registerFeishuCalendarFreebusyTool(api);
18
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ClawdbotConfig, RuntimeEnv } from "./nextclaw-sdk/feishu.js";
2
2
  import { resolveFeishuAccount } from "./accounts.js";
3
3
  import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
4
+ import { withTicket } from "./lark-ticket.js";
4
5
 
5
6
  export type FeishuCardActionEvent = {
6
7
  operator: {
@@ -69,11 +70,22 @@ export async function handleFeishuCardAction(params: {
69
70
  );
70
71
 
71
72
  // Dispatch as normal message
72
- await handleFeishuMessage({
73
- cfg,
74
- event: messageEvent,
75
- botOpenId: params.botOpenId,
76
- runtime,
77
- accountId,
78
- });
73
+ await withTicket(
74
+ {
75
+ accountId: account.accountId,
76
+ messageId: messageEvent.message.message_id,
77
+ chatId: messageEvent.message.chat_id,
78
+ senderOpenId: event.operator.open_id,
79
+ chatType: messageEvent.message.chat_type,
80
+ startTime: Date.now(),
81
+ },
82
+ () =>
83
+ handleFeishuMessage({
84
+ cfg,
85
+ event: messageEvent,
86
+ botOpenId: params.botOpenId,
87
+ runtime,
88
+ accountId,
89
+ }),
90
+ );
79
91
  }
@@ -92,6 +92,11 @@ const FeishuToolsConfigSchema = z
92
92
  drive: z.boolean().optional(), // Cloud storage operations (default: true)
93
93
  perm: z.boolean().optional(), // Permission management (default: false, sensitive)
94
94
  scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
95
+ calendar: z.boolean().optional(), // Calendar operations (default: true)
96
+ task: z.boolean().optional(), // Task operations (default: true)
97
+ sheets: z.boolean().optional(), // Sheets operations (default: true)
98
+ oauth: z.boolean().optional(), // OAuth operations (default: true)
99
+ identity: z.boolean().optional(), // Identity lookup operations (default: true)
95
100
  })
96
101
  .strict()
97
102
  .optional();
@@ -0,0 +1,188 @@
1
+ import { feishuFetch } from "./feishu-fetch.js";
2
+ import type { FeishuDomain } from "./types.js";
3
+
4
+ export type DeviceAuthResponse = {
5
+ deviceCode: string;
6
+ userCode: string;
7
+ verificationUri: string;
8
+ verificationUriComplete: string;
9
+ expiresIn: number;
10
+ interval: number;
11
+ };
12
+
13
+ export type DeviceFlowTokenData = {
14
+ accessToken: string;
15
+ refreshToken: string;
16
+ expiresIn: number;
17
+ refreshExpiresIn: number;
18
+ scope: string;
19
+ };
20
+
21
+ export type DeviceFlowResult =
22
+ | { ok: true; token: DeviceFlowTokenData }
23
+ | {
24
+ ok: false;
25
+ error: "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
26
+ message: string;
27
+ };
28
+
29
+ export function resolveOAuthEndpoints(domain: FeishuDomain): {
30
+ deviceAuthorization: string;
31
+ token: string;
32
+ } {
33
+ if (!domain || domain === "feishu") {
34
+ return {
35
+ deviceAuthorization: "https://accounts.feishu.cn/oauth/v1/device_authorization",
36
+ token: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
37
+ };
38
+ }
39
+ if (domain === "lark") {
40
+ return {
41
+ deviceAuthorization: "https://accounts.larksuite.com/oauth/v1/device_authorization",
42
+ token: "https://open.larksuite.com/open-apis/authen/v2/oauth/token",
43
+ };
44
+ }
45
+
46
+ const base = domain.replace(/\/+$/, "");
47
+ let accountsBase = base;
48
+ try {
49
+ const parsed = new URL(base);
50
+ if (parsed.hostname.startsWith("open.")) {
51
+ accountsBase = `${parsed.protocol}//${parsed.hostname.replace(/^open\./, "accounts.")}`;
52
+ }
53
+ } catch {
54
+ // ignore
55
+ }
56
+
57
+ return {
58
+ deviceAuthorization: `${accountsBase}/oauth/v1/device_authorization`,
59
+ token: `${base}/open-apis/authen/v2/oauth/token`,
60
+ };
61
+ }
62
+
63
+ export async function requestDeviceAuthorization(params: {
64
+ appId: string;
65
+ appSecret: string;
66
+ domain: FeishuDomain;
67
+ scope?: string;
68
+ }): Promise<DeviceAuthResponse> {
69
+ const endpoints = resolveOAuthEndpoints(params.domain);
70
+ let scope = params.scope?.trim() ?? "";
71
+ if (!scope.includes("offline_access")) {
72
+ scope = scope ? `${scope} offline_access` : "offline_access";
73
+ }
74
+
75
+ const basicAuth = Buffer.from(`${params.appId}:${params.appSecret}`).toString("base64");
76
+ const body = new URLSearchParams();
77
+ body.set("client_id", params.appId);
78
+ body.set("scope", scope);
79
+
80
+ const response = await feishuFetch(endpoints.deviceAuthorization, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/x-www-form-urlencoded",
84
+ Authorization: `Basic ${basicAuth}`,
85
+ },
86
+ body: body.toString(),
87
+ });
88
+
89
+ const data = (await response.json()) as Record<string, unknown>;
90
+ if (!response.ok || data.error) {
91
+ throw new Error(
92
+ String(data.error_description ?? data.error ?? `HTTP ${response.status}`),
93
+ );
94
+ }
95
+
96
+ return {
97
+ deviceCode: String(data.device_code ?? ""),
98
+ userCode: String(data.user_code ?? ""),
99
+ verificationUri: String(data.verification_uri ?? ""),
100
+ verificationUriComplete: String(data.verification_uri_complete ?? data.verification_uri ?? ""),
101
+ expiresIn: Number(data.expires_in ?? 240),
102
+ interval: Number(data.interval ?? 5),
103
+ };
104
+ }
105
+
106
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
107
+ return new Promise((resolve, reject) => {
108
+ const timer = setTimeout(resolve, ms);
109
+ signal?.addEventListener(
110
+ "abort",
111
+ () => {
112
+ clearTimeout(timer);
113
+ reject(new DOMException("Aborted", "AbortError"));
114
+ },
115
+ { once: true },
116
+ );
117
+ });
118
+ }
119
+
120
+ export async function pollDeviceToken(params: {
121
+ appId: string;
122
+ appSecret: string;
123
+ domain: FeishuDomain;
124
+ deviceCode: string;
125
+ interval: number;
126
+ expiresIn: number;
127
+ signal?: AbortSignal;
128
+ }): Promise<DeviceFlowResult> {
129
+ const endpoints = resolveOAuthEndpoints(params.domain);
130
+ const deadline = Date.now() + params.expiresIn * 1000;
131
+ let interval = params.interval;
132
+
133
+ while (Date.now() < deadline) {
134
+ if (params.signal?.aborted) {
135
+ return { ok: false, error: "expired_token", message: "Polling was cancelled" };
136
+ }
137
+
138
+ await sleep(interval * 1000, params.signal);
139
+
140
+ const response = await feishuFetch(endpoints.token, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
143
+ body: new URLSearchParams({
144
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
145
+ device_code: params.deviceCode,
146
+ client_id: params.appId,
147
+ client_secret: params.appSecret,
148
+ }).toString(),
149
+ });
150
+
151
+ const data = (await response.json()) as Record<string, unknown>;
152
+ const error = typeof data.error === "string" ? data.error : undefined;
153
+
154
+ if (!error && data.access_token) {
155
+ return {
156
+ ok: true,
157
+ token: {
158
+ accessToken: String(data.access_token),
159
+ refreshToken: String(data.refresh_token ?? ""),
160
+ expiresIn: Number(data.expires_in ?? 7200),
161
+ refreshExpiresIn: Number(data.refresh_token_expires_in ?? data.expires_in ?? 7200),
162
+ scope: String(data.scope ?? ""),
163
+ },
164
+ };
165
+ }
166
+
167
+ if (error === "authorization_pending") {
168
+ continue;
169
+ }
170
+ if (error === "slow_down") {
171
+ interval = Math.min(interval + 5, 60);
172
+ continue;
173
+ }
174
+ if (error === "access_denied" || error === "expired_token") {
175
+ return {
176
+ ok: false,
177
+ error,
178
+ message: String(data.error_description ?? error),
179
+ };
180
+ }
181
+ }
182
+
183
+ return {
184
+ ok: false,
185
+ error: "expired_token",
186
+ message: "Device authorization expired before approval finished.",
187
+ };
188
+ }
package/src/domains.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { FeishuDomain } from "./types.js";
2
+
3
+ export function openPlatformDomain(domain?: FeishuDomain): string {
4
+ return domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
5
+ }
6
+
7
+ export function wwwDomain(domain?: FeishuDomain): string {
8
+ return domain === "lark" ? "https://www.larksuite.com" : "https://www.feishu.cn";
9
+ }
10
+
11
+ export function resolveDomainUrl(domain: FeishuDomain): string {
12
+ if (domain === "feishu") {
13
+ return "https://open.feishu.cn";
14
+ }
15
+ if (domain === "lark") {
16
+ return "https://open.larksuite.com";
17
+ }
18
+ return domain.replace(/\/+$/, "");
19
+ }
@@ -0,0 +1,9 @@
1
+ const FEISHU_USER_AGENT = "nextclaw-feishu-plugin/0.2";
2
+
3
+ export function feishuFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
4
+ const headers = new Headers(init?.headers);
5
+ if (!headers.has("User-Agent")) {
6
+ headers.set("User-Agent", FEISHU_USER_AGENT);
7
+ }
8
+ return fetch(url, { ...init, headers });
9
+ }
@@ -0,0 +1,160 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
3
+ import { listEnabledFeishuAccounts } from "./accounts.js";
4
+ import { resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
5
+ import {
6
+ assertLarkOk,
7
+ createToolContext,
8
+ handleInvokeError,
9
+ json,
10
+ registerTool,
11
+ StringEnum,
12
+ } from "./user-tool-helpers.js";
13
+
14
+ const GetUserSchema = Type.Object({
15
+ user_id: Type.Optional(
16
+ Type.String({
17
+ description: "用户 ID(如 ou_xxx)。不传时获取当前消息发送者本人信息。",
18
+ }),
19
+ ),
20
+ user_id_type: Type.Optional(StringEnum(["open_id", "union_id", "user_id"])),
21
+ });
22
+
23
+ const SearchUserSchema = Type.Object({
24
+ query: Type.String({
25
+ description: "搜索关键词,可匹配姓名、邮箱、手机号等。",
26
+ }),
27
+ page_size: Type.Optional(
28
+ Type.Integer({
29
+ description: "分页大小,默认 20,最大 200。",
30
+ minimum: 1,
31
+ maximum: 200,
32
+ }),
33
+ ),
34
+ page_token: Type.Optional(
35
+ Type.String({
36
+ description: "翻页 token。",
37
+ }),
38
+ ),
39
+ });
40
+
41
+ export function registerFeishuIdentityTools(api: OpenClawPluginApi) {
42
+ if (!api.config) {
43
+ return;
44
+ }
45
+
46
+ const accounts = listEnabledFeishuAccounts(api.config);
47
+ if (accounts.length === 0) {
48
+ return;
49
+ }
50
+
51
+ const toolsConfig = resolveAnyEnabledFeishuToolsConfig(accounts);
52
+ if (!toolsConfig.identity) {
53
+ return;
54
+ }
55
+
56
+ registerFeishuGetUserTool(api);
57
+ registerFeishuSearchUserTool(api);
58
+ }
59
+
60
+ function registerFeishuGetUserTool(api: OpenClawPluginApi) {
61
+ const { toolClient, log } = createToolContext(api, "feishu_get_user");
62
+
63
+ registerTool(
64
+ api,
65
+ {
66
+ name: "feishu_get_user",
67
+ label: "Feishu: Get User",
68
+ description:
69
+ "获取飞书用户信息。不传 user_id 时默认获取当前消息发送者本人信息;传 user_id 时获取指定用户信息。",
70
+ parameters: GetUserSchema,
71
+ async execute(_toolCallId, params) {
72
+ const payload = params as {
73
+ user_id?: string;
74
+ user_id_type?: "open_id" | "union_id" | "user_id";
75
+ };
76
+
77
+ try {
78
+ const client = toolClient();
79
+ if (!payload.user_id) {
80
+ log.info("fetching current user info");
81
+ const response = await client.invoke(
82
+ "feishu_get_user.default",
83
+ (sdk, opts) => sdk.authen.userInfo.get({}, opts),
84
+ { as: "user" },
85
+ );
86
+ assertLarkOk(response);
87
+ return json({ user: response.data });
88
+ }
89
+
90
+ log.info(`fetching user ${payload.user_id}`);
91
+ const response = await client.invoke(
92
+ "feishu_get_user.default",
93
+ (sdk, opts) =>
94
+ sdk.contact.user.get(
95
+ {
96
+ path: { user_id: payload.user_id! },
97
+ params: {
98
+ user_id_type: payload.user_id_type ?? "open_id",
99
+ },
100
+ },
101
+ opts,
102
+ ),
103
+ { as: "user" },
104
+ );
105
+ assertLarkOk(response);
106
+ return json({ user: response.data?.user });
107
+ } catch (error) {
108
+ return handleInvokeError(error, api);
109
+ }
110
+ },
111
+ },
112
+ { name: "feishu_get_user" },
113
+ );
114
+ }
115
+
116
+ function registerFeishuSearchUserTool(api: OpenClawPluginApi) {
117
+ const { toolClient, log } = createToolContext(api, "feishu_search_user");
118
+
119
+ registerTool(
120
+ api,
121
+ {
122
+ name: "feishu_search_user",
123
+ label: "Feishu: Search User",
124
+ description: "搜索飞书员工信息,返回姓名、部门、open_id 等结果。",
125
+ parameters: SearchUserSchema,
126
+ async execute(_toolCallId, params) {
127
+ const payload = params as {
128
+ query: string;
129
+ page_size?: number;
130
+ page_token?: string;
131
+ };
132
+
133
+ try {
134
+ const client = toolClient();
135
+ log.info(`search query="${payload.query}"`);
136
+ const response = await client.invokeByPath<{
137
+ data?: { users?: unknown[]; has_more?: boolean; page_token?: string };
138
+ }>("feishu_search_user.default", "/open-apis/search/v1/user", {
139
+ method: "GET",
140
+ query: {
141
+ query: payload.query,
142
+ page_size: String(payload.page_size ?? 20),
143
+ ...(payload.page_token ? { page_token: payload.page_token } : {}),
144
+ },
145
+ as: "user",
146
+ });
147
+ assertLarkOk(response as { code?: number; msg?: string });
148
+ return json({
149
+ users: response.data?.users ?? [],
150
+ has_more: response.data?.has_more ?? false,
151
+ page_token: response.data?.page_token,
152
+ });
153
+ } catch (error) {
154
+ return handleInvokeError(error, api);
155
+ }
156
+ },
157
+ },
158
+ { name: "feishu_search_user" },
159
+ );
160
+ }
@@ -0,0 +1,26 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type LarkTicket = {
4
+ messageId: string;
5
+ chatId: string;
6
+ accountId: string;
7
+ startTime: number;
8
+ senderOpenId?: string;
9
+ chatType?: "p2p" | "group" | "private";
10
+ threadId?: string;
11
+ };
12
+
13
+ const store = new AsyncLocalStorage<LarkTicket>();
14
+
15
+ export function withTicket<T>(ticket: LarkTicket, fn: () => T | Promise<T>): T | Promise<T> {
16
+ return store.run(ticket, fn);
17
+ }
18
+
19
+ export function getTicket(): LarkTicket | undefined {
20
+ return store.getStore();
21
+ }
22
+
23
+ export function ticketElapsed(): number {
24
+ const ticket = getTicket();
25
+ return ticket ? Date.now() - ticket.startTime : 0;
26
+ }
@@ -1,5 +1,5 @@
1
1
  import * as crypto from "crypto";
2
- import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import type * as Lark from "@larksuiteoapi/node-sdk";
3
3
  import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "./nextclaw-sdk/feishu.js";
4
4
  import { resolveFeishuAccount } from "./accounts.js";
5
5
  import { raceWithTimeoutAndAbort } from "./async.js";
@@ -24,6 +24,7 @@ import { botNames, botOpenIds } from "./monitor.state.js";
24
24
  import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
25
25
  import { getFeishuRuntime } from "./runtime.js";
26
26
  import { getMessageFeishu } from "./send.js";
27
+ import { withTicket } from "./lark-ticket.js";
27
28
  import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
28
29
 
29
30
  const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
@@ -256,16 +257,28 @@ function registerEventHandlers(
256
257
  const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
257
258
  const chatId = event.message.chat_id?.trim() || "unknown";
258
259
  const task = () =>
259
- handleFeishuMessage({
260
- cfg,
261
- event,
262
- botOpenId: botOpenIds.get(accountId),
263
- botName: botNames.get(accountId),
264
- runtime,
265
- chatHistories,
266
- accountId,
267
- processingClaimHeld: true,
268
- });
260
+ withTicket(
261
+ {
262
+ accountId,
263
+ messageId: event.message.message_id,
264
+ chatId: event.message.chat_id,
265
+ senderOpenId: event.sender.sender_id.open_id?.trim() || undefined,
266
+ chatType: event.message.chat_type,
267
+ threadId: event.message.thread_id?.trim() || event.message.root_id?.trim() || undefined,
268
+ startTime: Date.now(),
269
+ },
270
+ () =>
271
+ handleFeishuMessage({
272
+ cfg,
273
+ event,
274
+ botOpenId: botOpenIds.get(accountId),
275
+ botName: botNames.get(accountId),
276
+ runtime,
277
+ chatHistories,
278
+ accountId,
279
+ processingClaimHeld: true,
280
+ }),
281
+ );
269
282
  await enqueue(chatId, task);
270
283
  };
271
284
  const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
@@ -441,15 +454,30 @@ function registerEventHandlers(
441
454
  if (!syntheticEvent) {
442
455
  return;
443
456
  }
444
- const promise = handleFeishuMessage({
445
- cfg,
446
- event: syntheticEvent,
447
- botOpenId: myBotId,
448
- botName: botNames.get(accountId),
449
- runtime,
450
- chatHistories,
451
- accountId,
452
- });
457
+ const promise = withTicket(
458
+ {
459
+ accountId,
460
+ messageId: syntheticEvent.message.message_id,
461
+ chatId: syntheticEvent.message.chat_id,
462
+ senderOpenId: syntheticEvent.sender.sender_id.open_id?.trim() || undefined,
463
+ chatType: syntheticEvent.message.chat_type,
464
+ threadId:
465
+ syntheticEvent.message.thread_id?.trim() ||
466
+ syntheticEvent.message.root_id?.trim() ||
467
+ undefined,
468
+ startTime: Date.now(),
469
+ },
470
+ () =>
471
+ handleFeishuMessage({
472
+ cfg,
473
+ event: syntheticEvent,
474
+ botOpenId: myBotId,
475
+ botName: botNames.get(accountId),
476
+ runtime,
477
+ chatHistories,
478
+ accountId,
479
+ }),
480
+ );
453
481
  if (fireAndForget) {
454
482
  promise.catch((err) => {
455
483
  error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);