@openclaw/feishu 2026.2.9 → 2026.2.12
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.ts +111 -35
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +11 -13
- package/src/config-schema.ts +28 -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 +146 -32
- package/src/send.ts +11 -7
- package/src/types.ts +7 -0
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/feishu",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.12",
|
|
4
4
|
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@larksuiteoapi/node-sdk": "^1.
|
|
7
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
8
8
|
"@sinclair/typebox": "0.34.48",
|
|
9
9
|
"zod": "^4.3.6"
|
|
10
10
|
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"openclaw": "workspace:*"
|
|
13
|
-
},
|
|
14
11
|
"openclaw": {
|
|
15
12
|
"extensions": [
|
|
16
13
|
"./index.ts"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseFeishuMessageEvent } from "./bot.js";
|
|
3
|
+
|
|
4
|
+
// Helper to build a minimal FeishuMessageEvent for testing
|
|
5
|
+
function makeEvent(
|
|
6
|
+
chatType: "p2p" | "group",
|
|
7
|
+
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
|
8
|
+
) {
|
|
9
|
+
return {
|
|
10
|
+
sender: {
|
|
11
|
+
sender_id: { user_id: "u1", open_id: "ou_sender" },
|
|
12
|
+
},
|
|
13
|
+
message: {
|
|
14
|
+
message_id: "msg_1",
|
|
15
|
+
chat_id: "oc_chat1",
|
|
16
|
+
chat_type: chatType,
|
|
17
|
+
message_type: "text",
|
|
18
|
+
content: JSON.stringify({ text: "hello" }),
|
|
19
|
+
mentions,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("parseFeishuMessageEvent – mentionedBot", () => {
|
|
25
|
+
const BOT_OPEN_ID = "ou_bot_123";
|
|
26
|
+
|
|
27
|
+
it("returns mentionedBot=false when there are no mentions", () => {
|
|
28
|
+
const event = makeEvent("group", []);
|
|
29
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
30
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns mentionedBot=true when bot is mentioned", () => {
|
|
34
|
+
const event = makeEvent("group", [
|
|
35
|
+
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
|
|
36
|
+
]);
|
|
37
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
38
|
+
expect(ctx.mentionedBot).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns mentionedBot=false when only other users are mentioned", () => {
|
|
42
|
+
const event = makeEvent("group", [
|
|
43
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
44
|
+
]);
|
|
45
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
46
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
|
|
50
|
+
const event = makeEvent("group", [
|
|
51
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
52
|
+
]);
|
|
53
|
+
const ctx = parseFeishuMessageEvent(event as any, undefined);
|
|
54
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
|
|
58
|
+
const event = makeEvent("group", [
|
|
59
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
60
|
+
]);
|
|
61
|
+
const ctx = parseFeishuMessageEvent(event as any, "");
|
|
62
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/bot.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
type HistoryEntry,
|
|
8
8
|
} from "openclaw/plugin-sdk";
|
|
9
9
|
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
10
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
10
11
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
11
12
|
import { createFeishuClient } from "./client.js";
|
|
12
|
-
import {
|
|
13
|
+
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
14
|
+
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
|
13
15
|
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
|
|
14
16
|
import {
|
|
15
17
|
resolveFeishuGroupConfig,
|
|
@@ -21,6 +23,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
|
21
23
|
import { getFeishuRuntime } from "./runtime.js";
|
|
22
24
|
import { getMessageFeishu } from "./send.js";
|
|
23
25
|
|
|
26
|
+
// --- Message deduplication ---
|
|
27
|
+
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
|
28
|
+
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
29
|
+
const DEDUP_MAX_SIZE = 1_000;
|
|
30
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
|
31
|
+
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
|
32
|
+
let lastCleanupTime = Date.now();
|
|
33
|
+
|
|
34
|
+
function tryRecordMessage(messageId: string): boolean {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
// Throttled cleanup: evict expired entries at most once per interval
|
|
38
|
+
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
|
39
|
+
for (const [id, ts] of processedMessageIds) {
|
|
40
|
+
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
|
|
41
|
+
}
|
|
42
|
+
lastCleanupTime = now;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (processedMessageIds.has(messageId)) return false;
|
|
46
|
+
|
|
47
|
+
// Evict oldest entries if cache is full
|
|
48
|
+
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
|
49
|
+
const first = processedMessageIds.keys().next().value!;
|
|
50
|
+
processedMessageIds.delete(first);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
processedMessageIds.set(messageId, now);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
// --- Permission error extraction ---
|
|
25
58
|
// Extract permission grant URL from Feishu API error response.
|
|
26
59
|
type PermissionError = {
|
|
@@ -30,16 +63,12 @@ type PermissionError = {
|
|
|
30
63
|
};
|
|
31
64
|
|
|
32
65
|
function extractPermissionError(err: unknown): PermissionError | null {
|
|
33
|
-
if (!err || typeof err !== "object")
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
66
|
+
if (!err || typeof err !== "object") return null;
|
|
36
67
|
|
|
37
68
|
// Axios error structure: err.response.data contains the Feishu error
|
|
38
69
|
const axiosErr = err as { response?: { data?: unknown } };
|
|
39
70
|
const data = axiosErr.response?.data;
|
|
40
|
-
if (!data || typeof data !== "object")
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
71
|
+
if (!data || typeof data !== "object") return null;
|
|
43
72
|
|
|
44
73
|
const feishuErr = data as {
|
|
45
74
|
code?: number;
|
|
@@ -48,9 +77,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|
|
48
77
|
};
|
|
49
78
|
|
|
50
79
|
// Feishu permission error code: 99991672
|
|
51
|
-
if (feishuErr.code !== 99991672)
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
80
|
+
if (feishuErr.code !== 99991672) return null;
|
|
54
81
|
|
|
55
82
|
// Extract the grant URL from the error message (contains the direct link)
|
|
56
83
|
const msg = feishuErr.msg ?? "";
|
|
@@ -82,28 +109,20 @@ type SenderNameResult = {
|
|
|
82
109
|
async function resolveFeishuSenderName(params: {
|
|
83
110
|
account: ResolvedFeishuAccount;
|
|
84
111
|
senderOpenId: string;
|
|
85
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
|
|
86
112
|
log: (...args: any[]) => void;
|
|
87
113
|
}): Promise<SenderNameResult> {
|
|
88
114
|
const { account, senderOpenId, log } = params;
|
|
89
|
-
if (!account.configured) {
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
if (!senderOpenId) {
|
|
93
|
-
return {};
|
|
94
|
-
}
|
|
115
|
+
if (!account.configured) return {};
|
|
116
|
+
if (!senderOpenId) return {};
|
|
95
117
|
|
|
96
118
|
const cached = senderNameCache.get(senderOpenId);
|
|
97
119
|
const now = Date.now();
|
|
98
|
-
if (cached && cached.expireAt > now) {
|
|
99
|
-
return { name: cached.name };
|
|
100
|
-
}
|
|
120
|
+
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
101
121
|
|
|
102
122
|
try {
|
|
103
123
|
const client = createFeishuClient(account);
|
|
104
124
|
|
|
105
125
|
// contact/v3/users/:user_id?user_id_type=open_id
|
|
106
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
|
107
126
|
const res: any = await client.contact.user.get({
|
|
108
127
|
path: { user_id: senderOpenId },
|
|
109
128
|
params: { user_id_type: "open_id" },
|
|
@@ -196,12 +215,8 @@ function parseMessageContent(content: string, messageType: string): string {
|
|
|
196
215
|
|
|
197
216
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
198
217
|
const mentions = event.message.mentions ?? [];
|
|
199
|
-
if (mentions.length === 0)
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
if (!botOpenId) {
|
|
203
|
-
return mentions.length > 0;
|
|
204
|
-
}
|
|
218
|
+
if (mentions.length === 0) return false;
|
|
219
|
+
if (!botOpenId) return false;
|
|
205
220
|
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
206
221
|
}
|
|
207
222
|
|
|
@@ -209,9 +224,7 @@ function stripBotMention(
|
|
|
209
224
|
text: string,
|
|
210
225
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
211
226
|
): string {
|
|
212
|
-
if (!mentions || mentions.length === 0)
|
|
213
|
-
return text;
|
|
214
|
-
}
|
|
227
|
+
if (!mentions || mentions.length === 0) return text;
|
|
215
228
|
let result = text;
|
|
216
229
|
for (const mention of mentions) {
|
|
217
230
|
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
|
@@ -523,6 +536,13 @@ export async function handleFeishuMessage(params: {
|
|
|
523
536
|
const log = runtime?.log ?? console.log;
|
|
524
537
|
const error = runtime?.error ?? console.error;
|
|
525
538
|
|
|
539
|
+
// Dedup check: skip if this message was already processed
|
|
540
|
+
const messageId = event.message.message_id;
|
|
541
|
+
if (!tryRecordMessage(messageId)) {
|
|
542
|
+
log(`feishu: skipping duplicate message ${messageId}`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
526
546
|
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
527
547
|
const isGroup = ctx.chatType === "group";
|
|
528
548
|
|
|
@@ -532,9 +552,7 @@ export async function handleFeishuMessage(params: {
|
|
|
532
552
|
senderOpenId: ctx.senderOpenId,
|
|
533
553
|
log,
|
|
534
554
|
});
|
|
535
|
-
if (senderResult.name) {
|
|
536
|
-
ctx = { ...ctx, senderName: senderResult.name };
|
|
537
|
-
}
|
|
555
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
538
556
|
|
|
539
557
|
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
540
558
|
let permissionErrorForAgent: PermissionError | undefined;
|
|
@@ -647,16 +665,61 @@ export async function handleFeishuMessage(params: {
|
|
|
647
665
|
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
648
666
|
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
649
667
|
|
|
650
|
-
|
|
668
|
+
// Resolve peer ID for session routing
|
|
669
|
+
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
|
670
|
+
// get a separate session from the main group chat.
|
|
671
|
+
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
|
672
|
+
if (isGroup && ctx.rootId) {
|
|
673
|
+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
674
|
+
const topicSessionMode =
|
|
675
|
+
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
676
|
+
if (topicSessionMode === "enabled") {
|
|
677
|
+
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
|
678
|
+
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
|
679
|
+
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
let route = core.channel.routing.resolveAgentRoute({
|
|
651
684
|
cfg,
|
|
652
685
|
channel: "feishu",
|
|
653
686
|
accountId: account.accountId,
|
|
654
687
|
peer: {
|
|
655
688
|
kind: isGroup ? "group" : "direct",
|
|
656
|
-
id:
|
|
689
|
+
id: peerId,
|
|
657
690
|
},
|
|
658
691
|
});
|
|
659
692
|
|
|
693
|
+
// Dynamic agent creation for DM users
|
|
694
|
+
// When enabled, creates a unique agent instance with its own workspace for each DM user.
|
|
695
|
+
let effectiveCfg = cfg;
|
|
696
|
+
if (!isGroup && route.matchedBy === "default") {
|
|
697
|
+
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
|
|
698
|
+
if (dynamicCfg?.enabled) {
|
|
699
|
+
const runtime = getFeishuRuntime();
|
|
700
|
+
const result = await maybeCreateDynamicAgent({
|
|
701
|
+
cfg,
|
|
702
|
+
runtime,
|
|
703
|
+
senderOpenId: ctx.senderOpenId,
|
|
704
|
+
dynamicCfg,
|
|
705
|
+
log: (msg) => log(msg),
|
|
706
|
+
});
|
|
707
|
+
if (result.created) {
|
|
708
|
+
effectiveCfg = result.updatedCfg;
|
|
709
|
+
// Re-resolve route with updated config
|
|
710
|
+
route = core.channel.routing.resolveAgentRoute({
|
|
711
|
+
cfg: result.updatedCfg,
|
|
712
|
+
channel: "feishu",
|
|
713
|
+
accountId: account.accountId,
|
|
714
|
+
peer: { kind: "direct", id: ctx.senderOpenId },
|
|
715
|
+
});
|
|
716
|
+
log(
|
|
717
|
+
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
660
723
|
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
661
724
|
const inboundLabel = isGroup
|
|
662
725
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
@@ -736,6 +799,7 @@ export async function handleFeishuMessage(params: {
|
|
|
736
799
|
|
|
737
800
|
const permissionCtx = core.channel.reply.finalizeInboundContext({
|
|
738
801
|
Body: permissionBody,
|
|
802
|
+
BodyForAgent: permissionNotifyBody,
|
|
739
803
|
RawBody: permissionNotifyBody,
|
|
740
804
|
CommandBody: permissionNotifyBody,
|
|
741
805
|
From: feishuFrom,
|
|
@@ -810,8 +874,19 @@ export async function handleFeishuMessage(params: {
|
|
|
810
874
|
});
|
|
811
875
|
}
|
|
812
876
|
|
|
877
|
+
const inboundHistory =
|
|
878
|
+
isGroup && historyKey && historyLimit > 0 && chatHistories
|
|
879
|
+
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
|
|
880
|
+
sender: entry.sender,
|
|
881
|
+
body: entry.body,
|
|
882
|
+
timestamp: entry.timestamp,
|
|
883
|
+
}))
|
|
884
|
+
: undefined;
|
|
885
|
+
|
|
813
886
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
814
887
|
Body: combinedBody,
|
|
888
|
+
BodyForAgent: ctx.content,
|
|
889
|
+
InboundHistory: inboundHistory,
|
|
815
890
|
RawBody: ctx.content,
|
|
816
891
|
CommandBody: ctx.content,
|
|
817
892
|
From: feishuFrom,
|
|
@@ -825,6 +900,7 @@ export async function handleFeishuMessage(params: {
|
|
|
825
900
|
Provider: "feishu" as const,
|
|
826
901
|
Surface: "feishu" as const,
|
|
827
902
|
MessageSid: ctx.messageId,
|
|
903
|
+
ReplyToBody: quotedContent ?? undefined,
|
|
828
904
|
Timestamp: Date.now(),
|
|
829
905
|
WasMentioned: ctx.mentionedBot,
|
|
830
906
|
CommandAuthorized: true,
|
|
@@ -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
|
@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
|
|
|
53
53
|
.strict()
|
|
54
54
|
.optional();
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Dynamic agent creation configuration.
|
|
58
|
+
* When enabled, a new agent is created for each unique DM user.
|
|
59
|
+
*/
|
|
60
|
+
const DynamicAgentCreationSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
enabled: z.boolean().optional(),
|
|
63
|
+
workspaceTemplate: z.string().optional(),
|
|
64
|
+
agentDirTemplate: z.string().optional(),
|
|
65
|
+
maxAgents: z.number().int().positive().optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict()
|
|
68
|
+
.optional();
|
|
69
|
+
|
|
56
70
|
/**
|
|
57
71
|
* Feishu tools configuration.
|
|
58
72
|
* Controls which tool categories are enabled.
|
|
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
|
|
|
72
86
|
.strict()
|
|
73
87
|
.optional();
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Topic session isolation mode for group chats.
|
|
91
|
+
* - "disabled" (default): All messages in a group share one session
|
|
92
|
+
* - "enabled": Messages in different topics get separate sessions
|
|
93
|
+
*
|
|
94
|
+
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
|
|
95
|
+
* for messages within a topic thread, allowing isolated conversations.
|
|
96
|
+
*/
|
|
97
|
+
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
|
98
|
+
|
|
75
99
|
export const FeishuGroupSchema = z
|
|
76
100
|
.object({
|
|
77
101
|
requireMention: z.boolean().optional(),
|
|
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
|
|
|
80
104
|
enabled: z.boolean().optional(),
|
|
81
105
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
82
106
|
systemPrompt: z.string().optional(),
|
|
107
|
+
topicSessionMode: TopicSessionModeSchema,
|
|
83
108
|
})
|
|
84
109
|
.strict();
|
|
85
110
|
|
|
@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
|
|
|
142
167
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
143
168
|
requireMention: z.boolean().optional().default(true),
|
|
144
169
|
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
|
170
|
+
topicSessionMode: TopicSessionModeSchema,
|
|
145
171
|
historyLimit: z.number().int().min(0).optional(),
|
|
146
172
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
147
173
|
dms: z.record(z.string(), DmConfigSchema).optional(),
|
|
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
|
|
|
152
178
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
153
179
|
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
|
154
180
|
tools: FeishuToolsConfigSchema,
|
|
181
|
+
// Dynamic agent creation for DM users
|
|
182
|
+
dynamicAgentCreation: DynamicAgentCreationSchema,
|
|
155
183
|
// Multi-account configuration
|
|
156
184
|
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
|
157
185
|
})
|
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
|
}
|
package/src/monitor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
2
2
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
|
+
import * as http from "http";
|
|
3
4
|
import type { ResolvedFeishuAccount } from "./types.js";
|
|
4
5
|
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
|
|
5
6
|
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
|
@@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
|
|
|
13
14
|
accountId?: string;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
|
-
// Per-account WebSocket clients and bot info
|
|
17
|
+
// Per-account WebSocket clients, HTTP servers, and bot info
|
|
17
18
|
const wsClients = new Map<string, Lark.WSClient>();
|
|
19
|
+
const httpServers = new Map<string, http.Server>();
|
|
18
20
|
const botOpenIds = new Map<string, string>();
|
|
19
21
|
|
|
20
22
|
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
|
@@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
32
|
+
* Register common event handlers on an EventDispatcher.
|
|
33
|
+
* When fireAndForget is true (webhook mode), message handling is not awaited
|
|
34
|
+
* to avoid blocking the HTTP response (Lark requires <3s response).
|
|
31
35
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
function registerEventHandlers(
|
|
37
|
+
eventDispatcher: Lark.EventDispatcher,
|
|
38
|
+
context: {
|
|
39
|
+
cfg: ClawdbotConfig;
|
|
40
|
+
accountId: string;
|
|
41
|
+
runtime?: RuntimeEnv;
|
|
42
|
+
chatHistories: Map<string, HistoryEntry[]>;
|
|
43
|
+
fireAndForget?: boolean;
|
|
44
|
+
},
|
|
45
|
+
) {
|
|
46
|
+
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
|
40
47
|
const log = runtime?.log ?? console.log;
|
|
41
48
|
const error = runtime?.error ?? console.error;
|
|
42
49
|
|
|
43
|
-
// Fetch bot open_id
|
|
44
|
-
const botOpenId = await fetchBotOpenId(account);
|
|
45
|
-
botOpenIds.set(accountId, botOpenId ?? "");
|
|
46
|
-
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
|
47
|
-
|
|
48
|
-
const connectionMode = account.config.connectionMode ?? "websocket";
|
|
49
|
-
|
|
50
|
-
if (connectionMode !== "websocket") {
|
|
51
|
-
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
|
56
|
-
|
|
57
|
-
const wsClient = createFeishuWSClient(account);
|
|
58
|
-
wsClients.set(accountId, wsClient);
|
|
59
|
-
|
|
60
|
-
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
61
|
-
const eventDispatcher = createEventDispatcher(account);
|
|
62
|
-
|
|
63
50
|
eventDispatcher.register({
|
|
64
51
|
"im.message.receive_v1": async (data) => {
|
|
65
52
|
try {
|
|
66
53
|
const event = data as unknown as FeishuMessageEvent;
|
|
67
|
-
|
|
54
|
+
const promise = handleFeishuMessage({
|
|
68
55
|
cfg,
|
|
69
56
|
event,
|
|
70
57
|
botOpenId: botOpenIds.get(accountId),
|
|
@@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
|
|
|
72
59
|
chatHistories,
|
|
73
60
|
accountId,
|
|
74
61
|
});
|
|
62
|
+
if (fireAndForget) {
|
|
63
|
+
promise.catch((err) => {
|
|
64
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
await promise;
|
|
68
|
+
}
|
|
75
69
|
} catch (err) {
|
|
76
70
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
77
71
|
}
|
|
@@ -96,6 +90,66 @@ async function monitorSingleAccount(params: {
|
|
|
96
90
|
}
|
|
97
91
|
},
|
|
98
92
|
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type MonitorAccountParams = {
|
|
96
|
+
cfg: ClawdbotConfig;
|
|
97
|
+
account: ResolvedFeishuAccount;
|
|
98
|
+
runtime?: RuntimeEnv;
|
|
99
|
+
abortSignal?: AbortSignal;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Monitor a single Feishu account.
|
|
104
|
+
*/
|
|
105
|
+
async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
|
|
106
|
+
const { cfg, account, runtime, abortSignal } = params;
|
|
107
|
+
const { accountId } = account;
|
|
108
|
+
const log = runtime?.log ?? console.log;
|
|
109
|
+
|
|
110
|
+
// Fetch bot open_id
|
|
111
|
+
const botOpenId = await fetchBotOpenId(account);
|
|
112
|
+
botOpenIds.set(accountId, botOpenId ?? "");
|
|
113
|
+
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
|
114
|
+
|
|
115
|
+
const connectionMode = account.config.connectionMode ?? "websocket";
|
|
116
|
+
const eventDispatcher = createEventDispatcher(account);
|
|
117
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
118
|
+
|
|
119
|
+
registerEventHandlers(eventDispatcher, {
|
|
120
|
+
cfg,
|
|
121
|
+
accountId,
|
|
122
|
+
runtime,
|
|
123
|
+
chatHistories,
|
|
124
|
+
fireAndForget: connectionMode === "webhook",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (connectionMode === "webhook") {
|
|
128
|
+
return monitorWebhook({ params, accountId, eventDispatcher });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return monitorWebSocket({ params, accountId, eventDispatcher });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type ConnectionParams = {
|
|
135
|
+
params: MonitorAccountParams;
|
|
136
|
+
accountId: string;
|
|
137
|
+
eventDispatcher: Lark.EventDispatcher;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
async function monitorWebSocket({
|
|
141
|
+
params,
|
|
142
|
+
accountId,
|
|
143
|
+
eventDispatcher,
|
|
144
|
+
}: ConnectionParams): Promise<void> {
|
|
145
|
+
const { account, runtime, abortSignal } = params;
|
|
146
|
+
const log = runtime?.log ?? console.log;
|
|
147
|
+
const error = runtime?.error ?? console.error;
|
|
148
|
+
|
|
149
|
+
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
|
150
|
+
|
|
151
|
+
const wsClient = createFeishuWSClient(account);
|
|
152
|
+
wsClients.set(accountId, wsClient);
|
|
99
153
|
|
|
100
154
|
return new Promise((resolve, reject) => {
|
|
101
155
|
const cleanup = () => {
|
|
@@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
|
|
|
118
172
|
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
119
173
|
|
|
120
174
|
try {
|
|
121
|
-
|
|
175
|
+
wsClient.start({ eventDispatcher });
|
|
122
176
|
log(`feishu[${accountId}]: WebSocket client started`);
|
|
123
177
|
} catch (err) {
|
|
124
178
|
cleanup();
|
|
@@ -128,6 +182,57 @@ async function monitorSingleAccount(params: {
|
|
|
128
182
|
});
|
|
129
183
|
}
|
|
130
184
|
|
|
185
|
+
async function monitorWebhook({
|
|
186
|
+
params,
|
|
187
|
+
accountId,
|
|
188
|
+
eventDispatcher,
|
|
189
|
+
}: ConnectionParams): Promise<void> {
|
|
190
|
+
const { account, runtime, abortSignal } = params;
|
|
191
|
+
const log = runtime?.log ?? console.log;
|
|
192
|
+
const error = runtime?.error ?? console.error;
|
|
193
|
+
|
|
194
|
+
const port = account.config.webhookPort ?? 3000;
|
|
195
|
+
const path = account.config.webhookPath ?? "/feishu/events";
|
|
196
|
+
|
|
197
|
+
log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
|
|
198
|
+
|
|
199
|
+
const server = http.createServer();
|
|
200
|
+
server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
|
|
201
|
+
httpServers.set(accountId, server);
|
|
202
|
+
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
server.close();
|
|
206
|
+
httpServers.delete(accountId);
|
|
207
|
+
botOpenIds.delete(accountId);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleAbort = () => {
|
|
211
|
+
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
|
|
212
|
+
cleanup();
|
|
213
|
+
resolve();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (abortSignal?.aborted) {
|
|
217
|
+
cleanup();
|
|
218
|
+
resolve();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
223
|
+
|
|
224
|
+
server.listen(port, () => {
|
|
225
|
+
log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
server.on("error", (err) => {
|
|
229
|
+
error(`feishu[${accountId}]: Webhook server error: ${err}`);
|
|
230
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
231
|
+
reject(err);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
131
236
|
/**
|
|
132
237
|
* Main entry: start monitoring for all enabled accounts.
|
|
133
238
|
*/
|
|
@@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
|
|
182
287
|
export function stopFeishuMonitor(accountId?: string): void {
|
|
183
288
|
if (accountId) {
|
|
184
289
|
wsClients.delete(accountId);
|
|
290
|
+
const server = httpServers.get(accountId);
|
|
291
|
+
if (server) {
|
|
292
|
+
server.close();
|
|
293
|
+
httpServers.delete(accountId);
|
|
294
|
+
}
|
|
185
295
|
botOpenIds.delete(accountId);
|
|
186
296
|
} else {
|
|
187
297
|
wsClients.clear();
|
|
298
|
+
for (const server of httpServers.values()) {
|
|
299
|
+
server.close();
|
|
300
|
+
}
|
|
301
|
+
httpServers.clear();
|
|
188
302
|
botOpenIds.clear();
|
|
189
303
|
}
|
|
190
304
|
}
|
package/src/send.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { MentionTarget } from "./mention.js";
|
|
3
|
-
import type { FeishuSendResult } from "./types.js";
|
|
3
|
+
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
|
|
4
4
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
5
|
import { createFeishuClient } from "./client.js";
|
|
6
6
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
|
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
|
|
|
281
281
|
/**
|
|
282
282
|
* Build a Feishu interactive card with markdown content.
|
|
283
283
|
* Cards render markdown properly (code blocks, tables, links, etc.)
|
|
284
|
+
* Uses schema 2.0 format for proper markdown rendering.
|
|
284
285
|
*/
|
|
285
286
|
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
|
286
287
|
return {
|
|
288
|
+
schema: "2.0",
|
|
287
289
|
config: {
|
|
288
290
|
wide_screen_mode: true,
|
|
289
291
|
},
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
body: {
|
|
293
|
+
elements: [
|
|
294
|
+
{
|
|
295
|
+
tag: "markdown",
|
|
296
|
+
content: text,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
296
300
|
};
|
|
297
301
|
}
|
|
298
302
|
|
package/src/types.ts
CHANGED