Skip to content

Commit f16e34b

Browse files
committed
fix(imessage): preserve media rpc fallback
1 parent b2bfaa0 commit f16e34b

2 files changed

Lines changed: 186 additions & 43 deletions

File tree

extensions/imessage/src/send.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,76 @@ describe("sendMessageIMessage receipts", () => {
140140
]);
141141
});
142142

143+
it("falls back to the existing rpc send path when send-attachment is unavailable", async () => {
144+
const client = createClient({ message_id: 12345 });
145+
const runCliJson = vi.fn().mockRejectedValueOnce(new Error("unknown command send-attachment"));
146+
147+
const result = await sendMessageIMessage("chat_guid:chat-1", "", {
148+
config: IMESSAGE_TEST_CFG,
149+
client,
150+
mediaUrl: "/tmp/image.png",
151+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
152+
runCliJson,
153+
});
154+
155+
expect(result.messageId).toBe("12345");
156+
expect(runCliJson.mock.calls).toEqual([
157+
[["send-attachment", "--chat", "chat-1", "--file", "/tmp/image.png", "--transport", "auto"]],
158+
]);
159+
expect(client.request).toHaveBeenCalledWith(
160+
"send",
161+
expect.objectContaining({
162+
chat_guid: "chat-1",
163+
file: "/tmp/image.png",
164+
text: "",
165+
}),
166+
expect.any(Object),
167+
);
168+
});
169+
170+
it("falls back to the existing rpc send path when chat_id lookup is unavailable", async () => {
171+
const client = createClient({ message_id: 12345 });
172+
const runCliJson = vi.fn().mockRejectedValueOnce(new Error("private API bridge unavailable"));
173+
174+
const result = await sendMessageIMessage("chat_id:42", "", {
175+
config: IMESSAGE_TEST_CFG,
176+
client,
177+
mediaUrl: "/tmp/image.png",
178+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
179+
runCliJson,
180+
});
181+
182+
expect(result.messageId).toBe("12345");
183+
expect(runCliJson.mock.calls).toEqual([[["group", "--chat-id", "42"]]]);
184+
expect(client.request).toHaveBeenCalledWith(
185+
"send",
186+
expect.objectContaining({
187+
chat_id: 42,
188+
file: "/tmp/image.png",
189+
text: "",
190+
}),
191+
expect.any(Object),
192+
);
193+
});
194+
195+
it("rejects failed send-attachment json instead of reporting success", async () => {
196+
const client = createClient({ message_id: 12345 });
197+
const runCliJson = vi
198+
.fn()
199+
.mockResolvedValueOnce({ success: false, error: "attachment delivery failed" });
200+
201+
await expect(
202+
sendMessageIMessage("chat_guid:chat-1", "", {
203+
config: IMESSAGE_TEST_CFG,
204+
client,
205+
mediaUrl: "/tmp/image.png",
206+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
207+
runCliJson,
208+
}),
209+
).rejects.toThrow("attachment delivery failed");
210+
expect(client.request).not.toHaveBeenCalled();
211+
});
212+
143213
it("keeps DM handle media sends on the existing rpc send path", async () => {
144214
const client = createClient({ message_id: 12345 });
145215
const runCliJson = vi.fn();

extensions/imessage/src/send.ts

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ function buildIMessageCliJsonArgs(args: readonly string[], dbPath?: string): str
217217
return [...args, ...(trimmedDbPath ? ["--db", trimmedDbPath] : []), "--json"];
218218
}
219219

220+
function resolveIMessageCliFailure(result: Record<string, unknown>): string | null {
221+
if (result.success !== false) {
222+
return null;
223+
}
224+
return typeof result.error === "string" && result.error.trim()
225+
? result.error.trim()
226+
: "iMessage action failed";
227+
}
228+
220229
async function runIMessageCliJson(
221230
cliPath: string,
222231
dbPath: string | undefined,
@@ -285,6 +294,11 @@ async function runIMessageCliJson(
285294
}
286295
}
287296
if (code === 0 && parsed) {
297+
const failure = resolveIMessageCliFailure(parsed);
298+
if (failure) {
299+
reject(new Error(failure));
300+
return;
301+
}
288302
resolve(parsed);
289303
return;
290304
}
@@ -302,6 +316,13 @@ function stringValue(value: unknown): string | undefined {
302316
return typeof value === "string" && value.trim() ? value.trim() : undefined;
303317
}
304318

319+
function isAttachmentCommandFallbackError(error: unknown): boolean {
320+
const message = error instanceof Error ? error.message : String(error);
321+
return /(?:unknown|unrecognized|invalid|unsupported)\s+(?:command|subcommand)|not a recognized command|send-attachment.*(?:not found|unsupported|unavailable)|private api bridge.*unavailable|requires the imsg private api bridge|run imsg launch/iu.test(
322+
message,
323+
);
324+
}
325+
305326
async function resolveAttachmentChatGuid(params: {
306327
target: ReturnType<typeof parseIMessageTarget>;
307328
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
@@ -316,6 +337,92 @@ async function resolveAttachmentChatGuid(params: {
316337
return stringValue(result.guid) ?? stringValue(result.chat_guid) ?? null;
317338
}
318339

340+
async function trySendAttachmentForExplicitChat(params: {
341+
accountId: string;
342+
target: ReturnType<typeof parseIMessageTarget>;
343+
filePath: string;
344+
echoText?: string;
345+
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
346+
}): Promise<IMessageSendResult | null> {
347+
let attachmentChatGuid: string | null = null;
348+
try {
349+
attachmentChatGuid = await resolveAttachmentChatGuid({
350+
target: params.target,
351+
runCliJson: params.runCliJson,
352+
});
353+
} catch (error) {
354+
if (isAttachmentCommandFallbackError(error)) {
355+
return null;
356+
}
357+
throw error;
358+
}
359+
if (!attachmentChatGuid) {
360+
return null;
361+
}
362+
363+
let result: Record<string, unknown>;
364+
try {
365+
result = await params.runCliJson([
366+
"send-attachment",
367+
"--chat",
368+
attachmentChatGuid,
369+
"--file",
370+
params.filePath,
371+
"--transport",
372+
"auto",
373+
]);
374+
} catch (error) {
375+
if (isAttachmentCommandFallbackError(error)) {
376+
return null;
377+
}
378+
throw error;
379+
}
380+
const failure = resolveIMessageCliFailure(result);
381+
if (failure) {
382+
const error = new Error(failure);
383+
if (isAttachmentCommandFallbackError(error)) {
384+
return null;
385+
}
386+
throw error;
387+
}
388+
389+
const resolvedId = resolveMessageId(result);
390+
const approvalBindingMessageId = resolveOutboundMessageGuid(result);
391+
const messageId = resolvedId ?? (result.ok || result.success ? "ok" : "unknown");
392+
const echoScope = resolveOutboundEchoScope({
393+
accountId: params.accountId,
394+
target: params.target,
395+
});
396+
if (echoScope) {
397+
rememberPersistedIMessageEcho({
398+
scope: echoScope,
399+
text: params.echoText,
400+
messageId: resolvedId ?? undefined,
401+
});
402+
}
403+
if (resolvedId) {
404+
rememberIMessageReplyCache({
405+
accountId: params.accountId,
406+
messageId: resolvedId,
407+
chatGuid: params.target.kind === "chat_guid" ? params.target.chatGuid : attachmentChatGuid,
408+
chatId: params.target.kind === "chat_id" ? params.target.chatId : undefined,
409+
timestamp: Date.now(),
410+
isFromMe: true,
411+
});
412+
}
413+
return {
414+
messageId,
415+
...(approvalBindingMessageId ? { guid: approvalBindingMessageId } : {}),
416+
sentText: "",
417+
...(params.echoText ? { echoText: params.echoText } : {}),
418+
receipt: createIMessageSendReceipt({
419+
messageId,
420+
target: params.target,
421+
kind: "media",
422+
}),
423+
};
424+
}
425+
319426
export async function sendMessageIMessage(
320427
to: string,
321428
text: string,
@@ -389,49 +496,15 @@ export async function sendMessageIMessage(
389496
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, opts.timeoutMs));
390497

391498
if (filePath && !message.trim() && !resolvedReplyToId) {
392-
const attachmentChatGuid = await resolveAttachmentChatGuid({ target, runCliJson });
393-
if (attachmentChatGuid) {
394-
const result = await runCliJson([
395-
"send-attachment",
396-
"--chat",
397-
attachmentChatGuid,
398-
"--file",
399-
filePath,
400-
"--transport",
401-
"auto",
402-
]);
403-
const resolvedId = resolveMessageId(result);
404-
const approvalBindingMessageId = resolveOutboundMessageGuid(result);
405-
const messageId = resolvedId ?? (result?.ok || result?.success ? "ok" : "unknown");
406-
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
407-
if (echoScope) {
408-
rememberPersistedIMessageEcho({
409-
scope: echoScope,
410-
text: echoText,
411-
messageId: resolvedId ?? undefined,
412-
});
413-
}
414-
if (resolvedId) {
415-
rememberIMessageReplyCache({
416-
accountId: account.accountId,
417-
messageId: resolvedId,
418-
chatGuid: target.kind === "chat_guid" ? target.chatGuid : attachmentChatGuid,
419-
chatId: target.kind === "chat_id" ? target.chatId : undefined,
420-
timestamp: Date.now(),
421-
isFromMe: true,
422-
});
423-
}
424-
return {
425-
messageId,
426-
...(approvalBindingMessageId ? { guid: approvalBindingMessageId } : {}),
427-
sentText: message,
428-
...(echoText ? { echoText } : {}),
429-
receipt: createIMessageSendReceipt({
430-
messageId,
431-
target,
432-
kind: "media",
433-
}),
434-
};
499+
const attachmentResult = await trySendAttachmentForExplicitChat({
500+
accountId: account.accountId,
501+
target,
502+
filePath,
503+
echoText,
504+
runCliJson,
505+
});
506+
if (attachmentResult) {
507+
return attachmentResult;
435508
}
436509
}
437510
const params: Record<string, unknown> = {

0 commit comments

Comments
 (0)