Files
openclaw/extensions/feishu/src/bot.ts
max 223eee0a20 refactor: unify peer kind to ChatType, rename dm to direct (#11881)
* fix: use .js extension for ESM imports of RoutePeerKind

The imports incorrectly used .ts extension which doesn't resolve
with moduleResolution: NodeNext. Changed to .js and added 'type'
import modifier.

* fix tsconfig

* refactor: unify peer kind to ChatType, rename dm to direct

- Replace RoutePeerKind with ChatType throughout codebase
- Change 'dm' literal values to 'direct' in routing/session keys
- Keep backward compat: normalizeChatType accepts 'dm' -> 'direct'
- Add ChatType export to plugin-sdk, deprecate RoutePeerKind
- Update session key parsing to accept both 'dm' and 'direct' markers
- Update all channel monitors and extensions to use ChatType

BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'.
Existing 'dm' keys still work via backward compat layer.

* fix tests

* test: update session key expectations for dmdirect migration

- Fix test expectations to expect :direct: in generated output
- Add explicit backward compat test for normalizeChatType('dm')
- Keep input test data with :dm: keys to verify backward compat

* fix: accept legacy 'dm' in session key parsing for backward compat

getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct:
to ensure old session keys continue to work correctly.

* test: add explicit backward compat tests for dmdirect migration

- session-key.test.ts: verify both :dm: and :direct: keys are valid
- getDmHistoryLimitFromSessionKey: verify both formats work

* feat: backward compat for resetByType.dm config key

* test: skip unix-path Nix tests on Windows
2026-02-09 09:20:52 +09:00

872 lines
26 KiB
TypeScript

import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "openclaw/plugin-sdk";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code: number;
message: string;
grantUrl?: string;
};
function extractPermissionError(err: unknown): PermissionError | 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;
}
const feishuErr = data as {
code?: number;
msg?: string;
error?: { permission_violations?: Array<{ uri?: string }> };
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) {
return null;
}
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
const grantUrl = urlMatch?.[0];
return {
code: feishuErr.code,
message: msg,
grantUrl,
};
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by open_id to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
type SenderNameResult = {
name?: string;
permissionError?: PermissionError;
};
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 {};
}
const cached = senderNameCache.get(senderOpenId);
const now = Date.now();
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" },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
// Check if this is a permission error
const permErr = extractPermissionError(err);
if (permErr) {
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
// Best-effort. Don't fail message handling if name lookup fails.
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
return {};
}
}
export type FeishuMessageEvent = {
sender: {
sender_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
sender_type?: string;
tenant_key?: string;
};
message: {
message_id: string;
root_id?: string;
parent_id?: string;
chat_id: string;
chat_type: "p2p" | "group";
message_type: string;
content: string;
mentions?: Array<{
key: string;
id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
name: string;
tenant_key?: string;
}>;
};
};
export type FeishuBotAddedEvent = {
chat_id: string;
operator_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
external: boolean;
operator_tenant_key?: string;
};
function parseMessageContent(content: string, messageType: string): string {
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "post") {
// Extract text content from rich text post
const { textContent } = parsePostContent(content);
return textContent;
}
return content;
} catch {
return content;
}
}
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) {
return false;
}
if (!botOpenId) {
return mentions.length > 0;
}
return mentions.some((m) => m.id.open_id === botOpenId);
}
function stripBotMention(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
): string {
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();
result = result.replace(new RegExp(mention.key, "g"), "").trim();
}
return result;
}
/**
* Parse media keys from message content based on message type.
*/
function parseMediaKeys(
content: string,
messageType: string,
): {
imageKey?: string;
fileKey?: string;
fileName?: string;
} {
try {
const parsed = JSON.parse(content);
switch (messageType) {
case "image":
return { imageKey: parsed.image_key };
case "file":
return { fileKey: parsed.file_key, fileName: parsed.file_name };
case "audio":
return { fileKey: parsed.file_key };
case "video":
// Video has both file_key (video) and image_key (thumbnail)
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
case "sticker":
return { fileKey: parsed.file_key };
default:
return {};
}
} catch {
return {};
}
}
/**
* Parse post (rich text) content and extract embedded image keys.
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
*/
function parsePostContent(content: string): {
textContent: string;
imageKeys: string[];
} {
try {
const parsed = JSON.parse(content);
const title = parsed.title || "";
const contentBlocks = parsed.content || [];
let textContent = title ? `${title}\n\n` : "";
const imageKeys: string[] = [];
for (const paragraph of contentBlocks) {
if (Array.isArray(paragraph)) {
for (const element of paragraph) {
if (element.tag === "text") {
textContent += element.text || "";
} else if (element.tag === "a") {
// Link: show text or href
textContent += element.text || element.href || "";
} else if (element.tag === "at") {
// Mention: @username
textContent += `@${element.user_name || element.user_id || ""}`;
} else if (element.tag === "img" && element.image_key) {
// Embedded image
imageKeys.push(element.image_key);
}
}
textContent += "\n";
}
}
return {
textContent: textContent.trim() || "[富文本消息]",
imageKeys,
};
} catch {
return { textContent: "[富文本消息]", imageKeys: [] };
}
}
/**
* Infer placeholder text based on message type.
*/
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message, downloading and saving to disk.
* Similar to Discord's resolveMediaList().
*/
async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
// Only process media message types (including post for embedded images)
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images
if (messageType === "post") {
const { imageKeys } = parsePostContent(content);
if (imageKeys.length === 0) {
return [];
}
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
for (const imageKey of imageKeys) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
return out;
}
// Handle other media types
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
let buffer: Buffer;
let contentType: string | undefined;
let fileName: string | undefined;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
if (!fileKey) {
return [];
}
const resourceType = messageType === "image" ? "image" : "file";
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
accountId,
});
buffer = result.buffer;
contentType = result.contentType;
fileName = result.fileName || mediaKeys.fileName;
// Detect mime type if not provided
if (!contentType) {
contentType = await core.media.detectMime({ buffer });
}
// Save to disk using core's saveMediaBuffer
const saved = await core.channel.media.saveMediaBuffer(
buffer,
contentType,
"inbound",
maxBytes,
fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}
/**
* Build media payload for inbound context.
* Similar to Discord's buildDiscordMediaPayload().
*/
function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
export function parseFeishuMessageEvent(
event: FeishuMessageEvent,
botOpenId?: string,
): FeishuMessageContext {
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
const mentionedBot = checkBotMentioned(event, botOpenId);
const content = stripBotMention(rawContent, event.message.mentions);
const ctx: FeishuMessageContext = {
chatId: event.message.chat_id,
messageId: event.message.message_id,
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
senderOpenId: event.sender.sender_id.open_id || "",
chatType: event.message.chat_type,
mentionedBot,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
content,
contentType: event.message.message_type,
};
// Detect mention forward request: message mentions bot + at least one other user
if (isMentionForwardRequest(event, botOpenId)) {
const mentionTargets = extractMentionTargets(event, botOpenId);
if (mentionTargets.length > 0) {
ctx.mentionTargets = mentionTargets;
// Extract message body (remove all @ placeholders)
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
}
}
return ctx;
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
}): Promise<void> {
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
// Resolve account with merged config
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const senderResult = await resolveFeishuSenderName({
account,
senderOpenId: ctx.senderOpenId,
log,
});
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent: PermissionError | undefined;
if (senderResult.permissionError) {
const appKey = account.appId ?? "default";
const now = Date.now();
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
permissionErrorNotifiedAt.set(appKey, now);
permissionErrorForAgent = senderResult.permissionError;
}
}
log(
`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`,
);
// Log mention targets if detected
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
}
const historyLimit = Math.max(
0,
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
if (isGroup) {
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed = isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
});
if (!groupAllowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
return;
}
// Additional sender-level allowlist check if group has specific allowFrom config
const senderAllowFrom = groupConfig?.allowFrom ?? [];
if (senderAllowFrom.length > 0) {
const senderAllowed = isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: senderAllowFrom,
senderId: ctx.senderOpenId,
senderName: ctx.senderName,
});
if (!senderAllowed) {
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
return;
}
}
const { requireMention } = resolveFeishuReplyPolicy({
isDirectMessage: false,
globalConfig: feishuCfg,
groupConfig,
});
if (requireMention && !ctx.mentionedBot) {
log(
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
);
if (chatHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: ctx.chatId,
limit: historyLimit,
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: Date.now(),
messageId: ctx.messageId,
},
});
}
return;
}
} else {
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const allowFrom = feishuCfg?.allowFrom ?? [];
if (dmPolicy === "allowlist") {
const match = resolveFeishuAllowlistMatch({
allowFrom,
senderId: ctx.senderOpenId,
});
if (!match.allowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
return;
}
}
}
try {
const core = getFeishuRuntime();
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? ctx.chatId : ctx.senderOpenId,
},
});
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
});
// Resolve media from message
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
const mediaList = await resolveFeishuMediaList({
cfg,
messageId: ctx.messageId,
messageType: event.message.message_type,
content: event.message.content,
maxBytes: mediaMaxBytes,
log,
accountId: account.accountId,
});
const mediaPayload = buildFeishuMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
// Build message body with quoted content if available
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// Include a readable speaker label so the model can attribute instructions.
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
// If there are mention targets, inform the agent that replies will auto-mention them
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
// If there's a permission error, dispatch a separate notification first
if (permissionErrorForAgent) {
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
const permissionBody = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: permissionNotifyBody,
});
const permissionCtx = core.channel.reply.finalizeInboundContext({
Body: permissionBody,
RawBody: permissionNotifyBody,
CommandBody: permissionNotifyBody,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: "system",
SenderId: "system",
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: `${ctx.messageId}:permission-error`,
Timestamp: Date.now(),
WasMentioned: false,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
});
const {
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
markDispatchIdle: markPermIdle,
} = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
accountId: account.accountId,
});
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
await core.channel.reply.dispatchReplyFromConfig({
ctx: permissionCtx,
cfg,
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
});
markPermIdle();
}
const body = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: messageBody,
});
let combinedBody = body;
const historyKey = isGroup ? ctx.chatId : undefined;
if (isGroup && historyKey && chatHistories) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
// Preserve speaker identity in group history as well.
from: `${ctx.chatId}:${entry.sender}`,
timestamp: entry.timestamp,
body: entry.body,
envelope: envelopeOptions,
}),
});
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: ctx.senderName ?? ctx.senderOpenId,
SenderId: ctx.senderOpenId,
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
);
} catch (err) {
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
}
}