@openclaw/feishu 2026.2.9 → 2026.2.13
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/package.json +2 -5
- package/src/bot.checkBotMentioned.test.ts +64 -0
- package/src/bot.test.ts +265 -0
- package/src/bot.ts +153 -52
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +11 -13
- package/src/config-schema.ts +34 -0
- package/src/dedup.ts +33 -0
- package/src/docx.ts +14 -4
- package/src/dynamic-agent.ts +131 -0
- package/src/media.test.ts +151 -0
- package/src/media.ts +27 -13
- package/src/monitor.ts +173 -33
- package/src/reply-dispatcher.test.ts +116 -0
- package/src/reply-dispatcher.ts +124 -67
- package/src/send.ts +11 -7
- package/src/streaming-card.ts +223 -0
- package/src/targets.test.ts +16 -0
- package/src/targets.ts +1 -1
- package/src/types.ts +7 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
|
|
6
|
+
vi.mock("./probe.js", () => ({
|
|
7
|
+
probeFeishu: probeFeishuMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { feishuPlugin } from "./channel.js";
|
|
11
|
+
|
|
12
|
+
describe("feishuPlugin.status.probeAccount", () => {
|
|
13
|
+
it("uses current account credentials for multi-account config", async () => {
|
|
14
|
+
const cfg = {
|
|
15
|
+
channels: {
|
|
16
|
+
feishu: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
accounts: {
|
|
19
|
+
main: {
|
|
20
|
+
appId: "cli_main",
|
|
21
|
+
appSecret: "secret_main",
|
|
22
|
+
enabled: true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
} as OpenClawConfig;
|
|
28
|
+
|
|
29
|
+
const account = feishuPlugin.config.resolveAccount(cfg, "main");
|
|
30
|
+
probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
|
|
31
|
+
|
|
32
|
+
const result = await feishuPlugin.status?.probeAccount?.({
|
|
33
|
+
account,
|
|
34
|
+
timeoutMs: 1_000,
|
|
35
|
+
cfg,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(probeFeishuMock).toHaveBeenCalledWith(
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
accountId: "main",
|
|
42
|
+
appId: "cli_main",
|
|
43
|
+
appSecret: "secret_main",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
|
|
|
3
3
|
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
|
4
4
|
import {
|
|
5
5
|
resolveFeishuAccount,
|
|
6
|
+
resolveFeishuCredentials,
|
|
6
7
|
listFeishuAccountIds,
|
|
7
8
|
resolveDefaultFeishuAccountId,
|
|
8
9
|
} from "./accounts.js";
|
|
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
|
|
|
17
18
|
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
|
18
19
|
import { probeFeishu } from "./probe.js";
|
|
19
20
|
import { sendMessageFeishu } from "./send.js";
|
|
20
|
-
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
|
|
21
|
+
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
|
21
22
|
|
|
22
23
|
const meta: ChannelMeta = {
|
|
23
24
|
id: "feishu",
|
|
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
47
48
|
},
|
|
48
49
|
},
|
|
49
50
|
capabilities: {
|
|
50
|
-
chatTypes: ["direct", "
|
|
51
|
+
chatTypes: ["direct", "channel"],
|
|
52
|
+
polls: false,
|
|
53
|
+
threads: true,
|
|
51
54
|
media: true,
|
|
52
55
|
reactions: true,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
nativeCommands: true,
|
|
56
|
-
blockStreaming: true,
|
|
56
|
+
edit: true,
|
|
57
|
+
reply: true,
|
|
57
58
|
},
|
|
58
59
|
agentPrompt: {
|
|
59
60
|
messageToolHints: () => [
|
|
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
92
93
|
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
93
94
|
},
|
|
94
95
|
requireMention: { type: "boolean" },
|
|
96
|
+
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
|
95
97
|
historyLimit: { type: "integer", minimum: 0 },
|
|
96
98
|
dmHistoryLimit: { type: "integer", minimum: 0 },
|
|
97
99
|
textChunkLimit: { type: "integer", minimum: 1 },
|
|
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
122
124
|
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
|
123
125
|
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
|
124
126
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
125
|
-
const
|
|
127
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
126
128
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
127
129
|
|
|
128
130
|
if (isDefault) {
|
|
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
217
219
|
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
|
218
220
|
)?.defaults?.groupPolicy;
|
|
219
221
|
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
220
|
-
if (groupPolicy !== "open")
|
|
221
|
-
return [];
|
|
222
|
-
}
|
|
222
|
+
if (groupPolicy !== "open") return [];
|
|
223
223
|
return [
|
|
224
224
|
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
|
|
225
225
|
];
|
|
@@ -321,9 +321,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
321
321
|
probe: snapshot.probe,
|
|
322
322
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
323
323
|
}),
|
|
324
|
-
probeAccount: async ({ account }) =>
|
|
325
|
-
return await probeFeishu(account);
|
|
326
|
-
},
|
|
324
|
+
probeAccount: async ({ account }) => await probeFeishu(account),
|
|
327
325
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
328
326
|
accountId: account.accountId,
|
|
329
327
|
enabled: account.enabled,
|
package/src/config-schema.ts
CHANGED
|
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
|
|
|
36
36
|
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
|
37
37
|
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
|
38
38
|
|
|
39
|
+
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
|
|
40
|
+
// for incremental text display with a "Thinking..." placeholder
|
|
41
|
+
const StreamingModeSchema = z.boolean().optional();
|
|
42
|
+
|
|
39
43
|
const BlockStreamingCoalesceSchema = z
|
|
40
44
|
.object({
|
|
41
45
|
enabled: z.boolean().optional(),
|
|
@@ -53,6 +57,20 @@ const ChannelHeartbeatVisibilitySchema = z
|
|
|
53
57
|
.strict()
|
|
54
58
|
.optional();
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Dynamic agent creation configuration.
|
|
62
|
+
* When enabled, a new agent is created for each unique DM user.
|
|
63
|
+
*/
|
|
64
|
+
const DynamicAgentCreationSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
enabled: z.boolean().optional(),
|
|
67
|
+
workspaceTemplate: z.string().optional(),
|
|
68
|
+
agentDirTemplate: z.string().optional(),
|
|
69
|
+
maxAgents: z.number().int().positive().optional(),
|
|
70
|
+
})
|
|
71
|
+
.strict()
|
|
72
|
+
.optional();
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Feishu tools configuration.
|
|
58
76
|
* Controls which tool categories are enabled.
|
|
@@ -72,6 +90,16 @@ const FeishuToolsConfigSchema = z
|
|
|
72
90
|
.strict()
|
|
73
91
|
.optional();
|
|
74
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Topic session isolation mode for group chats.
|
|
95
|
+
* - "disabled" (default): All messages in a group share one session
|
|
96
|
+
* - "enabled": Messages in different topics get separate sessions
|
|
97
|
+
*
|
|
98
|
+
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
|
|
99
|
+
* for messages within a topic thread, allowing isolated conversations.
|
|
100
|
+
*/
|
|
101
|
+
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
|
102
|
+
|
|
75
103
|
export const FeishuGroupSchema = z
|
|
76
104
|
.object({
|
|
77
105
|
requireMention: z.boolean().optional(),
|
|
@@ -80,6 +108,7 @@ export const FeishuGroupSchema = z
|
|
|
80
108
|
enabled: z.boolean().optional(),
|
|
81
109
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
82
110
|
systemPrompt: z.string().optional(),
|
|
111
|
+
topicSessionMode: TopicSessionModeSchema,
|
|
83
112
|
})
|
|
84
113
|
.strict();
|
|
85
114
|
|
|
@@ -117,6 +146,7 @@ export const FeishuAccountConfigSchema = z
|
|
|
117
146
|
mediaMaxMb: z.number().positive().optional(),
|
|
118
147
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
119
148
|
renderMode: RenderModeSchema,
|
|
149
|
+
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
|
120
150
|
tools: FeishuToolsConfigSchema,
|
|
121
151
|
})
|
|
122
152
|
.strict();
|
|
@@ -142,6 +172,7 @@ export const FeishuConfigSchema = z
|
|
|
142
172
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
143
173
|
requireMention: z.boolean().optional().default(true),
|
|
144
174
|
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
|
175
|
+
topicSessionMode: TopicSessionModeSchema,
|
|
145
176
|
historyLimit: z.number().int().min(0).optional(),
|
|
146
177
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
147
178
|
dms: z.record(z.string(), DmConfigSchema).optional(),
|
|
@@ -151,7 +182,10 @@ export const FeishuConfigSchema = z
|
|
|
151
182
|
mediaMaxMb: z.number().positive().optional(),
|
|
152
183
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
153
184
|
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
|
185
|
+
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
|
154
186
|
tools: FeishuToolsConfigSchema,
|
|
187
|
+
// Dynamic agent creation for DM users
|
|
188
|
+
dynamicAgentCreation: DynamicAgentCreationSchema,
|
|
155
189
|
// Multi-account configuration
|
|
156
190
|
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
|
157
191
|
})
|
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
|
2
|
+
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
3
|
+
const DEDUP_MAX_SIZE = 1_000;
|
|
4
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
|
5
|
+
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
|
6
|
+
let lastCleanupTime = Date.now();
|
|
7
|
+
|
|
8
|
+
export function tryRecordMessage(messageId: string): boolean {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
|
|
11
|
+
// Throttled cleanup: evict expired entries at most once per interval.
|
|
12
|
+
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
|
13
|
+
for (const [id, ts] of processedMessageIds) {
|
|
14
|
+
if (now - ts > DEDUP_TTL_MS) {
|
|
15
|
+
processedMessageIds.delete(id);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
lastCleanupTime = now;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (processedMessageIds.has(messageId)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Evict oldest entries if cache is full.
|
|
26
|
+
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
|
27
|
+
const first = processedMessageIds.keys().next().value!;
|
|
28
|
+
processedMessageIds.delete(first);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
processedMessageIds.set(messageId, now);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
package/src/docx.ts
CHANGED
|
@@ -92,6 +92,14 @@ async function convertMarkdown(client: Lark.Client, markdown: string) {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
|
|
96
|
+
if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
|
|
97
|
+
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
|
|
98
|
+
const sortedIds = new Set(firstLevelIds);
|
|
99
|
+
const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
|
|
100
|
+
return [...sorted, ...remaining];
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
|
|
96
104
|
async function insertBlocks(
|
|
97
105
|
client: Lark.Client,
|
|
@@ -279,12 +287,13 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
|
|
|
279
287
|
async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
|
|
280
288
|
const deleted = await clearDocumentContent(client, docToken);
|
|
281
289
|
|
|
282
|
-
const { blocks } = await convertMarkdown(client, markdown);
|
|
290
|
+
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
|
|
283
291
|
if (blocks.length === 0) {
|
|
284
292
|
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
|
|
285
293
|
}
|
|
294
|
+
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
|
286
295
|
|
|
287
|
-
const { children: inserted, skipped } = await insertBlocks(client, docToken,
|
|
296
|
+
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
|
|
288
297
|
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
|
|
289
298
|
|
|
290
299
|
return {
|
|
@@ -299,12 +308,13 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
|
|
|
299
308
|
}
|
|
300
309
|
|
|
301
310
|
async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
|
|
302
|
-
const { blocks } = await convertMarkdown(client, markdown);
|
|
311
|
+
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
|
|
303
312
|
if (blocks.length === 0) {
|
|
304
313
|
throw new Error("Content is empty");
|
|
305
314
|
}
|
|
315
|
+
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
|
|
306
316
|
|
|
307
|
-
const { children: inserted, skipped } = await insertBlocks(client, docToken,
|
|
317
|
+
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
|
|
308
318
|
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
|
|
309
319
|
|
|
310
320
|
return {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type MaybeCreateDynamicAgentResult = {
|
|
8
|
+
created: boolean;
|
|
9
|
+
updatedCfg: OpenClawConfig;
|
|
10
|
+
agentId?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a dynamic agent should be created for a DM user and create it if needed.
|
|
15
|
+
* This creates a unique agent instance with its own workspace for each DM user.
|
|
16
|
+
*/
|
|
17
|
+
export async function maybeCreateDynamicAgent(params: {
|
|
18
|
+
cfg: OpenClawConfig;
|
|
19
|
+
runtime: PluginRuntime;
|
|
20
|
+
senderOpenId: string;
|
|
21
|
+
dynamicCfg: DynamicAgentCreationConfig;
|
|
22
|
+
log: (msg: string) => void;
|
|
23
|
+
}): Promise<MaybeCreateDynamicAgentResult> {
|
|
24
|
+
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
|
|
25
|
+
|
|
26
|
+
// Check if there's already a binding for this user
|
|
27
|
+
const existingBindings = cfg.bindings ?? [];
|
|
28
|
+
const hasBinding = existingBindings.some(
|
|
29
|
+
(b) =>
|
|
30
|
+
b.match?.channel === "feishu" &&
|
|
31
|
+
b.match?.peer?.kind === "direct" &&
|
|
32
|
+
b.match?.peer?.id === senderOpenId,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (hasBinding) {
|
|
36
|
+
return { created: false, updatedCfg: cfg };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check maxAgents limit if configured
|
|
40
|
+
if (dynamicCfg.maxAgents !== undefined) {
|
|
41
|
+
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
|
|
42
|
+
a.id.startsWith("feishu-"),
|
|
43
|
+
).length;
|
|
44
|
+
if (feishuAgentCount >= dynamicCfg.maxAgents) {
|
|
45
|
+
log(
|
|
46
|
+
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
|
|
47
|
+
);
|
|
48
|
+
return { created: false, updatedCfg: cfg };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
|
|
53
|
+
const agentId = `feishu-${senderOpenId}`;
|
|
54
|
+
|
|
55
|
+
// Check if agent already exists (but binding was missing)
|
|
56
|
+
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
|
|
57
|
+
if (existingAgent) {
|
|
58
|
+
// Agent exists but binding doesn't - just add the binding
|
|
59
|
+
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
|
|
60
|
+
|
|
61
|
+
const updatedCfg: OpenClawConfig = {
|
|
62
|
+
...cfg,
|
|
63
|
+
bindings: [
|
|
64
|
+
...existingBindings,
|
|
65
|
+
{
|
|
66
|
+
agentId,
|
|
67
|
+
match: {
|
|
68
|
+
channel: "feishu",
|
|
69
|
+
peer: { kind: "direct", id: senderOpenId },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await runtime.config.writeConfigFile(updatedCfg);
|
|
76
|
+
return { created: true, updatedCfg, agentId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolve path templates with substitutions
|
|
80
|
+
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
|
|
81
|
+
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
|
|
82
|
+
|
|
83
|
+
const workspace = resolveUserPath(
|
|
84
|
+
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
|
|
85
|
+
);
|
|
86
|
+
const agentDir = resolveUserPath(
|
|
87
|
+
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
|
|
91
|
+
log(` workspace: ${workspace}`);
|
|
92
|
+
log(` agentDir: ${agentDir}`);
|
|
93
|
+
|
|
94
|
+
// Create directories
|
|
95
|
+
await fs.promises.mkdir(workspace, { recursive: true });
|
|
96
|
+
await fs.promises.mkdir(agentDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
// Update configuration with new agent and binding
|
|
99
|
+
const updatedCfg: OpenClawConfig = {
|
|
100
|
+
...cfg,
|
|
101
|
+
agents: {
|
|
102
|
+
...cfg.agents,
|
|
103
|
+
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
|
|
104
|
+
},
|
|
105
|
+
bindings: [
|
|
106
|
+
...existingBindings,
|
|
107
|
+
{
|
|
108
|
+
agentId,
|
|
109
|
+
match: {
|
|
110
|
+
channel: "feishu",
|
|
111
|
+
peer: { kind: "direct", id: senderOpenId },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Write updated config using PluginRuntime API
|
|
118
|
+
await runtime.config.writeConfigFile(updatedCfg);
|
|
119
|
+
|
|
120
|
+
return { created: true, updatedCfg, agentId };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve a path that may start with ~ to the user's home directory.
|
|
125
|
+
*/
|
|
126
|
+
function resolveUserPath(p: string): string {
|
|
127
|
+
if (p.startsWith("~/")) {
|
|
128
|
+
return path.join(os.homedir(), p.slice(2));
|
|
129
|
+
}
|
|
130
|
+
return p;
|
|
131
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
const fileCreateMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
const messageCreateMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
const messageReplyMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock("./client.js", () => ({
|
|
13
|
+
createFeishuClient: createFeishuClientMock,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("./accounts.js", () => ({
|
|
17
|
+
resolveFeishuAccount: resolveFeishuAccountMock,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./targets.js", () => ({
|
|
21
|
+
normalizeFeishuTarget: normalizeFeishuTargetMock,
|
|
22
|
+
resolveReceiveIdType: resolveReceiveIdTypeMock,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { sendMediaFeishu } from "./media.js";
|
|
26
|
+
|
|
27
|
+
describe("sendMediaFeishu msg_type routing", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
|
|
31
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
32
|
+
configured: true,
|
|
33
|
+
accountId: "main",
|
|
34
|
+
appId: "app_id",
|
|
35
|
+
appSecret: "app_secret",
|
|
36
|
+
domain: "feishu",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
normalizeFeishuTargetMock.mockReturnValue("ou_target");
|
|
40
|
+
resolveReceiveIdTypeMock.mockReturnValue("open_id");
|
|
41
|
+
|
|
42
|
+
createFeishuClientMock.mockReturnValue({
|
|
43
|
+
im: {
|
|
44
|
+
file: {
|
|
45
|
+
create: fileCreateMock,
|
|
46
|
+
},
|
|
47
|
+
message: {
|
|
48
|
+
create: messageCreateMock,
|
|
49
|
+
reply: messageReplyMock,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
fileCreateMock.mockResolvedValue({
|
|
55
|
+
code: 0,
|
|
56
|
+
data: { file_key: "file_key_1" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
messageCreateMock.mockResolvedValue({
|
|
60
|
+
code: 0,
|
|
61
|
+
data: { message_id: "msg_1" },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
messageReplyMock.mockResolvedValue({
|
|
65
|
+
code: 0,
|
|
66
|
+
data: { message_id: "reply_1" },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("uses msg_type=media for mp4", async () => {
|
|
71
|
+
await sendMediaFeishu({
|
|
72
|
+
cfg: {} as any,
|
|
73
|
+
to: "user:ou_target",
|
|
74
|
+
mediaBuffer: Buffer.from("video"),
|
|
75
|
+
fileName: "clip.mp4",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(fileCreateMock).toHaveBeenCalledWith(
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
data: expect.objectContaining({ file_type: "mp4" }),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
data: expect.objectContaining({ msg_type: "media" }),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("uses msg_type=media for opus", async () => {
|
|
92
|
+
await sendMediaFeishu({
|
|
93
|
+
cfg: {} as any,
|
|
94
|
+
to: "user:ou_target",
|
|
95
|
+
mediaBuffer: Buffer.from("audio"),
|
|
96
|
+
fileName: "voice.opus",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(fileCreateMock).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
data: expect.objectContaining({ file_type: "opus" }),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
data: expect.objectContaining({ msg_type: "media" }),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("uses msg_type=file for documents", async () => {
|
|
113
|
+
await sendMediaFeishu({
|
|
114
|
+
cfg: {} as any,
|
|
115
|
+
to: "user:ou_target",
|
|
116
|
+
mediaBuffer: Buffer.from("doc"),
|
|
117
|
+
fileName: "paper.pdf",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(fileCreateMock).toHaveBeenCalledWith(
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
data: expect.objectContaining({ file_type: "pdf" }),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
data: expect.objectContaining({ msg_type: "file" }),
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("uses msg_type=media when replying with mp4", async () => {
|
|
134
|
+
await sendMediaFeishu({
|
|
135
|
+
cfg: {} as any,
|
|
136
|
+
to: "user:ou_target",
|
|
137
|
+
mediaBuffer: Buffer.from("video"),
|
|
138
|
+
fileName: "reply.mp4",
|
|
139
|
+
replyToMessageId: "om_parent",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(messageReplyMock).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
path: { message_id: "om_parent" },
|
|
145
|
+
data: expect.objectContaining({ msg_type: "media" }),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(messageCreateMock).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/media.ts
CHANGED
|
@@ -210,15 +210,16 @@ export async function uploadImageFeishu(params: {
|
|
|
210
210
|
|
|
211
211
|
const client = createFeishuClient(account);
|
|
212
212
|
|
|
213
|
-
// SDK
|
|
214
|
-
//
|
|
215
|
-
|
|
213
|
+
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
|
214
|
+
// Using Readable.from(buffer) causes issues with form-data library
|
|
215
|
+
// See: https://github.com/larksuite/node-sdk/issues/121
|
|
216
|
+
const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
|
|
216
217
|
|
|
217
218
|
const response = await client.im.image.create({
|
|
218
219
|
data: {
|
|
219
220
|
image_type: imageType,
|
|
220
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK
|
|
221
|
-
image:
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
|
|
222
|
+
image: imageData as any,
|
|
222
223
|
},
|
|
223
224
|
});
|
|
224
225
|
|
|
@@ -258,16 +259,17 @@ export async function uploadFileFeishu(params: {
|
|
|
258
259
|
|
|
259
260
|
const client = createFeishuClient(account);
|
|
260
261
|
|
|
261
|
-
// SDK
|
|
262
|
-
//
|
|
263
|
-
|
|
262
|
+
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
|
263
|
+
// Using Readable.from(buffer) causes issues with form-data library
|
|
264
|
+
// See: https://github.com/larksuite/node-sdk/issues/121
|
|
265
|
+
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
|
|
264
266
|
|
|
265
267
|
const response = await client.im.file.create({
|
|
266
268
|
data: {
|
|
267
269
|
file_type: fileType,
|
|
268
270
|
file_name: fileName,
|
|
269
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK
|
|
270
|
-
file:
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
|
|
272
|
+
file: fileData as any,
|
|
271
273
|
...(duration !== undefined && { duration }),
|
|
272
274
|
},
|
|
273
275
|
});
|
|
@@ -357,10 +359,13 @@ export async function sendFileFeishu(params: {
|
|
|
357
359
|
cfg: ClawdbotConfig;
|
|
358
360
|
to: string;
|
|
359
361
|
fileKey: string;
|
|
362
|
+
/** Use "media" for audio/video files, "file" for documents */
|
|
363
|
+
msgType?: "file" | "media";
|
|
360
364
|
replyToMessageId?: string;
|
|
361
365
|
accountId?: string;
|
|
362
366
|
}): Promise<SendMediaResult> {
|
|
363
367
|
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
|
368
|
+
const msgType = params.msgType ?? "file";
|
|
364
369
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
365
370
|
if (!account.configured) {
|
|
366
371
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
@@ -380,7 +385,7 @@ export async function sendFileFeishu(params: {
|
|
|
380
385
|
path: { message_id: replyToMessageId },
|
|
381
386
|
data: {
|
|
382
387
|
content,
|
|
383
|
-
msg_type:
|
|
388
|
+
msg_type: msgType,
|
|
384
389
|
},
|
|
385
390
|
});
|
|
386
391
|
|
|
@@ -399,7 +404,7 @@ export async function sendFileFeishu(params: {
|
|
|
399
404
|
data: {
|
|
400
405
|
receive_id: receiveId,
|
|
401
406
|
content,
|
|
402
|
-
msg_type:
|
|
407
|
+
msg_type: msgType,
|
|
403
408
|
},
|
|
404
409
|
});
|
|
405
410
|
|
|
@@ -522,6 +527,15 @@ export async function sendMediaFeishu(params: {
|
|
|
522
527
|
fileType,
|
|
523
528
|
accountId,
|
|
524
529
|
});
|
|
525
|
-
|
|
530
|
+
// Feishu requires msg_type "media" for audio/video, "file" for documents
|
|
531
|
+
const isMedia = fileType === "mp4" || fileType === "opus";
|
|
532
|
+
return sendFileFeishu({
|
|
533
|
+
cfg,
|
|
534
|
+
to,
|
|
535
|
+
fileKey,
|
|
536
|
+
msgType: isMedia ? "media" : "file",
|
|
537
|
+
replyToMessageId,
|
|
538
|
+
accountId,
|
|
539
|
+
});
|
|
526
540
|
}
|
|
527
541
|
}
|