mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 07:20:59 +00:00
refactor: split remaining monitor runtime helpers
This commit is contained in:
471
extensions/feishu/src/bot-content.ts
Normal file
471
extensions/feishu/src/bot-content.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import type { FeishuMediaInfo } from "./types.js";
|
||||
|
||||
export type FeishuMention = {
|
||||
key: string;
|
||||
id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
name: string;
|
||||
tenant_key?: string;
|
||||
};
|
||||
|
||||
type FeishuMessageLike = {
|
||||
message: {
|
||||
content: string;
|
||||
message_type: string;
|
||||
mentions?: FeishuMention[];
|
||||
chat_id: string;
|
||||
root_id?: string;
|
||||
parent_id?: string;
|
||||
thread_id?: string;
|
||||
message_id: string;
|
||||
};
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
|
||||
export type ResolvedFeishuGroupSession = {
|
||||
peerId: string;
|
||||
parentPeer: { kind: "group"; id: string } | null;
|
||||
groupSessionScope: GroupSessionScope;
|
||||
replyInThread: boolean;
|
||||
threadReply: boolean;
|
||||
};
|
||||
|
||||
function buildFeishuConversationId(params: {
|
||||
chatId: string;
|
||||
scope: GroupSessionScope | "group_sender";
|
||||
topicId?: string;
|
||||
senderOpenId?: string;
|
||||
}): string {
|
||||
switch (params.scope) {
|
||||
case "group_sender":
|
||||
return `${params.chatId}:sender:${params.senderOpenId}`;
|
||||
case "group_topic":
|
||||
return `${params.chatId}:topic:${params.topicId}`;
|
||||
case "group_topic_sender":
|
||||
return `${params.chatId}:topic:${params.topicId}:sender:${params.senderOpenId}`;
|
||||
default:
|
||||
return params.chatId;
|
||||
}
|
||||
}
|
||||
|
||||
export 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");
|
||||
const topicScope =
|
||||
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
||||
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
||||
: null;
|
||||
|
||||
let peerId = chatId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({ chatId, scope: "group_topic", topicId: topicScope })
|
||||
: chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: topicScope,
|
||||
senderOpenId,
|
||||
})
|
||||
: buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
peerId = chatId;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
peerId,
|
||||
parentPeer:
|
||||
topicScope &&
|
||||
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
||||
? { kind: "group", id: chatId }
|
||||
: null,
|
||||
groupSessionScope,
|
||||
replyInThread,
|
||||
threadReply,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMessageContent(content: string, messageType: string): string {
|
||||
if (messageType === "post") {
|
||||
return parsePostContent(content).textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (messageType === "text") {
|
||||
return parsed.text || "";
|
||||
}
|
||||
if (messageType === "share_chat") {
|
||||
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()) {
|
||||
return share.body.trim();
|
||||
}
|
||||
if (typeof share.summary === "string" && share.summary.trim()) {
|
||||
return share.summary.trim();
|
||||
}
|
||||
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim()) {
|
||||
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
||||
}
|
||||
}
|
||||
return "[Forwarded message]";
|
||||
}
|
||||
if (messageType === "merge_forward") {
|
||||
return "[Merged and Forwarded Message - loading...]";
|
||||
}
|
||||
return content;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSubMessageContent(content: string, contentType: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
switch (contentType) {
|
||||
case "text":
|
||||
return parsed.text || content;
|
||||
case "post":
|
||||
return parsePostContent(content).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;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMergeForwardContent(params: {
|
||||
content: string;
|
||||
log?: (...args: any[]) => void;
|
||||
}): string {
|
||||
const { content, log } = params;
|
||||
const maxMessages = 50;
|
||||
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]";
|
||||
}
|
||||
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`);
|
||||
subMessages.sort(
|
||||
(a, b) => parseInt(a.create_time || "0", 10) - parseInt(b.create_time || "0", 10),
|
||||
);
|
||||
|
||||
const lines = ["[Merged and Forwarded Messages]"];
|
||||
for (const item of subMessages.slice(0, maxMessages)) {
|
||||
lines.push(`- ${formatSubMessageContent(item.body?.content || "", item.msg_type || "text")}`);
|
||||
}
|
||||
if (subMessages.length > maxMessages) {
|
||||
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function checkBotMentioned(event: FeishuMessageLike, botOpenId?: string): boolean {
|
||||
if (!botOpenId) {
|
||||
return false;
|
||||
}
|
||||
if ((event.message.content ?? "").includes("@_all")) {
|
||||
return true;
|
||||
}
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length > 0) {
|
||||
return mentions.some((mention) => mention.id.open_id === botOpenId);
|
||||
}
|
||||
if (event.message.message_type === "post") {
|
||||
return parsePostContent(event.message.content).mentionedOpenIds.some((id) => id === botOpenId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeMentions(
|
||||
text: string,
|
||||
mentions?: FeishuMention[],
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeFeishuCommandProbeBody(text: string): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return text
|
||||
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
|
||||
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export 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, fileName: parsed.file_name };
|
||||
case "file":
|
||||
case "audio":
|
||||
case "sticker":
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
case "video":
|
||||
case "media":
|
||||
return { fileKey, imageKey, fileName: parsed.file_name };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function toMessageResourceType(messageType: string): "image" | "file" {
|
||||
return messageType === "image" ? "image" : "file";
|
||||
}
|
||||
|
||||
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>";
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
|
||||
if (!mediaTypes.includes(messageType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: FeishuMediaInfo[] = [];
|
||||
const core = getFeishuRuntime();
|
||||
|
||||
if (messageType === "post") {
|
||||
const { imageKeys, mediaKeys } = parsePostContent(content);
|
||||
if (imageKeys.length === 0 && mediaKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (imageKeys.length > 0) {
|
||||
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
||||
}
|
||||
if (mediaKeys.length > 0) {
|
||||
log?.(`feishu: post message contains ${mediaKeys.length} embedded media file(s)`);
|
||||
}
|
||||
|
||||
for (const imageKey of imageKeys) {
|
||||
try {
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey: imageKey,
|
||||
type: "image",
|
||||
accountId,
|
||||
});
|
||||
const contentType =
|
||||
result.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 mediaKeys) {
|
||||
try {
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey: media.fileKey,
|
||||
type: "file",
|
||||
accountId,
|
||||
});
|
||||
const contentType =
|
||||
result.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;
|
||||
}
|
||||
|
||||
const mediaKeys = parseMediaKeys(content, messageType);
|
||||
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
|
||||
if (!fileKey) {
|
||||
return [];
|
||||
}
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey,
|
||||
type: toMessageResourceType(messageType),
|
||||
accountId,
|
||||
});
|
||||
const contentType =
|
||||
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
result.buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
result.fileName || mediaKeys.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;
|
||||
}
|
||||
@@ -22,13 +22,20 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import {
|
||||
checkBotMentioned,
|
||||
normalizeFeishuCommandProbeBody,
|
||||
normalizeMentions,
|
||||
parseMergeForwardContent,
|
||||
parseMessageContent,
|
||||
resolveFeishuGroupSession,
|
||||
resolveFeishuMediaList,
|
||||
toMessageResourceType,
|
||||
} from "./bot-content.js";
|
||||
import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
||||
import { finalizeFeishuMessageProcessing, 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,
|
||||
@@ -36,13 +43,14 @@ import {
|
||||
resolveFeishuAllowlistMatch,
|
||||
isFeishuGroupAllowed,
|
||||
} from "./policy.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
|
||||
import type { FeishuMessageContext, FeishuMediaInfo } from "./types.js";
|
||||
import type { FeishuMessageContext } from "./types.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
export { toMessageResourceType } from "./bot-content.js";
|
||||
|
||||
// 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>();
|
||||
@@ -91,546 +99,6 @@ export type FeishuBotAddedEvent = {
|
||||
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 = buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId: topicScope,
|
||||
})
|
||||
: chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: topicScope,
|
||||
senderOpenId,
|
||||
})
|
||||
: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_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): 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) {
|
||||
// Rely on Feishu mention IDs; display names can vary by alias/context.
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
function normalizeFeishuCommandProbeBody(text: string): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return text
|
||||
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
|
||||
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, fileName: parsed.file_name };
|
||||
case "file":
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
case "audio":
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
case "video":
|
||||
case "media":
|
||||
// Video/media has both file_key (video) and image_key (thumbnail)
|
||||
return { fileKey, imageKey, fileName: parsed.file_name };
|
||||
case "sticker":
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user