Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions examples/message-multipart-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await sdk.connect();
}

main().catch(console.error);
92 changes: 91 additions & 1 deletion modules/message.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
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(
private readonly http: AxiosInstance,
private readonly enqueueSend: <T>(task: () => Promise<T>) => Promise<T> = (task) => task(),
) {}

private async uploadMultipartAttachment(
part: Extract<SendMultipartMessagePart, { filePath: string }>,
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<MessageResponse> {
return this.enqueueSend(async () => {
const tempGuid = options.tempGuid || randomUUID();
Expand Down Expand Up @@ -41,6 +64,73 @@ export class MessageModule {
});
}

async sendMultipartMessage(options: SendMultipartMessageOptions): Promise<MessageResponse> {
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,
};
Comment thread
LingJueYa marked this conversation as resolved.
};

const uploadParts = async () => {
const parts: Awaited<ReturnType<typeof buildPayloadPart>>[] = [];

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;
Comment thread
LingJueYa marked this conversation as resolved.
};

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<MessageResponse> {
const response = await this.http.get(`/api/v1/message/${encodeURIComponent(guid)}`, {
params: options?.with ? { with: options.with.join(",") } : {},
Expand Down
24 changes: 24 additions & 0 deletions types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ export interface SendMessageOptions {
bubbleEffect?: BubbleEffect;
}

export type SendMultipartMessagePart =
| {
partIndex?: number;
text: string;
mention?: Record<string, unknown>;
}
| {
partIndex?: number;
filePath: string;
fileName?: string;
};

export interface SendMultipartMessageOptions {
chatGuid: string;
parts: SendMultipartMessagePart[];
tempGuid?: string;
subject?: string;
effectId?: string;
selectedMessageGuid?: string;
partIndex?: number;
Comment thread
LingJueYa marked this conversation as resolved.
ddScan?: boolean;
attributedBody?: Record<string, unknown> | Record<string, unknown>[];
}
Comment thread
LingJueYa marked this conversation as resolved.

export interface SendIMessageAppOptions {
chatGuid: string;
balloonBundleId: string;
Expand Down
Loading