@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/src/onboarding.ts CHANGED
@@ -3,9 +3,16 @@ import type {
3
3
  ChannelOnboardingDmPolicy,
4
4
  ClawdbotConfig,
5
5
  DmPolicy,
6
+ SecretInput,
6
7
  WizardPrompter,
7
8
  } from "openclaw/plugin-sdk";
8
- import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
9
+ import {
10
+ addWildcardAllowFrom,
11
+ DEFAULT_ACCOUNT_ID,
12
+ formatDocsLink,
13
+ hasConfiguredSecretInput,
14
+ promptSingleChannelSecretInput,
15
+ } from "openclaw/plugin-sdk";
9
16
  import { resolveFeishuCredentials } from "./accounts.js";
10
17
  import { probeFeishu } from "./probe.js";
11
18
  import type { FeishuConfig } from "./types.js";
@@ -104,23 +111,18 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
104
111
  );
105
112
  }
106
113
 
107
- async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
108
- appId: string;
109
- appSecret: string;
110
- }> {
114
+ async function promptFeishuAppId(params: {
115
+ prompter: WizardPrompter;
116
+ initialValue?: string;
117
+ }): Promise<string> {
111
118
  const appId = String(
112
- await prompter.text({
119
+ await params.prompter.text({
113
120
  message: "Enter Feishu App ID",
121
+ initialValue: params.initialValue,
114
122
  validate: (value) => (value?.trim() ? undefined : "Required"),
115
123
  }),
116
124
  ).trim();
117
- const appSecret = String(
118
- await prompter.text({
119
- message: "Enter Feishu App Secret",
120
- validate: (value) => (value?.trim() ? undefined : "Required"),
121
- }),
122
- ).trim();
123
- return { appId, appSecret };
125
+ return appId;
124
126
  }
125
127
 
126
128
  function setFeishuGroupPolicy(
@@ -167,13 +169,30 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
167
169
  channel,
168
170
  getStatus: async ({ cfg }) => {
169
171
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
170
- const configured = Boolean(resolveFeishuCredentials(feishuCfg));
172
+ const topLevelConfigured = Boolean(
173
+ feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret),
174
+ );
175
+ const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
176
+ if (!account || typeof account !== "object") {
177
+ return false;
178
+ }
179
+ const accountAppId =
180
+ typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim();
181
+ const accountSecretConfigured =
182
+ hasConfiguredSecretInput(account.appSecret) ||
183
+ hasConfiguredSecretInput(feishuCfg?.appSecret);
184
+ return Boolean(accountAppId && accountSecretConfigured);
185
+ });
186
+ const configured = topLevelConfigured || accountConfigured;
187
+ const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
188
+ allowUnresolvedSecretRef: true,
189
+ });
171
190
 
172
191
  // Try to probe if configured
173
192
  let probeResult = null;
174
- if (configured && feishuCfg) {
193
+ if (configured && resolvedCredentials) {
175
194
  try {
176
- probeResult = await probeFeishu(feishuCfg);
195
+ probeResult = await probeFeishu(resolvedCredentials);
177
196
  } catch {
178
197
  // Ignore probe errors
179
198
  }
@@ -201,52 +220,53 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
201
220
 
202
221
  configure: async ({ cfg, prompter }) => {
203
222
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
204
- const resolved = resolveFeishuCredentials(feishuCfg);
205
- const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
223
+ const resolved = resolveFeishuCredentials(feishuCfg, {
224
+ allowUnresolvedSecretRef: true,
225
+ });
226
+ const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
227
+ const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret);
206
228
  const canUseEnv = Boolean(
207
229
  !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
208
230
  );
209
231
 
210
232
  let next = cfg;
211
233
  let appId: string | null = null;
212
- let appSecret: string | null = null;
234
+ let appSecret: SecretInput | null = null;
235
+ let appSecretProbeValue: string | null = null;
213
236
 
214
237
  if (!resolved) {
215
238
  await noteFeishuCredentialHelp(prompter);
216
239
  }
217
240
 
218
- if (canUseEnv) {
219
- const keepEnv = await prompter.confirm({
220
- message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
221
- initialValue: true,
222
- });
223
- if (keepEnv) {
224
- next = {
225
- ...next,
226
- channels: {
227
- ...next.channels,
228
- feishu: { ...next.channels?.feishu, enabled: true },
229
- },
230
- };
231
- } else {
232
- const entered = await promptFeishuCredentials(prompter);
233
- appId = entered.appId;
234
- appSecret = entered.appSecret;
235
- }
236
- } else if (hasConfigCreds) {
237
- const keep = await prompter.confirm({
238
- message: "Feishu credentials already configured. Keep them?",
239
- initialValue: true,
241
+ const appSecretResult = await promptSingleChannelSecretInput({
242
+ cfg: next,
243
+ prompter,
244
+ providerHint: "feishu",
245
+ credentialLabel: "App Secret",
246
+ accountConfigured: Boolean(resolved),
247
+ canUseEnv,
248
+ hasConfigToken: hasConfigSecret,
249
+ envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
250
+ keepPrompt: "Feishu App Secret already configured. Keep it?",
251
+ inputPrompt: "Enter Feishu App Secret",
252
+ preferredEnvVar: "FEISHU_APP_SECRET",
253
+ });
254
+
255
+ if (appSecretResult.action === "use-env") {
256
+ next = {
257
+ ...next,
258
+ channels: {
259
+ ...next.channels,
260
+ feishu: { ...next.channels?.feishu, enabled: true },
261
+ },
262
+ };
263
+ } else if (appSecretResult.action === "set") {
264
+ appSecret = appSecretResult.value;
265
+ appSecretProbeValue = appSecretResult.resolvedValue;
266
+ appId = await promptFeishuAppId({
267
+ prompter,
268
+ initialValue: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(),
240
269
  });
241
- if (!keep) {
242
- const entered = await promptFeishuCredentials(prompter);
243
- appId = entered.appId;
244
- appSecret = entered.appSecret;
245
- }
246
- } else {
247
- const entered = await promptFeishuCredentials(prompter);
248
- appId = entered.appId;
249
- appSecret = entered.appSecret;
250
270
  }
251
271
 
252
272
  if (appId && appSecret) {
@@ -264,9 +284,12 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
264
284
  };
265
285
 
266
286
  // Test connection
267
- const testCfg = next.channels?.feishu as FeishuConfig;
268
287
  try {
269
- const probe = await probeFeishu(testCfg);
288
+ const probe = await probeFeishu({
289
+ appId,
290
+ appSecret: appSecretProbeValue ?? undefined,
291
+ domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain,
292
+ });
270
293
  if (probe.ok) {
271
294
  await prompter.note(
272
295
  `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
@@ -283,6 +306,75 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
283
306
  }
284
307
  }
285
308
 
309
+ const currentMode =
310
+ (next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket";
311
+ const connectionMode = (await prompter.select({
312
+ message: "Feishu connection mode",
313
+ options: [
314
+ { value: "websocket", label: "WebSocket (default)" },
315
+ { value: "webhook", label: "Webhook" },
316
+ ],
317
+ initialValue: currentMode,
318
+ })) as "websocket" | "webhook";
319
+ next = {
320
+ ...next,
321
+ channels: {
322
+ ...next.channels,
323
+ feishu: {
324
+ ...next.channels?.feishu,
325
+ connectionMode,
326
+ },
327
+ },
328
+ };
329
+
330
+ if (connectionMode === "webhook") {
331
+ const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
332
+ ?.verificationToken;
333
+ const verificationTokenResult = await promptSingleChannelSecretInput({
334
+ cfg: next,
335
+ prompter,
336
+ providerHint: "feishu-webhook",
337
+ credentialLabel: "verification token",
338
+ accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
339
+ canUseEnv: false,
340
+ hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
341
+ envPrompt: "",
342
+ keepPrompt: "Feishu verification token already configured. Keep it?",
343
+ inputPrompt: "Enter Feishu verification token",
344
+ preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
345
+ });
346
+ if (verificationTokenResult.action === "set") {
347
+ next = {
348
+ ...next,
349
+ channels: {
350
+ ...next.channels,
351
+ feishu: {
352
+ ...next.channels?.feishu,
353
+ verificationToken: verificationTokenResult.value,
354
+ },
355
+ },
356
+ };
357
+ }
358
+ const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
359
+ const webhookPath = String(
360
+ await prompter.text({
361
+ message: "Feishu webhook path",
362
+ initialValue: currentWebhookPath ?? "/feishu/events",
363
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
364
+ }),
365
+ ).trim();
366
+ next = {
367
+ ...next,
368
+ channels: {
369
+ ...next.channels,
370
+ feishu: {
371
+ ...next.channels?.feishu,
372
+ webhookPath,
373
+ },
374
+ },
375
+ };
376
+ }
377
+
286
378
  // Domain selection
287
379
  const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
288
380
  const domain = await prompter.select({
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
7
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
8
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("./media.js", () => ({
11
+ sendMediaFeishu: sendMediaFeishuMock,
12
+ }));
13
+
14
+ vi.mock("./send.js", () => ({
15
+ sendMessageFeishu: sendMessageFeishuMock,
16
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
17
+ }));
18
+
19
+ vi.mock("./runtime.js", () => ({
20
+ getFeishuRuntime: () => ({
21
+ channel: {
22
+ text: {
23
+ chunkMarkdownText: (text: string) => [text],
24
+ },
25
+ },
26
+ }),
27
+ }));
28
+
29
+ import { feishuOutbound } from "./outbound.js";
30
+ const sendText = feishuOutbound.sendText!;
31
+
32
+ describe("feishuOutbound.sendText local-image auto-convert", () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
36
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
37
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
38
+ });
39
+
40
+ async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
41
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-"));
42
+ const file = path.join(dir, `sample${ext}`);
43
+ await fs.writeFile(file, "image-data");
44
+ return { dir, file };
45
+ }
46
+
47
+ it("sends an absolute existing local image path as media", async () => {
48
+ const { dir, file } = await createTmpImage();
49
+ try {
50
+ const result = await sendText({
51
+ cfg: {} as any,
52
+ to: "chat_1",
53
+ text: file,
54
+ accountId: "main",
55
+ });
56
+
57
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ to: "chat_1",
60
+ mediaUrl: file,
61
+ accountId: "main",
62
+ }),
63
+ );
64
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
65
+ expect(result).toEqual(
66
+ expect.objectContaining({ channel: "feishu", messageId: "media_msg" }),
67
+ );
68
+ } finally {
69
+ await fs.rm(dir, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ it("keeps non-path text on the text-send path", async () => {
74
+ await sendText({
75
+ cfg: {} as any,
76
+ to: "chat_1",
77
+ text: "please upload /tmp/example.png",
78
+ accountId: "main",
79
+ });
80
+
81
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
82
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
83
+ expect.objectContaining({
84
+ to: "chat_1",
85
+ text: "please upload /tmp/example.png",
86
+ accountId: "main",
87
+ }),
88
+ );
89
+ });
90
+
91
+ it("falls back to plain text if local-image media send fails", async () => {
92
+ const { dir, file } = await createTmpImage();
93
+ sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
94
+ try {
95
+ await sendText({
96
+ cfg: {} as any,
97
+ to: "chat_1",
98
+ text: file,
99
+ accountId: "main",
100
+ });
101
+
102
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
103
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ to: "chat_1",
106
+ text: file,
107
+ accountId: "main",
108
+ }),
109
+ );
110
+ } finally {
111
+ await fs.rm(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ it("uses markdown cards when renderMode=card", async () => {
116
+ const result = await sendText({
117
+ cfg: {
118
+ channels: {
119
+ feishu: {
120
+ renderMode: "card",
121
+ },
122
+ },
123
+ } as any,
124
+ to: "chat_1",
125
+ text: "| a | b |\n| - | - |",
126
+ accountId: "main",
127
+ });
128
+
129
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ to: "chat_1",
132
+ text: "| a | b |\n| - | - |",
133
+ accountId: "main",
134
+ }),
135
+ );
136
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
137
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
138
+ });
139
+ });
140
+
141
+ describe("feishuOutbound.sendMedia renderMode", () => {
142
+ beforeEach(() => {
143
+ vi.clearAllMocks();
144
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
145
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
146
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
147
+ });
148
+
149
+ it("uses markdown cards for captions when renderMode=card", async () => {
150
+ const result = await feishuOutbound.sendMedia?.({
151
+ cfg: {
152
+ channels: {
153
+ feishu: {
154
+ renderMode: "card",
155
+ },
156
+ },
157
+ } as any,
158
+ to: "chat_1",
159
+ text: "| a | b |\n| - | - |",
160
+ mediaUrl: "https://example.com/image.png",
161
+ accountId: "main",
162
+ });
163
+
164
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
165
+ expect.objectContaining({
166
+ to: "chat_1",
167
+ text: "| a | b |\n| - | - |",
168
+ accountId: "main",
169
+ }),
170
+ );
171
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ to: "chat_1",
174
+ mediaUrl: "https://example.com/image.png",
175
+ accountId: "main",
176
+ }),
177
+ );
178
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
179
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
180
+ });
181
+ });
package/src/outbound.ts CHANGED
@@ -1,7 +1,64 @@
1
+ import fs from "fs";
2
+ import path from "path";
1
3
  import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
4
+ import { resolveFeishuAccount } from "./accounts.js";
2
5
  import { sendMediaFeishu } from "./media.js";
3
6
  import { getFeishuRuntime } from "./runtime.js";
4
- import { sendMessageFeishu } from "./send.js";
7
+ import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
8
+
9
+ function normalizePossibleLocalImagePath(text: string | undefined): string | null {
10
+ const raw = text?.trim();
11
+ if (!raw) return null;
12
+
13
+ // Only auto-convert when the message is a pure path-like payload.
14
+ // Avoid converting regular sentences that merely contain a path.
15
+ const hasWhitespace = /\s/.test(raw);
16
+ if (hasWhitespace) return null;
17
+
18
+ // Ignore links/data URLs; those should stay in normal mediaUrl/text paths.
19
+ if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null;
20
+
21
+ const ext = path.extname(raw).toLowerCase();
22
+ const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
23
+ ext,
24
+ );
25
+ if (!isImageExt) return null;
26
+
27
+ if (!path.isAbsolute(raw)) return null;
28
+ if (!fs.existsSync(raw)) return null;
29
+
30
+ // Fix race condition: wrap statSync in try-catch to handle file deletion
31
+ // between existsSync and statSync
32
+ try {
33
+ if (!fs.statSync(raw).isFile()) return null;
34
+ } catch {
35
+ // File may have been deleted or became inaccessible between checks
36
+ return null;
37
+ }
38
+
39
+ return raw;
40
+ }
41
+
42
+ function shouldUseCard(text: string): boolean {
43
+ return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
44
+ }
45
+
46
+ async function sendOutboundText(params: {
47
+ cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
48
+ to: string;
49
+ text: string;
50
+ accountId?: string;
51
+ }) {
52
+ const { cfg, to, text, accountId } = params;
53
+ const account = resolveFeishuAccount({ cfg, accountId });
54
+ const renderMode = account.config?.renderMode ?? "auto";
55
+
56
+ if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
57
+ return sendMarkdownCardFeishu({ cfg, to, text, accountId });
58
+ }
59
+
60
+ return sendMessageFeishu({ cfg, to, text, accountId });
61
+ }
5
62
 
6
63
  export const feishuOutbound: ChannelOutboundAdapter = {
7
64
  deliveryMode: "direct",
@@ -9,16 +66,45 @@ export const feishuOutbound: ChannelOutboundAdapter = {
9
66
  chunkerMode: "markdown",
10
67
  textChunkLimit: 4000,
11
68
  sendText: async ({ cfg, to, text, accountId }) => {
12
- const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
69
+ // Scheme A compatibility shim:
70
+ // when upstream accidentally returns a local image path as plain text,
71
+ // auto-upload and send as Feishu image message instead of leaking path text.
72
+ const localImagePath = normalizePossibleLocalImagePath(text);
73
+ if (localImagePath) {
74
+ try {
75
+ const result = await sendMediaFeishu({
76
+ cfg,
77
+ to,
78
+ mediaUrl: localImagePath,
79
+ accountId: accountId ?? undefined,
80
+ });
81
+ return { channel: "feishu", ...result };
82
+ } catch (err) {
83
+ console.error(`[feishu] local image path auto-send failed:`, err);
84
+ // fall through to plain text as last resort
85
+ }
86
+ }
87
+
88
+ const result = await sendOutboundText({
89
+ cfg,
90
+ to,
91
+ text,
92
+ accountId: accountId ?? undefined,
93
+ });
13
94
  return { channel: "feishu", ...result };
14
95
  },
15
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
96
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
16
97
  // Send text first if provided
17
98
  if (text?.trim()) {
18
- await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
99
+ await sendOutboundText({
100
+ cfg,
101
+ to,
102
+ text,
103
+ accountId: accountId ?? undefined,
104
+ });
19
105
  }
20
106
 
21
- // Upload and send media if URL provided
107
+ // Upload and send media if URL or local path provided
22
108
  if (mediaUrl) {
23
109
  try {
24
110
  const result = await sendMediaFeishu({
@@ -26,6 +112,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
26
112
  to,
27
113
  mediaUrl,
28
114
  accountId: accountId ?? undefined,
115
+ mediaLocalRoots,
29
116
  });
30
117
  return { channel: "feishu", ...result };
31
118
  } catch (err) {
@@ -33,7 +120,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
33
120
  console.error(`[feishu] sendMediaFeishu failed:`, err);
34
121
  // Fallback to URL link if upload fails
35
122
  const fallbackText = `📎 ${mediaUrl}`;
36
- const result = await sendMessageFeishu({
123
+ const result = await sendOutboundText({
37
124
  cfg,
38
125
  to,
39
126
  text: fallbackText,
@@ -44,7 +131,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
44
131
  }
45
132
 
46
133
  // No media URL, just return text result
47
- const result = await sendMessageFeishu({
134
+ const result = await sendOutboundText({
48
135
  cfg,
49
136
  to,
50
137
  text: text ?? "",
package/src/perm.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
4
- import { createFeishuClient } from "./client.js";
5
4
  import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
6
- import { resolveToolsConfig } from "./tools-config.js";
5
+ import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
7
6
 
8
7
  // ============ Helpers ============
9
8
 
@@ -129,42 +128,50 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
129
128
  return;
130
129
  }
131
130
 
132
- const firstAccount = accounts[0];
133
- const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
131
+ const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
134
132
  if (!toolsCfg.perm) {
135
133
  api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
136
134
  return;
137
135
  }
138
136
 
139
- const getClient = () => createFeishuClient(firstAccount);
137
+ type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string };
140
138
 
141
139
  api.registerTool(
142
- {
143
- name: "feishu_perm",
144
- label: "Feishu Perm",
145
- description: "Feishu permission management. Actions: list, add, remove",
146
- parameters: FeishuPermSchema,
147
- async execute(_toolCallId, params) {
148
- const p = params as FeishuPermParams;
149
- try {
150
- const client = getClient();
151
- switch (p.action) {
152
- case "list":
153
- return json(await listMembers(client, p.token, p.type));
154
- case "add":
155
- return json(
156
- await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
157
- );
158
- case "remove":
159
- return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
160
- default:
161
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
162
- return json({ error: `Unknown action: ${(p as any).action}` });
140
+ (ctx) => {
141
+ const defaultAccountId = ctx.agentAccountId;
142
+ return {
143
+ name: "feishu_perm",
144
+ label: "Feishu Perm",
145
+ description: "Feishu permission management. Actions: list, add, remove",
146
+ parameters: FeishuPermSchema,
147
+ async execute(_toolCallId, params) {
148
+ const p = params as FeishuPermExecuteParams;
149
+ try {
150
+ const client = createFeishuToolClient({
151
+ api,
152
+ executeParams: p,
153
+ defaultAccountId,
154
+ });
155
+ switch (p.action) {
156
+ case "list":
157
+ return json(await listMembers(client, p.token, p.type));
158
+ case "add":
159
+ return json(
160
+ await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
161
+ );
162
+ case "remove":
163
+ return json(
164
+ await removeMember(client, p.token, p.type, p.member_type, p.member_id),
165
+ );
166
+ default:
167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
168
+ return json({ error: `Unknown action: ${(p as any).action}` });
169
+ }
170
+ } catch (err) {
171
+ return json({ error: err instanceof Error ? err.message : String(err) });
163
172
  }
164
- } catch (err) {
165
- return json({ error: err instanceof Error ? err.message : String(err) });
166
- }
167
- },
173
+ },
174
+ };
168
175
  },
169
176
  { name: "feishu_perm" },
170
177
  );