openclaw/src/feishu/message-dispatch.ts
2026-01-29 12:27:25 +08:00

310 lines
9.9 KiB
TypeScript

/**
* Feishu Message Dispatch
*
* Routes incoming Feishu messages through the agent dispatch system,
* similar to how Telegram messages are handled.
*/
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { getStartupChatIds, type ResolvedFeishuAccount } from "./accounts.js";
import type { FeishuMessageContext } from "./monitor.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { dispatchInboundMessageWithBufferedDispatcher } from "../auto-reply/dispatch.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../config/group-policy.js";
import { danger } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { createDedupeCache } from "../infra/dedupe.js";
import { formatUncaughtError } from "../infra/errors.js";
import { sendMessageFeishu } from "./send.js";
// Message deduplication cache to prevent processing duplicate messages
const feishuMessageDedupe = createDedupeCache({
maxSize: 1000,
ttlMs: 60_000, // 1 minute
});
export type DispatchFeishuMessageParams = {
ctx: FeishuMessageContext;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
account: ResolvedFeishuAccount;
};
/**
* Build peer ID for Feishu (group may include thread)
*/
function buildFeishuPeerId(chatId: string, threadId?: string): string {
return threadId ? `${chatId}:${threadId}` : chatId;
}
/**
* Check if sender is allowed based on config
*/
function isFeishuSenderAllowed(
ctx: FeishuMessageContext,
cfg: ClawdbotConfig,
account: ResolvedFeishuAccount,
): { allowed: boolean; reason?: string } {
const isGroup = ctx.chatType === "group";
const senderId = ctx.senderId;
// When allowOnlyStartupChats is true: only groups in startupChatId may send; no DMs
if (account.config.allowOnlyStartupChats) {
const allowedChatIds = getStartupChatIds(account.config);
if (allowedChatIds.length === 0) {
return { allowed: false, reason: "allow_only_startup_chats_no_list" };
}
if (!isGroup) {
return { allowed: false, reason: "allow_only_startup_chats_no_dm" };
}
if (!allowedChatIds.includes(ctx.chatId)) {
return { allowed: false, reason: "allow_only_startup_chats_group_not_allowed" };
}
return { allowed: true };
}
if (isGroup) {
// Check group policy
const groupPolicySetting = account.config.groupPolicy ?? "open";
if (groupPolicySetting === "disabled") {
return { allowed: false, reason: "group_disabled" };
}
const groupPolicy = resolveChannelGroupPolicy({
cfg,
channel: "feishu",
accountId: account.accountId,
groupId: ctx.chatId,
});
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
return { allowed: false, reason: "group_not_in_allowlist" };
}
// Check groupAllowFrom for sender filtering
const groupAllowFrom = account.config.groupAllowFrom ?? [];
if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) {
return { allowed: false, reason: "sender_not_in_group_allowlist" };
}
} else {
// DM policy
const dmPolicy = account.config.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
return { allowed: false, reason: "dm_disabled" };
}
if (dmPolicy === "allowlist") {
const allowFrom = account.config.allowFrom ?? [];
if (allowFrom.length > 0 && !allowFrom.includes(senderId)) {
return { allowed: false, reason: "sender_not_in_allowlist" };
}
}
// TODO: Handle pairing mode for unknown DMs
// For now, allow through if not explicitly blocked
}
return { allowed: true };
}
/**
* Check if mention is required in group chats
*/
function requiresFeishuMention(
ctx: FeishuMessageContext,
cfg: ClawdbotConfig,
account: ResolvedFeishuAccount,
): boolean {
if (ctx.chatType !== "group") return false;
return resolveChannelGroupRequireMention({
cfg,
channel: "feishu",
accountId: account.accountId,
groupId: ctx.chatId,
});
}
/**
* Dispatch a Feishu message to the agent system
*/
export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams): Promise<void> {
const { ctx, cfg, runtime, account } = params;
const log = runtime?.log ?? console.log;
// Deduplicate messages to prevent processing the same message twice
const dedupeKey = `feishu:${ctx.messageId}`;
if (feishuMessageDedupe.check(dedupeKey)) {
log(`feishu: skipping duplicate message ${ctx.messageId}`);
return;
}
log(
`feishu: dispatchFeishuMessage called - chatId=${ctx.chatId}, senderId=${ctx.senderId}, chatType=${ctx.chatType}`,
);
// Record channel activity
recordChannelActivity({
channel: "feishu",
accountId: account.accountId,
direction: "inbound",
});
const isGroup = ctx.chatType === "group";
const peerId = buildFeishuPeerId(ctx.chatId, ctx.threadId);
// Resolve agent route for session key
const route = resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
log(`feishu: resolved route - sessionKey=${route.sessionKey}, agentId=${route.agentId}`);
// Check if sender is allowed
const accessCheck = isFeishuSenderAllowed(ctx, cfg, account);
if (!accessCheck.allowed) {
log(`feishu: blocked message from ${ctx.senderId} (${accessCheck.reason})`);
return;
}
log(`feishu: sender allowed`);
// Check mention requirement for groups
if (isGroup && requiresFeishuMention(ctx, cfg, account) && !ctx.wasMentioned) {
log(`feishu: mention required but not mentioned in group ${ctx.chatId}`);
return;
}
// Skip empty messages
const messageText = ctx.text.trim();
if (!messageText) {
log(`feishu: skipping empty message`);
return;
}
// Normalize command body (strip bot mention prefix if present)
const commandBody = normalizeCommandBody(messageText);
// Check if this is a control command
const isControlCommand = hasControlCommand(commandBody, cfg, undefined);
log(
`feishu: processing message - text="${messageText.substring(0, 100)}", isCommand=${isControlCommand}`,
);
// Build message context for dispatch
const envelopeOpts = resolveEnvelopeFormatOptions(cfg);
const conversationLabel = isGroup ? `group:${ctx.chatId}` : ctx.senderId;
// Format envelope for agent context
const bodyForAgent = formatInboundEnvelope({
channel: "Feishu",
from: conversationLabel,
body: messageText,
chatType: isGroup ? "group" : "direct",
senderLabel: ctx.senderId, // TODO: resolve sender display name
envelope: envelopeOpts,
});
const msgContext = {
Body: bodyForAgent,
BodyForAgent: bodyForAgent,
RawBody: messageText,
CommandBody: commandBody,
BodyForCommands: commandBody,
From: isGroup ? `feishu:group:${ctx.chatId}` : `feishu:${ctx.chatId}`,
To: `feishu:${ctx.chatId}`,
SessionKey: route.sessionKey,
AccountId: account.accountId,
MessageSid: ctx.messageId,
ReplyToId: ctx.replyToMessageId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
Provider: "feishu",
Surface: "feishu",
WasMentioned: ctx.wasMentioned,
SenderId: ctx.senderId,
Timestamp: Date.now(),
MessageThreadId: ctx.threadId,
// Commands are authorized if sender passed access checks
CommandAuthorized: true,
// Originating channel info for reply routing
OriginatingChannel: "feishu" as const,
OriginatingTo: ctx.chatId,
};
log(`feishu: dispatching to agent system...`);
try {
// Dispatch through the agent system
await dispatchInboundMessageWithBufferedDispatcher({
ctx: msgContext,
cfg,
dispatcherOptions: {
deliver: async (payload) => {
// Check if processing was aborted (message was recalled)
if (ctx.abortSignal?.aborted) {
log(`feishu: delivery skipped - message was recalled`);
return;
}
log(
`feishu: deliver callback called - hasText=${!!payload.text}, textLength=${payload.text?.length ?? 0}`,
);
// Send response back to Feishu; in groups, @mention the user who asked
if (payload.text) {
let replyText = payload.text;
if (ctx.chatType === "group" && ctx.senderId) {
// Feishu text/interactive: <at id="open_id"></at> mentions the user
replyText = `<at id="${ctx.senderId}"></at> ${replyText}`;
}
try {
log(`feishu: sending reply to ${ctx.chatId}...`);
await sendMessageFeishu({
to: ctx.chatId,
text: replyText,
accountId: account.accountId,
config: cfg,
receiveIdType: "chat_id",
autoRichText: true, // Enable markdown rendering
});
log(`feishu: reply sent successfully`);
} catch (sendErr) {
runtime?.error?.(
danger(`feishu: failed to send reply: ${formatUncaughtError(sendErr)}`),
);
}
}
},
onError: (err) => {
// Don't report errors for aborted messages
if (ctx.abortSignal?.aborted) {
log(`feishu: error ignored - message was recalled`);
return;
}
runtime?.error?.(danger(`feishu: dispatch error: ${formatUncaughtError(err)}`));
},
},
});
log(`feishu: dispatch completed`);
} catch (err) {
// Don't report errors for aborted messages
if (ctx.abortSignal?.aborted) {
log(`feishu: dispatch aborted - message was recalled`);
return;
}
runtime?.error?.(danger(`feishu: message dispatch failed: ${formatUncaughtError(err)}`));
}
}