310 lines
9.9 KiB
TypeScript
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)}`));
|
|
}
|
|
}
|