fix(imessage): send group media via attachment command

* fix(imessage): send chat media via attachment command

* fix(imessage): satisfy bundled lint

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-05-25 19:24:37 -07:00
committed by GitHub
parent eab8d29db2
commit f32273257c
2 changed files with 228 additions and 16 deletions

View File

@@ -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("<media:image>");
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" });

View File

@@ -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<IMessageRpcClient>;
runCliJson?: (args: readonly string[]) => Promise<Record<string, unknown>>;
};
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<Record<string, unknown>> {
return await new Promise((resolve, reject) => {
const child = spawn(cliPath, buildIMessageCliJsonArgs(args, dbPath), {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killEscalation: ReturnType<typeof setTimeout> | 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<string, unknown> | null = null;
if (last) {
try {
const json = JSON.parse(last) as unknown;
if (json && typeof json === "object" && !Array.isArray(json)) {
parsed = json as Record<string, unknown>;
}
} 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<typeof parseIMessageTarget>;
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
}): Promise<string | null> {
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<string, unknown> = {
text: message,
service: service || "auto",