@openclaw/feishu 2026.2.25 → 2026.3.2

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.
Files changed (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
3
  import { registerFeishuBitableTools } from "./src/bitable.js";
4
4
  import { feishuPlugin } from "./src/channel.js";
5
+ import { registerFeishuChatTools } from "./src/chat.js";
5
6
  import { registerFeishuDocTools } from "./src/docx.js";
6
7
  import { registerFeishuDriveTools } from "./src/drive.js";
7
8
  import { registerFeishuPermTools } from "./src/perm.js";
@@ -53,6 +54,7 @@ const plugin = {
53
54
  setFeishuRuntime(api.runtime);
54
55
  api.registerChannel({ plugin: feishuPlugin });
55
56
  registerFeishuDocTools(api);
57
+ registerFeishuChatTools(api);
56
58
  registerFeishuWikiTools(api);
57
59
  registerFeishuDriveTools(api);
58
60
  registerFeishuPermTools(api);
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.25",
3
+ "version": "2026.3.2",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@larksuiteoapi/node-sdk": "^1.59.0",
8
8
  "@sinclair/typebox": "0.34.48",
9
+ "https-proxy-agent": "^7.0.6",
9
10
  "zod": "^4.3.6"
10
11
  },
11
12
  "openclaw": {
@@ -6,7 +6,7 @@ description: |
6
6
 
7
7
  # Feishu Document Tool
8
8
 
9
- Single tool `feishu_doc` with action parameter for all document operations.
9
+ Single tool `feishu_doc` with action parameter for all document operations, including table creation for Docx.
10
10
 
11
11
  ## Token Extraction
12
12
 
@@ -43,15 +43,22 @@ Appends markdown to end of document.
43
43
  ### Create Document
44
44
 
45
45
  ```json
46
- { "action": "create", "title": "New Document" }
46
+ { "action": "create", "title": "New Document", "owner_open_id": "ou_xxx" }
47
47
  ```
48
48
 
49
49
  With folder:
50
50
 
51
51
  ```json
52
- { "action": "create", "title": "New Document", "folder_token": "fldcnXXX" }
52
+ {
53
+ "action": "create",
54
+ "title": "New Document",
55
+ "folder_token": "fldcnXXX",
56
+ "owner_open_id": "ou_xxx"
57
+ }
53
58
  ```
54
59
 
60
+ **Important:** Always pass `owner_open_id` with the requesting user's `open_id` (from inbound metadata `sender_id`) so the user automatically gets `full_access` permission on the created document. Without this, only the bot app has access.
61
+
55
62
  ### List Blocks
56
63
 
57
64
  ```json
@@ -83,6 +90,105 @@ Returns full block data including tables, images. Use this to read structured co
83
90
  { "action": "delete_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" }
84
91
  ```
85
92
 
93
+ ### Create Table (Docx Table Block)
94
+
95
+ ```json
96
+ {
97
+ "action": "create_table",
98
+ "doc_token": "ABC123def",
99
+ "row_size": 2,
100
+ "column_size": 2,
101
+ "column_width": [200, 200]
102
+ }
103
+ ```
104
+
105
+ Optional: `parent_block_id` to insert under a specific block.
106
+
107
+ ### Write Table Cells
108
+
109
+ ```json
110
+ {
111
+ "action": "write_table_cells",
112
+ "doc_token": "ABC123def",
113
+ "table_block_id": "doxcnTABLE",
114
+ "values": [
115
+ ["A1", "B1"],
116
+ ["A2", "B2"]
117
+ ]
118
+ }
119
+ ```
120
+
121
+ ### Create Table With Values (One-step)
122
+
123
+ ```json
124
+ {
125
+ "action": "create_table_with_values",
126
+ "doc_token": "ABC123def",
127
+ "row_size": 2,
128
+ "column_size": 2,
129
+ "column_width": [200, 200],
130
+ "values": [
131
+ ["A1", "B1"],
132
+ ["A2", "B2"]
133
+ ]
134
+ }
135
+ ```
136
+
137
+ Optional: `parent_block_id` to insert under a specific block.
138
+
139
+ ### Upload Image to Docx (from URL or local file)
140
+
141
+ ```json
142
+ {
143
+ "action": "upload_image",
144
+ "doc_token": "ABC123def",
145
+ "url": "https://example.com/image.png"
146
+ }
147
+ ```
148
+
149
+ Or local path with position control:
150
+
151
+ ```json
152
+ {
153
+ "action": "upload_image",
154
+ "doc_token": "ABC123def",
155
+ "file_path": "/tmp/image.png",
156
+ "parent_block_id": "doxcnParent",
157
+ "index": 5
158
+ }
159
+ ```
160
+
161
+ Optional `index` (0-based) inserts the image at a specific position among sibling blocks. Omit to append at end.
162
+
163
+ **Note:** Image display size is determined by the uploaded image's pixel dimensions. For small images (e.g. 480x270 GIFs), scale to 800px+ width before uploading to ensure proper display.
164
+
165
+ ### Upload File Attachment to Docx (from URL or local file)
166
+
167
+ ```json
168
+ {
169
+ "action": "upload_file",
170
+ "doc_token": "ABC123def",
171
+ "url": "https://example.com/report.pdf"
172
+ }
173
+ ```
174
+
175
+ Or local path:
176
+
177
+ ```json
178
+ {
179
+ "action": "upload_file",
180
+ "doc_token": "ABC123def",
181
+ "file_path": "/tmp/report.pdf",
182
+ "filename": "Q1-report.pdf"
183
+ }
184
+ ```
185
+
186
+ Rules:
187
+
188
+ - exactly one of `url` / `file_path`
189
+ - optional `filename` override
190
+ - optional `parent_block_id`
191
+
86
192
  ## Reading Workflow
87
193
 
88
194
  1. Start with `action: "read"` - get plain text + statistics
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveDefaultFeishuAccountId,
4
+ resolveDefaultFeishuAccountSelection,
5
+ resolveFeishuAccount,
6
+ } from "./accounts.js";
7
+
8
+ describe("resolveDefaultFeishuAccountId", () => {
9
+ it("prefers channels.feishu.defaultAccount when configured", () => {
10
+ const cfg = {
11
+ channels: {
12
+ feishu: {
13
+ defaultAccount: "router-d",
14
+ accounts: {
15
+ default: { appId: "cli_default", appSecret: "secret_default" },
16
+ "router-d": { appId: "cli_router", appSecret: "secret_router" },
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
23
+ });
24
+
25
+ it("normalizes configured defaultAccount before lookup", () => {
26
+ const cfg = {
27
+ channels: {
28
+ feishu: {
29
+ defaultAccount: "Router D",
30
+ accounts: {
31
+ "router-d": { appId: "cli_router", appSecret: "secret_router" },
32
+ },
33
+ },
34
+ },
35
+ };
36
+
37
+ expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
38
+ });
39
+
40
+ it("keeps configured defaultAccount even when not present in accounts map", () => {
41
+ const cfg = {
42
+ channels: {
43
+ feishu: {
44
+ defaultAccount: "router-d",
45
+ accounts: {
46
+ default: { appId: "cli_default", appSecret: "secret_default" },
47
+ zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
48
+ },
49
+ },
50
+ },
51
+ };
52
+
53
+ expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
54
+ });
55
+
56
+ it("falls back to literal default account id when present", () => {
57
+ const cfg = {
58
+ channels: {
59
+ feishu: {
60
+ accounts: {
61
+ default: { appId: "cli_default", appSecret: "secret_default" },
62
+ zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
63
+ },
64
+ },
65
+ },
66
+ };
67
+
68
+ expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
69
+ });
70
+
71
+ it("reports selection source for configured defaults and mapped defaults", () => {
72
+ const explicitDefaultCfg = {
73
+ channels: {
74
+ feishu: {
75
+ defaultAccount: "router-d",
76
+ accounts: {},
77
+ },
78
+ },
79
+ };
80
+ expect(resolveDefaultFeishuAccountSelection(explicitDefaultCfg as never)).toEqual({
81
+ accountId: "router-d",
82
+ source: "explicit-default",
83
+ });
84
+
85
+ const mappedDefaultCfg = {
86
+ channels: {
87
+ feishu: {
88
+ accounts: {
89
+ default: { appId: "cli_default", appSecret: "secret_default" },
90
+ },
91
+ },
92
+ },
93
+ };
94
+ expect(resolveDefaultFeishuAccountSelection(mappedDefaultCfg as never)).toEqual({
95
+ accountId: "default",
96
+ source: "mapped-default",
97
+ });
98
+ });
99
+ });
100
+
101
+ describe("resolveFeishuAccount", () => {
102
+ it("uses top-level credentials with configured default account id even without account map entry", () => {
103
+ const cfg = {
104
+ channels: {
105
+ feishu: {
106
+ defaultAccount: "router-d",
107
+ appId: "top_level_app",
108
+ appSecret: "top_level_secret",
109
+ accounts: {
110
+ default: { appId: "cli_default", appSecret: "secret_default" },
111
+ },
112
+ },
113
+ },
114
+ };
115
+
116
+ const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
117
+ expect(account.accountId).toBe("router-d");
118
+ expect(account.selectionSource).toBe("explicit-default");
119
+ expect(account.configured).toBe(true);
120
+ expect(account.appId).toBe("top_level_app");
121
+ });
122
+
123
+ it("uses configured default account when accountId is omitted", () => {
124
+ const cfg = {
125
+ channels: {
126
+ feishu: {
127
+ defaultAccount: "router-d",
128
+ accounts: {
129
+ default: { enabled: true },
130
+ "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true },
131
+ },
132
+ },
133
+ },
134
+ };
135
+
136
+ const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
137
+ expect(account.accountId).toBe("router-d");
138
+ expect(account.selectionSource).toBe("explicit-default");
139
+ expect(account.configured).toBe(true);
140
+ expect(account.appId).toBe("cli_router");
141
+ });
142
+
143
+ it("keeps explicit accountId selection", () => {
144
+ const cfg = {
145
+ channels: {
146
+ feishu: {
147
+ defaultAccount: "router-d",
148
+ accounts: {
149
+ default: { appId: "cli_default", appSecret: "secret_default" },
150
+ "router-d": { appId: "cli_router", appSecret: "secret_router" },
151
+ },
152
+ },
153
+ },
154
+ };
155
+
156
+ const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
157
+ expect(account.accountId).toBe("default");
158
+ expect(account.selectionSource).toBe("explicit");
159
+ expect(account.appId).toBe("cli_default");
160
+ });
161
+ });
package/src/accounts.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
+ import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
3
4
  import type {
4
5
  FeishuConfig,
5
6
  FeishuAccountConfig,
7
+ FeishuDefaultAccountSelectionSource,
6
8
  FeishuDomain,
7
9
  ResolvedFeishuAccount,
8
10
  } from "./types.js";
@@ -32,14 +34,38 @@ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
32
34
  }
33
35
 
34
36
  /**
35
- * Resolve the default account ID.
37
+ * Resolve the default account selection and its source.
36
38
  */
37
- export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
39
+ export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
40
+ accountId: string;
41
+ source: FeishuDefaultAccountSelectionSource;
42
+ } {
43
+ const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
44
+ const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
45
+ if (preferred) {
46
+ return {
47
+ accountId: preferred,
48
+ source: "explicit-default",
49
+ };
50
+ }
38
51
  const ids = listFeishuAccountIds(cfg);
39
52
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
40
- return DEFAULT_ACCOUNT_ID;
53
+ return {
54
+ accountId: DEFAULT_ACCOUNT_ID,
55
+ source: "mapped-default",
56
+ };
41
57
  }
42
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
58
+ return {
59
+ accountId: ids[0] ?? DEFAULT_ACCOUNT_ID,
60
+ source: "fallback",
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Resolve the default account ID.
66
+ */
67
+ export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
68
+ return resolveDefaultFeishuAccountSelection(cfg).accountId;
43
69
  }
44
70
 
45
71
  /**
@@ -64,7 +90,7 @@ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): Feish
64
90
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
65
91
 
66
92
  // Extract base config (exclude accounts field to avoid recursion)
67
- const { accounts: _ignored, ...base } = feishuCfg ?? {};
93
+ const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...base } = feishuCfg ?? {};
68
94
 
69
95
  // Get account-specific overrides
70
96
  const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -82,9 +108,34 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
82
108
  encryptKey?: string;
83
109
  verificationToken?: string;
84
110
  domain: FeishuDomain;
111
+ } | null;
112
+ export function resolveFeishuCredentials(
113
+ cfg: FeishuConfig | undefined,
114
+ options: { allowUnresolvedSecretRef?: boolean },
115
+ ): {
116
+ appId: string;
117
+ appSecret: string;
118
+ encryptKey?: string;
119
+ verificationToken?: string;
120
+ domain: FeishuDomain;
121
+ } | null;
122
+ export function resolveFeishuCredentials(
123
+ cfg?: FeishuConfig,
124
+ options?: { allowUnresolvedSecretRef?: boolean },
125
+ ): {
126
+ appId: string;
127
+ appSecret: string;
128
+ encryptKey?: string;
129
+ verificationToken?: string;
130
+ domain: FeishuDomain;
85
131
  } | null {
86
132
  const appId = cfg?.appId?.trim();
87
- const appSecret = cfg?.appSecret?.trim();
133
+ const appSecret = options?.allowUnresolvedSecretRef
134
+ ? normalizeSecretInputString(cfg?.appSecret)
135
+ : normalizeResolvedSecretInputString({
136
+ value: cfg?.appSecret,
137
+ path: "channels.feishu.appSecret",
138
+ });
88
139
  if (!appId || !appSecret) {
89
140
  return null;
90
141
  }
@@ -92,7 +143,13 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
92
143
  appId,
93
144
  appSecret,
94
145
  encryptKey: cfg?.encryptKey?.trim() || undefined,
95
- verificationToken: cfg?.verificationToken?.trim() || undefined,
146
+ verificationToken:
147
+ (options?.allowUnresolvedSecretRef
148
+ ? normalizeSecretInputString(cfg?.verificationToken)
149
+ : normalizeResolvedSecretInputString({
150
+ value: cfg?.verificationToken,
151
+ path: "channels.feishu.verificationToken",
152
+ })) || undefined,
96
153
  domain: cfg?.domain ?? "feishu",
97
154
  };
98
155
  }
@@ -104,7 +161,17 @@ export function resolveFeishuAccount(params: {
104
161
  cfg: ClawdbotConfig;
105
162
  accountId?: string | null;
106
163
  }): ResolvedFeishuAccount {
107
- const accountId = normalizeAccountId(params.accountId);
164
+ const hasExplicitAccountId =
165
+ typeof params.accountId === "string" && params.accountId.trim() !== "";
166
+ const defaultSelection = hasExplicitAccountId
167
+ ? null
168
+ : resolveDefaultFeishuAccountSelection(params.cfg);
169
+ const accountId = hasExplicitAccountId
170
+ ? normalizeAccountId(params.accountId)
171
+ : (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
172
+ const selectionSource = hasExplicitAccountId
173
+ ? "explicit"
174
+ : (defaultSelection?.source ?? "fallback");
108
175
  const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
109
176
 
110
177
  // Base enabled state (top-level)
@@ -122,6 +189,7 @@ export function resolveFeishuAccount(params: {
122
189
 
123
190
  return {
124
191
  accountId,
192
+ selectionSource,
125
193
  enabled,
126
194
  configured: Boolean(creds),
127
195
  name: (merged as FeishuAccountConfig).name?.trim() || undefined,
package/src/async.ts ADDED
@@ -0,0 +1,62 @@
1
+ const RACE_TIMEOUT = Symbol("race-timeout");
2
+ const RACE_ABORT = Symbol("race-abort");
3
+
4
+ export type RaceWithTimeoutAndAbortResult<T> =
5
+ | { status: "resolved"; value: T }
6
+ | { status: "timeout" }
7
+ | { status: "aborted" };
8
+
9
+ export async function raceWithTimeoutAndAbort<T>(
10
+ promise: Promise<T>,
11
+ options: {
12
+ timeoutMs?: number;
13
+ abortSignal?: AbortSignal;
14
+ } = {},
15
+ ): Promise<RaceWithTimeoutAndAbortResult<T>> {
16
+ if (options.abortSignal?.aborted) {
17
+ return { status: "aborted" };
18
+ }
19
+
20
+ if (options.timeoutMs === undefined && !options.abortSignal) {
21
+ return { status: "resolved", value: await promise };
22
+ }
23
+
24
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
25
+ let abortHandler: (() => void) | undefined;
26
+ const contenders: Array<Promise<T | typeof RACE_TIMEOUT | typeof RACE_ABORT>> = [promise];
27
+
28
+ if (options.timeoutMs !== undefined) {
29
+ contenders.push(
30
+ new Promise((resolve) => {
31
+ timeoutHandle = setTimeout(() => resolve(RACE_TIMEOUT), options.timeoutMs);
32
+ }),
33
+ );
34
+ }
35
+
36
+ if (options.abortSignal) {
37
+ contenders.push(
38
+ new Promise((resolve) => {
39
+ abortHandler = () => resolve(RACE_ABORT);
40
+ options.abortSignal?.addEventListener("abort", abortHandler, { once: true });
41
+ }),
42
+ );
43
+ }
44
+
45
+ try {
46
+ const result = await Promise.race(contenders);
47
+ if (result === RACE_TIMEOUT) {
48
+ return { status: "timeout" };
49
+ }
50
+ if (result === RACE_ABORT) {
51
+ return { status: "aborted" };
52
+ }
53
+ return { status: "resolved", value: result };
54
+ } finally {
55
+ if (timeoutHandle) {
56
+ clearTimeout(timeoutHandle);
57
+ }
58
+ if (abortHandler) {
59
+ options.abortSignal?.removeEventListener("abort", abortHandler);
60
+ }
61
+ }
62
+ }