diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index d70f68c8651..d20ea7bb2f1 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -17,6 +17,7 @@ export async function sendIMessageOutbound(params: { text: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; + audioAsVoice?: boolean; accountId?: string; deps?: { [channelId: string]: unknown }; replyToId?: string; @@ -36,6 +37,7 @@ export async function sendIMessageOutbound(params: { config: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), + ...(params.audioAsVoice ? { audioAsVoice: true } : {}), maxBytes, accountId: params.accountId ?? undefined, replyToId: params.replyToId ?? undefined, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 2f3eb48372a..77493251e6d 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -113,11 +113,16 @@ const imessageMessageAdapter = defineChannelMessageAdapter({ text: ctx.text, mediaUrl: ctx.mediaUrl, mediaLocalRoots: ctx.mediaLocalRoots, + audioAsVoice: ctx.audioAsVoice, accountId: ctx.accountId ?? undefined, deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps, replyToId: ctx.replyToId ?? undefined, }); - return toIMessageMessageSendResult(result, "media", ctx.replyToId); + return toIMessageMessageSendResult( + result, + ctx.audioAsVoice ? "voice" : "media", + ctx.replyToId, + ); }, }, }); @@ -362,6 +367,7 @@ export const imessagePlugin: ChannelPlugin { expect(result.receipt.sentAt).toBeGreaterThan(0); }); + it("sends audioAsVoice media through send-attachment audio transport", async () => { + const client = createClient({ message_id: 12345 }); + const runCliJson = vi.fn().mockResolvedValueOnce({ messageId: "p:0/voice-guid" }); + + const result = await sendMessageIMessage("chat_guid:chat-1", "", { + config: IMESSAGE_TEST_CFG, + client, + mediaUrl: "/tmp/voice.caf", + audioAsVoice: true, + resolveAttachmentImpl: async () => ({ path: "/tmp/voice.caf", contentType: "audio/x-caf" }), + runCliJson, + }); + + expect(result.messageId).toBe("p:0/voice-guid"); + expect(runCliJson.mock.calls).toEqual([ + [ + [ + "send-attachment", + "--chat", + "chat-1", + "--file", + "/tmp/voice.caf", + "--audio", + "--transport", + "auto", + ], + ], + ]); + expect(result.receipt.parts.map((part) => part.kind)).toEqual(["voice"]); + expect(client["request"]).not.toHaveBeenCalled(); + }); + it("resolves chat_id media-only payloads before using send-attachment", async () => { const client = createClient({ message_id: 12345 }); const runCliJson = vi diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 5ca888a2d4a..95b73c91d0c 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -48,6 +48,7 @@ type IMessageSendOpts = { mediaUrl?: string; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; + audioAsVoice?: boolean; maxBytes?: number; timeoutMs?: number; chatId?: number; @@ -729,6 +730,7 @@ async function trySendAttachmentForTarget(params: { target: ReturnType; service?: IMessageService; filePath: string; + audioAsVoice?: boolean; echoText?: string; runCliJson: (args: readonly string[]) => Promise>; resolveMessageGuidImpl?: IMessageSendOpts["resolveMessageGuidImpl"]; @@ -758,6 +760,7 @@ async function trySendAttachmentForTarget(params: { attachmentChatTarget, "--file", params.filePath, + ...(params.audioAsVoice ? ["--audio"] : []), "--transport", "auto", ]); @@ -822,7 +825,7 @@ async function trySendAttachmentForTarget(params: { receipt: createIMessageSendReceipt({ messageId, target: params.target, - kind: "media", + kind: params.audioAsVoice ? "voice" : "media", }), }; } @@ -915,6 +918,7 @@ export async function sendMessageIMessage( target, service, filePath, + audioAsVoice: opts.audioAsVoice, echoText: attachmentEchoText, runCliJson, resolveMessageGuidImpl: opts.resolveMessageGuidImpl, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 1a4e7186a16..3538e0278c5 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -83,6 +83,13 @@ export function createIMessagePluginBase(params: { capabilities: { chatTypes: ["direct", "group"], media: true, + tts: { + voice: { + synthesisTarget: "audio-file", + audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"], + preferAudioFileFormat: "caf", + }, + }, reactions: true, edit: true, unsend: true, diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index 133d1abe0d0..54b0d627400 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -112,6 +112,14 @@ describe("createIMessageTestPlugin", () => { }); }); + it("declares native iMessage voice memo TTS delivery", () => { + expect(imessagePlugin.capabilities.tts?.voice).toStrictEqual({ + synthesisTarget: "audio-file", + audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"], + preferAudioFileFormat: "caf", + }); + }); + it("preserves the local approval prompt suppressor through attached-result composition", () => { const suppressor = imessagePlugin.outbound?.shouldSuppressLocalPayloadPrompt; if (!suppressor) { @@ -208,7 +216,7 @@ describe("createIMessageTestPlugin", () => { const sendIMessage = async ( _to: string, _text: string, - opts?: { mediaUrl?: string; replyToId?: string }, + opts?: { mediaUrl?: string; replyToId?: string; audioAsVoice?: boolean }, ) => { const messageId = opts?.mediaUrl ? "imsg-media-1" : "imsg-text-1"; return { @@ -216,7 +224,7 @@ describe("createIMessageTestPlugin", () => { sentText: opts?.mediaUrl ? "" : "hello", receipt: createMessageReceiptFromOutboundResults({ results: [{ channel: "imessage", messageId }], - kind: opts?.mediaUrl ? "media" : "text", + kind: opts?.audioAsVoice ? "voice" : opts?.mediaUrl ? "media" : "text", ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}), }), }; @@ -247,11 +255,13 @@ describe("createIMessageTestPlugin", () => { text: "caption", mediaUrl: "/tmp/image.png", mediaLocalRoots: ["/tmp"], + audioAsVoice: true, deps: { imessage: sendIMessage }, } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); expect(result.receipt.platformMessageIds).toEqual(["imsg-media-1"]); + expect(result.receipt.parts.map((part) => part.kind)).toEqual(["voice"]); }, replyTo: async () => { const result = await sendText({ diff --git a/src/channels/plugins/tts-capabilities.test.ts b/src/channels/plugins/tts-capabilities.test.ts index eb256585053..14088629dd9 100644 --- a/src/channels/plugins/tts-capabilities.test.ts +++ b/src/channels/plugins/tts-capabilities.test.ts @@ -39,6 +39,7 @@ describe("resolveChannelTtsVoiceDelivery", () => { voice: { synthesisTarget: "audio-file", audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"], + preferAudioFileFormat: "caf", }, }, }), @@ -89,6 +90,7 @@ describe("resolveChannelTtsVoiceDelivery", () => { expect(resolveChannelTtsVoiceDelivery("imessage")).toEqual({ synthesisTarget: "audio-file", audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"], + preferAudioFileFormat: "caf", }); expect(resolveChannelTtsVoiceDelivery("discord")).toEqual({ synthesisTarget: "voice-note",