diff --git a/README.md b/README.md index 5584f92..cb62a31 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,15 @@ interface ClientConfig { } ``` +### Remote Server Setup + +If your SDK code runs on a different machine than the iMessage server: + +- `serverUrl` points to the server machine +- `filePath` points to the SDK machine + +The SDK reads local files first, then uploads the file bytes to the server. + --- ## Core Concepts @@ -612,6 +621,38 @@ const message = await sdk.attachments.sendAttachment({ }); ``` +### Send Multiple Images In One Request + +Use `sendMultipartMessage()` when you want to send multiple images together as one multipart iMessage request: + +```typescript +const message = await sdk.messages.sendMultipartMessage({ + chatGuid: "iMessage;+;chat123456789", + parts: [ + { + partIndex: 0, + filePath: "/path/to/image-1.jpg", + }, + { + partIndex: 1, + filePath: "/path/to/image-2.jpg", + }, + { + partIndex: 2, + filePath: "/path/to/image-3.jpg", + }, + ], +}); +``` + +Notes: +- `sendMultipartMessage()` uploads each file first, then sends all parts together in a single `/api/v1/message/multipart` request. +- This requires the Private API path on the server. +- The target `chatGuid` must already exist. If you only have a phone number/email, create or fetch the chat first. +- If the SDK and server are on different machines, each `filePath` must exist on the SDK machine. The SDK uploads the file bytes to the server before sending. + +> Example: [message-multipart-images.ts](./examples/message-multipart-images.ts) + ### Send Audio Messages ```typescript diff --git a/examples/message-multipart-images.ts b/examples/message-multipart-images.ts new file mode 100644 index 0000000..1ea23cc --- /dev/null +++ b/examples/message-multipart-images.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createSDK, handleError } from "./utils"; + +const CHAT_GUID = process.env.CHAT_GUID; +const IMAGE_PATHS = (process.env.IMAGE_PATHS || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + +const DEFAULT_IMAGE_PATHS = [path.join(__dirname, "test-image.png"), path.join(__dirname, "test-image.png")]; + +async function main() { + const sdk = createSDK(); + + sdk.on("ready", async () => { + if (!CHAT_GUID) { + console.error("CHAT_GUID is required and should be an existing chat GUID for multipart sends"); + await sdk.close(); + process.exit(1); + } + + const imagePaths = IMAGE_PATHS.length > 0 ? IMAGE_PATHS : DEFAULT_IMAGE_PATHS; + + const missing = imagePaths.filter((filePath) => !fs.existsSync(filePath)); + if (missing.length > 0) { + console.error("missing files:"); + for (const filePath of missing) { + console.error(`- ${filePath}`); + } + await sdk.close(); + process.exit(1); + } + + try { + const message = await sdk.messages.sendMultipartMessage({ + chatGuid: CHAT_GUID, + parts: imagePaths.map((filePath, index) => ({ + partIndex: index, + filePath, + fileName: path.basename(filePath), + })), + }); + + const sentMessage = await sdk.messages.getMessage(message.guid, { + with: ["attachments"], + }); + + console.log(`sent multipart message: ${message.guid}`); + console.log(`attachments: ${sentMessage.attachments?.length ?? 0}`); + console.log(`${new Date(message.dateCreated).toLocaleString()}`); + } catch (error) { + handleError(error, "Failed to send multipart images"); + await sdk.close(); + process.exit(1); + } + + await sdk.close(); + process.exit(0); + }); + + await sdk.connect(); +} + +main().catch((error) => { + handleError(error, "Failed to start multipart image example"); + process.exit(1); +}); diff --git a/modules/message.ts b/modules/message.ts index 3a9a711..8b753d9 100644 --- a/modules/message.ts +++ b/modules/message.ts @@ -1,7 +1,15 @@ import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; import type { AxiosInstance } from "axios"; +import FormData from "form-data"; import { createChatWithMessage, extractAddress, extractService, isChatNotExistError } from "../lib/auto-create-chat"; -import type { MessageResponse, SendMessageOptions } from "../types"; +import type { + MessageResponse, + SendMessageOptions, + SendMultipartMessageOptions, + SendMultipartMessagePart, +} from "../types"; export class MessageModule { constructor( @@ -9,6 +17,21 @@ export class MessageModule { private readonly enqueueSend: (task: () => Promise) => Promise = (task) => task(), ) {} + private async uploadMultipartAttachment( + part: Extract, + fileName = part.fileName || path.basename(part.filePath), + ) { + const fileBuffer = await readFile(part.filePath); + const form = new FormData(); + form.append("attachment", fileBuffer, fileName); + + const response = await this.http.post("/api/v1/attachment/upload", form, { + headers: form.getHeaders(), + }); + + return response.data.data.path as string; + } + async sendMessage(options: SendMessageOptions): Promise { return this.enqueueSend(async () => { const tempGuid = options.tempGuid || randomUUID(); @@ -41,6 +64,73 @@ export class MessageModule { }); } + async sendMultipartMessage(options: SendMultipartMessageOptions): Promise { + return this.enqueueSend(async () => { + const tempGuid = options.tempGuid || randomUUID(); + + const buildPayloadPart = async (part: SendMultipartMessagePart, index: number) => { + const resolvedPartIndex = part.partIndex ?? index; + + if ("text" in part) { + return { + partIndex: resolvedPartIndex, + text: part.text, + ...(part.mention ? { mention: part.mention } : {}), + }; + } + + const fileName = part.fileName || path.basename(part.filePath); + const uploadedPath = await this.uploadMultipartAttachment(part, fileName); + + return { + partIndex: resolvedPartIndex, + attachment: uploadedPath, + name: fileName, + }; + }; + + const uploadParts = async () => { + const parts: Awaited>[] = []; + + for (const [index, part] of options.parts.entries()) { + parts.push(await buildPayloadPart(part, index)); + } + + return parts; + }; + + const send = async (chatGuid: string) => { + const parts = await uploadParts(); + const payload = { + chatGuid, + tempGuid, + parts, + subject: options.subject, + effectId: options.effectId, + selectedMessageGuid: options.selectedMessageGuid, + partIndex: options.partIndex ?? 0, + ddScan: options.ddScan ?? false, + attributedBody: options.attributedBody, + }; + + const response = await this.http.post("/api/v1/message/multipart", payload); + return response.data.data as MessageResponse; + }; + + try { + return await send(options.chatGuid); + } catch (error: unknown) { + if (isChatNotExistError(error)) { + throw new Error( + "Chat does not exist for multipart send. Use an existing chatGuid, or create the chat first before calling sendMultipartMessage().", + ); + } + + throw error; + } + }); + } + async getMessage(guid: string, options?: { with?: string[] }): Promise { const response = await this.http.get(`/api/v1/message/${encodeURIComponent(guid)}`, { params: options?.with ? { with: options.with.join(",") } : {}, diff --git a/types/message.ts b/types/message.ts index 1f6560e..6d272a3 100644 --- a/types/message.ts +++ b/types/message.ts @@ -41,6 +41,30 @@ export interface SendMessageOptions { bubbleEffect?: BubbleEffect; } +export type SendMultipartMessagePart = + | { + partIndex?: number; + text: string; + mention?: Record; + } + | { + partIndex?: number; + filePath: string; + fileName?: string; + }; + +export interface SendMultipartMessageOptions { + chatGuid: string; + parts: SendMultipartMessagePart[]; + tempGuid?: string; + subject?: string; + effectId?: string; + selectedMessageGuid?: string; + partIndex?: number; + ddScan?: boolean; + attributedBody?: Record | Record[]; +} + export interface SendIMessageAppOptions { chatGuid: string; balloonBundleId: string;