@openclaw/feishu 2026.2.6 → 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/bitable.ts +4 -2
- package/src/bot.checkBotMentioned.test.ts +64 -0
- package/src/bot.ts +112 -36
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +41 -25
- 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/onboarding.ts +4 -1
- package/src/outbound.ts +20 -5
- package/src/reply-dispatcher.ts +3 -8
- 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"
|
package/src/bitable.ts
CHANGED
|
@@ -212,7 +212,8 @@ async function createRecord(
|
|
|
212
212
|
) {
|
|
213
213
|
const res = await client.bitable.appTableRecord.create({
|
|
214
214
|
path: { app_token: appToken, table_id: tableId },
|
|
215
|
-
|
|
215
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
216
|
+
data: { fields: fields as any },
|
|
216
217
|
});
|
|
217
218
|
if (res.code !== 0) {
|
|
218
219
|
throw new Error(res.msg);
|
|
@@ -232,7 +233,8 @@ async function updateRecord(
|
|
|
232
233
|
) {
|
|
233
234
|
const res = await client.bitable.appTableRecord.update({
|
|
234
235
|
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
|
235
|
-
|
|
236
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
237
|
+
data: { fields: fields as any },
|
|
236
238
|
});
|
|
237
239
|
if (res.code !== 0) {
|
|
238
240
|
throw new Error(res.msg);
|
|
@@ -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
|
-
kind: isGroup ? "group" : "
|
|
656
|
-
id:
|
|
688
|
+
kind: isGroup ? "group" : "direct",
|
|
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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
|
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,9 +18,9 @@ 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
|
-
const meta = {
|
|
23
|
+
const meta: ChannelMeta = {
|
|
23
24
|
id: "feishu",
|
|
24
25
|
label: "Feishu",
|
|
25
26
|
selectionLabel: "Feishu/Lark (飞书)",
|
|
@@ -28,7 +29,7 @@ const meta = {
|
|
|
28
29
|
blurb: "飞书/Lark enterprise messaging.",
|
|
29
30
|
aliases: ["lark"],
|
|
30
31
|
order: 70,
|
|
31
|
-
}
|
|
32
|
+
};
|
|
32
33
|
|
|
33
34
|
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
34
35
|
id: "feishu",
|
|
@@ -38,23 +39,22 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
38
39
|
pairing: {
|
|
39
40
|
idLabel: "feishuUserId",
|
|
40
41
|
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
|
|
41
|
-
notifyApproval: async ({ cfg, id
|
|
42
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
42
43
|
await sendMessageFeishu({
|
|
43
44
|
cfg,
|
|
44
45
|
to: id,
|
|
45
46
|
text: PAIRING_APPROVED_MESSAGE,
|
|
46
|
-
accountId,
|
|
47
47
|
});
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
capabilities: {
|
|
51
|
-
chatTypes: ["direct", "
|
|
51
|
+
chatTypes: ["direct", "channel"],
|
|
52
|
+
polls: false,
|
|
53
|
+
threads: true,
|
|
52
54
|
media: true,
|
|
53
55
|
reactions: true,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
nativeCommands: true,
|
|
57
|
-
blockStreaming: true,
|
|
56
|
+
edit: true,
|
|
57
|
+
reply: true,
|
|
58
58
|
},
|
|
59
59
|
agentPrompt: {
|
|
60
60
|
messageToolHints: () => [
|
|
@@ -93,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
93
93
|
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
94
94
|
},
|
|
95
95
|
requireMention: { type: "boolean" },
|
|
96
|
+
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
|
96
97
|
historyLimit: { type: "integer", minimum: 0 },
|
|
97
98
|
dmHistoryLimit: { type: "integer", minimum: 0 },
|
|
98
99
|
textChunkLimit: { type: "integer", minimum: 1 },
|
|
@@ -123,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
123
124
|
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
|
124
125
|
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
|
125
126
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
126
|
-
const
|
|
127
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
127
128
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
128
129
|
|
|
129
130
|
if (isDefault) {
|
|
@@ -202,7 +203,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
202
203
|
}),
|
|
203
204
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
204
205
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
205
|
-
return account.config?.allowFrom ?? [];
|
|
206
|
+
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
|
|
206
207
|
},
|
|
207
208
|
formatAllowFrom: ({ allowFrom }) =>
|
|
208
209
|
allowFrom
|
|
@@ -218,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
218
219
|
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
|
219
220
|
)?.defaults?.groupPolicy;
|
|
220
221
|
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
221
|
-
if (groupPolicy !== "open")
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
222
|
+
if (groupPolicy !== "open") return [];
|
|
224
223
|
return [
|
|
225
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.`,
|
|
226
225
|
];
|
|
@@ -265,7 +264,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
265
264
|
},
|
|
266
265
|
onboarding: feishuOnboardingAdapter,
|
|
267
266
|
messaging: {
|
|
268
|
-
normalizeTarget: normalizeFeishuTarget,
|
|
267
|
+
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
|
|
269
268
|
targetResolver: {
|
|
270
269
|
looksLikeId: looksLikeFeishuId,
|
|
271
270
|
hint: "<chatId|user:openId|chat:chatId>",
|
|
@@ -274,13 +273,33 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
274
273
|
directory: {
|
|
275
274
|
self: async () => null,
|
|
276
275
|
listPeers: async ({ cfg, query, limit, accountId }) =>
|
|
277
|
-
listFeishuDirectoryPeers({
|
|
276
|
+
listFeishuDirectoryPeers({
|
|
277
|
+
cfg,
|
|
278
|
+
query: query ?? undefined,
|
|
279
|
+
limit: limit ?? undefined,
|
|
280
|
+
accountId: accountId ?? undefined,
|
|
281
|
+
}),
|
|
278
282
|
listGroups: async ({ cfg, query, limit, accountId }) =>
|
|
279
|
-
listFeishuDirectoryGroups({
|
|
283
|
+
listFeishuDirectoryGroups({
|
|
284
|
+
cfg,
|
|
285
|
+
query: query ?? undefined,
|
|
286
|
+
limit: limit ?? undefined,
|
|
287
|
+
accountId: accountId ?? undefined,
|
|
288
|
+
}),
|
|
280
289
|
listPeersLive: async ({ cfg, query, limit, accountId }) =>
|
|
281
|
-
listFeishuDirectoryPeersLive({
|
|
290
|
+
listFeishuDirectoryPeersLive({
|
|
291
|
+
cfg,
|
|
292
|
+
query: query ?? undefined,
|
|
293
|
+
limit: limit ?? undefined,
|
|
294
|
+
accountId: accountId ?? undefined,
|
|
295
|
+
}),
|
|
282
296
|
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
|
|
283
|
-
listFeishuDirectoryGroupsLive({
|
|
297
|
+
listFeishuDirectoryGroupsLive({
|
|
298
|
+
cfg,
|
|
299
|
+
query: query ?? undefined,
|
|
300
|
+
limit: limit ?? undefined,
|
|
301
|
+
accountId: accountId ?? undefined,
|
|
302
|
+
}),
|
|
284
303
|
},
|
|
285
304
|
outbound: feishuOutbound,
|
|
286
305
|
status: {
|
|
@@ -302,10 +321,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
302
321
|
probe: snapshot.probe,
|
|
303
322
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
304
323
|
}),
|
|
305
|
-
probeAccount: async ({
|
|
306
|
-
const account = resolveFeishuAccount({ cfg, accountId });
|
|
307
|
-
return await probeFeishu(account);
|
|
308
|
-
},
|
|
324
|
+
probeAccount: async ({ account }) => await probeFeishu(account),
|
|
309
325
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
310
326
|
accountId: account.accountId,
|
|
311
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 {
|