mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* 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
872 lines
26 KiB
TypeScript
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)}`);
|
|
}
|
|
}
|