@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.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +55 -1
- package/src/targets.ts +32 -7
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- 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 {
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
193
|
+
if (configured && resolvedCredentials) {
|
|
175
194
|
try {
|
|
176
|
-
probeResult = await probeFeishu(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
137
|
+
type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string };
|
|
140
138
|
|
|
141
139
|
api.registerTool(
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
}
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
168
175
|
},
|
|
169
176
|
{ name: "feishu_perm" },
|
|
170
177
|
);
|