diff --git a/README.md b/README.md index e45f5d8..4ea9b16 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,26 @@ rejects `image/gif`, `image/webp`, `image/avif`, `image/tiff`, `image/bmp`, and sent as normal attachments; the background pipeline is stricter because the server converts the input image into Apple's background package format. -Multipart sends are atomic and can mix text, mentions, and uploaded -attachments: +Multipart sends are atomic and can mix text, mentions, and attachments. + +Byte-backed multipart attachments are uploaded first through the existing +`attachments.upload(...)` flow, then sent as uploaded attachment GUIDs: + +```ts +const bytes = await readFile("photo.png"); + +await im.messages.sendMultipart(chatGuid, [ + { text: "look at this" }, + { + attachment: { + fileName: "photo.png", + data: bytes, + }, + }, +]); +``` + +You can still pass previously uploaded attachment GUIDs directly: ```ts await im.messages.sendMultipart(chatGuid, [ diff --git a/src/client.ts b/src/client.ts index 2f6d962..ae845c7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -200,7 +200,8 @@ export interface LocationsResource { * - `sendAttachment(chat, attachment, options)` sends an uploaded attachment * by GUID with replies, effects, and audio-message mode. * - `sendMultipart(chat, parts, options)` sends multiple text / attachment / - * mention bubbles atomically. + * mention bubbles atomically, with support for uploaded GUIDs or byte-backed + * attachment inputs. * - `edit(chat, message, newText, options)` edits an existing message. * - `unsend(chat, message, options)` retracts an existing message. * - `setReaction(chat, message, reaction, isSet, options)` adds or removes @@ -399,7 +400,8 @@ export interface AdvancedIMessage extends AsyncDisposable { * - `sendAttachment(chat, attachment, options)` sends an uploaded attachment * by GUID with replies, effects, and audio-message mode. * - `sendMultipart(chat, parts, options)` sends multiple text / attachment / - * mention bubbles atomically. + * mention bubbles atomically, with support for uploaded GUIDs or byte-backed + * attachment inputs. * - `edit(chat, message, newText, options)` edits an existing message. * - `unsend(chat, message, options)` retracts an existing message. * - `setReaction(chat, message, reaction, isSet, options)` adds or removes @@ -455,11 +457,13 @@ export function createClient(options: ClientOptions): AdvancedIMessage { token: options.token, }); - const messages = new MessagesImpl(clients.messages); + const attachments = new AttachmentsImpl(clients.attachments); + const messages = new MessagesImpl(clients.messages, { + uploadAttachment: async (input) => attachments.upload(input), + }); const chats = new ChatsImpl(clients.chats); const events = new EventsImpl(clients.events); const groups = new GroupsImpl(clients.groups); - const attachments = new AttachmentsImpl(clients.attachments); const addresses = new AddressesImpl(clients.addresses); const polls = new PollsImpl(clients.polls); const locations = new LocationsImpl(clients.locations); diff --git a/src/resources/messages.ts b/src/resources/messages.ts index d8b24ae..df97d28 100644 --- a/src/resources/messages.ts +++ b/src/resources/messages.ts @@ -10,6 +10,10 @@ import { mapReplyTarget, mapTextFormatInput, } from "../transport/mapper.ts"; +import type { + AttachmentInput, + UploadAttachmentResult, +} from "../types/attachments.ts"; import { normalizeChatGuid } from "../types/chat-guid.ts"; import type { MessageEffect } from "../types/effects.ts"; import type { MessageEvent } from "../types/events.ts"; @@ -50,6 +54,18 @@ function toReactionKind( } } +function hasByteBackedAttachment(part: MessagePart): part is MessagePart & { + readonly attachment: NonNullable; +} { + return part.attachment !== undefined; +} + +interface MessagesResourceDependencies { + readonly uploadAttachment?: ( + input: AttachmentInput + ) => Promise; +} + /** * Message APIs. * @@ -60,8 +76,8 @@ function toReactionKind( * by attachment GUID; supports replies, effects, audio-message mode, and * `clientMessageId`. * - `sendMultipart(chat, parts, options)` sends multiple bubbles atomically; - * parts can contain text, uploaded attachment GUIDs, mentions, formatting, - * and bubble indexes. + * parts can contain text, uploaded attachment GUIDs, byte-backed + * attachments, mentions, formatting, and bubble indexes. * - `edit(chat, message, newText, options)` edits an existing message and can * target a specific multipart bubble with `partIndex`. * - `unsend(chat, message, options)` retracts an existing message and can @@ -82,9 +98,48 @@ function toReactionKind( */ export class MessagesResource { private readonly _client: MessageServiceClient; + private readonly _uploadAttachment: + | ((input: AttachmentInput) => Promise) + | undefined; - constructor(client: MessageServiceClient) { + constructor( + client: MessageServiceClient, + dependencies?: MessagesResourceDependencies + ) { this._client = client; + this._uploadAttachment = dependencies?.uploadAttachment; + } + + private async normalizeMultipartParts( + parts: readonly MessagePart[] + ): Promise { + if (!parts.some(hasByteBackedAttachment)) { + return parts; + } + + if (!this._uploadAttachment) { + throw new Error( + "messages.sendMultipart received a byte-backed attachment part without upload support" + ); + } + + const normalizedParts: MessagePart[] = []; + for (const part of parts) { + if (!hasByteBackedAttachment(part) || part.attachmentGuid) { + normalizedParts.push(part); + continue; + } + + const uploadResult = await this._uploadAttachment(part.attachment); + const { attachment, attachmentName, ...rest } = part; + normalizedParts.push({ + ...rest, + attachmentGuid: uploadResult.attachment.guid, + attachmentName: attachmentName ?? attachment.fileName, + }); + } + + return normalizedParts; } /** @@ -173,9 +228,10 @@ export class MessagesResource { } ): Promise { try { + const normalizedParts = await this.normalizeMultipartParts(parts); const response = await this._client.sendMultipartMessage({ chatGuid: normalizeChatGuid(chat), - parts: parts.map(mapOutgoingMessagePart), + parts: normalizedParts.map(mapOutgoingMessagePart), replyTo: mapReplyTarget(options?.replyTo), subject: options?.subject, effectId: options?.effect, diff --git a/src/types/messages.ts b/src/types/messages.ts index ad99f85..207dc04 100644 --- a/src/types/messages.ts +++ b/src/types/messages.ts @@ -3,7 +3,7 @@ */ import type { SingleServiceAddressInfo } from "./addresses.js"; -import type { AttachmentInfo } from "./attachments.js"; +import type { AttachmentInfo, AttachmentInput } from "./attachments.js"; import type { MessageEffect, TextEffect } from "./effects.js"; import type { MessageItemType } from "./enums.js"; @@ -185,7 +185,19 @@ export interface SendOptions { readonly subject?: string; } +export interface MultipartAttachmentInput extends AttachmentInput { + /** + * Optional MIME hint for call-site clarity. + * + * Note: current upload transport infers type from bytes and filename; this + * field is not sent separately. + */ + readonly mimeType?: string; +} + export interface MessagePart { + /** Byte-backed attachment input. `sendMultipart(...)` uploads this first. */ + readonly attachment?: MultipartAttachmentInput; /** Uploaded attachment guid for an attachment bubble. */ readonly attachmentGuid?: string; /** Optional display name for the attachment bubble. */ diff --git a/tests/unit/messages-resource.test.ts b/tests/unit/messages-resource.test.ts index 880f7af..12afcf5 100644 --- a/tests/unit/messages-resource.test.ts +++ b/tests/unit/messages-resource.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { ValidationError } from "../../src/errors/imessage-error.ts"; import { MessageReactionKind } from "../../src/generated/photon/imessage/v1/message_types.ts"; import { MessagesResource } from "../../src/resources/messages.ts"; +import type { UploadAttachmentResult } from "../../src/types/attachments.ts"; import { MessageEffect, TextEffect } from "../../src/types/effects.ts"; const chatGuidValue = "any;-;alice.com"; @@ -43,6 +44,25 @@ function makeMessage(guid: string) { }; } +function makeUploadResult( + guid: string, + fileName: string +): UploadAttachmentResult { + return { + attachment: { + fileName, + guid, + isHidden: false, + isOutgoing: true, + isSticker: false, + mimeType: "image/png", + totalBytes: 3, + transferState: "finished", + uti: "public.png", + }, + }; +} + describe("MessagesResource", () => { it("includes sticker width when placing a sticker", async () => { let capturedRequest: Record | undefined; @@ -579,6 +599,114 @@ describe("MessagesResource", () => { }); }); + describe("byte-backed multipart attachments", () => { + it("uploads byte-backed parts and sends the uploaded attachment guid", async () => { + let captured: Record | undefined; + const uploadedInputs: Array<{ fileName: string; data: Uint8Array }> = []; + const resource = new MessagesResource( + { + async sendMultipartMessage(request: Record) { + captured = request; + return { message: makeMessage("p-buffer") }; + }, + } as any, + { + uploadAttachment: async (input) => { + uploadedInputs.push({ fileName: input.fileName, data: input.data }); + return makeUploadResult("uploaded-att-1", input.fileName); + }, + } + ); + + const bytes = new Uint8Array([1, 2, 3]); + await resource.sendMultipart(chatGuidValue, [ + { text: "before" }, + { + attachment: { + data: bytes, + fileName: "photo.png", + }, + }, + { text: "after" }, + ]); + + expect(uploadedInputs).toEqual([{ fileName: "photo.png", data: bytes }]); + + const parts = captured?.parts as Record[]; + expect(parts).toHaveLength(3); + expect(parts[0]?.text).toBe("before"); + expect(parts[1]?.attachment).toEqual({ + attachmentGuid: "uploaded-att-1", + attachmentName: "photo.png", + }); + expect(parts[1]?.attachment).not.toHaveProperty("data"); + expect(parts[2]?.text).toBe("after"); + }); + + it("preserves bubbleIndex on uploaded multipart attachment parts", async () => { + let captured: Record | undefined; + const resource = new MessagesResource( + { + async sendMultipartMessage(request: Record) { + captured = request; + return { message: makeMessage("p-buffer-bubble") }; + }, + } as any, + { + uploadAttachment: async (input) => + makeUploadResult("uploaded-att-bubble", input.fileName), + } + ); + + await resource.sendMultipart(chatGuidValue, [ + { + attachment: { + data: new Uint8Array([5, 6, 7]), + fileName: "bubble.png", + }, + bubbleIndex: 4, + }, + ]); + + expect((captured?.parts as Record[])[0]).toMatchObject({ + bubbleIndex: 4, + attachment: { + attachmentGuid: "uploaded-att-bubble", + attachmentName: "bubble.png", + }, + }); + }); + + it("does not send multipart request when byte-backed attachment upload fails", async () => { + let sendCalls = 0; + const resource = new MessagesResource( + { + async sendMultipartMessage() { + sendCalls += 1; + return { message: makeMessage("p-should-not-send") }; + }, + } as any, + { + uploadAttachment: async () => { + throw new Error("upload failed"); + }, + } + ); + + await expect( + resource.sendMultipart(chatGuidValue, [ + { + attachment: { + data: new Uint8Array([9, 9, 9]), + fileName: "failed.png", + }, + }, + ]) + ).rejects.toThrow("upload failed"); + expect(sendCalls).toBe(0); + }); + }); + describe("StickerPlacement → wire (exhaustive)", () => { it("forwards minimal placement (x, y only)", async () => { let captured: Record | undefined;