import { formatReactionSummary } from "./botHelpers.ts"; import { getBotName } from "../settings/agentStack.ts"; import type { BotContext } from "./botContext.ts"; import { CONVERSATION_HISTORY_PROMPT_LIMIT, CONVERSATION_HISTORY_PROMPT_MAX_AGE_HOURS, CONVERSATION_HISTORY_PROMPT_WINDOW_AFTER, CONVERSATION_HISTORY_PROMPT_WINDOW_BEFORE } from "./replyPipelineShared.ts";
const IMAGE_EXT_RE = /.(png|jpe?g|gif|webp|bmp|heic|heif)$/i; const VIDEO_EXT_RE = /.(mp4|m4v|mov|webm|mkv|avi|mpeg|mpg)$/i; const ANIMATED_IMAGE_EXT_RE = /.gif$/i; const MAX_IMAGE_INPUTS = 3; const MAX_VIDEO_INPUTS = 3;
type HistoryAttachment = { url?: string; proxyURL?: string; name?: string; contentType?: string; };
type HistoryAttachmentCollection = { size?: number; values: () => IterableIterator; };
type HistoryEmbed = { url?: string; type?: string; video?: { url?: string; proxyURL?: string; } | null; };
type HistoryReactionCollection = { size?: number; values: () => IterableIterator<{ count?: number; emoji?: { id?: string; name?: string; } | null; }>; };
type HistoryMember = { displayName?: string | null; user?: { username?: string; } | null; };
type HistoryMessage = { id?: string; createdTimestamp?: number; guildId?: string; channelId?: string; content?: string; attachments?: HistoryAttachmentCollection; embeds?: HistoryEmbed[]; reactions?: { cache?: HistoryReactionCollection; } | null; reference?: { messageId?: string; } | null; partial?: boolean; fetch?: () => Promise; author?: { id?: string; username?: string; bot?: boolean; } | null; member?: { displayName?: string | null; } | null; guild?: { members?: { cache?: { get?: (id: string) => HistoryMember | undefined; } | null; } | null; } | null; };
type HistoryReaction = { partial?: boolean; fetch?: () => Promise; emoji?: { id?: string; name?: string; } | null; message?: HistoryMessage | null; };
type HistoryUser = { id?: string; username?: string; globalName?: string | null; };
type ConversationHistoryOptions = { guildId?: string | null; channelId?: string | null; queryText?: string; limit?: number; maxAgeHours?: number; before?: number; after?: number; };
function getBotUserId(ctx: BotContext) { return String(ctx.botUserId || ctx.client.user?.id || "").trim(); }
async function resolvePartialMessage(message: HistoryMessage | null | undefined) { if (!message) return null; let resolved = message; if (resolved.partial && typeof resolved.fetch === "function") { try { resolved = await resolved.fetch(); } catch { return null; } } return resolved; }
async function resolvePartialReaction(reaction: HistoryReaction | null | undefined) { if (!reaction) return null; let resolved = reaction; if (resolved.partial && typeof resolved.fetch === "function") { try { resolved = await resolved.fetch(); } catch { return null; } } return resolved; }
export function composeMessageContentForHistory(message: HistoryMessage, baseText = "") { const parts = []; const text = String(baseText || "").trim(); if (text) parts.push(text);
if (message?.attachments?.size) { for (const attachment of message.attachments.values()) { const url = String(attachment.url || attachment.proxyURL || "").trim(); if (!url) continue; parts.push(url); } }
if (Array.isArray(message?.embeds) && message.embeds.length) { for (const embed of message.embeds) { const videoUrl = String(embed?.video?.url || embed?.video?.proxyURL || "").trim(); const embedUrl = String(embed?.url || "").trim(); if (videoUrl) parts.push(videoUrl); if (embedUrl) parts.push(embedUrl); } }
const reactionSummary = formatReactionSummary(message);
if (reactionSummary) {
parts.push([reactions: ${reactionSummary}]);
}
return parts.join(" ").replace(/\s+/g, " ").trim(); }
export function getImageInputs(message: HistoryMessage) { const images = [];
for (const attachment of message.attachments?.values?.() || []) { if (images.length >= MAX_IMAGE_INPUTS) break;
const url = String(attachment.url || attachment.proxyURL || "").trim();
if (!url) continue;
const filename = String(attachment.name || "").trim();
const contentType = String(attachment.contentType || "").toLowerCase();
const urlPath = url.split("?")[0];
const isAnimatedImage = contentType === "image/gif" || ANIMATED_IMAGE_EXT_RE.test(filename) || ANIMATED_IMAGE_EXT_RE.test(urlPath);
const isImage =
contentType.startsWith("image/") || IMAGE_EXT_RE.test(filename) || IMAGE_EXT_RE.test(urlPath);
if (!isImage || isAnimatedImage) continue;
images.push({ url, filename, contentType });
}
return images; }
function deriveFilenameFromUrl(url: string, fallback = "(unnamed)") { const normalizedUrl = String(url || "").trim(); if (!normalizedUrl) return fallback; try { const parsed = new URL(normalizedUrl); const segment = parsed.pathname.split("/").filter(Boolean).at(-1) || ""; const decoded = decodeURIComponent(segment).trim(); return decoded || fallback; } catch { const segment = normalizedUrl.split("?")[0].split("/").filter(Boolean).at(-1) || ""; const decoded = decodeURIComponent(segment).trim(); return decoded || fallback; } }
function isLikelyVideoAttachment({ url, filename, contentType }: { url: string; filename: string; contentType: string; }) { const urlPath = String(url || "").split("?")[0]; return ( String(contentType || "").startsWith("video/") || String(contentType || "").toLowerCase() === "image/gif" || VIDEO_EXT_RE.test(String(filename || "")) || VIDEO_EXT_RE.test(urlPath) || ANIMATED_IMAGE_EXT_RE.test(String(filename || "")) || ANIMATED_IMAGE_EXT_RE.test(urlPath) ); }
export function getVideoInputs(message: HistoryMessage) { const videos = []; const seen = new Set(); const pushVideo = (entry: { url: string; filename: string; contentType: string }) => { const url = String(entry.url || "").trim(); if (!url || seen.has(url) || videos.length >= MAX_VIDEO_INPUTS) return; seen.add(url); videos.push({ url, filename: String(entry.filename || "(unnamed)").trim() || "(unnamed)", contentType: String(entry.contentType || "").toLowerCase() }); };
for (const attachment of message.attachments?.values?.() || []) { if (videos.length >= MAX_VIDEO_INPUTS) break;
const url = String(attachment.url || attachment.proxyURL || "").trim();
if (!url) continue;
const filename = String(attachment.name || "").trim() || deriveFilenameFromUrl(url);
const contentType = String(attachment.contentType || "").toLowerCase();
if (!isLikelyVideoAttachment({ url, filename, contentType })) continue;
pushVideo({ url, filename, contentType });
}
for (const embed of Array.isArray(message.embeds) ? message.embeds : []) { if (videos.length >= MAX_VIDEO_INPUTS) break;
const embedVideoUrl = String(embed?.video?.url || embed?.video?.proxyURL || "").trim();
if (embedVideoUrl) {
const filename = deriveFilenameFromUrl(embedVideoUrl);
pushVideo({
url: embedVideoUrl,
filename,
contentType: ""
});
continue;
}
const embedUrl = String(embed?.url || "").trim();
if (!embedUrl) continue;
const filename = deriveFilenameFromUrl(embedUrl);
const embedType = String(embed?.type || "").toLowerCase();
if (embedType !== "video" && !isLikelyVideoAttachment({ url: embedUrl, filename, contentType: "" })) continue;
pushVideo({
url: embedUrl,
filename,
contentType: ""
});
}
return videos; }
export async function syncMessageSnapshotFromReaction( ctx: BotContext, reaction: HistoryReaction | null | undefined ) { const resolvedReaction = await resolvePartialReaction(reaction); if (!resolvedReaction) return; await syncMessageSnapshot(ctx, resolvedReaction.message); }
export async function recordReactionHistoryEvent( ctx: BotContext, reaction: HistoryReaction | null | undefined, user: HistoryUser | null | undefined ) { if (!reaction || !user) return;
const resolvedReaction = await resolvePartialReaction(reaction); if (!resolvedReaction) return;
const targetMessage = await resolvePartialMessage(resolvedReaction.message); const botUserId = getBotUserId(ctx); const targetAuthorId = String(targetMessage?.author?.id || "").trim(); if (!botUserId || targetAuthorId !== botUserId) return;
const reactingUserId = String(user.id || "").trim(); if (!reactingUserId || reactingUserId === botUserId) return;
const channelId = String(targetMessage?.channelId || "").trim(); const guildId = String(targetMessage?.guildId || "").trim(); const targetMessageId = String(targetMessage?.id || "").trim(); if (!channelId || !guildId || !targetMessageId) return;
const reactionLabel = describeReactionForHistory(resolvedReaction.emoji); if (!reactionLabel) return;
const reactingMember = targetMessage?.guild?.members?.cache?.get?.(reactingUserId);
const reactingName = String(
reactingMember?.displayName || user.globalName || user.username || reactingUserId
).trim();
const targetAuthorName = String(
targetMessage?.member?.displayName ||
targetMessage?.author?.username ||
getBotName(ctx.store.getSettings())
).trim();
const targetSnippet = summarizeReactionTargetText(String(targetMessage?.content || ""));
const content = [
${reactingName} reacted with ${reactionLabel} to ${targetAuthorName}'s message,
targetSnippet ? "${targetSnippet}" : ""
]
.filter(Boolean)
.join(": ");
ctx.store.recordMessage({ messageId: buildReactionEventMessageId({ targetMessageId, reactingUserId, emoji: reactionLabel, createdAt: Date.now() }), createdAt: Date.now(), guildId, channelId, authorId: reactingUserId, authorName: reactingName, isBot: false, content, referencedMessageId: targetMessageId }); }
export async function syncMessageSnapshot( ctx: BotContext, message: HistoryMessage | null | undefined ) { const resolved = await resolvePartialMessage(message); if (!resolved?.guildId || !resolved?.channelId || !resolved?.id || !resolved?.author?.id) return;
ctx.store.recordMessage({ messageId: resolved.id, createdAt: resolved.createdTimestamp, guildId: resolved.guildId, channelId: resolved.channelId, authorId: resolved.author.id, authorName: resolved.member?.displayName || resolved.author.username || "unknown", isBot: Boolean(resolved.author.bot), content: composeMessageContentForHistory(resolved, String(resolved.content || "").trim()), referencedMessageId: resolved.reference?.messageId }); }
export async function getConversationHistoryForPrompt(
ctx: BotContext,
{
guildId = null,
channelId = null,
queryText = "",
limit = CONVERSATION_HISTORY_PROMPT_LIMIT,
maxAgeHours = CONVERSATION_HISTORY_PROMPT_MAX_AGE_HOURS,
before = CONVERSATION_HISTORY_PROMPT_WINDOW_BEFORE,
after = CONVERSATION_HISTORY_PROMPT_WINDOW_AFTER
}: ConversationHistoryOptions = {}
) {
if (!ctx.store || typeof ctx.store.searchConversationWindows !== "function") return [];
const normalizedGuildId = String(guildId || "").trim() || null;
const normalizedChannelId = String(channelId || "").trim() || null;
if (!normalizedGuildId && !normalizedChannelId) return [];
const normalizedQuery = String(queryText || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 320);
if (!normalizedQuery) return [];
try {
if (ctx.memory && typeof ctx.memory.searchConversationHistory === "function") {
return await ctx.memory.searchConversationHistory({
guildId: normalizedGuildId,
channelId: normalizedChannelId,
queryText: normalizedQuery,
settings: ctx.store.getSettings(),
trace: {
guildId: normalizedGuildId,
channelId: normalizedChannelId,
source: "conversation_history_prompt"
},
limit,
maxAgeHours,
before,
after
});
}
return ctx.store.searchConversationWindows({
guildId: normalizedGuildId,
channelId: normalizedChannelId,
queryText: normalizedQuery,
limit,
maxAgeHours,
before,
after
});
} catch (error) {
try {
ctx.store.logAction({
kind: "bot_error",
guildId: normalizedGuildId || null,
channelId: normalizedChannelId,
userId: getBotUserId(ctx) || null,
content: conversation_history_search: ${String(error?.message || error)}
});
} catch {
// Logging must not mask the original prompt-context fallback path.
}
return [];
}
}
export function isLikelyImageUrl(rawUrl: string) { const text = String(rawUrl || "").trim(); if (!text) return false; try { const parsed = new URL(text); const pathname = String(parsed.pathname || "").toLowerCase(); if (IMAGE_EXT_RE.test(pathname) || pathname.endsWith(".avif")) return true; const formatParam = String(parsed.searchParams.get("format") || "") .trim() .toLowerCase(); if (formatParam && /^(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/.test(formatParam)) return true; return false; } catch { return false; } }
export function parseHistoryImageReference(rawUrl: string) { const text = String(rawUrl || "").trim(); if (!text) return { filename: "(unnamed)", contentType: "" }; try { const parsed = new URL(text); const pathname = String(parsed.pathname || ""); const segment = pathname.split("/").pop() || ""; const decoded = decodeURIComponent(segment || ""); const fallback = decoded || segment || "(unnamed)"; const ext = fallback.includes(".") ? fallback.split(".").pop() : ""; let contentType = normalizeImageContentTypeFromExt(ext); if (!contentType) { const formatParam = String(parsed.searchParams.get("format") || "") .trim() .toLowerCase(); contentType = normalizeImageContentTypeFromExt(formatParam); } return { filename: fallback, contentType }; } catch { return { filename: "(unnamed)", contentType: "" }; } }
function normalizeImageContentTypeFromExt(rawExt: string) { const ext = String(rawExt || "") .trim() .toLowerCase() .replace(/^./, ""); if (!ext) return ""; if (ext === "jpg" || ext === "jpeg") return "image/jpeg"; if (ext === "png") return "image/png"; if (ext === "gif") return "image/gif"; if (ext === "webp") return "image/webp"; if (ext === "bmp") return "image/bmp"; if (ext === "heic") return "image/heic"; if (ext === "heif") return "image/heif"; if (ext === "avif") return "image/avif"; return ""; }
function describeReactionForHistory(emoji: HistoryReaction["emoji"]) {
const id = String(emoji?.id || "").trim();
const name = String(emoji?.name || "").trim();
if (id && name) return :${name}:;
if (name) return name;
return "";
}
function summarizeReactionTargetText(text: string, maxLen = 80) {
const normalized = String(text || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return "";
if (normalized.length <= maxLen) return normalized;
return ${normalized.slice(0, Math.max(1, maxLen - 3)).trimEnd()}...;
}
function buildReactionEventMessageId({
targetMessageId,
reactingUserId,
emoji,
createdAt
}: {
targetMessageId: string;
reactingUserId: string;
emoji: string;
createdAt: number;
}) {
const safeEmoji = String(emoji || "")
.trim()
.replace(/\s+/g, "_")
.slice(0, 32);
return reaction:${targetMessageId}:${reactingUserId}:${safeEmoji}:${Number(createdAt) || Date.now()};
}
