mirror of https://github.com/openclaw/openclaw.git
feat(feishu): sync community contributions from clawdbot-feishu (#12662)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49c60e9065
commit
5c2cb6c591
|
|
@ -6,10 +6,17 @@ import {
|
|||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} 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 { 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 {
|
||||
resolveFeishuGroupConfig,
|
||||
|
|
@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|||
import { getFeishuRuntime } from "./runtime.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 ---
|
||||
// Extract permission grant URL from Feishu API error response.
|
||||
type PermissionError = {
|
||||
|
|
@ -30,16 +68,12 @@ type PermissionError = {
|
|||
};
|
||||
|
||||
function extractPermissionError(err: unknown): PermissionError | null {
|
||||
if (!err || typeof err !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!err || typeof err !== "object") return null;
|
||||
|
||||
// Axios error structure: err.response.data contains the Feishu error
|
||||
const axiosErr = err as { response?: { data?: unknown } };
|
||||
const data = axiosErr.response?.data;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== "object") return null;
|
||||
|
||||
const feishuErr = data as {
|
||||
code?: number;
|
||||
|
|
@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|||
};
|
||||
|
||||
// Feishu permission error code: 99991672
|
||||
if (feishuErr.code !== 99991672) {
|
||||
return null;
|
||||
}
|
||||
if (feishuErr.code !== 99991672) return null;
|
||||
|
||||
// Extract the grant URL from the error message (contains the direct link)
|
||||
const msg = feishuErr.msg ?? "";
|
||||
|
|
@ -82,28 +114,20 @@ type SenderNameResult = {
|
|||
async function resolveFeishuSenderName(params: {
|
||||
account: ResolvedFeishuAccount;
|
||||
senderOpenId: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
|
||||
log: (...args: any[]) => void;
|
||||
}): Promise<SenderNameResult> {
|
||||
const { account, senderOpenId, log } = params;
|
||||
if (!account.configured) {
|
||||
return {};
|
||||
}
|
||||
if (!senderOpenId) {
|
||||
return {};
|
||||
}
|
||||
if (!account.configured) return {};
|
||||
if (!senderOpenId) return {};
|
||||
|
||||
const cached = senderNameCache.get(senderOpenId);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) {
|
||||
return { name: cached.name };
|
||||
}
|
||||
if (cached && cached.expireAt > now) return { name: cached.name };
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
// 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({
|
||||
path: { user_id: senderOpenId },
|
||||
params: { user_id_type: "open_id" },
|
||||
|
|
@ -196,12 +220,8 @@ function parseMessageContent(content: string, messageType: string): string {
|
|||
|
||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!botOpenId) {
|
||||
return mentions.length > 0;
|
||||
}
|
||||
if (mentions.length === 0) return false;
|
||||
if (!botOpenId) return mentions.length > 0;
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
|
||||
|
|
@ -209,9 +229,7 @@ function stripBotMention(
|
|||
text: string,
|
||||
mentions?: FeishuMessageEvent["message"]["mentions"],
|
||||
): string {
|
||||
if (!mentions || mentions.length === 0) {
|
||||
return text;
|
||||
}
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
let result = text;
|
||||
for (const mention of mentions) {
|
||||
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 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);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
|
||||
|
|
@ -532,9 +557,7 @@ export async function handleFeishuMessage(params: {
|
|||
senderOpenId: ctx.senderOpenId,
|
||||
log,
|
||||
});
|
||||
if (senderResult.name) {
|
||||
ctx = { ...ctx, senderName: senderResult.name };
|
||||
}
|
||||
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
||||
|
||||
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
||||
let permissionErrorForAgent: PermissionError | undefined;
|
||||
|
|
@ -647,16 +670,61 @@ export async function handleFeishuMessage(params: {
|
|||
const feishuFrom = `feishu:${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,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
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 inboundLabel = isGroup
|
||||
? `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 {
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuCredentials,
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
|
|
@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
|
|||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
|
||||
const meta: ChannelMeta = {
|
||||
id: "feishu",
|
||||
|
|
@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
chatTypes: ["direct", "channel"],
|
||||
polls: false,
|
||||
threads: true,
|
||||
media: true,
|
||||
reactions: true,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: true,
|
||||
blockStreaming: true,
|
||||
edit: true,
|
||||
reply: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
|
|
@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
requireMention: { type: "boolean" },
|
||||
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
||||
historyLimit: { type: "integer", minimum: 0 },
|
||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||
textChunkLimit: { type: "integer", minimum: 1 },
|
||||
|
|
@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const _account = resolveFeishuAccount({ cfg, accountId });
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
if (isDefault) {
|
||||
|
|
@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
||||
)?.defaults?.groupPolicy;
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
if (groupPolicy !== "open") 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.`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
|
|||
.strict()
|
||||
.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.
|
||||
* Controls which tool categories are enabled.
|
||||
|
|
@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
|
|||
.strict()
|
||||
.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
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
|
|
@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
|
|||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
topicSessionMode: TopicSessionModeSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
|
|||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional().default(true),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
topicSessionMode: TopicSessionModeSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema).optional(),
|
||||
|
|
@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
|
|||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
||||
tools: FeishuToolsConfigSchema,
|
||||
// Dynamic agent creation for DM users
|
||||
dynamicAgentCreation: DynamicAgentCreationSchema,
|
||||
// Multi-account configuration
|
||||
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import * as http from "http";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||
|
|
@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
|
|||
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 httpServers = new Map<string, http.Server>();
|
||||
const botOpenIds = new Map<string, string>();
|
||||
|
||||
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: {
|
||||
cfg: ClawdbotConfig;
|
||||
account: ResolvedFeishuAccount;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { cfg, account, runtime, abortSignal } = params;
|
||||
const { accountId } = account;
|
||||
function registerEventHandlers(
|
||||
eventDispatcher: Lark.EventDispatcher,
|
||||
context: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
runtime?: RuntimeEnv;
|
||||
chatHistories: Map<string, HistoryEntry[]>;
|
||||
fireAndForget?: boolean;
|
||||
},
|
||||
) {
|
||||
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
||||
const log = runtime?.log ?? console.log;
|
||||
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({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuMessageEvent;
|
||||
await handleFeishuMessage({
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
|
|
@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
|
|||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
if (fireAndForget) {
|
||||
promise.catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
} catch (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) => {
|
||||
const cleanup = () => {
|
||||
|
|
@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
|
|||
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
||||
|
||||
try {
|
||||
void wsClient.start({ eventDispatcher });
|
||||
wsClient.start({ eventDispatcher });
|
||||
log(`feishu[${accountId}]: WebSocket client started`);
|
||||
} catch (err) {
|
||||
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.
|
||||
*/
|
||||
|
|
@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
|||
export function stopFeishuMonitor(accountId?: string): void {
|
||||
if (accountId) {
|
||||
wsClients.delete(accountId);
|
||||
const server = httpServers.get(accountId);
|
||||
if (server) {
|
||||
server.close();
|
||||
httpServers.delete(accountId);
|
||||
}
|
||||
botOpenIds.delete(accountId);
|
||||
} else {
|
||||
wsClients.clear();
|
||||
for (const server of httpServers.values()) {
|
||||
server.close();
|
||||
}
|
||||
httpServers.clear();
|
||||
botOpenIds.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
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 { createFeishuClient } from "./client.js";
|
||||
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
||||
|
|
@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
|
|||
/**
|
||||
* Build a Feishu interactive card with markdown content.
|
||||
* 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> {
|
||||
return {
|
||||
schema: "2.0",
|
||||
config: {
|
||||
wide_screen_mode: true,
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
|
|||
perm?: boolean;
|
||||
scopes?: boolean;
|
||||
};
|
||||
|
||||
export type DynamicAgentCreationConfig = {
|
||||
enabled?: boolean;
|
||||
workspaceTemplate?: string;
|
||||
agentDirTemplate?: string;
|
||||
maxAgents?: number;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue