@openclaw/feishu 2026.3.1 → 2026.3.7

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 (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/feishu";
3
3
  import { registerFeishuBitableTools } from "./src/bitable.js";
4
4
  import { feishuPlugin } from "./src/channel.js";
5
5
  import { registerFeishuChatTools } from "./src/chat.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.3.1",
3
+ "version": "2026.3.7",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -1,5 +1,42 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js";
2
+ import {
3
+ resolveDefaultFeishuAccountId,
4
+ resolveDefaultFeishuAccountSelection,
5
+ resolveFeishuAccount,
6
+ resolveFeishuCredentials,
7
+ } from "./accounts.js";
8
+ import type { FeishuConfig } from "./types.js";
9
+
10
+ const asConfig = (value: Partial<FeishuConfig>) => value as FeishuConfig;
11
+
12
+ function withEnvVar(key: string, value: string | undefined, run: () => void) {
13
+ const prev = process.env[key];
14
+ if (value === undefined) {
15
+ delete process.env[key];
16
+ } else {
17
+ process.env[key] = value;
18
+ }
19
+ try {
20
+ run();
21
+ } finally {
22
+ if (prev === undefined) {
23
+ delete process.env[key];
24
+ } else {
25
+ process.env[key] = prev;
26
+ }
27
+ }
28
+ }
29
+
30
+ function expectUnresolvedEnvSecretRefError(key: string) {
31
+ expect(() =>
32
+ resolveFeishuCredentials(
33
+ asConfig({
34
+ appId: "cli_123",
35
+ appSecret: { source: "env", provider: "default", id: key } as never,
36
+ }),
37
+ ),
38
+ ).toThrow(/unresolved SecretRef/i);
39
+ }
3
40
 
4
41
  describe("resolveDefaultFeishuAccountId", () => {
5
42
  it("prefers channels.feishu.defaultAccount when configured", () => {
@@ -8,8 +45,8 @@ describe("resolveDefaultFeishuAccountId", () => {
8
45
  feishu: {
9
46
  defaultAccount: "router-d",
10
47
  accounts: {
11
- default: { appId: "cli_default", appSecret: "secret_default" },
12
- "router-d": { appId: "cli_router", appSecret: "secret_router" },
48
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
49
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
13
50
  },
14
51
  },
15
52
  },
@@ -24,7 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => {
24
61
  feishu: {
25
62
  defaultAccount: "Router D",
26
63
  accounts: {
27
- "router-d": { appId: "cli_router", appSecret: "secret_router" },
64
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
28
65
  },
29
66
  },
30
67
  },
@@ -33,14 +70,29 @@ describe("resolveDefaultFeishuAccountId", () => {
33
70
  expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
34
71
  });
35
72
 
36
- it("falls back to literal default account id when preferred is missing", () => {
73
+ it("keeps configured defaultAccount even when not present in accounts map", () => {
37
74
  const cfg = {
38
75
  channels: {
39
76
  feishu: {
40
- defaultAccount: "missing",
77
+ defaultAccount: "router-d",
41
78
  accounts: {
42
- default: { appId: "cli_default", appSecret: "secret_default" },
43
- zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
79
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
80
+ zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
81
+ },
82
+ },
83
+ },
84
+ };
85
+
86
+ expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
87
+ });
88
+
89
+ it("falls back to literal default account id when present", () => {
90
+ const cfg = {
91
+ channels: {
92
+ feishu: {
93
+ accounts: {
94
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
95
+ zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
44
96
  },
45
97
  },
46
98
  },
@@ -48,9 +100,171 @@ describe("resolveDefaultFeishuAccountId", () => {
48
100
 
49
101
  expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
50
102
  });
103
+
104
+ it("reports selection source for configured defaults and mapped defaults", () => {
105
+ const explicitDefaultCfg = {
106
+ channels: {
107
+ feishu: {
108
+ defaultAccount: "router-d",
109
+ accounts: {},
110
+ },
111
+ },
112
+ };
113
+ expect(resolveDefaultFeishuAccountSelection(explicitDefaultCfg as never)).toEqual({
114
+ accountId: "router-d",
115
+ source: "explicit-default",
116
+ });
117
+
118
+ const mappedDefaultCfg = {
119
+ channels: {
120
+ feishu: {
121
+ accounts: {
122
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
123
+ },
124
+ },
125
+ },
126
+ };
127
+ expect(resolveDefaultFeishuAccountSelection(mappedDefaultCfg as never)).toEqual({
128
+ accountId: "default",
129
+ source: "mapped-default",
130
+ });
131
+ });
132
+ });
133
+
134
+ describe("resolveFeishuCredentials", () => {
135
+ it("throws unresolved SecretRef errors by default for unsupported secret sources", () => {
136
+ expect(() =>
137
+ resolveFeishuCredentials(
138
+ asConfig({
139
+ appId: "cli_123",
140
+ appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
141
+ }),
142
+ ),
143
+ ).toThrow(/unresolved SecretRef/i);
144
+ });
145
+
146
+ it("returns null (without throwing) when unresolved SecretRef is allowed", () => {
147
+ const creds = resolveFeishuCredentials(
148
+ asConfig({
149
+ appId: "cli_123",
150
+ appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
151
+ }),
152
+ { allowUnresolvedSecretRef: true },
153
+ );
154
+
155
+ expect(creds).toBeNull();
156
+ });
157
+
158
+ it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => {
159
+ const key = "FEISHU_APP_SECRET_MISSING_TEST";
160
+ withEnvVar(key, undefined, () => {
161
+ expectUnresolvedEnvSecretRefError(key);
162
+ });
163
+ });
164
+
165
+ it("resolves env SecretRef objects when unresolved refs are allowed", () => {
166
+ const key = "FEISHU_APP_SECRET_TEST";
167
+ const prev = process.env[key];
168
+ process.env[key] = " secret_from_env ";
169
+
170
+ try {
171
+ const creds = resolveFeishuCredentials(
172
+ asConfig({
173
+ appId: "cli_123",
174
+ appSecret: { source: "env", provider: "default", id: key } as never,
175
+ }),
176
+ { allowUnresolvedSecretRef: true },
177
+ );
178
+
179
+ expect(creds).toEqual({
180
+ appId: "cli_123",
181
+ appSecret: "secret_from_env", // pragma: allowlist secret
182
+ encryptKey: undefined,
183
+ verificationToken: undefined,
184
+ domain: "feishu",
185
+ });
186
+ } finally {
187
+ if (prev === undefined) {
188
+ delete process.env[key];
189
+ } else {
190
+ process.env[key] = prev;
191
+ }
192
+ }
193
+ });
194
+
195
+ it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
196
+ const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
197
+ const prev = process.env[key];
198
+ process.env[key] = " secret_from_env_alias ";
199
+
200
+ try {
201
+ const creds = resolveFeishuCredentials(
202
+ asConfig({
203
+ appId: "cli_123",
204
+ appSecret: { source: "env", provider: "corp-env", id: key } as never,
205
+ }),
206
+ { allowUnresolvedSecretRef: true },
207
+ );
208
+
209
+ expect(creds?.appSecret).toBe("secret_from_env_alias");
210
+ } finally {
211
+ if (prev === undefined) {
212
+ delete process.env[key];
213
+ } else {
214
+ process.env[key] = prev;
215
+ }
216
+ }
217
+ });
218
+
219
+ it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => {
220
+ const key = "FEISHU_APP_SECRET_POLICY_TEST";
221
+ withEnvVar(key, "secret_from_env", () => {
222
+ expectUnresolvedEnvSecretRefError(key);
223
+ });
224
+ });
225
+
226
+ it("trims and returns credentials when values are valid strings", () => {
227
+ const creds = resolveFeishuCredentials(
228
+ asConfig({
229
+ appId: " cli_123 ",
230
+ appSecret: " secret_456 ",
231
+ encryptKey: " enc ",
232
+ verificationToken: " vt ",
233
+ }),
234
+ );
235
+
236
+ expect(creds).toEqual({
237
+ appId: "cli_123",
238
+ appSecret: "secret_456", // pragma: allowlist secret
239
+ encryptKey: "enc",
240
+ verificationToken: "vt",
241
+ domain: "feishu",
242
+ });
243
+ });
51
244
  });
52
245
 
53
246
  describe("resolveFeishuAccount", () => {
247
+ it("uses top-level credentials with configured default account id even without account map entry", () => {
248
+ const cfg = {
249
+ channels: {
250
+ feishu: {
251
+ defaultAccount: "router-d",
252
+ appId: "top_level_app",
253
+ appSecret: "top_level_secret", // pragma: allowlist secret
254
+ accounts: {
255
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
256
+ },
257
+ },
258
+ },
259
+ };
260
+
261
+ const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
262
+ expect(account.accountId).toBe("router-d");
263
+ expect(account.selectionSource).toBe("explicit-default");
264
+ expect(account.configured).toBe(true);
265
+ expect(account.appId).toBe("top_level_app");
266
+ });
267
+
54
268
  it("uses configured default account when accountId is omitted", () => {
55
269
  const cfg = {
56
270
  channels: {
@@ -58,7 +272,7 @@ describe("resolveFeishuAccount", () => {
58
272
  defaultAccount: "router-d",
59
273
  accounts: {
60
274
  default: { enabled: true },
61
- "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true },
275
+ "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, // pragma: allowlist secret
62
276
  },
63
277
  },
64
278
  },
@@ -66,6 +280,7 @@ describe("resolveFeishuAccount", () => {
66
280
 
67
281
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
68
282
  expect(account.accountId).toBe("router-d");
283
+ expect(account.selectionSource).toBe("explicit-default");
69
284
  expect(account.configured).toBe(true);
70
285
  expect(account.appId).toBe("cli_router");
71
286
  });
@@ -76,8 +291,8 @@ describe("resolveFeishuAccount", () => {
76
291
  feishu: {
77
292
  defaultAccount: "router-d",
78
293
  accounts: {
79
- default: { appId: "cli_default", appSecret: "secret_default" },
80
- "router-d": { appId: "cli_router", appSecret: "secret_router" },
294
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
295
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
81
296
  },
82
297
  },
83
298
  },
@@ -85,6 +300,48 @@ describe("resolveFeishuAccount", () => {
85
300
 
86
301
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
87
302
  expect(account.accountId).toBe("default");
303
+ expect(account.selectionSource).toBe("explicit");
88
304
  expect(account.appId).toBe("cli_default");
89
305
  });
306
+
307
+ it("surfaces unresolved SecretRef errors in account resolution", () => {
308
+ expect(() =>
309
+ resolveFeishuAccount({
310
+ cfg: {
311
+ channels: {
312
+ feishu: {
313
+ accounts: {
314
+ main: {
315
+ appId: "cli_123",
316
+ appSecret: { source: "file", provider: "default", id: "path/to/secret" },
317
+ } as never,
318
+ },
319
+ },
320
+ },
321
+ } as never,
322
+ accountId: "main",
323
+ }),
324
+ ).toThrow(/unresolved SecretRef/i);
325
+ });
326
+
327
+ it("does not throw when account name is non-string", () => {
328
+ expect(() =>
329
+ resolveFeishuAccount({
330
+ cfg: {
331
+ channels: {
332
+ feishu: {
333
+ accounts: {
334
+ main: {
335
+ name: { bad: true },
336
+ appId: "cli_123",
337
+ appSecret: "secret_456", // pragma: allowlist secret
338
+ } as never,
339
+ },
340
+ },
341
+ },
342
+ } as never,
343
+ accountId: "main",
344
+ }),
345
+ ).not.toThrow();
346
+ });
90
347
  });
package/src/accounts.ts CHANGED
@@ -1,8 +1,10 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
1
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
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,19 +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
+ } {
38
43
  const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
39
44
  const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
40
- const ids = listFeishuAccountIds(cfg);
41
- if (preferred && ids.includes(preferred)) {
42
- return preferred;
45
+ if (preferred) {
46
+ return {
47
+ accountId: preferred,
48
+ source: "explicit-default",
49
+ };
43
50
  }
51
+ const ids = listFeishuAccountIds(cfg);
44
52
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
45
- return DEFAULT_ACCOUNT_ID;
53
+ return {
54
+ accountId: DEFAULT_ACCOUNT_ID,
55
+ source: "mapped-default",
56
+ };
46
57
  }
47
- 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;
48
69
  }
49
70
 
50
71
  /**
@@ -87,17 +108,75 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
87
108
  encryptKey?: string;
88
109
  verificationToken?: string;
89
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;
90
131
  } | null {
91
- const appId = cfg?.appId?.trim();
92
- const appSecret = cfg?.appSecret?.trim();
132
+ const normalizeString = (value: unknown): string | undefined => {
133
+ if (typeof value !== "string") {
134
+ return undefined;
135
+ }
136
+ const trimmed = value.trim();
137
+ return trimmed ? trimmed : undefined;
138
+ };
139
+
140
+ const resolveSecretLike = (value: unknown, path: string): string | undefined => {
141
+ const asString = normalizeString(value);
142
+ if (asString) {
143
+ return asString;
144
+ }
145
+
146
+ // In relaxed/onboarding paths only: allow direct env SecretRef reads for UX.
147
+ // Default resolution path must preserve unresolved-ref diagnostics/policy semantics.
148
+ if (options?.allowUnresolvedSecretRef && typeof value === "object" && value !== null) {
149
+ const rec = value as Record<string, unknown>;
150
+ const source = normalizeString(rec.source)?.toLowerCase();
151
+ const id = normalizeString(rec.id);
152
+ if (source === "env" && id) {
153
+ const envValue = normalizeString(process.env[id]);
154
+ if (envValue) {
155
+ return envValue;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (options?.allowUnresolvedSecretRef) {
161
+ return normalizeSecretInputString(value);
162
+ }
163
+ return normalizeResolvedSecretInputString({ value, path });
164
+ };
165
+
166
+ const appId = resolveSecretLike(cfg?.appId, "channels.feishu.appId");
167
+ const appSecret = resolveSecretLike(cfg?.appSecret, "channels.feishu.appSecret");
168
+
93
169
  if (!appId || !appSecret) {
94
170
  return null;
95
171
  }
96
172
  return {
97
173
  appId,
98
174
  appSecret,
99
- encryptKey: cfg?.encryptKey?.trim() || undefined,
100
- verificationToken: cfg?.verificationToken?.trim() || undefined,
175
+ encryptKey: normalizeString(cfg?.encryptKey),
176
+ verificationToken: resolveSecretLike(
177
+ cfg?.verificationToken,
178
+ "channels.feishu.verificationToken",
179
+ ),
101
180
  domain: cfg?.domain ?? "feishu",
102
181
  };
103
182
  }
@@ -111,9 +190,15 @@ export function resolveFeishuAccount(params: {
111
190
  }): ResolvedFeishuAccount {
112
191
  const hasExplicitAccountId =
113
192
  typeof params.accountId === "string" && params.accountId.trim() !== "";
193
+ const defaultSelection = hasExplicitAccountId
194
+ ? null
195
+ : resolveDefaultFeishuAccountSelection(params.cfg);
114
196
  const accountId = hasExplicitAccountId
115
197
  ? normalizeAccountId(params.accountId)
116
- : resolveDefaultFeishuAccountId(params.cfg);
198
+ : (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
199
+ const selectionSource = hasExplicitAccountId
200
+ ? "explicit"
201
+ : (defaultSelection?.source ?? "fallback");
117
202
  const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
118
203
 
119
204
  // Base enabled state (top-level)
@@ -128,12 +213,14 @@ export function resolveFeishuAccount(params: {
128
213
 
129
214
  // Resolve credentials from merged config
130
215
  const creds = resolveFeishuCredentials(merged);
216
+ const accountName = (merged as FeishuAccountConfig).name;
131
217
 
132
218
  return {
133
219
  accountId,
220
+ selectionSource,
134
221
  enabled,
135
222
  configured: Boolean(creds),
136
- name: (merged as FeishuAccountConfig).name?.trim() || undefined,
223
+ name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
137
224
  appId: creds?.appId,
138
225
  appSecret: creds?.appSecret,
139
226
  encryptKey: creds?.encryptKey,
package/src/bitable.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import { Type } from "@sinclair/typebox";
3
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
4
4
  import { listEnabledFeishuAccounts } from "./accounts.js";
5
5
  import { createFeishuToolClient } from "./tool-account.js";
6
6
 
@@ -13,6 +13,31 @@ function json(data: unknown) {
13
13
  };
14
14
  }
15
15
 
16
+ type LarkResponse<T = unknown> = { code?: number; msg?: string; data?: T };
17
+
18
+ export class LarkApiError extends Error {
19
+ readonly code: number;
20
+ readonly api: string;
21
+ readonly context?: Record<string, unknown>;
22
+ constructor(code: number, message: string, api: string, context?: Record<string, unknown>) {
23
+ super(`[${api}] code=${code} message=${message}`);
24
+ this.name = "LarkApiError";
25
+ this.code = code;
26
+ this.api = api;
27
+ this.context = context;
28
+ }
29
+ }
30
+
31
+ function ensureLarkSuccess<T>(
32
+ res: LarkResponse<T>,
33
+ api: string,
34
+ context?: Record<string, unknown>,
35
+ ): asserts res is LarkResponse<T> & { code: 0 } {
36
+ if (res.code !== 0) {
37
+ throw new LarkApiError(res.code ?? -1, res.msg ?? "unknown error", api, context);
38
+ }
39
+ }
40
+
16
41
  /** Field type ID to human-readable name */
17
42
  const FIELD_TYPE_NAMES: Record<number, string> = {
18
43
  1: "Text",
@@ -69,9 +94,7 @@ async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Prom
69
94
  const res = await client.wiki.space.getNode({
70
95
  params: { token: nodeToken },
71
96
  });
72
- if (res.code !== 0) {
73
- throw new Error(res.msg);
74
- }
97
+ ensureLarkSuccess(res, "wiki.space.getNode", { nodeToken });
75
98
 
76
99
  const node = res.data?.node;
77
100
  if (!node) {
@@ -102,9 +125,7 @@ async function getBitableMeta(client: Lark.Client, url: string) {
102
125
  const res = await client.bitable.app.get({
103
126
  path: { app_token: appToken },
104
127
  });
105
- if (res.code !== 0) {
106
- throw new Error(res.msg);
107
- }
128
+ ensureLarkSuccess(res, "bitable.app.get", { appToken });
108
129
 
109
130
  // List tables if no table_id specified
110
131
  let tables: { table_id: string; name: string }[] = [];
@@ -136,9 +157,7 @@ async function listFields(client: Lark.Client, appToken: string, tableId: string
136
157
  const res = await client.bitable.appTableField.list({
137
158
  path: { app_token: appToken, table_id: tableId },
138
159
  });
139
- if (res.code !== 0) {
140
- throw new Error(res.msg);
141
- }
160
+ ensureLarkSuccess(res, "bitable.appTableField.list", { appToken, tableId });
142
161
 
143
162
  const fields = res.data?.items ?? [];
144
163
  return {
@@ -168,9 +187,7 @@ async function listRecords(
168
187
  ...(pageToken && { page_token: pageToken }),
169
188
  },
170
189
  });
171
- if (res.code !== 0) {
172
- throw new Error(res.msg);
173
- }
190
+ ensureLarkSuccess(res, "bitable.appTableRecord.list", { appToken, tableId, pageSize });
174
191
 
175
192
  return {
176
193
  records: res.data?.items ?? [],
@@ -184,9 +201,7 @@ async function getRecord(client: Lark.Client, appToken: string, tableId: string,
184
201
  const res = await client.bitable.appTableRecord.get({
185
202
  path: { app_token: appToken, table_id: tableId, record_id: recordId },
186
203
  });
187
- if (res.code !== 0) {
188
- throw new Error(res.msg);
189
- }
204
+ ensureLarkSuccess(res, "bitable.appTableRecord.get", { appToken, tableId, recordId });
190
205
 
191
206
  return {
192
207
  record: res.data?.record,
@@ -204,9 +219,7 @@ async function createRecord(
204
219
  // oxlint-disable-next-line typescript/no-explicit-any
205
220
  data: { fields: fields as any },
206
221
  });
207
- if (res.code !== 0) {
208
- throw new Error(res.msg);
209
- }
222
+ ensureLarkSuccess(res, "bitable.appTableRecord.create", { appToken, tableId });
210
223
 
211
224
  return {
212
225
  record: res.data?.record,
@@ -334,9 +347,7 @@ async function createApp(
334
347
  ...(folderToken && { folder_token: folderToken }),
335
348
  },
336
349
  });
337
- if (res.code !== 0) {
338
- throw new Error(res.msg);
339
- }
350
+ ensureLarkSuccess(res, "bitable.app.create", { name, folderToken });
340
351
 
341
352
  const appToken = res.data?.app?.app_token;
342
353
  if (!appToken) {
@@ -393,9 +404,12 @@ async function createField(
393
404
  ...(property && { property }),
394
405
  },
395
406
  });
396
- if (res.code !== 0) {
397
- throw new Error(res.msg);
398
- }
407
+ ensureLarkSuccess(res, "bitable.appTableField.create", {
408
+ appToken,
409
+ tableId,
410
+ fieldName,
411
+ fieldType,
412
+ });
399
413
 
400
414
  return {
401
415
  field_id: res.data?.field?.field_id,
@@ -417,9 +431,7 @@ async function updateRecord(
417
431
  // oxlint-disable-next-line typescript/no-explicit-any
418
432
  data: { fields: fields as any },
419
433
  });
420
- if (res.code !== 0) {
421
- throw new Error(res.msg);
422
- }
434
+ ensureLarkSuccess(res, "bitable.appTableRecord.update", { appToken, tableId, recordId });
423
435
 
424
436
  return {
425
437
  record: res.data?.record,
@@ -3,7 +3,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
3
3
 
4
4
  // Helper to build a minimal FeishuMessageEvent for testing
5
5
  function makeEvent(
6
- chatType: "p2p" | "group",
6
+ chatType: "p2p" | "group" | "private",
7
7
  mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
8
8
  text = "hello",
9
9
  ) {
@@ -76,6 +76,14 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
76
76
  expect(ctx.mentionedBot).toBe(true);
77
77
  });
78
78
 
79
+ it("returns mentionedBot=true when bot mention name differs from configured botName", () => {
80
+ const event = makeEvent("group", [
81
+ { key: "@_user_1", name: "OpenClaw Bot (Alias)", id: { open_id: BOT_OPEN_ID } },
82
+ ]);
83
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID, "OpenClaw Bot");
84
+ expect(ctx.mentionedBot).toBe(true);
85
+ });
86
+
79
87
  it("returns mentionedBot=false when only other users are mentioned", () => {
80
88
  const event = makeEvent("group", [
81
89
  { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },