mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 05:41:37 +00:00
feat(feishu): sync community contributions from clawdbot-feishu (#12662)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,17 @@ import {
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
import type {
|
||||||
|
FeishuConfig,
|
||||||
|
FeishuMessageContext,
|
||||||
|
FeishuMediaInfo,
|
||||||
|
ResolvedFeishuAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||||
import { resolveFeishuAccount } from "./accounts.js";
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import { downloadMessageResourceFeishu } from "./media.js";
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||||
|
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
||||||
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
|
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
|
||||||
import {
|
import {
|
||||||
resolveFeishuGroupConfig,
|
resolveFeishuGroupConfig,
|
||||||
@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import { getMessageFeishu } from "./send.js";
|
import { getMessageFeishu } from "./send.js";
|
||||||
|
|
||||||
|
// --- Message deduplication ---
|
||||||
|
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
||||||
|
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
const DEDUP_MAX_SIZE = 1_000;
|
||||||
|
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
||||||
|
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
||||||
|
let lastCleanupTime = Date.now();
|
||||||
|
|
||||||
|
function tryRecordMessage(messageId: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Throttled cleanup: evict expired entries at most once per interval
|
||||||
|
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
||||||
|
for (const [id, ts] of processedMessageIds) {
|
||||||
|
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
|
||||||
|
}
|
||||||
|
lastCleanupTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedMessageIds.has(messageId)) return false;
|
||||||
|
|
||||||
|
// Evict oldest entries if cache is full
|
||||||
|
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
||||||
|
const first = processedMessageIds.keys().next().value!;
|
||||||
|
processedMessageIds.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedMessageIds.set(messageId, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Permission error extraction ---
|
// --- Permission error extraction ---
|
||||||
// Extract permission grant URL from Feishu API error response.
|
// Extract permission grant URL from Feishu API error response.
|
||||||
type PermissionError = {
|
type PermissionError = {
|
||||||
@@ -30,16 +68,12 @@ type PermissionError = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function extractPermissionError(err: unknown): PermissionError | null {
|
function extractPermissionError(err: unknown): PermissionError | null {
|
||||||
if (!err || typeof err !== "object") {
|
if (!err || typeof err !== "object") return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Axios error structure: err.response.data contains the Feishu error
|
// Axios error structure: err.response.data contains the Feishu error
|
||||||
const axiosErr = err as { response?: { data?: unknown } };
|
const axiosErr = err as { response?: { data?: unknown } };
|
||||||
const data = axiosErr.response?.data;
|
const data = axiosErr.response?.data;
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== "object") return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const feishuErr = data as {
|
const feishuErr = data as {
|
||||||
code?: number;
|
code?: number;
|
||||||
@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Feishu permission error code: 99991672
|
// Feishu permission error code: 99991672
|
||||||
if (feishuErr.code !== 99991672) {
|
if (feishuErr.code !== 99991672) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the grant URL from the error message (contains the direct link)
|
// Extract the grant URL from the error message (contains the direct link)
|
||||||
const msg = feishuErr.msg ?? "";
|
const msg = feishuErr.msg ?? "";
|
||||||
@@ -82,28 +114,20 @@ type SenderNameResult = {
|
|||||||
async function resolveFeishuSenderName(params: {
|
async function resolveFeishuSenderName(params: {
|
||||||
account: ResolvedFeishuAccount;
|
account: ResolvedFeishuAccount;
|
||||||
senderOpenId: string;
|
senderOpenId: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
|
|
||||||
log: (...args: any[]) => void;
|
log: (...args: any[]) => void;
|
||||||
}): Promise<SenderNameResult> {
|
}): Promise<SenderNameResult> {
|
||||||
const { account, senderOpenId, log } = params;
|
const { account, senderOpenId, log } = params;
|
||||||
if (!account.configured) {
|
if (!account.configured) return {};
|
||||||
return {};
|
if (!senderOpenId) return {};
|
||||||
}
|
|
||||||
if (!senderOpenId) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = senderNameCache.get(senderOpenId);
|
const cached = senderNameCache.get(senderOpenId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cached && cached.expireAt > now) {
|
if (cached && cached.expireAt > now) return { name: cached.name };
|
||||||
return { name: cached.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = createFeishuClient(account);
|
const client = createFeishuClient(account);
|
||||||
|
|
||||||
// contact/v3/users/:user_id?user_id_type=open_id
|
// contact/v3/users/:user_id?user_id_type=open_id
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
|
||||||
const res: any = await client.contact.user.get({
|
const res: any = await client.contact.user.get({
|
||||||
path: { user_id: senderOpenId },
|
path: { user_id: senderOpenId },
|
||||||
params: { user_id_type: "open_id" },
|
params: { user_id_type: "open_id" },
|
||||||
@@ -196,12 +220,8 @@ function parseMessageContent(content: string, messageType: string): string {
|
|||||||
|
|
||||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||||
const mentions = event.message.mentions ?? [];
|
const mentions = event.message.mentions ?? [];
|
||||||
if (mentions.length === 0) {
|
if (mentions.length === 0) return false;
|
||||||
return false;
|
if (!botOpenId) return mentions.length > 0;
|
||||||
}
|
|
||||||
if (!botOpenId) {
|
|
||||||
return mentions.length > 0;
|
|
||||||
}
|
|
||||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,9 +229,7 @@ function stripBotMention(
|
|||||||
text: string,
|
text: string,
|
||||||
mentions?: FeishuMessageEvent["message"]["mentions"],
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
||||||
): string {
|
): string {
|
||||||
if (!mentions || mentions.length === 0) {
|
if (!mentions || mentions.length === 0) return text;
|
||||||
return text;
|
|
||||||
}
|
|
||||||
let result = text;
|
let result = text;
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
||||||
@@ -523,6 +541,13 @@ export async function handleFeishuMessage(params: {
|
|||||||
const log = runtime?.log ?? console.log;
|
const log = runtime?.log ?? console.log;
|
||||||
const error = runtime?.error ?? console.error;
|
const error = runtime?.error ?? console.error;
|
||||||
|
|
||||||
|
// Dedup check: skip if this message was already processed
|
||||||
|
const messageId = event.message.message_id;
|
||||||
|
if (!tryRecordMessage(messageId)) {
|
||||||
|
log(`feishu: skipping duplicate message ${messageId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
||||||
const isGroup = ctx.chatType === "group";
|
const isGroup = ctx.chatType === "group";
|
||||||
|
|
||||||
@@ -532,9 +557,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
senderOpenId: ctx.senderOpenId,
|
senderOpenId: ctx.senderOpenId,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
if (senderResult.name) {
|
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
||||||
ctx = { ...ctx, senderName: senderResult.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
||||||
let permissionErrorForAgent: PermissionError | undefined;
|
let permissionErrorForAgent: PermissionError | undefined;
|
||||||
@@ -647,16 +670,61 @@ export async function handleFeishuMessage(params: {
|
|||||||
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
||||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
// Resolve peer ID for session routing
|
||||||
|
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
||||||
|
// get a separate session from the main group chat.
|
||||||
|
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
||||||
|
if (isGroup && ctx.rootId) {
|
||||||
|
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
||||||
|
const topicSessionMode =
|
||||||
|
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||||
|
if (topicSessionMode === "enabled") {
|
||||||
|
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
||||||
|
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
||||||
|
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "direct",
|
kind: isGroup ? "group" : "direct",
|
||||||
id: isGroup ? ctx.chatId : ctx.senderOpenId,
|
id: peerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dynamic agent creation for DM users
|
||||||
|
// When enabled, creates a unique agent instance with its own workspace for each DM user.
|
||||||
|
let effectiveCfg = cfg;
|
||||||
|
if (!isGroup && route.matchedBy === "default") {
|
||||||
|
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
|
||||||
|
if (dynamicCfg?.enabled) {
|
||||||
|
const runtime = getFeishuRuntime();
|
||||||
|
const result = await maybeCreateDynamicAgent({
|
||||||
|
cfg,
|
||||||
|
runtime,
|
||||||
|
senderOpenId: ctx.senderOpenId,
|
||||||
|
dynamicCfg,
|
||||||
|
log: (msg) => log(msg),
|
||||||
|
});
|
||||||
|
if (result.created) {
|
||||||
|
effectiveCfg = result.updatedCfg;
|
||||||
|
// Re-resolve route with updated config
|
||||||
|
route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg: result.updatedCfg,
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: { kind: "dm", id: ctx.senderOpenId },
|
||||||
|
});
|
||||||
|
log(
|
||||||
|
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||||
const inboundLabel = isGroup
|
const inboundLabel = isGroup
|
||||||
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
|
|||||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||||
import {
|
import {
|
||||||
resolveFeishuAccount,
|
resolveFeishuAccount,
|
||||||
|
resolveFeishuCredentials,
|
||||||
listFeishuAccountIds,
|
listFeishuAccountIds,
|
||||||
resolveDefaultFeishuAccountId,
|
resolveDefaultFeishuAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
|
|||||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||||
import { probeFeishu } from "./probe.js";
|
import { probeFeishu } from "./probe.js";
|
||||||
import { sendMessageFeishu } from "./send.js";
|
import { sendMessageFeishu } from "./send.js";
|
||||||
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
|
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||||
|
|
||||||
const meta: ChannelMeta = {
|
const meta: ChannelMeta = {
|
||||||
id: "feishu",
|
id: "feishu",
|
||||||
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "channel"],
|
||||||
|
polls: false,
|
||||||
|
threads: true,
|
||||||
media: true,
|
media: true,
|
||||||
reactions: true,
|
reactions: true,
|
||||||
threads: false,
|
edit: true,
|
||||||
polls: false,
|
reply: true,
|
||||||
nativeCommands: true,
|
|
||||||
blockStreaming: true,
|
|
||||||
},
|
},
|
||||||
agentPrompt: {
|
agentPrompt: {
|
||||||
messageToolHints: () => [
|
messageToolHints: () => [
|
||||||
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||||
},
|
},
|
||||||
requireMention: { type: "boolean" },
|
requireMention: { type: "boolean" },
|
||||||
|
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
||||||
historyLimit: { type: "integer", minimum: 0 },
|
historyLimit: { type: "integer", minimum: 0 },
|
||||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||||
textChunkLimit: { type: "integer", minimum: 1 },
|
textChunkLimit: { type: "integer", minimum: 1 },
|
||||||
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
||||||
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
const _account = resolveFeishuAccount({ cfg, accountId });
|
const account = resolveFeishuAccount({ cfg, accountId });
|
||||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
||||||
)?.defaults?.groupPolicy;
|
)?.defaults?.groupPolicy;
|
||||||
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") {
|
if (groupPolicy !== "open") return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
|
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic agent creation configuration.
|
||||||
|
* When enabled, a new agent is created for each unique DM user.
|
||||||
|
*/
|
||||||
|
const DynamicAgentCreationSchema = z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
workspaceTemplate: z.string().optional(),
|
||||||
|
agentDirTemplate: z.string().optional(),
|
||||||
|
maxAgents: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feishu tools configuration.
|
* Feishu tools configuration.
|
||||||
* Controls which tool categories are enabled.
|
* Controls which tool categories are enabled.
|
||||||
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topic session isolation mode for group chats.
|
||||||
|
* - "disabled" (default): All messages in a group share one session
|
||||||
|
* - "enabled": Messages in different topics get separate sessions
|
||||||
|
*
|
||||||
|
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
|
||||||
|
* for messages within a topic thread, allowing isolated conversations.
|
||||||
|
*/
|
||||||
|
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
||||||
|
|
||||||
export const FeishuGroupSchema = z
|
export const FeishuGroupSchema = z
|
||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
|
topicSessionMode: TopicSessionModeSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
requireMention: z.boolean().optional().default(true),
|
requireMention: z.boolean().optional().default(true),
|
||||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||||
|
topicSessionMode: TopicSessionModeSchema,
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema).optional(),
|
dms: z.record(z.string(), DmConfigSchema).optional(),
|
||||||
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
|
|||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
||||||
tools: FeishuToolsConfigSchema,
|
tools: FeishuToolsConfigSchema,
|
||||||
|
// Dynamic agent creation for DM users
|
||||||
|
dynamicAgentCreation: DynamicAgentCreationSchema,
|
||||||
// Multi-account configuration
|
// Multi-account configuration
|
||||||
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
131
extensions/feishu/src/dynamic-agent.ts
Normal file
131
extensions/feishu/src/dynamic-agent.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||||
|
|
||||||
|
export type MaybeCreateDynamicAgentResult = {
|
||||||
|
created: boolean;
|
||||||
|
updatedCfg: OpenClawConfig;
|
||||||
|
agentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a dynamic agent should be created for a DM user and create it if needed.
|
||||||
|
* This creates a unique agent instance with its own workspace for each DM user.
|
||||||
|
*/
|
||||||
|
export async function maybeCreateDynamicAgent(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
runtime: PluginRuntime;
|
||||||
|
senderOpenId: string;
|
||||||
|
dynamicCfg: DynamicAgentCreationConfig;
|
||||||
|
log: (msg: string) => void;
|
||||||
|
}): Promise<MaybeCreateDynamicAgentResult> {
|
||||||
|
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
|
||||||
|
|
||||||
|
// Check if there's already a binding for this user
|
||||||
|
const existingBindings = cfg.bindings ?? [];
|
||||||
|
const hasBinding = existingBindings.some(
|
||||||
|
(b) =>
|
||||||
|
b.match?.channel === "feishu" &&
|
||||||
|
b.match?.peer?.kind === "dm" &&
|
||||||
|
b.match?.peer?.id === senderOpenId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasBinding) {
|
||||||
|
return { created: false, updatedCfg: cfg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check maxAgents limit if configured
|
||||||
|
if (dynamicCfg.maxAgents !== undefined) {
|
||||||
|
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
|
||||||
|
a.id.startsWith("feishu-"),
|
||||||
|
).length;
|
||||||
|
if (feishuAgentCount >= dynamicCfg.maxAgents) {
|
||||||
|
log(
|
||||||
|
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
|
||||||
|
);
|
||||||
|
return { created: false, updatedCfg: cfg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
|
||||||
|
const agentId = `feishu-${senderOpenId}`;
|
||||||
|
|
||||||
|
// Check if agent already exists (but binding was missing)
|
||||||
|
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
|
||||||
|
if (existingAgent) {
|
||||||
|
// Agent exists but binding doesn't - just add the binding
|
||||||
|
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
|
||||||
|
|
||||||
|
const updatedCfg: OpenClawConfig = {
|
||||||
|
...cfg,
|
||||||
|
bindings: [
|
||||||
|
...existingBindings,
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
match: {
|
||||||
|
channel: "feishu",
|
||||||
|
peer: { kind: "dm", id: senderOpenId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await runtime.config.writeConfigFile(updatedCfg);
|
||||||
|
return { created: true, updatedCfg, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve path templates with substitutions
|
||||||
|
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
|
||||||
|
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
|
||||||
|
|
||||||
|
const workspace = resolveUserPath(
|
||||||
|
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
|
||||||
|
);
|
||||||
|
const agentDir = resolveUserPath(
|
||||||
|
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
|
||||||
|
);
|
||||||
|
|
||||||
|
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
|
||||||
|
log(` workspace: ${workspace}`);
|
||||||
|
log(` agentDir: ${agentDir}`);
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
await fs.promises.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.promises.mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
// Update configuration with new agent and binding
|
||||||
|
const updatedCfg: OpenClawConfig = {
|
||||||
|
...cfg,
|
||||||
|
agents: {
|
||||||
|
...cfg.agents,
|
||||||
|
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
...existingBindings,
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
match: {
|
||||||
|
channel: "feishu",
|
||||||
|
peer: { kind: "dm", id: senderOpenId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write updated config using PluginRuntime API
|
||||||
|
await runtime.config.writeConfigFile(updatedCfg);
|
||||||
|
|
||||||
|
return { created: true, updatedCfg, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path that may start with ~ to the user's home directory.
|
||||||
|
*/
|
||||||
|
function resolveUserPath(p: string): string {
|
||||||
|
if (p.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), p.slice(2));
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
||||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import * as http from "http";
|
||||||
import type { ResolvedFeishuAccount } from "./types.js";
|
import type { ResolvedFeishuAccount } from "./types.js";
|
||||||
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
|
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
|
||||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||||
@@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-account WebSocket clients and bot info
|
// Per-account WebSocket clients, HTTP servers, and bot info
|
||||||
const wsClients = new Map<string, Lark.WSClient>();
|
const wsClients = new Map<string, Lark.WSClient>();
|
||||||
|
const httpServers = new Map<string, http.Server>();
|
||||||
const botOpenIds = new Map<string, string>();
|
const botOpenIds = new Map<string, string>();
|
||||||
|
|
||||||
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
||||||
@@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitor a single Feishu account.
|
* Register common event handlers on an EventDispatcher.
|
||||||
|
* When fireAndForget is true (webhook mode), message handling is not awaited
|
||||||
|
* to avoid blocking the HTTP response (Lark requires <3s response).
|
||||||
*/
|
*/
|
||||||
async function monitorSingleAccount(params: {
|
function registerEventHandlers(
|
||||||
cfg: ClawdbotConfig;
|
eventDispatcher: Lark.EventDispatcher,
|
||||||
account: ResolvedFeishuAccount;
|
context: {
|
||||||
runtime?: RuntimeEnv;
|
cfg: ClawdbotConfig;
|
||||||
abortSignal?: AbortSignal;
|
accountId: string;
|
||||||
}): Promise<void> {
|
runtime?: RuntimeEnv;
|
||||||
const { cfg, account, runtime, abortSignal } = params;
|
chatHistories: Map<string, HistoryEntry[]>;
|
||||||
const { accountId } = account;
|
fireAndForget?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
||||||
const log = runtime?.log ?? console.log;
|
const log = runtime?.log ?? console.log;
|
||||||
const error = runtime?.error ?? console.error;
|
const error = runtime?.error ?? console.error;
|
||||||
|
|
||||||
// Fetch bot open_id
|
|
||||||
const botOpenId = await fetchBotOpenId(account);
|
|
||||||
botOpenIds.set(accountId, botOpenId ?? "");
|
|
||||||
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
|
||||||
|
|
||||||
const connectionMode = account.config.connectionMode ?? "websocket";
|
|
||||||
|
|
||||||
if (connectionMode !== "websocket") {
|
|
||||||
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
|
||||||
|
|
||||||
const wsClient = createFeishuWSClient(account);
|
|
||||||
wsClients.set(accountId, wsClient);
|
|
||||||
|
|
||||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
||||||
const eventDispatcher = createEventDispatcher(account);
|
|
||||||
|
|
||||||
eventDispatcher.register({
|
eventDispatcher.register({
|
||||||
"im.message.receive_v1": async (data) => {
|
"im.message.receive_v1": async (data) => {
|
||||||
try {
|
try {
|
||||||
const event = data as unknown as FeishuMessageEvent;
|
const event = data as unknown as FeishuMessageEvent;
|
||||||
await handleFeishuMessage({
|
const promise = handleFeishuMessage({
|
||||||
cfg,
|
cfg,
|
||||||
event,
|
event,
|
||||||
botOpenId: botOpenIds.get(accountId),
|
botOpenId: botOpenIds.get(accountId),
|
||||||
@@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
|
|||||||
chatHistories,
|
chatHistories,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
|
if (fireAndForget) {
|
||||||
|
promise.catch((err) => {
|
||||||
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -96,6 +90,66 @@ async function monitorSingleAccount(params: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitorAccountParams = {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor a single Feishu account.
|
||||||
|
*/
|
||||||
|
async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
|
||||||
|
const { cfg, account, runtime, abortSignal } = params;
|
||||||
|
const { accountId } = account;
|
||||||
|
const log = runtime?.log ?? console.log;
|
||||||
|
|
||||||
|
// Fetch bot open_id
|
||||||
|
const botOpenId = await fetchBotOpenId(account);
|
||||||
|
botOpenIds.set(accountId, botOpenId ?? "");
|
||||||
|
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
||||||
|
|
||||||
|
const connectionMode = account.config.connectionMode ?? "websocket";
|
||||||
|
const eventDispatcher = createEventDispatcher(account);
|
||||||
|
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||||
|
|
||||||
|
registerEventHandlers(eventDispatcher, {
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
runtime,
|
||||||
|
chatHistories,
|
||||||
|
fireAndForget: connectionMode === "webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (connectionMode === "webhook") {
|
||||||
|
return monitorWebhook({ params, accountId, eventDispatcher });
|
||||||
|
}
|
||||||
|
|
||||||
|
return monitorWebSocket({ params, accountId, eventDispatcher });
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionParams = {
|
||||||
|
params: MonitorAccountParams;
|
||||||
|
accountId: string;
|
||||||
|
eventDispatcher: Lark.EventDispatcher;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function monitorWebSocket({
|
||||||
|
params,
|
||||||
|
accountId,
|
||||||
|
eventDispatcher,
|
||||||
|
}: ConnectionParams): Promise<void> {
|
||||||
|
const { account, runtime, abortSignal } = params;
|
||||||
|
const log = runtime?.log ?? console.log;
|
||||||
|
const error = runtime?.error ?? console.error;
|
||||||
|
|
||||||
|
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
||||||
|
|
||||||
|
const wsClient = createFeishuWSClient(account);
|
||||||
|
wsClients.set(accountId, wsClient);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
|
|||||||
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
void wsClient.start({ eventDispatcher });
|
wsClient.start({ eventDispatcher });
|
||||||
log(`feishu[${accountId}]: WebSocket client started`);
|
log(`feishu[${accountId}]: WebSocket client started`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -128,6 +182,57 @@ async function monitorSingleAccount(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function monitorWebhook({
|
||||||
|
params,
|
||||||
|
accountId,
|
||||||
|
eventDispatcher,
|
||||||
|
}: ConnectionParams): Promise<void> {
|
||||||
|
const { account, runtime, abortSignal } = params;
|
||||||
|
const log = runtime?.log ?? console.log;
|
||||||
|
const error = runtime?.error ?? console.error;
|
||||||
|
|
||||||
|
const port = account.config.webhookPort ?? 3000;
|
||||||
|
const path = account.config.webhookPath ?? "/feishu/events";
|
||||||
|
|
||||||
|
log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
|
||||||
|
|
||||||
|
const server = http.createServer();
|
||||||
|
server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
|
||||||
|
httpServers.set(accountId, server);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
server.close();
|
||||||
|
httpServers.delete(accountId);
|
||||||
|
botOpenIds.delete(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbort = () => {
|
||||||
|
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
error(`feishu[${accountId}]: Webhook server error: ${err}`);
|
||||||
|
abortSignal?.removeEventListener("abort", handleAbort);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main entry: start monitoring for all enabled accounts.
|
* Main entry: start monitoring for all enabled accounts.
|
||||||
*/
|
*/
|
||||||
@@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
|||||||
export function stopFeishuMonitor(accountId?: string): void {
|
export function stopFeishuMonitor(accountId?: string): void {
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
wsClients.delete(accountId);
|
wsClients.delete(accountId);
|
||||||
|
const server = httpServers.get(accountId);
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
httpServers.delete(accountId);
|
||||||
|
}
|
||||||
botOpenIds.delete(accountId);
|
botOpenIds.delete(accountId);
|
||||||
} else {
|
} else {
|
||||||
wsClients.clear();
|
wsClients.clear();
|
||||||
|
for (const server of httpServers.values()) {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
httpServers.clear();
|
||||||
botOpenIds.clear();
|
botOpenIds.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||||
import type { MentionTarget } from "./mention.js";
|
import type { MentionTarget } from "./mention.js";
|
||||||
import type { FeishuSendResult } from "./types.js";
|
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
|
||||||
import { resolveFeishuAccount } from "./accounts.js";
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
||||||
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
|
|||||||
/**
|
/**
|
||||||
* Build a Feishu interactive card with markdown content.
|
* Build a Feishu interactive card with markdown content.
|
||||||
* Cards render markdown properly (code blocks, tables, links, etc.)
|
* Cards render markdown properly (code blocks, tables, links, etc.)
|
||||||
|
* Uses schema 2.0 format for proper markdown rendering.
|
||||||
*/
|
*/
|
||||||
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
|
schema: "2.0",
|
||||||
config: {
|
config: {
|
||||||
wide_screen_mode: true,
|
wide_screen_mode: true,
|
||||||
},
|
},
|
||||||
elements: [
|
body: {
|
||||||
{
|
elements: [
|
||||||
tag: "markdown",
|
{
|
||||||
content: text,
|
tag: "markdown",
|
||||||
},
|
content: text,
|
||||||
],
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
|
|||||||
perm?: boolean;
|
perm?: boolean;
|
||||||
scopes?: boolean;
|
scopes?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DynamicAgentCreationConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
workspaceTemplate?: string;
|
||||||
|
agentDirTemplate?: string;
|
||||||
|
maxAgents?: number;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user