@nextclaw/channel-plugin-feishu 0.2.15 → 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 CHANGED
@@ -1,12 +1,17 @@
1
1
  import type { OpenClawPluginApi } from "./src/nextclaw-sdk/feishu.js";
2
+ import { registerFeishuCalendarTools } from "./src/calendar.js";
2
3
  import { emptyPluginConfigSchema } from "./src/nextclaw-sdk/feishu.js";
3
4
  import { registerFeishuBitableTools } from "./src/bitable.js";
4
5
  import { feishuPlugin } from "./src/channel.js";
5
6
  import { registerFeishuChatTools } from "./src/chat.js";
6
7
  import { registerFeishuDocTools } from "./src/docx.js";
7
8
  import { registerFeishuDriveTools } from "./src/drive.js";
9
+ import { registerFeishuIdentityTools } from "./src/identity.js";
10
+ import { registerFeishuOAuthTool } from "./src/oauth.js";
8
11
  import { registerFeishuPermTools } from "./src/perm.js";
9
12
  import { setFeishuRuntime } from "./src/runtime.js";
13
+ import { registerFeishuSheetsTools } from "./src/sheets.js";
14
+ import { registerFeishuTaskTools } from "./src/task.js";
10
15
  import { registerFeishuWikiTools } from "./src/wiki.js";
11
16
 
12
17
  export { monitorFeishuProvider } from "./src/monitor.js";
@@ -59,6 +64,11 @@ const plugin = {
59
64
  registerFeishuDriveTools(api);
60
65
  registerFeishuPermTools(api);
61
66
  registerFeishuBitableTools(api);
67
+ registerFeishuCalendarTools(api);
68
+ registerFeishuTaskTools(api);
69
+ registerFeishuSheetsTools(api);
70
+ registerFeishuIdentityTools(api);
71
+ registerFeishuOAuthTool(api);
62
72
  },
63
73
  };
64
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/channel-plugin-feishu",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "private": false,
5
5
  "description": "NextClaw Feishu/Lark channel plugin with doc/wiki/drive tools.",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ listFeishuAccountIds,
3
4
  resolveDefaultFeishuAccountId,
4
5
  resolveDefaultFeishuAccountSelection,
5
6
  resolveFeishuAccount,
@@ -56,6 +57,15 @@ function expectUnresolvedEnvSecretRefError(key: string) {
56
57
  }
57
58
 
58
59
  describe("resolveDefaultFeishuAccountId", () => {
60
+ it("falls back safely when channel config is missing", () => {
61
+ expect(listFeishuAccountIds(undefined)).toEqual(["default"]);
62
+ expect(resolveDefaultFeishuAccountId(undefined)).toBe("default");
63
+ expect(resolveDefaultFeishuAccountSelection(undefined)).toEqual({
64
+ accountId: "default",
65
+ source: "mapped-default",
66
+ });
67
+ });
68
+
59
69
  it("prefers channels.feishu.defaultAccount when configured", () => {
60
70
  const cfg = {
61
71
  channels: {
package/src/accounts.ts CHANGED
@@ -12,8 +12,8 @@ import type {
12
12
  /**
13
13
  * List all configured account IDs from the accounts field.
14
14
  */
15
- function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
16
- const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
15
+ function listConfiguredAccountIds(cfg?: ClawdbotConfig): string[] {
16
+ const accounts = (cfg?.channels?.feishu as FeishuConfig | undefined)?.accounts;
17
17
  if (!accounts || typeof accounts !== "object") {
18
18
  return [];
19
19
  }
@@ -24,7 +24,7 @@ function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
24
24
  * List all Feishu account IDs.
25
25
  * If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
26
26
  */
27
- export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
27
+ export function listFeishuAccountIds(cfg?: ClawdbotConfig): string[] {
28
28
  const ids = listConfiguredAccountIds(cfg);
29
29
  if (ids.length === 0) {
30
30
  // Backward compatibility: no accounts configured, use default
@@ -36,11 +36,11 @@ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
36
36
  /**
37
37
  * Resolve the default account selection and its source.
38
38
  */
39
- export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
39
+ export function resolveDefaultFeishuAccountSelection(cfg?: ClawdbotConfig): {
40
40
  accountId: string;
41
41
  source: FeishuDefaultAccountSelectionSource;
42
42
  } {
43
- const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
43
+ const preferredRaw = (cfg?.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
44
44
  const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
45
45
  if (preferred) {
46
46
  return {
@@ -64,7 +64,7 @@ export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
64
64
  /**
65
65
  * Resolve the default account ID.
66
66
  */
67
- export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
67
+ export function resolveDefaultFeishuAccountId(cfg?: ClawdbotConfig): string {
68
68
  return resolveDefaultFeishuAccountSelection(cfg).accountId;
69
69
  }
70
70
 
@@ -72,10 +72,10 @@ export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
72
72
  * Get the raw account-specific config.
73
73
  */
74
74
  function resolveAccountConfig(
75
- cfg: ClawdbotConfig,
75
+ cfg: ClawdbotConfig | undefined,
76
76
  accountId: string,
77
77
  ): FeishuAccountConfig | undefined {
78
- const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
78
+ const accounts = (cfg?.channels?.feishu as FeishuConfig | undefined)?.accounts;
79
79
  if (!accounts || typeof accounts !== "object") {
80
80
  return undefined;
81
81
  }
@@ -86,8 +86,8 @@ function resolveAccountConfig(
86
86
  * Merge top-level config with account-specific config.
87
87
  * Account-specific fields override top-level fields.
88
88
  */
89
- function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig {
90
- const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
89
+ function mergeFeishuAccountConfig(cfg: ClawdbotConfig | undefined, accountId: string): FeishuConfig {
90
+ const feishuCfg = cfg?.channels?.feishu as FeishuConfig | undefined;
91
91
 
92
92
  // Extract base config (exclude accounts field to avoid recursion)
93
93
  const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...base } = feishuCfg ?? {};
@@ -0,0 +1,75 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { AppScopeCheckFailedError } from "./auth-errors.js";
3
+
4
+ type CacheEntry = {
5
+ rawScopes: Array<{ scope: string; token_types?: string[] }>;
6
+ fetchedAt: number;
7
+ };
8
+
9
+ const cache = new Map<string, CacheEntry>();
10
+ const CACHE_TTL_MS = 30 * 1000;
11
+
12
+ export function invalidateAppScopeCache(appId: string): void {
13
+ cache.delete(appId);
14
+ }
15
+
16
+ export async function getAppGrantedScopes(
17
+ sdk: Lark.Client,
18
+ appId: string,
19
+ tokenType?: "user" | "tenant",
20
+ ): Promise<string[]> {
21
+ const cached = cache.get(appId);
22
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
23
+ return cached.rawScopes
24
+ .filter((scope) => !tokenType || !scope.token_types || scope.token_types.includes(tokenType))
25
+ .map((scope) => scope.scope);
26
+ }
27
+
28
+ try {
29
+ const response = await (sdk as unknown as {
30
+ request: (params: {
31
+ method: string;
32
+ url: string;
33
+ params: Record<string, string>;
34
+ }) => Promise<{ code?: number; data?: { app?: { scopes?: Array<{ scope?: string; token_types?: string[] }> } } }>;
35
+ }).request({
36
+ method: "GET",
37
+ url: `/open-apis/application/v6/applications/${appId}`,
38
+ params: { lang: "zh_cn" },
39
+ });
40
+
41
+ if (response.code !== 0) {
42
+ throw new AppScopeCheckFailedError(appId);
43
+ }
44
+
45
+ const rawScopes =
46
+ response.data?.app?.scopes
47
+ ?.filter((scope): scope is { scope: string; token_types?: string[] } =>
48
+ typeof scope.scope === "string" && scope.scope.length > 0,
49
+ ) ?? [];
50
+ cache.set(appId, { rawScopes, fetchedAt: Date.now() });
51
+
52
+ return rawScopes
53
+ .filter((scope) => !tokenType || !scope.token_types || scope.token_types.includes(tokenType))
54
+ .map((scope) => scope.scope);
55
+ } catch (error) {
56
+ if (error instanceof AppScopeCheckFailedError) {
57
+ throw error;
58
+ }
59
+
60
+ const statusCode =
61
+ (error as { response?: { status?: number }; status?: number; statusCode?: number }).response
62
+ ?.status ??
63
+ (error as { status?: number }).status ??
64
+ (error as { statusCode?: number }).statusCode;
65
+ if (statusCode === 400 || statusCode === 403) {
66
+ throw new AppScopeCheckFailedError(appId);
67
+ }
68
+ return [];
69
+ }
70
+ }
71
+
72
+ export function missingScopes(appGranted: string[], apiRequired: string[]): string[] {
73
+ const granted = new Set(appGranted);
74
+ return apiRequired.filter((scope) => !granted.has(scope));
75
+ }
@@ -0,0 +1,90 @@
1
+ export const LARK_ERROR = {
2
+ APP_SCOPE_MISSING: 99991672,
3
+ USER_SCOPE_INSUFFICIENT: 99991679,
4
+ TOKEN_INVALID: 99991668,
5
+ TOKEN_EXPIRED: 99991677,
6
+ REFRESH_TOKEN_INVALID: 20026,
7
+ REFRESH_TOKEN_EXPIRED: 20037,
8
+ REFRESH_TOKEN_REVOKED: 20064,
9
+ REFRESH_TOKEN_ALREADY_USED: 20073,
10
+ REFRESH_SERVER_ERROR: 20050,
11
+ } as const;
12
+
13
+ export const REFRESH_TOKEN_RETRYABLE = new Set<number>([LARK_ERROR.REFRESH_SERVER_ERROR]);
14
+ export const TOKEN_RETRY_CODES = new Set<number>([
15
+ LARK_ERROR.TOKEN_INVALID,
16
+ LARK_ERROR.TOKEN_EXPIRED,
17
+ ]);
18
+
19
+ export type ScopeErrorInfo = {
20
+ apiName: string;
21
+ scopes: string[];
22
+ appScopeVerified?: boolean;
23
+ appId?: string;
24
+ };
25
+
26
+ export class NeedAuthorizationError extends Error {
27
+ readonly userOpenId: string;
28
+
29
+ constructor(userOpenId: string) {
30
+ super("need_user_authorization");
31
+ this.name = "NeedAuthorizationError";
32
+ this.userOpenId = userOpenId;
33
+ }
34
+ }
35
+
36
+ export class AppScopeCheckFailedError extends Error {
37
+ readonly appId?: string;
38
+
39
+ constructor(appId?: string) {
40
+ super("应用缺少 application:application:self_manage 权限,无法查询应用权限配置。");
41
+ this.name = "AppScopeCheckFailedError";
42
+ this.appId = appId;
43
+ }
44
+ }
45
+
46
+ export class AppScopeMissingError extends Error {
47
+ readonly apiName: string;
48
+ readonly missingScopes: string[];
49
+ readonly appId?: string;
50
+
51
+ constructor(info: ScopeErrorInfo) {
52
+ super(`应用缺少权限 [${info.scopes.join(", ")}],请管理员在飞书开放平台开通。`);
53
+ this.name = "AppScopeMissingError";
54
+ this.apiName = info.apiName;
55
+ this.missingScopes = info.scopes;
56
+ this.appId = info.appId;
57
+ }
58
+ }
59
+
60
+ export class UserAuthRequiredError extends Error {
61
+ readonly userOpenId: string;
62
+ readonly apiName: string;
63
+ readonly requiredScopes: string[];
64
+ readonly appScopeVerified: boolean;
65
+ readonly appId?: string;
66
+
67
+ constructor(userOpenId: string, info: ScopeErrorInfo) {
68
+ super("need_user_authorization");
69
+ this.name = "UserAuthRequiredError";
70
+ this.userOpenId = userOpenId;
71
+ this.apiName = info.apiName;
72
+ this.requiredScopes = info.scopes;
73
+ this.appScopeVerified = info.appScopeVerified ?? true;
74
+ this.appId = info.appId;
75
+ }
76
+ }
77
+
78
+ export class UserScopeInsufficientError extends Error {
79
+ readonly userOpenId: string;
80
+ readonly apiName: string;
81
+ readonly missingScopes: string[];
82
+
83
+ constructor(userOpenId: string, info: ScopeErrorInfo) {
84
+ super("user_scope_insufficient");
85
+ this.name = "UserScopeInsufficientError";
86
+ this.userOpenId = userOpenId;
87
+ this.apiName = info.apiName;
88
+ this.missingScopes = info.scopes;
89
+ }
90
+ }
@@ -0,0 +1,72 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
3
+ import { assertLarkOk, createToolContext, handleInvokeError, json, registerTool } from "./user-tool-helpers.js";
4
+
5
+ const CalendarCalendarSchema = Type.Union([
6
+ Type.Object({ action: Type.Literal("list"), page_size: Type.Optional(Type.Number()), page_token: Type.Optional(Type.String()) }),
7
+ Type.Object({ action: Type.Literal("get"), calendar_id: Type.String() }),
8
+ Type.Object({ action: Type.Literal("primary") }),
9
+ ]);
10
+
11
+ export function registerFeishuCalendarCalendarTool(api: OpenClawPluginApi) {
12
+ const { toolClient } = createToolContext(api, "feishu_calendar_calendar");
13
+ registerTool(
14
+ api,
15
+ {
16
+ name: "feishu_calendar_calendar",
17
+ label: "Feishu Calendar",
18
+ description: "按本人身份查询飞书日历列表、主日历和指定日历详情。",
19
+ parameters: CalendarCalendarSchema,
20
+ async execute(_toolCallId, params) {
21
+ const payload = params as {
22
+ action: "list" | "get" | "primary";
23
+ page_size?: number;
24
+ page_token?: string;
25
+ calendar_id?: string;
26
+ };
27
+ try {
28
+ const client = toolClient();
29
+ if (payload.action === "list") {
30
+ const response = await client.invoke(
31
+ "feishu_calendar_calendar.list",
32
+ (sdk, opts) =>
33
+ sdk.calendar.calendar.list(
34
+ { params: { page_size: payload.page_size, page_token: payload.page_token } },
35
+ opts,
36
+ ),
37
+ { as: "user" },
38
+ );
39
+ assertLarkOk(response);
40
+ return json({
41
+ calendars: (response.data as { calendar_list?: unknown[] } | undefined)?.calendar_list ?? [],
42
+ has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
43
+ page_token: (response.data as { page_token?: string } | undefined)?.page_token,
44
+ });
45
+ }
46
+ if (payload.action === "primary") {
47
+ const response = await client.invoke(
48
+ "feishu_calendar_calendar.primary",
49
+ (sdk, opts) => sdk.calendar.calendar.primary({}, opts),
50
+ { as: "user" },
51
+ );
52
+ assertLarkOk(response);
53
+ return json({
54
+ calendars: (response.data as { calendars?: unknown[] } | undefined)?.calendars ?? [],
55
+ });
56
+ }
57
+ const response = await client.invoke(
58
+ "feishu_calendar_calendar.get",
59
+ (sdk, opts) =>
60
+ sdk.calendar.calendar.get({ path: { calendar_id: payload.calendar_id! } }, opts),
61
+ { as: "user" },
62
+ );
63
+ assertLarkOk(response);
64
+ return json({ calendar: (response.data as { calendar?: unknown } | undefined)?.calendar ?? response.data });
65
+ } catch (error) {
66
+ return handleInvokeError(error, api);
67
+ }
68
+ },
69
+ },
70
+ { name: "feishu_calendar_calendar" },
71
+ );
72
+ }
@@ -0,0 +1,96 @@
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 CalendarAttendeeSchema = Type.Union([
6
+ Type.Object({
7
+ action: Type.Literal("create"),
8
+ calendar_id: Type.String(),
9
+ event_id: Type.String(),
10
+ attendees: Type.Array(Type.Object({ type: StringEnum(["user", "chat", "resource", "third_party"]), attendee_id: Type.String() })),
11
+ need_notification: Type.Optional(Type.Boolean()),
12
+ }),
13
+ Type.Object({
14
+ action: Type.Literal("list"),
15
+ calendar_id: Type.String(),
16
+ event_id: Type.String(),
17
+ page_size: Type.Optional(Type.Number()),
18
+ page_token: Type.Optional(Type.String()),
19
+ user_id_type: Type.Optional(StringEnum(["open_id", "union_id", "user_id"])),
20
+ }),
21
+ ]);
22
+
23
+ export function registerFeishuCalendarEventAttendeeTool(api: OpenClawPluginApi) {
24
+ const { toolClient } = createToolContext(api, "feishu_calendar_event_attendee");
25
+ registerTool(api, {
26
+ name: "feishu_calendar_event_attendee",
27
+ label: "Feishu Calendar Event Attendee",
28
+ description: "按本人身份添加日程参会人或查看参会人列表。",
29
+ parameters: CalendarAttendeeSchema,
30
+ async execute(_toolCallId, params) {
31
+ const payload = params as {
32
+ action: "create" | "list";
33
+ calendar_id: string;
34
+ event_id: string;
35
+ attendees?: Array<{ type: "user" | "chat" | "resource" | "third_party"; attendee_id: string }>;
36
+ need_notification?: boolean;
37
+ page_size?: number;
38
+ page_token?: string;
39
+ user_id_type?: "open_id" | "union_id" | "user_id";
40
+ };
41
+ try {
42
+ const client = toolClient();
43
+ if (payload.action === "create") {
44
+ const attendeePayload = (payload.attendees ?? []).map((attendee) => {
45
+ if (attendee.type === "user") return { type: attendee.type, user_id: attendee.attendee_id };
46
+ if (attendee.type === "chat") return { type: attendee.type, chat_id: attendee.attendee_id };
47
+ if (attendee.type === "resource") return { type: attendee.type, room_id: attendee.attendee_id };
48
+ return { type: attendee.type, third_party_email: attendee.attendee_id };
49
+ });
50
+ const response = await client.invoke(
51
+ "feishu_calendar_event_attendee.create",
52
+ (sdk, opts) =>
53
+ sdk.calendar.calendarEventAttendee.create(
54
+ {
55
+ path: { calendar_id: payload.calendar_id, event_id: payload.event_id },
56
+ params: { user_id_type: "open_id" as never },
57
+ data: {
58
+ attendees: attendeePayload,
59
+ need_notification: payload.need_notification ?? true,
60
+ },
61
+ },
62
+ opts,
63
+ ),
64
+ { as: "user" },
65
+ );
66
+ assertLarkOk(response);
67
+ return json({ attendees: (response.data as { attendees?: unknown[] } | undefined)?.attendees ?? [] });
68
+ }
69
+ const response = await client.invoke(
70
+ "feishu_calendar_event_attendee.list",
71
+ (sdk, opts) =>
72
+ sdk.calendar.calendarEventAttendee.list(
73
+ {
74
+ path: { calendar_id: payload.calendar_id, event_id: payload.event_id },
75
+ params: {
76
+ page_size: payload.page_size,
77
+ page_token: payload.page_token,
78
+ user_id_type: payload.user_id_type ?? "open_id",
79
+ },
80
+ },
81
+ opts,
82
+ ),
83
+ { as: "user" },
84
+ );
85
+ assertLarkOk(response);
86
+ return json({
87
+ attendees: (response.data as { items?: unknown[] } | undefined)?.items ?? [],
88
+ has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
89
+ page_token: (response.data as { page_token?: string } | undefined)?.page_token,
90
+ });
91
+ } catch (error) {
92
+ return handleInvokeError(error, api);
93
+ }
94
+ },
95
+ }, { name: "feishu_calendar_event_attendee" });
96
+ }