diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 03698cc2a49..7ed589c1fee 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -60,34 +60,33 @@ describe("sendMessageIMessage receipts", () => { expect(result.receipt.sentAt).toBeGreaterThan(0); }); - it("attaches a media receipt after attachment resolution", async () => { + it("sends explicit chat media-only payloads through send-attachment auto transport", async () => { const client = createClient({ message_id: 12345 }); + const runCliJson = vi + .fn() + .mockResolvedValueOnce({ messageId: "p:0/media-guid", transferGuid: "transfer-1" }); const result = await sendMessageIMessage("chat_guid:chat-1", "", { config: IMESSAGE_TEST_CFG, client, mediaUrl: "/tmp/image.png", resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), + runCliJson, }); - expect(result.messageId).toBe("12345"); + expect(result.messageId).toBe("p:0/media-guid"); expect(result.sentText).toBe(""); expect(result.echoText).toBe(""); - expect(result.receipt.primaryPlatformMessageId).toBe("12345"); - expect(result.receipt.platformMessageIds).toEqual(["12345"]); - expect(client.request).toHaveBeenCalledWith( - "send", - expect.objectContaining({ - chat_guid: "chat-1", - file: "/tmp/image.png", - text: "", - }), - expect.any(Object), - ); + expect(result.receipt.primaryPlatformMessageId).toBe("p:0/media-guid"); + expect(result.receipt.platformMessageIds).toEqual(["p:0/media-guid"]); + expect(client.request).not.toHaveBeenCalled(); + expect(runCliJson.mock.calls).toEqual([ + [["send-attachment", "--chat", "chat-1", "--file", "/tmp/image.png", "--transport", "auto"]], + ]); expect(result.receipt.raw).toEqual([ { channel: "imessage", - messageId: "12345", + messageId: "p:0/media-guid", conversationId: "chat-1", meta: { targetKind: "chat_guid" }, }, @@ -95,11 +94,11 @@ describe("sendMessageIMessage receipts", () => { expect(result.receipt.parts).toEqual([ { index: 0, - platformMessageId: "12345", + platformMessageId: "p:0/media-guid", kind: "media", raw: { channel: "imessage", - messageId: "12345", + messageId: "p:0/media-guid", conversationId: "chat-1", meta: { targetKind: "chat_guid" }, }, @@ -108,6 +107,63 @@ describe("sendMessageIMessage receipts", () => { expect(result.receipt.sentAt).toBeGreaterThan(0); }); + it("resolves chat_id media-only payloads before using send-attachment", async () => { + const client = createClient({ message_id: 12345 }); + const runCliJson = vi + .fn() + .mockResolvedValueOnce({ guid: "any;+;group-guid" }) + .mockResolvedValueOnce({ messageId: "p:0/media-guid" }); + + const result = await sendMessageIMessage("chat_id:42", "", { + config: IMESSAGE_TEST_CFG, + client, + mediaUrl: "/tmp/image.png", + resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), + runCliJson, + }); + + expect(result.messageId).toBe("p:0/media-guid"); + expect(client.request).not.toHaveBeenCalled(); + expect(runCliJson.mock.calls).toEqual([ + [["group", "--chat-id", "42"]], + [ + [ + "send-attachment", + "--chat", + "any;+;group-guid", + "--file", + "/tmp/image.png", + "--transport", + "auto", + ], + ], + ]); + }); + + it("keeps DM handle media sends on the existing rpc send path", async () => { + const client = createClient({ message_id: 12345 }); + const runCliJson = vi.fn(); + + await sendMessageIMessage("+15551234567", "", { + config: IMESSAGE_TEST_CFG, + client, + mediaUrl: "/tmp/image.png", + resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), + runCliJson, + }); + + expect(runCliJson).not.toHaveBeenCalled(); + expect(client.request).toHaveBeenCalledWith( + "send", + expect.objectContaining({ + to: "+15551234567", + file: "/tmp/image.png", + text: "", + }), + expect.any(Object), + ); + }); + it("preserves literal media placeholder text when no attachment is sent", async () => { const client = createClient({ guid: "p:0/imsg-text" }); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 9bd77574ab6..9f51338050a 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { createMessageReceiptFromOutboundResults, type MessageReceipt, @@ -52,6 +53,7 @@ type IMessageSendOpts = { }, ) => Promise<{ path: string; contentType?: string }>; createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; + runCliJson?: (args: readonly string[]) => Promise>; }; export type IMessageSendResult = { @@ -210,6 +212,110 @@ function resolveOutboundEchoScope(params: { return `${params.accountId}:imessage:${params.target.to}`; } +function buildIMessageCliJsonArgs(args: readonly string[], dbPath?: string): string[] { + const trimmedDbPath = dbPath?.trim(); + return [...args, ...(trimmedDbPath ? ["--db", trimmedDbPath] : []), "--json"]; +} + +async function runIMessageCliJson( + cliPath: string, + dbPath: string | undefined, + args: readonly string[], + timeoutMs?: number, +): Promise> { + return await new Promise((resolve, reject) => { + const child = spawn(cliPath, buildIMessageCliJsonArgs(args, dbPath), { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let killEscalation: ReturnType | null = null; + const timer = + timeoutMs && timeoutMs > 0 + ? setTimeout(() => { + child.kill("SIGTERM"); + killEscalation = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // best-effort + } + }, 2000); + reject(new Error(`iMessage action timed out after ${timeoutMs}ms`)); + }, timeoutMs) + : null; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + if (timer) { + clearTimeout(timer); + } + if (killEscalation) { + clearTimeout(killEscalation); + } + reject(error); + }); + child.on("close", (code) => { + if (timer) { + clearTimeout(timer); + } + if (killEscalation) { + clearTimeout(killEscalation); + } + const lines = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const last = lines.at(-1); + let parsed: Record | null = null; + if (last) { + try { + const json = JSON.parse(last) as unknown; + if (json && typeof json === "object" && !Array.isArray(json)) { + parsed = json as Record; + } + } catch { + // handled below + } + } + if (code === 0 && parsed) { + resolve(parsed); + return; + } + if (parsed && typeof parsed.error === "string" && parsed.error.trim()) { + reject(new Error(parsed.error.trim())); + return; + } + const detail = stderr.trim() || stdout.trim() || `imsg exited with code ${code}`; + reject(new Error(detail)); + }); + }); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function resolveAttachmentChatGuid(params: { + target: ReturnType; + runCliJson: (args: readonly string[]) => Promise>; +}): Promise { + if (params.target.kind === "chat_guid") { + return params.target.chatGuid; + } + if (params.target.kind !== "chat_id") { + return null; + } + const result = await params.runCliJson(["group", "--chat-id", String(params.target.chatId)]); + return stringValue(result.guid) ?? stringValue(result.chat_guid) ?? null; +} + export async function sendMessageIMessage( to: string, text: string, @@ -278,6 +384,56 @@ export async function sendMessageIMessage( } const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined); const resolvedReplyToId = sanitizeReplyToId(opts.replyToId); + const runCliJson = + opts.runCliJson ?? + ((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, opts.timeoutMs)); + + if (filePath && !message.trim() && !resolvedReplyToId) { + const attachmentChatGuid = await resolveAttachmentChatGuid({ target, runCliJson }); + if (attachmentChatGuid) { + const result = await runCliJson([ + "send-attachment", + "--chat", + attachmentChatGuid, + "--file", + filePath, + "--transport", + "auto", + ]); + const resolvedId = resolveMessageId(result); + const approvalBindingMessageId = resolveOutboundMessageGuid(result); + const messageId = resolvedId ?? (result?.ok || result?.success ? "ok" : "unknown"); + const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target }); + if (echoScope) { + rememberPersistedIMessageEcho({ + scope: echoScope, + text: echoText, + messageId: resolvedId ?? undefined, + }); + } + if (resolvedId) { + rememberIMessageReplyCache({ + accountId: account.accountId, + messageId: resolvedId, + chatGuid: target.kind === "chat_guid" ? target.chatGuid : attachmentChatGuid, + chatId: target.kind === "chat_id" ? target.chatId : undefined, + timestamp: Date.now(), + isFromMe: true, + }); + } + return { + messageId, + ...(approvalBindingMessageId ? { guid: approvalBindingMessageId } : {}), + sentText: message, + ...(echoText ? { echoText } : {}), + receipt: createIMessageSendReceipt({ + messageId, + target, + kind: "media", + }), + }; + } + } const params: Record = { text: message, service: service || "auto",