mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-15 12:00:43 +00:00
* fix(feishu): comprehensive reply mechanism fix — outbound replyToId forwarding + topic-aware reply targeting - Forward replyToId from ChannelOutboundContext through sendText/sendMedia to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling reply-to-message via the message tool. - Fix group reply targeting: use ctx.messageId (triggering message) in normal groups to prevent silent topic thread creation (#32980). Preserve ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender) and groups with explicit replyInThread config. - Add regression tests for both fixes. Fixes #32980 Fixes #32958 Related #19784 * fix: normalize Feishu delivery.to before comparing with messaging tool targets - Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu - Apply normalization in matchesMessagingToolDeliveryTarget before comparison - This ensures cron duplicate suppression works when session uses prefixed targets (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx) Fixes review comment on PR #32755 (cherry picked from commitfc20106f16) * fix(feishu): catch thrown SDK errors for withdrawn reply targets The Feishu Lark SDK can throw exceptions (SDK errors with .code or AxiosErrors with .response.data.code) for withdrawn/deleted reply targets, in addition to returning error codes in the response object. Wrap reply calls in sendMessageFeishu and sendCardFeishu with try-catch to handle thrown withdrawn/not-found errors (230011, 231003) and fall back to client.im.message.create, matching the existing response-level fallback behavior. Also extract sendFallbackDirect helper to deduplicate the direct-send fallback block across both functions. Closes #33496 (cherry picked from commitad0901aec1) * feishu: forward outbound reply target context (cherry picked from commit c129a691fcf552a1cebe1e8a22ea8611ffc3b377) * feishu extension: tighten reply target fallback semantics (cherry picked from commit f85ec610f267020b66713c09e648ec004b2e26f1) * fix(feishu): align synthesized fallback typing and changelog attribution * test(feishu): cover group_topic_sender reply targeting --------- Co-authored-by: Xu Zimo <xuzimojimmy@163.com> Co-authored-by: Munem Hashmi <munem.hashmi@gmail.com> Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
1546 lines
52 KiB
TypeScript
1546 lines
52 KiB
TypeScript
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
import {
|
|
buildAgentMediaPayload,
|
|
buildPendingHistoryContextFromMap,
|
|
clearHistoryEntriesIfEnabled,
|
|
createScopedPairingAccess,
|
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
type HistoryEntry,
|
|
normalizeAgentId,
|
|
recordPendingHistoryEntryIfEnabled,
|
|
resolveOpenProviderRuntimeGroupPolicy,
|
|
resolveDefaultGroupPolicy,
|
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
} from "openclaw/plugin-sdk/feishu";
|
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
import { createFeishuClient } from "./client.js";
|
|
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
import { downloadMessageResourceFeishu } from "./media.js";
|
|
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
|
import {
|
|
resolveFeishuGroupConfig,
|
|
resolveFeishuReplyPolicy,
|
|
resolveFeishuAllowlistMatch,
|
|
isFeishuGroupAllowed,
|
|
} from "./policy.js";
|
|
import { parsePostContent } from "./post.js";
|
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
import { getFeishuRuntime } from "./runtime.js";
|
|
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
|
|
// --- Permission error extraction ---
|
|
// Extract permission grant URL from Feishu API error response.
|
|
type PermissionError = {
|
|
code: number;
|
|
message: string;
|
|
grantUrl?: string;
|
|
};
|
|
|
|
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
|
|
|
|
// Feishu API sometimes returns incorrect scope names in permission error
|
|
// responses (e.g. "contact:contact.base:readonly" instead of the valid
|
|
// "contact:user.base:readonly"). This map corrects known mismatches.
|
|
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
|
|
"contact:contact.base:readonly": "contact:user.base:readonly",
|
|
};
|
|
|
|
function correctFeishuScopeInUrl(url: string): string {
|
|
let corrected = url;
|
|
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
|
|
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
|
|
corrected = corrected.replaceAll(wrong, right);
|
|
}
|
|
return corrected;
|
|
}
|
|
|
|
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
|
|
const message = permissionError.message.toLowerCase();
|
|
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
|
|
}
|
|
|
|
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] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
|
|
|
|
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 sender id (open_id/user_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;
|
|
};
|
|
|
|
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
|
|
const trimmed = senderId.trim();
|
|
if (trimmed.startsWith("ou_")) {
|
|
return "open_id";
|
|
}
|
|
if (trimmed.startsWith("on_")) {
|
|
return "union_id";
|
|
}
|
|
return "user_id";
|
|
}
|
|
|
|
async function resolveFeishuSenderName(params: {
|
|
account: ResolvedFeishuAccount;
|
|
senderId: string;
|
|
log: (...args: any[]) => void;
|
|
}): Promise<SenderNameResult> {
|
|
const { account, senderId, log } = params;
|
|
if (!account.configured) return {};
|
|
|
|
const normalizedSenderId = senderId.trim();
|
|
if (!normalizedSenderId) return {};
|
|
|
|
const cached = senderNameCache.get(normalizedSenderId);
|
|
const now = Date.now();
|
|
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
|
|
try {
|
|
const client = createFeishuClient(account);
|
|
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
|
|
|
|
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
|
|
const res: any = await client.contact.user.get({
|
|
path: { user_id: normalizedSenderId },
|
|
params: { user_id_type: userIdType },
|
|
});
|
|
|
|
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(normalizedSenderId, { 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) {
|
|
if (shouldSuppressPermissionErrorNotice(permErr)) {
|
|
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
|
|
return {};
|
|
}
|
|
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 ${normalizedSenderId}: ${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;
|
|
thread_id?: string;
|
|
chat_id: string;
|
|
chat_type: "p2p" | "group" | "private";
|
|
message_type: string;
|
|
content: string;
|
|
create_time?: 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;
|
|
};
|
|
|
|
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
|
|
|
type ResolvedFeishuGroupSession = {
|
|
peerId: string;
|
|
parentPeer: { kind: "group"; id: string } | null;
|
|
groupSessionScope: GroupSessionScope;
|
|
replyInThread: boolean;
|
|
threadReply: boolean;
|
|
};
|
|
|
|
function resolveFeishuGroupSession(params: {
|
|
chatId: string;
|
|
senderOpenId: string;
|
|
messageId: string;
|
|
rootId?: string;
|
|
threadId?: string;
|
|
groupConfig?: {
|
|
groupSessionScope?: GroupSessionScope;
|
|
topicSessionMode?: "enabled" | "disabled";
|
|
replyInThread?: "enabled" | "disabled";
|
|
};
|
|
feishuCfg?: {
|
|
groupSessionScope?: GroupSessionScope;
|
|
topicSessionMode?: "enabled" | "disabled";
|
|
replyInThread?: "enabled" | "disabled";
|
|
};
|
|
}): ResolvedFeishuGroupSession {
|
|
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
|
|
|
|
const normalizedThreadId = threadId?.trim();
|
|
const normalizedRootId = rootId?.trim();
|
|
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
|
|
const replyInThread =
|
|
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
|
|
threadReply;
|
|
|
|
const legacyTopicSessionMode =
|
|
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
const groupSessionScope: GroupSessionScope =
|
|
groupConfig?.groupSessionScope ??
|
|
feishuCfg?.groupSessionScope ??
|
|
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
|
|
|
// Keep topic session keys stable across the "first turn creates thread" flow:
|
|
// first turn may only have message_id, while the next turn carries root_id/thread_id.
|
|
// Prefer root_id first so both turns stay on the same peer key.
|
|
const topicScope =
|
|
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
|
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
|
: null;
|
|
|
|
let peerId = chatId;
|
|
switch (groupSessionScope) {
|
|
case "group_sender":
|
|
peerId = `${chatId}:sender:${senderOpenId}`;
|
|
break;
|
|
case "group_topic":
|
|
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
|
break;
|
|
case "group_topic_sender":
|
|
peerId = topicScope
|
|
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
|
: `${chatId}:sender:${senderOpenId}`;
|
|
break;
|
|
case "group":
|
|
default:
|
|
peerId = chatId;
|
|
break;
|
|
}
|
|
|
|
const parentPeer =
|
|
topicScope &&
|
|
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
|
? {
|
|
kind: "group" as const,
|
|
id: chatId,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
peerId,
|
|
parentPeer,
|
|
groupSessionScope,
|
|
replyInThread,
|
|
threadReply,
|
|
};
|
|
}
|
|
|
|
function parseMessageContent(content: string, messageType: string): string {
|
|
if (messageType === "post") {
|
|
// Extract text content from rich text post
|
|
const { textContent } = parsePostContent(content);
|
|
return textContent;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
if (messageType === "text") {
|
|
return parsed.text || "";
|
|
}
|
|
if (messageType === "share_chat") {
|
|
// Preserve available summary text for merged/forwarded chat messages.
|
|
if (parsed && typeof parsed === "object") {
|
|
const share = parsed as {
|
|
body?: unknown;
|
|
summary?: unknown;
|
|
share_chat_id?: unknown;
|
|
};
|
|
if (typeof share.body === "string" && share.body.trim().length > 0) {
|
|
return share.body.trim();
|
|
}
|
|
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
|
|
return share.summary.trim();
|
|
}
|
|
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
|
|
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
|
}
|
|
}
|
|
return "[Forwarded message]";
|
|
}
|
|
if (messageType === "merge_forward") {
|
|
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
|
|
return "[Merged and Forwarded Message - loading...]";
|
|
}
|
|
return content;
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse merge_forward message content and fetch sub-messages.
|
|
* Returns formatted text content of all sub-messages.
|
|
*/
|
|
function parseMergeForwardContent(params: {
|
|
content: string;
|
|
log?: (...args: any[]) => void;
|
|
}): string {
|
|
const { content, log } = params;
|
|
const maxMessages = 50;
|
|
|
|
// For merge_forward, the API returns all sub-messages in items array
|
|
// with upper_message_id pointing to the merge_forward message.
|
|
// The 'content' parameter here is actually the full API response items array as JSON.
|
|
log?.(`feishu: parsing merge_forward sub-messages from API response`);
|
|
|
|
let items: Array<{
|
|
message_id?: string;
|
|
msg_type?: string;
|
|
body?: { content?: string };
|
|
sender?: { id?: string };
|
|
upper_message_id?: string;
|
|
create_time?: string;
|
|
}>;
|
|
|
|
try {
|
|
items = JSON.parse(content);
|
|
} catch {
|
|
log?.(`feishu: merge_forward items parse failed`);
|
|
return "[Merged and Forwarded Message - parse error]";
|
|
}
|
|
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
return "[Merged and Forwarded Message - no sub-messages]";
|
|
}
|
|
|
|
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
|
|
const subMessages = items.filter((item) => item.upper_message_id);
|
|
|
|
if (subMessages.length === 0) {
|
|
return "[Merged and Forwarded Message - no sub-messages found]";
|
|
}
|
|
|
|
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
|
|
|
|
// Sort by create_time
|
|
subMessages.sort((a, b) => {
|
|
const timeA = parseInt(a.create_time || "0", 10);
|
|
const timeB = parseInt(b.create_time || "0", 10);
|
|
return timeA - timeB;
|
|
});
|
|
|
|
// Format output
|
|
const lines: string[] = ["[Merged and Forwarded Messages]"];
|
|
const limitedMessages = subMessages.slice(0, maxMessages);
|
|
|
|
for (const item of limitedMessages) {
|
|
const msgContent = item.body?.content || "";
|
|
const msgType = item.msg_type || "text";
|
|
const formatted = formatSubMessageContent(msgContent, msgType);
|
|
lines.push(`- ${formatted}`);
|
|
}
|
|
|
|
if (subMessages.length > maxMessages) {
|
|
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Format sub-message content based on message type.
|
|
*/
|
|
function formatSubMessageContent(content: string, contentType: string): string {
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
switch (contentType) {
|
|
case "text":
|
|
return parsed.text || content;
|
|
case "post": {
|
|
const { textContent } = parsePostContent(content);
|
|
return textContent;
|
|
}
|
|
case "image":
|
|
return "[Image]";
|
|
case "file":
|
|
return `[File: ${parsed.file_name || "unknown"}]`;
|
|
case "audio":
|
|
return "[Audio]";
|
|
case "video":
|
|
return "[Video]";
|
|
case "sticker":
|
|
return "[Sticker]";
|
|
case "merge_forward":
|
|
return "[Nested Merged Forward]";
|
|
default:
|
|
return `[${contentType}]`;
|
|
}
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
function checkBotMentioned(
|
|
event: FeishuMessageEvent,
|
|
botOpenId?: string,
|
|
botName?: string,
|
|
): boolean {
|
|
if (!botOpenId) return false;
|
|
// Check for @all (@_all in Feishu) — treat as mentioning every bot
|
|
const rawContent = event.message.content ?? "";
|
|
if (rawContent.includes("@_all")) return true;
|
|
const mentions = event.message.mentions ?? [];
|
|
if (mentions.length > 0) {
|
|
return mentions.some((m) => {
|
|
if (m.id.open_id !== botOpenId) return false;
|
|
// Guard against Feishu WS open_id remapping in multi-app groups:
|
|
// if botName is known and mention name differs, this is a false positive.
|
|
if (botName && m.name && m.name !== botName) return false;
|
|
return true;
|
|
});
|
|
}
|
|
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
|
if (event.message.message_type === "post") {
|
|
const { mentionedOpenIds } = parsePostContent(event.message.content);
|
|
return mentionedOpenIds.some((id) => id === botOpenId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeMentions(
|
|
text: string,
|
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
botStripId?: string,
|
|
): string {
|
|
if (!mentions || mentions.length === 0) return text;
|
|
|
|
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const escapeName = (value: string) => value.replace(/</g, "<").replace(/>/g, ">");
|
|
let result = text;
|
|
|
|
for (const mention of mentions) {
|
|
const mentionId = mention.id.open_id;
|
|
const replacement =
|
|
botStripId && mentionId === botStripId
|
|
? ""
|
|
: mentionId
|
|
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
|
|
: `@${mention.name}`;
|
|
|
|
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).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);
|
|
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
|
|
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
|
switch (messageType) {
|
|
case "image":
|
|
return { imageKey };
|
|
case "file":
|
|
return { fileKey, fileName: parsed.file_name };
|
|
case "audio":
|
|
return { fileKey };
|
|
case "video":
|
|
case "media":
|
|
// Video/media has both file_key (video) and image_key (thumbnail)
|
|
return { fileKey, imageKey };
|
|
case "sticker":
|
|
return { fileKey };
|
|
default:
|
|
return {};
|
|
}
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map Feishu message type to messageResource.get resource type.
|
|
* Feishu messageResource API supports only: image | file.
|
|
*/
|
|
export function toMessageResourceType(messageType: string): "image" | "file" {
|
|
return messageType === "image" ? "image" : "file";
|
|
}
|
|
|
|
/**
|
|
* 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":
|
|
case "media":
|
|
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", "media", "sticker", "post"];
|
|
if (!mediaTypes.includes(messageType)) {
|
|
return [];
|
|
}
|
|
|
|
const out: FeishuMediaInfo[] = [];
|
|
const core = getFeishuRuntime();
|
|
|
|
// Handle post (rich text) messages with embedded images/media.
|
|
if (messageType === "post") {
|
|
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
|
|
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (imageKeys.length > 0) {
|
|
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
}
|
|
if (postMediaKeys.length > 0) {
|
|
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(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)}`);
|
|
}
|
|
}
|
|
|
|
for (const media of postMediaKeys) {
|
|
try {
|
|
const result = await downloadMessageResourceFeishu({
|
|
cfg,
|
|
messageId,
|
|
fileKey: media.fileKey,
|
|
type: "file",
|
|
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:video>",
|
|
});
|
|
|
|
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
|
|
} catch (err) {
|
|
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${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.fileKey || mediaKeys.imageKey;
|
|
if (!fileKey) {
|
|
return [];
|
|
}
|
|
|
|
const resourceType = toMessageResourceType(messageType);
|
|
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;
|
|
}
|
|
|
|
// --- Broadcast support ---
|
|
// Resolve broadcast agent list for a given peer (group) ID.
|
|
// Returns null if no broadcast config exists or the peer is not in the broadcast list.
|
|
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
|
|
const broadcast = (cfg as Record<string, unknown>).broadcast;
|
|
if (!broadcast || typeof broadcast !== "object") return null;
|
|
const agents = (broadcast as Record<string, unknown>)[peerId];
|
|
if (!Array.isArray(agents) || agents.length === 0) return null;
|
|
return agents as string[];
|
|
}
|
|
|
|
// Build a session key for a broadcast target agent by replacing the agent ID prefix.
|
|
// Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
|
|
export function buildBroadcastSessionKey(
|
|
baseSessionKey: string,
|
|
originalAgentId: string,
|
|
targetAgentId: string,
|
|
): string {
|
|
const prefix = `agent:${originalAgentId}:`;
|
|
if (baseSessionKey.startsWith(prefix)) {
|
|
return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
|
|
}
|
|
return baseSessionKey;
|
|
}
|
|
|
|
/**
|
|
* Build media payload for inbound context.
|
|
* Similar to Discord's buildDiscordMediaPayload().
|
|
*/
|
|
export function parseFeishuMessageEvent(
|
|
event: FeishuMessageEvent,
|
|
botOpenId?: string,
|
|
botName?: string,
|
|
): FeishuMessageContext {
|
|
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
const mentionedBot = checkBotMentioned(event, botOpenId, botName);
|
|
const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
|
|
// In p2p, the bot mention is a pure addressing prefix with no semantic value;
|
|
// strip it so slash commands like @Bot /help still have a leading /.
|
|
// Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
|
|
const content = normalizeMentions(
|
|
rawContent,
|
|
event.message.mentions,
|
|
event.message.chat_type === "p2p" ? botOpenId : undefined,
|
|
);
|
|
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
|
const senderUserId = event.sender.sender_id.user_id?.trim();
|
|
const senderFallbackId = senderOpenId || senderUserId || "";
|
|
|
|
const ctx: FeishuMessageContext = {
|
|
chatId: event.message.chat_id,
|
|
messageId: event.message.message_id,
|
|
senderId: senderUserId || senderOpenId || "",
|
|
// Keep the historical field name, but fall back to user_id when open_id is unavailable
|
|
// (common in some mobile app deliveries).
|
|
senderOpenId: senderFallbackId,
|
|
chatType: event.message.chat_type,
|
|
mentionedBot,
|
|
hasAnyMention,
|
|
rootId: event.message.root_id || undefined,
|
|
parentId: event.message.parent_id || undefined,
|
|
threadId: event.message.thread_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;
|
|
}
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
export function buildFeishuAgentBody(params: {
|
|
ctx: Pick<
|
|
FeishuMessageContext,
|
|
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
|
|
>;
|
|
quotedContent?: string;
|
|
permissionErrorForAgent?: PermissionError;
|
|
botOpenId?: string;
|
|
}): string {
|
|
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
|
|
let messageBody = ctx.content;
|
|
if (quotedContent) {
|
|
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
}
|
|
|
|
// DMs already have per-sender sessions, but this label still improves attribution.
|
|
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
messageBody = `${speaker}: ${messageBody}`;
|
|
|
|
if (ctx.hasAnyMention) {
|
|
const botIdHint = botOpenId?.trim();
|
|
messageBody +=
|
|
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
|
|
`Treat these as real mentions of Feishu entities (users or bots).]`;
|
|
if (botIdHint) {
|
|
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
|
|
}
|
|
}
|
|
|
|
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.]`;
|
|
}
|
|
|
|
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
|
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
|
|
|
if (permissionErrorForAgent) {
|
|
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
messageBody += `\n\n[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}]`;
|
|
}
|
|
|
|
return messageBody;
|
|
}
|
|
|
|
export async function handleFeishuMessage(params: {
|
|
cfg: ClawdbotConfig;
|
|
event: FeishuMessageEvent;
|
|
botOpenId?: string;
|
|
botName?: string;
|
|
runtime?: RuntimeEnv;
|
|
chatHistories?: Map<string, HistoryEntry[]>;
|
|
accountId?: string;
|
|
}): Promise<void> {
|
|
const { cfg, event, botOpenId, botName, 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;
|
|
|
|
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
|
|
// before the async persistent check completes.
|
|
const messageId = event.message.message_id;
|
|
const memoryDedupeKey = `${account.accountId}:${messageId}`;
|
|
if (!tryRecordMessage(memoryDedupeKey)) {
|
|
log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
|
|
return;
|
|
}
|
|
// Persistent dedup survives restarts and reconnects.
|
|
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
|
log(`feishu: skipping duplicate message ${messageId}`);
|
|
return;
|
|
}
|
|
|
|
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
|
const isGroup = ctx.chatType === "group";
|
|
const isDirect = !isGroup;
|
|
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
|
|
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
if (event.message.message_type === "merge_forward") {
|
|
log(
|
|
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
|
|
);
|
|
try {
|
|
// Websocket event doesn't include sub-messages, need to fetch via API
|
|
// The API returns all sub-messages in the items array
|
|
const client = createFeishuClient(account);
|
|
const response = (await client.im.message.get({
|
|
path: { message_id: event.message.message_id },
|
|
})) as { code?: number; data?: { items?: unknown[] } };
|
|
|
|
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
|
|
log(
|
|
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
|
|
);
|
|
const expandedContent = parseMergeForwardContent({
|
|
content: JSON.stringify(response.data.items),
|
|
log,
|
|
});
|
|
ctx = { ...ctx, content: expandedContent };
|
|
} else {
|
|
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
|
|
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
|
|
}
|
|
} catch (err) {
|
|
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
|
|
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
|
|
}
|
|
}
|
|
|
|
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
let permissionErrorForAgent: PermissionError | undefined;
|
|
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
const senderResult = await resolveFeishuSenderName({
|
|
account,
|
|
senderId: ctx.senderOpenId,
|
|
log,
|
|
});
|
|
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
|
|
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
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,
|
|
);
|
|
const groupConfig = isGroup
|
|
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
: undefined;
|
|
const groupSession = isGroup
|
|
? resolveFeishuGroupSession({
|
|
chatId: ctx.chatId,
|
|
senderOpenId: ctx.senderOpenId,
|
|
messageId: ctx.messageId,
|
|
rootId: ctx.rootId,
|
|
threadId: ctx.threadId,
|
|
groupConfig,
|
|
feishuCfg,
|
|
})
|
|
: null;
|
|
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
|
|
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
|
|
const broadcastAgents = rawBroadcastAgents
|
|
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
|
: null;
|
|
|
|
let requireMention = false; // DMs never require mention; groups may override below
|
|
if (isGroup) {
|
|
if (groupConfig?.enabled === false) {
|
|
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
return;
|
|
}
|
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
groupPolicy: feishuCfg?.groupPolicy,
|
|
defaultGroupPolicy,
|
|
});
|
|
warnMissingProviderGroupPolicyFallbackOnce({
|
|
providerMissingFallbackApplied,
|
|
providerKey: "feishu",
|
|
accountId: account.accountId,
|
|
log,
|
|
});
|
|
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
|
|
// 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}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
const effectiveSenderAllowFrom =
|
|
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
if (effectiveSenderAllowFrom.length > 0) {
|
|
const senderAllowed = isFeishuGroupAllowed({
|
|
groupPolicy: "allowlist",
|
|
allowFrom: effectiveSenderAllowFrom,
|
|
senderId: ctx.senderOpenId,
|
|
senderIds: [senderUserId],
|
|
senderName: ctx.senderName,
|
|
});
|
|
if (!senderAllowed) {
|
|
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
({ requireMention } = resolveFeishuReplyPolicy({
|
|
isDirectMessage: false,
|
|
globalConfig: feishuCfg,
|
|
groupConfig,
|
|
}));
|
|
|
|
if (requireMention && !ctx.mentionedBot) {
|
|
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
|
|
// Record to pending history for non-broadcast groups only. For broadcast groups,
|
|
// the mentioned handler's broadcast dispatch writes the turn directly into all
|
|
// agent sessions — buffering here would cause duplicate replay when this account
|
|
// later becomes active via buildPendingHistoryContextFromMap.
|
|
if (!broadcastAgents && chatHistories && groupHistoryKey) {
|
|
recordPendingHistoryEntryIfEnabled({
|
|
historyMap: chatHistories,
|
|
historyKey: groupHistoryKey,
|
|
limit: historyLimit,
|
|
entry: {
|
|
sender: ctx.senderOpenId,
|
|
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
timestamp: Date.now(),
|
|
messageId: ctx.messageId,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
} else {
|
|
}
|
|
|
|
try {
|
|
const core = getFeishuRuntime();
|
|
const pairing = createScopedPairingAccess({
|
|
core,
|
|
channel: "feishu",
|
|
accountId: account.accountId,
|
|
});
|
|
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
ctx.content,
|
|
cfg,
|
|
);
|
|
const storeAllowFrom =
|
|
!isGroup &&
|
|
dmPolicy !== "allowlist" &&
|
|
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
? await pairing.readAllowFromStore().catch(() => [])
|
|
: [];
|
|
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
const dmAllowed = resolveFeishuAllowlistMatch({
|
|
allowFrom: effectiveDmAllowFrom,
|
|
senderId: ctx.senderOpenId,
|
|
senderIds: [senderUserId],
|
|
senderName: ctx.senderName,
|
|
}).allowed;
|
|
|
|
if (isDirect && dmPolicy !== "open" && !dmAllowed) {
|
|
if (dmPolicy === "pairing") {
|
|
const { code, created } = await pairing.upsertPairingRequest({
|
|
id: ctx.senderOpenId,
|
|
meta: { name: ctx.senderName },
|
|
});
|
|
if (created) {
|
|
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
try {
|
|
await sendMessageFeishu({
|
|
cfg,
|
|
to: `chat:${ctx.chatId}`,
|
|
text: core.channel.pairing.buildPairingReply({
|
|
channel: "feishu",
|
|
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
code,
|
|
}),
|
|
accountId: account.accountId,
|
|
});
|
|
} catch (err) {
|
|
log(
|
|
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
log(
|
|
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const commandAllowFrom = isGroup
|
|
? (groupConfig?.allowFrom ?? configAllowFrom)
|
|
: effectiveDmAllowFrom;
|
|
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
|
|
allowFrom: commandAllowFrom,
|
|
senderId: ctx.senderOpenId,
|
|
senderIds: [senderUserId],
|
|
senderName: ctx.senderName,
|
|
}).allowed;
|
|
const commandAuthorized = shouldComputeCommandAuthorized
|
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
useAccessGroups,
|
|
authorizers: [
|
|
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
],
|
|
})
|
|
: undefined;
|
|
|
|
// 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 peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
|
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
|
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
|
|
|
if (isGroup && groupSession) {
|
|
log(
|
|
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
|
|
);
|
|
}
|
|
|
|
let route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: account.accountId,
|
|
peer: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: peerId,
|
|
},
|
|
parentPeer,
|
|
});
|
|
|
|
// 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: "direct", 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}`
|
|
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
|
|
// Do not enqueue inbound user previews as system events.
|
|
// System events are prepended to future prompts and can be misread as
|
|
// authoritative transcript turns.
|
|
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
|
|
|
|
// 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 = buildAgentMediaPayload(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);
|
|
const messageBody = buildFeishuAgentBody({
|
|
ctx,
|
|
quotedContent,
|
|
permissionErrorForAgent,
|
|
botOpenId,
|
|
});
|
|
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
if (permissionErrorForAgent) {
|
|
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
|
|
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
|
}
|
|
|
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
channel: "Feishu",
|
|
from: envelopeFrom,
|
|
timestamp: new Date(),
|
|
envelope: envelopeOptions,
|
|
body: messageBody,
|
|
});
|
|
|
|
let combinedBody = body;
|
|
const historyKey = groupHistoryKey;
|
|
|
|
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 inboundHistory =
|
|
isGroup && historyKey && historyLimit > 0 && chatHistories
|
|
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
|
|
sender: entry.sender,
|
|
body: entry.body,
|
|
timestamp: entry.timestamp,
|
|
}))
|
|
: undefined;
|
|
|
|
// --- Shared context builder for dispatch ---
|
|
const buildCtxPayloadForAgent = (
|
|
agentSessionKey: string,
|
|
agentAccountId: string,
|
|
wasMentioned: boolean,
|
|
) =>
|
|
core.channel.reply.finalizeInboundContext({
|
|
Body: combinedBody,
|
|
BodyForAgent: messageBody,
|
|
InboundHistory: inboundHistory,
|
|
ReplyToId: ctx.parentId,
|
|
RootMessageId: ctx.rootId,
|
|
RawBody: ctx.content,
|
|
CommandBody: ctx.content,
|
|
From: feishuFrom,
|
|
To: feishuTo,
|
|
SessionKey: agentSessionKey,
|
|
AccountId: agentAccountId,
|
|
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,
|
|
ReplyToBody: quotedContent ?? undefined,
|
|
Timestamp: Date.now(),
|
|
WasMentioned: wasMentioned,
|
|
CommandAuthorized: commandAuthorized,
|
|
OriginatingChannel: "feishu" as const,
|
|
OriginatingTo: feishuTo,
|
|
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
|
...mediaPayload,
|
|
});
|
|
|
|
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
const messageCreateTimeMs = event.message.create_time
|
|
? parseInt(event.message.create_time, 10)
|
|
: undefined;
|
|
// Determine reply target based on group session mode:
|
|
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
|
// root so the bot stays in the same thread.
|
|
// - Groups with explicit replyInThread config: reply to the root so the bot
|
|
// stays in the thread the user expects.
|
|
// - Normal groups (auto-detected threadReply from root_id): reply to the
|
|
// triggering message itself. Using rootId here would silently push the
|
|
// reply into a topic thread invisible in the main chat view (#32980).
|
|
const isTopicSession =
|
|
isGroup &&
|
|
(groupSession?.groupSessionScope === "group_topic" ||
|
|
groupSession?.groupSessionScope === "group_topic_sender");
|
|
const configReplyInThread =
|
|
isGroup &&
|
|
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
|
const replyTargetMessageId =
|
|
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
|
|
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
|
|
|
if (broadcastAgents) {
|
|
// Cross-account dedup: in multi-account setups, Feishu delivers the same
|
|
// event to every bot account in the group. Only one account should handle
|
|
// broadcast dispatch to avoid duplicate agent sessions and race conditions.
|
|
// Uses a shared "broadcast" namespace (not per-account) so the first handler
|
|
// to reach this point claims the message; subsequent accounts skip.
|
|
if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// --- Broadcast dispatch: send message to all configured agents ---
|
|
const strategy =
|
|
((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
|
|
?.strategy || "parallel";
|
|
const activeAgentId =
|
|
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
|
|
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
|
|
const hasKnownAgents = agentIds.length > 0;
|
|
|
|
log(
|
|
`feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
|
|
);
|
|
|
|
const dispatchForAgent = async (agentId: string) => {
|
|
if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
|
const agentCtx = buildCtxPayloadForAgent(
|
|
agentSessionKey,
|
|
route.accountId,
|
|
ctx.mentionedBot && agentId === activeAgentId,
|
|
);
|
|
|
|
if (agentId === activeAgentId) {
|
|
// Active agent: real Feishu dispatcher (responds on Feishu)
|
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
cfg,
|
|
agentId,
|
|
runtime: runtime as RuntimeEnv,
|
|
chatId: ctx.chatId,
|
|
replyToMessageId: replyTargetMessageId,
|
|
skipReplyToInMessages: !isGroup,
|
|
replyInThread,
|
|
rootId: ctx.rootId,
|
|
threadReply,
|
|
mentionTargets: ctx.mentionTargets,
|
|
accountId: account.accountId,
|
|
messageCreateTimeMs,
|
|
});
|
|
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
);
|
|
await core.channel.reply.withReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => markDispatchIdle(),
|
|
run: () =>
|
|
core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: agentCtx,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions,
|
|
}),
|
|
});
|
|
} else {
|
|
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
|
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
|
|
// mutate observer sessions — only the active agent should execute commands.
|
|
delete (agentCtx as Record<string, unknown>).CommandAuthorized;
|
|
const noopDispatcher = {
|
|
sendToolResult: () => false,
|
|
sendBlockReply: () => false,
|
|
sendFinalReply: () => false,
|
|
waitForIdle: async () => {},
|
|
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
markComplete: () => {},
|
|
};
|
|
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
);
|
|
await core.channel.reply.withReplyDispatcher({
|
|
dispatcher: noopDispatcher,
|
|
run: () =>
|
|
core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: agentCtx,
|
|
cfg,
|
|
dispatcher: noopDispatcher,
|
|
}),
|
|
});
|
|
}
|
|
};
|
|
|
|
if (strategy === "sequential") {
|
|
for (const agentId of broadcastAgents) {
|
|
try {
|
|
await dispatchForAgent(agentId);
|
|
} catch (err) {
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
|
|
for (let i = 0; i < results.length; i++) {
|
|
if (results[i].status === "rejected") {
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isGroup && historyKey && chatHistories) {
|
|
clearHistoryEntriesIfEnabled({
|
|
historyMap: chatHistories,
|
|
historyKey,
|
|
limit: historyLimit,
|
|
});
|
|
}
|
|
|
|
log(
|
|
`feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
|
|
);
|
|
} else {
|
|
// --- Single-agent dispatch (existing behavior) ---
|
|
const ctxPayload = buildCtxPayloadForAgent(
|
|
route.sessionKey,
|
|
route.accountId,
|
|
ctx.mentionedBot,
|
|
);
|
|
|
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
runtime: runtime as RuntimeEnv,
|
|
chatId: ctx.chatId,
|
|
replyToMessageId: replyTargetMessageId,
|
|
skipReplyToInMessages: !isGroup,
|
|
replyInThread,
|
|
rootId: ctx.rootId,
|
|
threadReply,
|
|
mentionTargets: ctx.mentionTargets,
|
|
accountId: account.accountId,
|
|
messageCreateTimeMs,
|
|
});
|
|
|
|
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => {
|
|
markDispatchIdle();
|
|
},
|
|
run: () =>
|
|
core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions,
|
|
}),
|
|
});
|
|
|
|
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)}`);
|
|
}
|
|
}
|