import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { convertMarkdownTables } from "../markdown/tables.js"; import { kindFromMime } from "../media/mime.js"; import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; export type IMessageSendOpts = { cliPath?: string; dbPath?: string; service?: IMessageService; region?: string; accountId?: string; replyToId?: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; maxBytes?: number; timeoutMs?: number; chatId?: number; client?: IMessageRpcClient; config?: ReturnType; account?: ResolvedIMessageAccount; resolveAttachmentImpl?: ( mediaUrl: string, maxBytes: number, options?: { localRoots?: readonly string[] }, ) => Promise<{ path: string; contentType?: string }>; createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; }; export type IMessageSendResult = { messageId: string; }; const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i; const MAX_REPLY_TO_ID_LENGTH = 256; function stripUnsafeReplyTagChars(value: string): string { let next = ""; for (const ch of value) { const code = ch.charCodeAt(0); if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") { continue; } next += ch; } return next; } function sanitizeReplyToId(rawReplyToId?: string): string | undefined { const trimmed = rawReplyToId?.trim(); if (!trimmed) { return undefined; } const sanitized = stripUnsafeReplyTagChars(trimmed).trim(); if (!sanitized) { return undefined; } if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) { return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH); } return sanitized; } function prependReplyTagIfNeeded(message: string, replyToId?: string): string { const resolvedReplyToId = sanitizeReplyToId(replyToId); if (!resolvedReplyToId) { return message; } const replyTag = `[[reply_to:${resolvedReplyToId}]]`; const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE); if (existingLeadingTag) { const remainder = message.slice(existingLeadingTag[0].length).trimStart(); return remainder ? `${replyTag} ${remainder}` : replyTag; } const trimmedMessage = message.trimStart(); return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag; } function resolveMessageId(result: Record | null | undefined): string | null { if (!result) { return null; } const raw = (typeof result.messageId === "string" && result.messageId.trim()) || (typeof result.message_id === "string" && result.message_id.trim()) || (typeof result.id === "string" && result.id.trim()) || (typeof result.guid === "string" && result.guid.trim()) || (typeof result.message_id === "number" ? String(result.message_id) : null) || (typeof result.id === "number" ? String(result.id) : null); return raw ? String(raw).trim() : null; } export async function sendMessageIMessage( to: string, text: string, opts: IMessageSendOpts = {}, ): Promise { const cfg = opts.config ?? loadConfig(); const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId, }); const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg"; const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim(); const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to); const service = opts.service ?? (target.kind === "handle" ? target.service : undefined) ?? (account.config.service as IMessageService | undefined); const region = opts.region?.trim() || account.config.region?.trim() || "US"; const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 : 16 * 1024 * 1024; let message = text ?? ""; let filePath: string | undefined; if (opts.mediaUrl?.trim()) { const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl; const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, { localRoots: opts.mediaLocalRoots, }); filePath = resolved.path; if (!message.trim()) { const kind = kindFromMime(resolved.contentType ?? undefined); if (kind) { message = kind === "image" ? "" : ``; } } } if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } if (message.trim()) { const tableMode = resolveMarkdownTableMode({ cfg, channel: "imessage", accountId: account.accountId, }); message = convertMarkdownTables(message, tableMode); } message = prependReplyTagIfNeeded(message, opts.replyToId); const params: Record = { text: message, service: service || "auto", region, }; if (filePath) { params.file = filePath; } if (target.kind === "chat_id") { params.chat_id = target.chatId; } else if (target.kind === "chat_guid") { params.chat_guid = target.chatGuid; } else if (target.kind === "chat_identifier") { params.chat_identifier = target.chatIdentifier; } else { params.to = target.to; } const client = opts.client ?? (opts.createClient ? await opts.createClient({ cliPath, dbPath }) : await createIMessageRpcClient({ cliPath, dbPath })); const shouldClose = !opts.client; try { const result = await client.request<{ ok?: string }>("send", params, { timeoutMs: opts.timeoutMs, }); const resolvedId = resolveMessageId(result); return { messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"), }; } finally { if (shouldClose) { await client.stop(); } } }