Skip to content

Commit 5c2cb6c

Browse files
doodlewindclaude
andauthored
feat(feishu): sync community contributions from clawdbot-feishu (#12662)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49c60e9 commit 5c2cb6c

7 files changed

Lines changed: 437 additions & 85 deletions

File tree

extensions/feishu/src/bot.ts

Lines changed: 104 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ import {
66
DEFAULT_GROUP_HISTORY_LIMIT,
77
type HistoryEntry,
88
} from "openclaw/plugin-sdk";
9-
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
9+
import type {
10+
FeishuConfig,
11+
FeishuMessageContext,
12+
FeishuMediaInfo,
13+
ResolvedFeishuAccount,
14+
} from "./types.js";
15+
import type { DynamicAgentCreationConfig } from "./types.js";
1016
import { resolveFeishuAccount } from "./accounts.js";
1117
import { createFeishuClient } from "./client.js";
12-
import { downloadMessageResourceFeishu } from "./media.js";
18+
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
19+
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
1320
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
1421
import {
1522
resolveFeishuGroupConfig,
@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
2128
import { getFeishuRuntime } from "./runtime.js";
2229
import { getMessageFeishu } from "./send.js";
2330

31+
// --- Message deduplication ---
32+
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
33+
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
34+
const DEDUP_MAX_SIZE = 1_000;
35+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
36+
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
37+
let lastCleanupTime = Date.now();
38+
39+
function tryRecordMessage(messageId: string): boolean {
40+
const now = Date.now();
41+
42+
// Throttled cleanup: evict expired entries at most once per interval
43+
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
44+
for (const [id, ts] of processedMessageIds) {
45+
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
46+
}
47+
lastCleanupTime = now;
48+
}
49+
50+
if (processedMessageIds.has(messageId)) return false;
51+
52+
// Evict oldest entries if cache is full
53+
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
54+
const first = processedMessageIds.keys().next().value!;
55+
processedMessageIds.delete(first);
56+
}
57+
58+
processedMessageIds.set(messageId, now);
59+
return true;
60+
}
61+
2462
// --- Permission error extraction ---
2563
// Extract permission grant URL from Feishu API error response.
2664
type PermissionError = {
@@ -30,16 +68,12 @@ type PermissionError = {
3068
};
3169

3270
function extractPermissionError(err: unknown): PermissionError | null {
33-
if (!err || typeof err !== "object") {
34-
return null;
35-
}
71+
if (!err || typeof err !== "object") return null;
3672

3773
// Axios error structure: err.response.data contains the Feishu error
3874
const axiosErr = err as { response?: { data?: unknown } };
3975
const data = axiosErr.response?.data;
40-
if (!data || typeof data !== "object") {
41-
return null;
42-
}
76+
if (!data || typeof data !== "object") return null;
4377

4478
const feishuErr = data as {
4579
code?: number;
@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
4882
};
4983

5084
// Feishu permission error code: 99991672
51-
if (feishuErr.code !== 99991672) {
52-
return null;
53-
}
85+
if (feishuErr.code !== 99991672) return null;
5486

5587
// Extract the grant URL from the error message (contains the direct link)
5688
const msg = feishuErr.msg ?? "";
@@ -82,28 +114,20 @@ type SenderNameResult = {
82114
async function resolveFeishuSenderName(params: {
83115
account: ResolvedFeishuAccount;
84116
senderOpenId: string;
85-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
86117
log: (...args: any[]) => void;
87118
}): Promise<SenderNameResult> {
88119
const { account, senderOpenId, log } = params;
89-
if (!account.configured) {
90-
return {};
91-
}
92-
if (!senderOpenId) {
93-
return {};
94-
}
120+
if (!account.configured) return {};
121+
if (!senderOpenId) return {};
95122

96123
const cached = senderNameCache.get(senderOpenId);
97124
const now = Date.now();
98-
if (cached && cached.expireAt > now) {
99-
return { name: cached.name };
100-
}
125+
if (cached && cached.expireAt > now) return { name: cached.name };
101126

102127
try {
103128
const client = createFeishuClient(account);
104129

105130
// contact/v3/users/:user_id?user_id_type=open_id
106-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
107131
const res: any = await client.contact.user.get({
108132
path: { user_id: senderOpenId },
109133
params: { user_id_type: "open_id" },
@@ -196,22 +220,16 @@ function parseMessageContent(content: string, messageType: string): string {
196220

197221
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
198222
const mentions = event.message.mentions ?? [];
199-
if (mentions.length === 0) {
200-
return false;
201-
}
202-
if (!botOpenId) {
203-
return mentions.length > 0;
204-
}
223+
if (mentions.length === 0) return false;
224+
if (!botOpenId) return mentions.length > 0;
205225
return mentions.some((m) => m.id.open_id === botOpenId);
206226
}
207227

208228
function stripBotMention(
209229
text: string,
210230
mentions?: FeishuMessageEvent["message"]["mentions"],
211231
): string {
212-
if (!mentions || mentions.length === 0) {
213-
return text;
214-
}
232+
if (!mentions || mentions.length === 0) return text;
215233
let result = text;
216234
for (const mention of mentions) {
217235
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
@@ -523,6 +541,13 @@ export async function handleFeishuMessage(params: {
523541
const log = runtime?.log ?? console.log;
524542
const error = runtime?.error ?? console.error;
525543

544+
// Dedup check: skip if this message was already processed
545+
const messageId = event.message.message_id;
546+
if (!tryRecordMessage(messageId)) {
547+
log(`feishu: skipping duplicate message ${messageId}`);
548+
return;
549+
}
550+
526551
let ctx = parseFeishuMessageEvent(event, botOpenId);
527552
const isGroup = ctx.chatType === "group";
528553

@@ -532,9 +557,7 @@ export async function handleFeishuMessage(params: {
532557
senderOpenId: ctx.senderOpenId,
533558
log,
534559
});
535-
if (senderResult.name) {
536-
ctx = { ...ctx, senderName: senderResult.name };
537-
}
560+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
538561

539562
// Track permission error to inform agent later (with cooldown to avoid repetition)
540563
let permissionErrorForAgent: PermissionError | undefined;
@@ -647,16 +670,61 @@ export async function handleFeishuMessage(params: {
647670
const feishuFrom = `feishu:${ctx.senderOpenId}`;
648671
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
649672

650-
const route = core.channel.routing.resolveAgentRoute({
673+
// Resolve peer ID for session routing
674+
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
675+
// get a separate session from the main group chat.
676+
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
677+
if (isGroup && ctx.rootId) {
678+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
679+
const topicSessionMode =
680+
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
681+
if (topicSessionMode === "enabled") {
682+
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
683+
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
684+
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
685+
}
686+
}
687+
688+
let route = core.channel.routing.resolveAgentRoute({
651689
cfg,
652690
channel: "feishu",
653691
accountId: account.accountId,
654692
peer: {
655693
kind: isGroup ? "group" : "direct",
656-
id: isGroup ? ctx.chatId : ctx.senderOpenId,
694+
id: peerId,
657695
},
658696
});
659697

698+
// Dynamic agent creation for DM users
699+
// When enabled, creates a unique agent instance with its own workspace for each DM user.
700+
let effectiveCfg = cfg;
701+
if (!isGroup && route.matchedBy === "default") {
702+
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
703+
if (dynamicCfg?.enabled) {
704+
const runtime = getFeishuRuntime();
705+
const result = await maybeCreateDynamicAgent({
706+
cfg,
707+
runtime,
708+
senderOpenId: ctx.senderOpenId,
709+
dynamicCfg,
710+
log: (msg) => log(msg),
711+
});
712+
if (result.created) {
713+
effectiveCfg = result.updatedCfg;
714+
// Re-resolve route with updated config
715+
route = core.channel.routing.resolveAgentRoute({
716+
cfg: result.updatedCfg,
717+
channel: "feishu",
718+
accountId: account.accountId,
719+
peer: { kind: "dm", id: ctx.senderOpenId },
720+
});
721+
log(
722+
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
723+
);
724+
}
725+
}
726+
}
727+
660728
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
661729
const inboundLabel = isGroup
662730
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`

extensions/feishu/src/channel.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
33
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
44
import {
55
resolveFeishuAccount,
6+
resolveFeishuCredentials,
67
listFeishuAccountIds,
78
resolveDefaultFeishuAccountId,
89
} from "./accounts.js";
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
1718
import { resolveFeishuGroupToolPolicy } from "./policy.js";
1819
import { probeFeishu } from "./probe.js";
1920
import { sendMessageFeishu } from "./send.js";
20-
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
21+
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
2122

2223
const meta: ChannelMeta = {
2324
id: "feishu",
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
4748
},
4849
},
4950
capabilities: {
50-
chatTypes: ["direct", "group"],
51+
chatTypes: ["direct", "channel"],
52+
polls: false,
53+
threads: true,
5154
media: true,
5255
reactions: true,
53-
threads: false,
54-
polls: false,
55-
nativeCommands: true,
56-
blockStreaming: true,
56+
edit: true,
57+
reply: true,
5758
},
5859
agentPrompt: {
5960
messageToolHints: () => [
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
9293
items: { oneOf: [{ type: "string" }, { type: "number" }] },
9394
},
9495
requireMention: { type: "boolean" },
96+
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
9597
historyLimit: { type: "integer", minimum: 0 },
9698
dmHistoryLimit: { type: "integer", minimum: 0 },
9799
textChunkLimit: { type: "integer", minimum: 1 },
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
122124
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
123125
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
124126
setAccountEnabled: ({ cfg, accountId, enabled }) => {
125-
const _account = resolveFeishuAccount({ cfg, accountId });
127+
const account = resolveFeishuAccount({ cfg, accountId });
126128
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
127129

128130
if (isDefault) {
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
217219
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
218220
)?.defaults?.groupPolicy;
219221
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
220-
if (groupPolicy !== "open") {
221-
return [];
222-
}
222+
if (groupPolicy !== "open") return [];
223223
return [
224224
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
225225
];

extensions/feishu/src/config-schema.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
5353
.strict()
5454
.optional();
5555

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+
5670
/**
5771
* Feishu tools configuration.
5872
* Controls which tool categories are enabled.
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
7286
.strict()
7387
.optional();
7488

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+
7599
export const FeishuGroupSchema = z
76100
.object({
77101
requireMention: z.boolean().optional(),
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
80104
enabled: z.boolean().optional(),
81105
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
82106
systemPrompt: z.string().optional(),
107+
topicSessionMode: TopicSessionModeSchema,
83108
})
84109
.strict();
85110

@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
142167
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
143168
requireMention: z.boolean().optional().default(true),
144169
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
170+
topicSessionMode: TopicSessionModeSchema,
145171
historyLimit: z.number().int().min(0).optional(),
146172
dmHistoryLimit: z.number().int().min(0).optional(),
147173
dms: z.record(z.string(), DmConfigSchema).optional(),
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
152178
heartbeat: ChannelHeartbeatVisibilitySchema,
153179
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
154180
tools: FeishuToolsConfigSchema,
181+
// Dynamic agent creation for DM users
182+
dynamicAgentCreation: DynamicAgentCreationSchema,
155183
// Multi-account configuration
156184
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
157185
})

0 commit comments

Comments
 (0)