export type OutboundReplyPayload = { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string; }; export type SendableOutboundReplyParts = { text: string; trimmedText: string; mediaUrls: string[]; mediaCount: number; hasText: boolean; hasMedia: boolean; hasContent: boolean; }; /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, ): OutboundReplyPayload { const text = typeof payload.text === "string" ? payload.text : undefined; const mediaUrls = Array.isArray(payload.mediaUrls) ? payload.mediaUrls.filter( (entry): entry is string => typeof entry === "string" && entry.length > 0, ) : undefined; const mediaUrl = typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined; const replyToId = typeof payload.replyToId === "string" ? payload.replyToId : undefined; return { text, mediaUrls, mediaUrl, replyToId, }; } /** Wrap a deliverer so callers can hand it arbitrary payloads while channels receive normalized data. */ export function createNormalizedOutboundDeliverer( handler: (payload: OutboundReplyPayload) => Promise, ): (payload: unknown) => Promise { return async (payload: unknown) => { const normalized = payload && typeof payload === "object" ? normalizeOutboundReplyPayload(payload as Record) : {}; await handler(normalized); }; } /** Prefer multi-attachment payloads, then fall back to the legacy single-media field. */ export function resolveOutboundMediaUrls(payload: { mediaUrls?: string[]; mediaUrl?: string; }): string[] { if (payload.mediaUrls?.length) { return payload.mediaUrls; } if (payload.mediaUrl) { return [payload.mediaUrl]; } return []; } /** Count outbound media items after legacy single-media fallback normalization. */ export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number { return resolveOutboundMediaUrls(payload).length; } /** Check whether an outbound payload includes any media after normalization. */ export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean { return countOutboundMedia(payload) > 0; } /** Check whether an outbound payload includes text, optionally trimming whitespace first. */ export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean { const text = options?.trim ? payload.text?.trim() : payload.text; return Boolean(text); } /** Check whether an outbound payload includes any sendable text or media. */ export function hasOutboundReplyContent( payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, options?: { trimText?: boolean }, ): boolean { return hasOutboundText(payload, { trim: options?.trimText }) || hasOutboundMedia(payload); } /** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */ export function resolveSendableOutboundReplyParts( payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, options?: { text?: string }, ): SendableOutboundReplyParts { const text = options?.text ?? payload.text ?? ""; const trimmedText = text.trim(); const mediaUrls = resolveOutboundMediaUrls(payload) .map((entry) => entry.trim()) .filter(Boolean); const mediaCount = mediaUrls.length; const hasText = Boolean(trimmedText); const hasMedia = mediaCount > 0; return { text, trimmedText, mediaUrls, mediaCount, hasText, hasMedia, hasContent: hasText || hasMedia, }; } /** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */ export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] { if (chunks.length > 0) { return [...chunks]; } if (!text) { return []; } return [text]; } /** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, TResult, >(params: { ctx: TContext; textChunkLimit?: number; chunker?: ((text: string, limit: number) => string[]) | null; sendText: (ctx: TContext & { text: string }) => Promise; sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise; emptyResult: TResult; }): Promise { const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }; const text = payload.text ?? ""; const urls = resolveOutboundMediaUrls(payload); if (!text && urls.length === 0) { return params.emptyResult; } if (urls.length > 0) { let lastResult = await params.sendMedia({ ...params.ctx, text, mediaUrl: urls[0], }); for (let i = 1; i < urls.length; i++) { lastResult = await params.sendMedia({ ...params.ctx, text: "", mediaUrl: urls[i], }); } return lastResult; } const limit = params.textChunkLimit; const chunks = limit && params.chunker ? params.chunker(text, limit) : [text]; let lastResult: TResult; for (const chunk of chunks) { lastResult = await params.sendText({ ...params.ctx, text: chunk }); } return lastResult!; } /** Detect numeric-looking target ids for channels that distinguish ids from handles. */ export function isNumericTargetId(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { return false; } return /^\d{3,}$/.test(trimmed); } /** Append attachment links to plain text when the channel cannot send media inline. */ export function formatTextWithAttachmentLinks( text: string | undefined, mediaUrls: string[], ): string { const trimmedText = text?.trim() ?? ""; if (!trimmedText && mediaUrls.length === 0) { return ""; } const mediaBlock = mediaUrls.length ? mediaUrls.map((url) => `Attachment: ${url}`).join("\n") : ""; if (!trimmedText) { return mediaBlock; } if (!mediaBlock) { return trimmedText; } return `${trimmedText}\n\n${mediaBlock}`; } /** Send a caption with only the first media item, mirroring caption-limited channel transports. */ export async function sendMediaWithLeadingCaption(params: { mediaUrls: string[]; caption: string; send: (payload: { mediaUrl: string; caption?: string }) => Promise; onError?: (params: { error: unknown; mediaUrl: string; caption?: string; index: number; isFirst: boolean; }) => Promise | void; }): Promise { if (params.mediaUrls.length === 0) { return false; } for (const [index, mediaUrl] of params.mediaUrls.entries()) { const isFirst = index === 0; const caption = isFirst ? params.caption : undefined; try { await params.send({ mediaUrl, caption }); } catch (error) { if (params.onError) { await params.onError({ error, mediaUrl, caption, index, isFirst, }); continue; } throw error; } } return true; } export async function deliverTextOrMediaReply(params: { payload: OutboundReplyPayload; text: string; chunkText?: (text: string) => readonly string[]; sendText: (text: string) => Promise; sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise; onMediaError?: (params: { error: unknown; mediaUrl: string; caption?: string; index: number; isFirst: boolean; }) => Promise | void; }): Promise<"empty" | "text" | "media"> { const { mediaUrls } = resolveSendableOutboundReplyParts(params.payload, { text: params.text, }); const sentMedia = await sendMediaWithLeadingCaption({ mediaUrls, caption: params.text, send: params.sendMedia, onError: params.onMediaError, }); if (sentMedia) { return "media"; } if (!params.text) { return "empty"; } const chunks = params.chunkText ? params.chunkText(params.text) : [params.text]; let sentText = false; for (const chunk of chunks) { if (!chunk) { continue; } await params.sendText(chunk); sentText = true; } return sentText ? "text" : "empty"; } export async function deliverFormattedTextWithAttachments(params: { payload: OutboundReplyPayload; send: (params: { text: string; replyToId?: string }) => Promise; }): Promise { const text = formatTextWithAttachmentLinks( params.payload.text, resolveOutboundMediaUrls(params.payload), ); if (!text) { return false; } await params.send({ text, replyToId: params.payload.replyToId, }); return true; }