Skip to content

Commit 1d6e5f7

Browse files
authored
fix(imessage): make inbound image attachments readable by agents (#78580)
Stage native iMessage inbound attachments into managed media and convert HEIC/HEIF images to JPEG before dispatch. Thanks @homer-byte.
1 parent 58591c3 commit 1d6e5f7

6 files changed

Lines changed: 465 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,7 @@ Docs: https://docs.openclaw.ai
767767
- Discord/groups: tell Discord-channel agents to wrap bare URLs as `<https://example.com>` so link previews do not expand into uninvited embeds. (#78614)
768768
- Agents/fallback: fail fast on session write-lock timeouts instead of trying fallback models for local file contention. Fixes #66646. Thanks @sallyom.
769769
- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang.
770+
- iMessage: stage native inbound attachments into OpenClaw-managed media and convert HEIC/HEIF images to JPEG before dispatch, so image tools can read photos sent over native iMessage without requiring BlueBubbles.
770771
- Agents/Gateway: throttle and cap live exec command-output events so noisy tool runs cannot flood Gateway WebSocket clients or starve RPC handling. (#78645) Thanks @joshavant.
771772
- Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight.
772773
- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { createIMessageRpcClient } from "./client.js";
4+
import { monitorIMessageProvider } from "./monitor.js";
5+
import type { stageIMessageAttachments } from "./monitor/media-staging.js";
6+
7+
const waitForTransportReadyMock = vi.hoisted(() =>
8+
vi.fn<typeof waitForTransportReady>(async () => {}),
9+
);
10+
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
11+
const stageIMessageAttachmentsMock = vi.hoisted(() => vi.fn<typeof stageIMessageAttachments>());
12+
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn(async () => [] as string[]));
13+
14+
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
15+
waitForTransportReady: waitForTransportReadyMock,
16+
}));
17+
18+
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
19+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
20+
return {
21+
...actual,
22+
readChannelAllowFromStore: readChannelAllowFromStoreMock,
23+
recordInboundSession: vi.fn(),
24+
upsertChannelPairingRequest: vi.fn(),
25+
};
26+
});
27+
28+
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
29+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
30+
return {
31+
...actual,
32+
createChannelInboundDebouncer: vi.fn((opts) => ({
33+
debouncer: {
34+
enqueue: async (entry: unknown) => await opts.onFlush([entry]),
35+
},
36+
})),
37+
shouldDebounceTextInbound: vi.fn(() => false),
38+
};
39+
});
40+
41+
vi.mock("./client.js", () => ({
42+
createIMessageRpcClient: createIMessageRpcClientMock,
43+
}));
44+
45+
vi.mock("./monitor/abort-handler.js", () => ({
46+
attachIMessageMonitorAbortHandler: vi.fn(() => () => {}),
47+
}));
48+
49+
vi.mock("./monitor/media-staging.js", () => ({
50+
stageIMessageAttachments: stageIMessageAttachmentsMock,
51+
}));
52+
53+
describe("iMessage monitor attachment policy", () => {
54+
it("does not stage local attachments for messages dropped by inbound policy", async () => {
55+
stageIMessageAttachmentsMock.mockResolvedValue([]);
56+
readChannelAllowFromStoreMock.mockResolvedValue([]);
57+
58+
const attachmentPath = "/Users/openclaw/Library/Messages/Attachments/AA/BB/photo.heic";
59+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
60+
const client = {
61+
request: vi.fn(async () => ({ subscription: 1 })),
62+
waitForClose: vi.fn(async () => {
63+
onNotification?.({
64+
method: "message",
65+
params: {
66+
message: {
67+
id: 1,
68+
chat_id: 123,
69+
sender: "+15550001111",
70+
is_from_me: false,
71+
is_group: true,
72+
text: "no mention here",
73+
attachments: [
74+
{
75+
original_path: attachmentPath,
76+
mime_type: "image/heic",
77+
missing: false,
78+
},
79+
],
80+
},
81+
},
82+
});
83+
await Promise.resolve();
84+
await Promise.resolve();
85+
}),
86+
stop: vi.fn(async () => {}),
87+
};
88+
createIMessageRpcClientMock.mockImplementation(async (params) => {
89+
if (!params?.onNotification) {
90+
throw new Error("expected iMessage notification handler");
91+
}
92+
onNotification = params.onNotification;
93+
return client as never;
94+
});
95+
96+
await monitorIMessageProvider({
97+
config: {
98+
channels: {
99+
imessage: {
100+
includeAttachments: true,
101+
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
102+
dmPolicy: "open",
103+
groupPolicy: "open",
104+
groups: { "*": { requireMention: true } },
105+
},
106+
},
107+
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
108+
session: { mainKey: "main" },
109+
} as never,
110+
});
111+
112+
expect(readChannelAllowFromStoreMock).toHaveBeenCalled();
113+
expect(stageIMessageAttachmentsMock).not.toHaveBeenCalled();
114+
});
115+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { stageIMessageAttachments } from "./media-staging.js";
6+
7+
let tempDir: string;
8+
9+
async function writeTempFile(name: string, contents: Buffer | string): Promise<string> {
10+
const filePath = path.join(tempDir, name);
11+
await fs.writeFile(filePath, contents);
12+
return filePath;
13+
}
14+
15+
describe("stageIMessageAttachments", () => {
16+
beforeEach(async () => {
17+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-media-"));
18+
});
19+
20+
afterEach(async () => {
21+
await fs.rm(tempDir, { recursive: true, force: true });
22+
});
23+
24+
it("copies allowed iMessage attachments into the inbound media store", async () => {
25+
const sourcePath = await writeTempFile("photo.png", Buffer.from("png-bytes"));
26+
const saveMediaBuffer = vi.fn(async () => ({
27+
id: "saved.png",
28+
path: "/state/media/inbound/saved.png",
29+
size: 9,
30+
contentType: "image/png",
31+
}));
32+
33+
await expect(
34+
stageIMessageAttachments(
35+
[{ original_path: sourcePath, mime_type: "image/png", missing: false }],
36+
{ maxBytes: 1024, allowedRoots: [tempDir], deps: { saveMediaBuffer } },
37+
),
38+
).resolves.toEqual([{ path: "/state/media/inbound/saved.png", contentType: "image/png" }]);
39+
40+
expect(saveMediaBuffer).toHaveBeenCalledWith(
41+
Buffer.from("png-bytes"),
42+
"image/png",
43+
"inbound",
44+
1024,
45+
"photo.png",
46+
);
47+
});
48+
49+
it("drops attachments whose canonical path escapes the allowed root", async () => {
50+
const allowedRoot = path.join(tempDir, "allowed");
51+
const outsideRoot = path.join(tempDir, "outside");
52+
await fs.mkdir(allowedRoot, { recursive: true });
53+
await fs.mkdir(outsideRoot, { recursive: true });
54+
const outsidePath = path.join(outsideRoot, "secret.png");
55+
await fs.writeFile(outsidePath, Buffer.from("secret-bytes"));
56+
await fs.symlink(outsideRoot, path.join(allowedRoot, "link"), "dir");
57+
58+
const saveMediaBuffer = vi.fn();
59+
const logVerbose = vi.fn();
60+
61+
await expect(
62+
stageIMessageAttachments(
63+
[
64+
{
65+
original_path: path.join(allowedRoot, "link", "secret.png"),
66+
mime_type: "image/png",
67+
missing: false,
68+
},
69+
],
70+
{ maxBytes: 1024, allowedRoots: [allowedRoot], deps: { saveMediaBuffer, logVerbose } },
71+
),
72+
).resolves.toEqual([]);
73+
74+
expect(saveMediaBuffer).not.toHaveBeenCalled();
75+
expect(logVerbose).toHaveBeenCalledWith(
76+
expect.stringContaining("attachment path resolves outside allowed roots"),
77+
);
78+
});
79+
80+
it("converts HEIC iMessage attachments to JPEG before staging", async () => {
81+
const sourcePath = await writeTempFile("IMG_0001.HEIC", Buffer.from("heic-bytes"));
82+
const saveMediaBuffer = vi.fn(async () => ({
83+
id: "saved.jpg",
84+
path: "/state/media/inbound/saved.jpg",
85+
size: 10,
86+
contentType: "image/jpeg",
87+
}));
88+
const convertHeicToJpeg = vi.fn(async () => Buffer.from("jpeg-bytes"));
89+
90+
await stageIMessageAttachments(
91+
[{ original_path: sourcePath, mime_type: "image/heic", missing: false }],
92+
{ maxBytes: 1024, deps: { saveMediaBuffer, convertHeicToJpeg } },
93+
);
94+
95+
expect(convertHeicToJpeg).toHaveBeenCalledWith(sourcePath, 1024);
96+
expect(saveMediaBuffer).toHaveBeenCalledWith(
97+
Buffer.from("jpeg-bytes"),
98+
"image/jpeg",
99+
"inbound",
100+
1024,
101+
"IMG_0001.jpg",
102+
);
103+
});
104+
105+
it("drops attachments over the inbound media limit", async () => {
106+
const sourcePath = await writeTempFile("huge.png", Buffer.from("too large"));
107+
const saveMediaBuffer = vi.fn();
108+
const logVerbose = vi.fn();
109+
110+
await expect(
111+
stageIMessageAttachments(
112+
[{ original_path: sourcePath, mime_type: "image/png", missing: false }],
113+
{ maxBytes: 4, deps: { saveMediaBuffer, logVerbose } },
114+
),
115+
).resolves.toEqual([]);
116+
117+
expect(saveMediaBuffer).not.toHaveBeenCalled();
118+
expect(logVerbose).toHaveBeenCalledWith(expect.stringContaining("failed to stage"));
119+
});
120+
});

0 commit comments

Comments
 (0)