Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a section header for multipart messages.

The multipart documentation starts without a section header, inconsistent with other major sections like "## Send Messages", "## Send Attachments", and "## Chat Backgrounds". This impacts navigation and makes the document harder to scan.

📝 Suggested fix
+## Multipart Messages
+
 Multipart sends are atomic and can mix text, mentions, and attachments.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Multipart sends are atomic and can mix text, mentions, and attachments.
## Multipart Messages
Multipart sends are atomic and can mix text, mentions, and attachments.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 198, Add a proper section header before the multipart
documentation line ("Multipart sends are atomic and can mix text, mentions, and
attachments.") to match the style of other major sections (e.g., "## Send
Messages", "## Send Attachments"); insert a header like "## Multipart Messages"
immediately above that sentence so the multipart content is discoverable and
consistent with the rest of the README.


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, [
Expand Down
12 changes: 8 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 60 additions & 4 deletions src/resources/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +54,18 @@ function toReactionKind(
}
}

function hasByteBackedAttachment(part: MessagePart): part is MessagePart & {
readonly attachment: NonNullable<MessagePart["attachment"]>;
} {
return part.attachment !== undefined;
}

interface MessagesResourceDependencies {
readonly uploadAttachment?: (
input: AttachmentInput
) => Promise<UploadAttachmentResult>;
}

/**
* Message APIs.
*
Expand All @@ -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
Expand All @@ -82,9 +98,48 @@ function toReactionKind(
*/
export class MessagesResource {
private readonly _client: MessageServiceClient;
private readonly _uploadAttachment:
| ((input: AttachmentInput) => Promise<UploadAttachmentResult>)
| undefined;

constructor(client: MessageServiceClient) {
constructor(
client: MessageServiceClient,
dependencies?: MessagesResourceDependencies
) {
this._client = client;
this._uploadAttachment = dependencies?.uploadAttachment;
}

private async normalizeMultipartParts(
parts: readonly MessagePart[]
): Promise<readonly MessagePart[]> {
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;
}

/**
Expand Down Expand Up @@ -173,9 +228,10 @@ export class MessagesResource {
}
): Promise<Message> {
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,
Expand Down
14 changes: 13 additions & 1 deletion src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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. */
Expand Down
128 changes: 128 additions & 0 deletions tests/unit/messages-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> | undefined;
Expand Down Expand Up @@ -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<string, unknown> | undefined;
const uploadedInputs: Array<{ fileName: string; data: Uint8Array }> = [];
const resource = new MessagesResource(
{
async sendMultipartMessage(request: Record<string, unknown>) {
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<string, unknown>[];
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<string, unknown> | undefined;
const resource = new MessagesResource(
{
async sendMultipartMessage(request: Record<string, unknown>) {
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<string, unknown>[])[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<string, unknown> | undefined;
Expand Down