src/bot/botHelpers.ts

import { clamp } from "../utils.ts"; import { normalizeAutomationSchedule } from "./automation.ts"; import { normalizeMentionLookupKey } from "./mentionLookup.ts"; import { normalizeWhitespaceText } from "../normalization/text.ts"; import { extractJsonObjectFromText } from "../normalization/jsonExtraction.ts"; import { getDiscoverySettings } from "../settings/agentStack.ts";

const URL_IN_TEXT_RE = /https?://[^\s<>()]+/gi; const STRUCTURED_REPLY_CODE_FENCE_OPEN_RE = /^```(?:json)?\s*/i; const STRUCTURED_REPLY_TEXT_FIELD_RE = /"text"\s*:\s*"((?:\.|[^"\]))"/s; const STRUCTURED_REPLY_SKIP_TRUE_RE = /"skip"\s:\s*true\b/i;

type ParseState = "json" | "recovered_json" | "unstructured"; // English-only fallback for explicit user opt-outs; normal prompt/tool policy remains the source of truth. const EN_WEB_SEARCH_OPTOUT_RE = /\b(?:do\snot|don't|dont|no)\b[\w\s,]{0,24}\b(?:google|search|look\sup)\b/i; const DEFAULT_MAX_MEDIA_PROMPT_LEN = 900; const MAX_MEDIA_PROMPT_FLOOR = 120; const MAX_MEDIA_PROMPT_CEILING = 2000; export const MAX_WEB_QUERY_LEN = 220; export const MAX_GIF_QUERY_LEN = 120; export const MAX_IMAGE_LOOKUP_QUERY_LEN = 220; export const MAX_BROWSER_BROWSE_QUERY_LEN = 500; const MAX_REPLY_TEXT_LEN = 3600; const MAX_INITIATIVE_TEXT_LEN = 3600; const MAX_INITIATIVE_REASON_LEN = 240; const MAX_INITIATIVE_CHANNEL_ID_LEN = 80; const MAX_AUTOMATION_TITLE_LEN = 90; const MAX_AUTOMATION_INSTRUCTION_LEN = 360; const MAX_AUTOMATION_TARGET_QUERY_LEN = 180;

export function resolveMaxMediaPromptLen(settings) { const raw = Number(getDiscoverySettings(settings).maxMediaPromptChars); if (!Number.isFinite(raw)) return DEFAULT_MAX_MEDIA_PROMPT_LEN; return clamp(Math.floor(raw), MAX_MEDIA_PROMPT_FLOOR, MAX_MEDIA_PROMPT_CEILING); } const REPLY_MEDIA_TYPES = new Set(["image_simple", "image_complex", "video", "gif", "tool_images"]); const REPLY_AUTOMATION_OPERATION_TYPES = new Set(["create", "pause", "resume", "delete", "list", "none"]); const REPLY_SCREEN_SHARE_ACTION_TYPES = new Set(["start_watch", "none"]); const MAX_SCREEN_SHARE_REASON_LEN = 180; const MENTION_CANDIDATE_RE = /(?<![\w<])@([a-z0-9][a-z0-9 ._'-]{0,63})/gi; export const MAX_MENTION_CANDIDATES = 8; const MAX_MENTION_LOOKUP_VARIANTS = 8;

function emptyStructuredAutomationAction() { return { operation: null, title: null, instruction: null, schedule: null, targetQuery: null, automationId: null, runImmediately: false, targetChannelId: null }; }

function emptyStructuredScreenShareIntent() { return { action: null, confidence: 0, reason: null }; }

export const REPLY_OUTPUT_SCHEMA = { type: "object", additionalProperties: false, properties: { text: { type: "string" }, skip: { type: "boolean" }, reactionEmoji: { type: ["string", "null"] }, media: { anyOf: [ { type: "null" }, { type: "object", additionalProperties: false, properties: { type: { type: "string", enum: ["image_simple", "image_complex", "video", "gif", "tool_images", "none"] }, prompt: { type: ["string", "null"] } }, required: ["type", "prompt"] } ] }, automationAction: { type: "object", additionalProperties: false, properties: { operation: { type: "string", enum: ["create", "pause", "resume", "delete", "list", "none"] }, title: { type: ["string", "null"] }, instruction: { type: ["string", "null"] }, schedule: { anyOf: [ { type: "null" }, { type: "object", additionalProperties: false, properties: { kind: { type: "string", enum: ["daily", "interval", "once"] }, hour: { type: ["number", "null"] }, minute: { type: ["number", "null"] }, everyMinutes: { type: ["number", "null"] }, atIso: { type: ["string", "null"] } }, required: ["kind", "hour", "minute", "everyMinutes", "atIso"] } ] }, targetQuery: { type: ["string", "null"] }, automationId: { type: ["number", "null"] }, runImmediately: { type: "boolean" }, targetChannelId: { type: ["string", "null"] } }, required: [ "operation", "title", "instruction", "schedule", "targetQuery", "automationId", "runImmediately", "targetChannelId" ] }, screenWatchIntent: { type: "object", additionalProperties: false, properties: { action: { type: "string", enum: ["start_watch", "none"] }, confidence: { type: "number" }, reason: { type: ["string", "null"] } }, required: ["action", "confidence", "reason"] }, }, required: [ "text", "skip", "reactionEmoji", "media", "automationAction", "screenWatchIntent" ] };

export const REPLY_OUTPUT_JSON_SCHEMA = JSON.stringify(REPLY_OUTPUT_SCHEMA);

const INITIATIVE_OUTPUT_SCHEMA = { type: "object", additionalProperties: false, properties: { action: { type: "string", enum: ["post_now", "hold", "drop"] }, channelId: { type: ["string", "null"] }, replyToMessageId: { type: ["string", "null"] }, text: { type: "string" }, mediaDirective: { type: "string", enum: ["none", "image", "video", "gif"] }, mediaPrompt: { type: ["string", "null"] }, reason: { type: "string" } }, required: ["action", "channelId", "text", "mediaDirective", "mediaPrompt", "reason"] };

export const INITIATIVE_OUTPUT_JSON_SCHEMA = JSON.stringify(INITIATIVE_OUTPUT_SCHEMA);

export function formatReactionSummary(message) { const cache = message?.reactions?.cache; if (!cache?.size) return "";

const rows = []; for (const reaction of cache.values()) { const count = Number(reaction?.count || 0); if (!Number.isFinite(count) || count <= 0) continue; const label = normalizeReactionLabel(reaction?.emoji); if (!label) continue; rows.push({ label, count }); }

if (!rows.length) return "";

rows.sort((a, b) => { if (b.count !== a.count) return b.count - a.count; return a.label.localeCompare(b.label); });

return rows .slice(0, 6) .map((row) => ${row.label}x${row.count}) .join(", "); }

function normalizeReactionLabel(emoji) { const id = String(emoji?.id || "").trim(); const rawName = String(emoji?.name || "").trim(); if (id) { const safe = sanitizeReactionLabel(rawName); return safe ? custom:${safe} : custom:${id}; } if (!rawName) return "";

const safe = sanitizeReactionLabel(rawName); if (safe) return safe;

const codepoints = [...rawName] .map((char) => char.codePointAt(0)) .filter((value) => Number.isFinite(value)) .map((value) => value.toString(16)); if (!codepoints.length) return ""; return u${codepoints.join("_")}; }

function sanitizeReactionLabel(value) { return String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9_+-]+/g, "") .slice(0, 32); }

export function extractUrlsFromText(text) { URL_IN_TEXT_RE.lastIndex = 0; return [...String(text || "").matchAll(URL_IN_TEXT_RE)].map((match) => String(match[0] || "")); }

export function emptyMentionResolution() { return { text: "", attemptedCount: 0, resolvedCount: 0, ambiguousCount: 0, unresolvedCount: 0 }; }

export function extractMentionCandidates(text, maxItems = MAX_MENTION_CANDIDATES) { const source = String(text || ""); if (!source.includes("@")) return [];

const out = []; MENTION_CANDIDATE_RE.lastIndex = 0; let match; while ((match = MENTION_CANDIDATE_RE.exec(source)) && out.length < Math.max(1, Number(maxItems) || 1)) { const rawCandidate = String(match[1] || ""); const withoutTrailingSpace = rawCandidate.replace(/\s+$/g, ""); const withoutTrailingPunctuation = withoutTrailingSpace .replace(/[.,:;!?)]}]+$/g, "") .replace(/\s+$/g, ""); const start = match.index; const variants = buildMentionLookupVariants({ mentionText: withoutTrailingPunctuation, mentionStart: start }); if (!variants.length) continue; const end = variants[0].end; if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start + 1) continue;

out.push({
  start,
  end,
  variants
});

}

return out; }

function buildMentionLookupVariants({ mentionText, mentionStart }) { const source = String(mentionText || "").trim(); if (!source) return [];

const wordRe = /[a-z0-9][a-z0-9._'-]*/gi; const tokens = []; let token; while ((token = wordRe.exec(source))) { tokens.push({ end: token.index + String(token[0] || "").length }); } if (!tokens.length) return [];

const variants = []; const seen = new Set(); const maxTokens = Math.min(tokens.length, MAX_MENTION_LOOKUP_VARIANTS); for (let count = maxTokens; count >= 1; count -= 1) { const tokenEnd = tokens[count - 1]?.end; if (!Number.isFinite(tokenEnd) || tokenEnd <= 0) continue; const prefix = source.slice(0, tokenEnd).replace(/\s+$/g, ""); if (!prefix) continue; if (/^\d{2,}$/.test(prefix)) continue; const lookupKey = normalizeMentionLookupKey(prefix); if (!lookupKey || lookupKey === "everyone" || lookupKey === "here") continue; if (seen.has(lookupKey)) continue; seen.add(lookupKey); variants.push({ lookupKey, end: mentionStart + 1 + prefix.length }); }

return variants; }

export function collectMemberLookupKeys(member) { const keys = new Set(); const values = [ member?.displayName, member?.nickname, member?.user?.globalName, member?.user?.username ];

for (const value of values) { const normalized = normalizeMentionLookupKey(value); if (!normalized) continue; keys.add(normalized); }

return keys; }

function normalizeMediaPromptContext(rawText) { URL_IN_TEXT_RE.lastIndex = 0; return String(rawText || "") .replace(URL_IN_TEXT_RE, "") .replace(/\s+/g, " ") .trim() .slice(0, 260); }

function composeMediaPrompt({ promptText, contextText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN, memoryFacts = [], intro, contextLabel, fallbackScene, fallbackContext, styleGuidance = [], hardConstraints = [] }) { const requested = normalizeDirectiveText(promptText, maxLen); const memoryHints = formatMediaMemoryHints(memoryFacts, 5);

return [ intro, Scene: ${requested || contextText || fallbackScene}., ${contextLabel}: ${contextText || fallbackContext}., memoryHints || null, "Style guidance:", ...styleGuidance, "Hard constraints:", ...hardConstraints ] .filter(Boolean) .join(" "); }

export function composeDiscoveryImagePrompt( imagePrompt, postText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN, memoryFacts = [] ) { const topic = normalizeMediaPromptContext(postText); return composeMediaPrompt({ promptText: imagePrompt, contextText: topic, maxLen, memoryFacts, intro: "Create a vivid, shareable image for a Discord post.", contextLabel: "Mood/topic context (do not render as text)", fallbackScene: "general chat mood", fallbackContext: "general chat mood", styleGuidance: [ "- Describe a concrete scene with a clear subject, action, and environment.", "- Use cinematic or editorial framing: strong focal point, depth of field, deliberate camera angle.", "- Include expressive lighting (golden hour, neon glow, dramatic chiaroscuro, soft diffused, etc.).", "- Choose a cohesive color palette that reinforces the mood.", "- Favor a specific visual medium when it fits (photo-realistic, illustration, 3D render, pixel art, watercolor, cel-shaded, collage)." ], hardConstraints: [ "- Absolutely no visible text, letters, numbers, logos, subtitles, captions, UI elements, or watermarks anywhere in the image.", "- Do not render any words from the scene description or topic context as text inside the image.", "- Keep the composition clean with a single strong focal point." ] }); }

export function composeDiscoveryVideoPrompt( videoPrompt, postText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN, memoryFacts = [] ) { const topic = normalizeMediaPromptContext(postText); return composeMediaPrompt({ promptText: videoPrompt, contextText: topic, maxLen, memoryFacts, intro: "Create a short, dynamic, shareable video clip for a Discord post.", contextLabel: "Mood/topic context (do not render as text)", fallbackScene: "general chat mood", fallbackContext: "general chat mood", styleGuidance: [ "- Describe a concrete motion arc: what the viewer sees at the start, what changes, and how it resolves.", "- Specify camera behavior (slow pan, tracking shot, static wide, zoom-in, dolly, handheld shake).", "- Include lighting mood and color palette.", "- Keep the action legible in a short social-clip format (3-6 seconds of clear motion)." ], hardConstraints: [ "- No visible text, captions, subtitles, logos, watermarks, or UI overlays.", "- Smooth, continuous motion without abrupt jumps or flicker." ] }); }

function formatMediaMemoryHints(memoryFacts = [], maxItems = 5) { const out = collectMemoryFactHints(memoryFacts, maxItems); if (!out.length) return ""; return Relevant memory facts (use only when they match the scene): ${out.join(" | ")}; }

export function collectMemoryFactHints(memoryFacts = [], maxItems = 5) { const rows = Array.isArray(memoryFacts) ? memoryFacts : []; const out = []; const seen = new Set(); const cap = Math.max(1, Math.floor(Number(maxItems) || 5));

for (const row of rows) { const value = typeof row === "string" ? row : row?.fact; const normalized = String(value || "") .replace(/\s+/g, " ") .trim() .slice(0, 140); if (!normalized) continue; const key = normalized.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(normalized); if (out.length >= cap) break; }

return out; }

export function composeReplyImagePrompt( imagePrompt, replyText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN, memoryFacts = [] ) { const context = normalizeMediaPromptContext(replyText); return composeMediaPrompt({ promptText: imagePrompt, contextText: context, maxLen, memoryFacts, intro: "Create a vivid image to accompany a Discord chat reply.", contextLabel: "Conversational context (do not render as text)", fallbackScene: "chat reaction", fallbackContext: "chat context", styleGuidance: [ "- Describe a concrete scene with a clear subject, action, and setting.", "- Use expressive framing and lighting to sell the mood.", "- Pick a visual medium that fits the tone (photo, illustration, 3D render, pixel art, etc.)." ], hardConstraints: [ "- No visible text, letters, numbers, logos, subtitles, captions, UI, or watermarks.", "- Keep the composition clean with one clear focal point." ] }); }

export function composeReplyVideoPrompt( videoPrompt, replyText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN, memoryFacts = [] ) { const context = normalizeMediaPromptContext(replyText); return composeMediaPrompt({ promptText: videoPrompt, contextText: context, maxLen, memoryFacts, intro: "Create a short, dynamic video clip to accompany a Discord chat reply.", contextLabel: "Conversational context (do not render as text)", fallbackScene: "chat reaction", fallbackContext: "chat context", styleGuidance: [ "- Describe a concrete motion arc: what starts, what changes, how it ends.", "- Specify camera behavior (pan, tracking, zoom, static, handheld).", "- Include lighting and color palette.", "- Keep the action clear in a short social-clip format." ], hardConstraints: [ "- No visible text, captions, subtitles, logos, watermarks, or UI overlays.", "- Smooth, continuous motion." ] }); }

export function parseStructuredReplyOutput(rawText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN) { const fallbackText = String(rawText || "").trim(); const parsed = extractJsonObjectFromText(fallbackText); if (!parsed) { const recoveredText = recoverStructuredReplyText(fallbackText); if (recoveredText !== null) { return { text: recoveredText, imagePrompt: null, complexImagePrompt: null, videoPrompt: null, gifQuery: null, mediaDirective: null, reactionEmoji: null, automationAction: emptyStructuredAutomationAction(),

    screenWatchIntent: emptyStructuredScreenShareIntent(),
    parseState: "recovered_json" as ParseState
  };
}

return {
  text: "",
  imagePrompt: null,
  complexImagePrompt: null,
  videoPrompt: null,
  gifQuery: null,
  mediaDirective: null,
  reactionEmoji: null,
  automationAction: emptyStructuredAutomationAction(),
  screenWatchIntent: emptyStructuredScreenShareIntent(),
  parseState: "unstructured" as ParseState
};

}

const baseText = normalizeDirectiveMessageText(parsed?.text, MAX_REPLY_TEXT_LEN); const skip = parsed?.skip === true; const text = skip ? "[SKIP]" : baseText; const reactionEmoji = normalizeDirectiveText(parsed?.reactionEmoji, 64) || null; const automationAction = normalizeStructuredAutomationAction(parsed?.automationAction); const mediaDirective = normalizeStructuredMediaDirective(parsed?.media, maxLen); const screenWatchIntent = normalizeStructuredScreenShareIntent(parsed?.screenWatchIntent);

return { text: text || "", imagePrompt: mediaDirective?.type === "image_simple" ? mediaDirective.prompt : null, complexImagePrompt: mediaDirective?.type === "image_complex" ? mediaDirective.prompt : null, videoPrompt: mediaDirective?.type === "video" ? mediaDirective.prompt : null, gifQuery: mediaDirective?.type === "gif" ? mediaDirective.prompt : null, mediaDirective, reactionEmoji, automationAction, screenWatchIntent, parseState: "json" as ParseState }; }

export function parseStructuredInitiativeOutput(rawText, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN) { const fallbackText = String(rawText || "").trim(); const parsed = extractJsonObjectFromText(fallbackText); if (!parsed) { return { action: "drop", skip: true, channelId: null, replyToMessageId: null, text: "", mediaDirective: "none", mediaPrompt: null, reason: "", contractViolation: false, contractViolationReason: null, parseState: "unstructured" as ParseState }; }

const rawAction = String( parsed?.action ?? (parsed?.skip === true ? "drop" : "post_now") ) .trim() .toLowerCase(); const action = rawAction === "post_now" || rawAction === "hold" || rawAction === "drop" ? rawAction : "drop"; const skip = action === "drop"; const channelId = skip ? null : normalizeDirectiveText(parsed?.channelId, MAX_INITIATIVE_CHANNEL_ID_LEN) || null; const replyToMessageId = skip ? null : normalizeDirectiveText(parsed?.replyToMessageId, MAX_INITIATIVE_CHANNEL_ID_LEN) || null; const text = skip ? "" : normalizeDirectiveMessageText(parsed?.text, MAX_INITIATIVE_TEXT_LEN) || ""; const rawMediaDirective = String(parsed?.mediaDirective || "none").trim().toLowerCase(); const mediaDirective: "none" | "image" | "video" | "gif" = rawMediaDirective === "image" || rawMediaDirective === "video" || rawMediaDirective === "gif" ? rawMediaDirective : "none"; const mediaPrompt = mediaDirective === "none" ? null : normalizeDirectiveText(parsed?.mediaPrompt, maxLen) || null; const reason = normalizeDirectiveText(parsed?.reason, MAX_INITIATIVE_REASON_LEN) || ""; const contractViolation = action !== "drop" && (!channelId || !text); const contractViolationReason = !contractViolation ? null : !channelId && !text ? "missing_channel_id_and_text" : !channelId ? "missing_channel_id" : "missing_text";

return { action, skip, channelId: skip ? null : channelId, replyToMessageId: skip ? null : replyToMessageId, text, mediaDirective, mediaPrompt, reason, contractViolation, contractViolationReason, parseState: "json" as ParseState }; }

function recoverStructuredReplyText(rawText) { const candidate = stripStructuredReplyCodeFence(rawText); if (!candidate) return null; // Only attempt recovery if the text was structurally trying to be JSON // (starts with '{'), not arbitrary prose that happens to contain "text": "..." const trimmed = candidate.trimStart(); if (!trimmed.startsWith("{")) return null; if (STRUCTURED_REPLY_SKIP_TRUE_RE.test(candidate)) return "[SKIP]"; const textMatch = candidate.match(STRUCTURED_REPLY_TEXT_FIELD_RE); if (!textMatch) return null; const decoded = decodeJsonStringField(textMatch[1]); return normalizeDirectiveMessageText(decoded, MAX_REPLY_TEXT_LEN) || null; }

function stripStructuredReplyCodeFence(rawText) { const raw = String(rawText || "").trim(); if (!raw) return ""; if (!STRUCTURED_REPLY_CODE_FENCE_OPEN_RE.test(raw)) return raw; const withoutOpenFence = raw.replace(STRUCTURED_REPLY_CODE_FENCE_OPEN_RE, ""); const closingFenceIndex = withoutOpenFence.lastIndexOf("```"); if (closingFenceIndex < 0) return withoutOpenFence.trim(); return withoutOpenFence.slice(0, closingFenceIndex).trim(); }

function decodeJsonStringField(rawValue) { const encoded = String(rawValue || ""); if (!encoded) return ""; try { const decoded = JSON.parse("${encoded}"); return typeof decoded === "string" ? decoded : encoded; } catch { return encoded .replace(/
/g, " ") .replace(/\r/g, "\r") .replace(/\t/g, "\t") .replace(/\"/g, """) .replace(/\\/g, "\"); } }

function normalizeStructuredMediaDirective(rawMedia, maxLen = DEFAULT_MAX_MEDIA_PROMPT_LEN) { if (!rawMedia || typeof rawMedia !== "object") return null; const rawType = String(rawMedia.type || "") .trim() .toLowerCase(); if (!rawType || rawType === "none") return null; if (!REPLY_MEDIA_TYPES.has(rawType)) return null; if (rawType === "tool_images") { return { type: rawType, prompt: null }; } const prompt = normalizeDirectiveText(rawMedia.prompt, rawType === "gif" ? MAX_GIF_QUERY_LEN : maxLen); if (!prompt) return null; return { type: rawType, prompt }; }

function normalizeStructuredScreenShareIntent(rawIntent) { if (!rawIntent || typeof rawIntent !== "object") { return { action: null, confidence: 0, reason: null }; }

const actionLabel = String(rawIntent.action || rawIntent.intent || "") .trim() .toLowerCase(); if (!REPLY_SCREEN_SHARE_ACTION_TYPES.has(actionLabel)) { return { action: null, confidence: 0, reason: null }; }

const confidenceRaw = Number(rawIntent.confidence); const confidence = Number.isFinite(confidenceRaw) ? clamp(confidenceRaw, 0, 1) : 0; const reason = normalizeDirectiveText(rawIntent.reason, MAX_SCREEN_SHARE_REASON_LEN) || null;

return { action: actionLabel === "none" ? null : actionLabel, confidence, reason }; }

function normalizeStructuredAutomationAction(rawAction) { const empty = { operation: null, title: null, instruction: null, schedule: null, targetQuery: null, automationId: null, runImmediately: false, targetChannelId: null }; if (!rawAction || typeof rawAction !== "object") return empty;

const operation = normalizeAutomationOperation(rawAction.operation ?? rawAction.op); if (!operation || operation === "none") return empty;

const automationIdRaw = Number(rawAction.automationId ?? rawAction.id); const automationId = Number.isInteger(automationIdRaw) && automationIdRaw > 0 ? automationIdRaw : null; const targetQuery = normalizeDirectiveText( rawAction.targetQuery ?? rawAction.query ?? rawAction.target, MAX_AUTOMATION_TARGET_QUERY_LEN ); const targetChannelId = normalizeDirectiveText(rawAction.targetChannelId ?? rawAction.channelId, 40); const runImmediately = rawAction.runImmediately === true;

if (operation === "create") { const title = normalizeDirectiveText(rawAction.title, MAX_AUTOMATION_TITLE_LEN) || null; const instruction = normalizeDirectiveText(rawAction.instruction ?? rawAction.task ?? rawAction.prompt, MAX_AUTOMATION_INSTRUCTION_LEN) || null; const schedule = normalizeAutomationSchedule(rawAction.schedule, { nowMs: Date.now(), allowPastOnce: false });

if (!instruction || !schedule) return empty;

return {
  operation,
  title,
  instruction,
  schedule,
  targetQuery: targetQuery || null,
  automationId: null,
  runImmediately,
  targetChannelId: targetChannelId || null
};

}

return { operation, title: null, instruction: null, schedule: null, targetQuery: targetQuery || null, automationId, runImmediately: false, targetChannelId: targetChannelId || null }; }

function normalizeAutomationOperation(rawValue) { const normalized = String(rawValue || "") .trim() .toLowerCase(); if (!normalized) return "none"; if (normalized === "stop" || normalized === "disable") return "pause"; if (normalized === "start" || normalized === "enable" || normalized === "unpause") return "resume"; if (normalized === "remove" || normalized === "cancel") return "delete"; if (!REPLY_AUTOMATION_OPERATION_TYPES.has(normalized)) return "none"; return normalized; }

export function pickReplyMediaDirective(parsed) { return parsed?.mediaDirective || null; }

export function normalizeDirectiveText(text, maxLen) { return normalizeWhitespaceText(text, { maxLen }); }

function normalizeDirectiveMessageText(text, maxLen) { let normalized = String(text || "").replace(/\r ?/g, " "); normalized = normalized .split(" ") .map((line) => line.replace(/[^\S ]+/g, " ").trim()) .join(" ") .replace(/ {3,}/g, "

") .trim();

const maxCandidate = Number(maxLen); if (!Number.isFinite(maxCandidate)) return normalized; const boundedMax = Math.max(0, Math.floor(maxCandidate)); if (!boundedMax || normalized.length <= boundedMax) return normalized; return normalized.slice(0, boundedMax).trimEnd(); }

export function serializeForPrompt(value, maxLen = 1200) { try { return String(JSON.stringify(value ?? {}, null, 2)).slice(0, Math.max(40, Number(maxLen) || 1200)); } catch { return "{}"; } }

export function isWebSearchOptOutText(rawText) { return EN_WEB_SEARCH_OPTOUT_RE.test(String(rawText || "")); }

const DISCORD_MSG_SPLIT_LIMIT = 1900;

export function splitDiscordMessage(text, maxLen = DISCORD_MSG_SPLIT_LIMIT) { if (!text || text.length <= maxLen) return [text]; const chunks = []; let remaining = text; while (remaining.length > maxLen) { let idx = remaining.lastIndexOf("

", maxLen); if (idx <= 0) idx = remaining.lastIndexOf(". ", maxLen); if (idx > 0 && remaining[idx] === ".") idx += 1; if (idx <= 0) idx = remaining.lastIndexOf(" ", maxLen); if (idx <= 0) idx = remaining.lastIndexOf(" ", maxLen); if (idx <= 0) idx = maxLen; chunks.push(remaining.slice(0, idx).trimEnd()); remaining = remaining.slice(idx).trimStart(); } if (remaining) chunks.push(remaining); return chunks; }

export function normalizeReactionEmojiToken(emojiToken) { const token = String(emojiToken || "").trim(); const custom = token.match(/^<a?:([^:>]+):(\d+)>$/); if (custom) { return ${custom[1]}:${custom[2]}; } return token; }

export function embedWebSearchSources(text, webSearch) { const base = String(text || "").trim(); if (!base) return ""; if (!webSearch?.used) return base;

const results = Array.isArray(webSearch?.results) ? webSearch.results : []; if (!results.length) return base;

const textWithPlainCitations = base.replace(/[(\d{1,2})](\s*<?https?://[^)\s>]+[^)]*)/g, "[$1]"); const citedIndices = [...new Set( [...textWithPlainCitations.matchAll(/[(\d{1,2})]/g)] .map((match) => Number(match[1]) - 1) .filter((index) => Number.isInteger(index) && index >= 0 && index < results.length) )].sort((a, b) => a - b);

if (!citedIndices.length) return textWithPlainCitations;

const urlLines = []; const domainLines = []; for (const index of citedIndices) { const row = results[index]; const url = String(row?.url || "").trim(); if (!url) continue; const domain = String(row?.domain || extractDomainForSourceLabel(url) || "source"); urlLines.push([${index + 1}] ${domain} - <${url}>); domainLines.push([${index + 1}] ${domain}); } if (!urlLines.length) return textWithPlainCitations;

const inlineLinked = textWithPlainCitations.replace(/[(\d{1,2})]/g, (full, rawIndex) => { const index = Number(rawIndex) - 1; const row = results[index]; const url = String(row?.url || "").trim(); if (!url) return full; return [${index + 1}](<${url}>); });

const MAX_CONTENT_LEN = 1900; const withUrls = `${inlineLinked}

Sources: ${urlLines.join(" ")}`; if (withUrls.length <= MAX_CONTENT_LEN) return withUrls;

const withDomains = `${inlineLinked}

Sources: ${domainLines.join(" ")}`; if (withDomains.length <= MAX_CONTENT_LEN) return withDomains;

const plainWithUrls = `${textWithPlainCitations}

Sources: ${urlLines.join(" ")}`; if (plainWithUrls.length <= MAX_CONTENT_LEN) return plainWithUrls;

const plainWithDomains = `${textWithPlainCitations}

Sources: ${domainLines.join(" ")}`; if (plainWithDomains.length <= MAX_CONTENT_LEN) return plainWithDomains;

return textWithPlainCitations; }

export function normalizeSkipSentinel(text) { const value = String(text || "").trim(); if (!value) return ""; if (/^[SKIP]$/i.test(value)) return "[SKIP]";

const withoutTrailingSkip = value.replace(/\s*[SKIP]\s*$/i, "").trim(); return withoutTrailingSkip || "[SKIP]"; }

function extractDomainForSourceLabel(rawUrl) { try { return new URL(String(rawUrl || "")).hostname.replace(/^www./i, "").toLowerCase(); } catch { return ""; } }