openclaw-imessage-photon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethan Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # openclaw-imessage-photon
2
+
3
+ **English** · [中文](README.zh-CN.md)
4
+
5
+ iMessage channel plugin for [OpenClaw](https://github.com/contaxy/openclaw) via
6
+ **Photon Spectrum Cloud** — no Mac, no iMessage server, no bridge process.
7
+
8
+ Run iMessage on your VPS/cloud server: your agent talks to iMessage contacts
9
+ directly through Photon's cloud relay, as a native OpenClaw channel plugin
10
+ (runs inside the gateway process — no per-message CLI spawn, no temporary
11
+ agent processes).
12
+
13
+ ## Highlights
14
+
15
+ - **Native channel plugin** — runs in the OpenClaw gateway process; messages
16
+ dispatch straight into the embedded agent runtime.
17
+ - **No Mac required** — Photon Spectrum Cloud relays iMessage.
18
+ - **Text, images, voice, polls, effects, contacts** — both directions.
19
+ - **Voice transcription** — plug any `tools.media.audio` STT provider
20
+ (Deepgram, ElevenLabs, Groq, ...) via ordinary OpenClaw config.
21
+ - **Green-friendly defaults** — DM allowlist, seen-ack reaction, tapbacks,
22
+ auto-reconnect, read receipts, typing indicators.
23
+ - **Onboarding wizard** — `openclaw onboard` guides through Photon project
24
+ setup (credentials via `SPECTRUM_PROJECT_ID` / `SPECTRUM_PROJECT_SECRET`,
25
+ or paste from the dashboard).
26
+
27
+ ## Installation
28
+
29
+ Requirements: OpenClaw ≥ 2026.7.1, Node 22+.
30
+
31
+ **Via npm (recommended)** — either ask an AI agent to run it, or:
32
+
33
+ ```bash
34
+ npx -y openclaw-imessage-photon-cli@latest install
35
+ ```
36
+
37
+ Or install the plugin package directly:
38
+
39
+ ```bash
40
+ openclaw plugins install npm:openclaw-imessage-photon
41
+ ```
42
+
43
+ **From source:**
44
+ git clone https://github.com/ethanjtch/openclaw-imessage-photon
45
+ cd openclaw-imessage-photon
46
+ npm install
47
+ npm run build
48
+ openclaw plugins install ./imessage-photon
49
+ openclaw gateway restart
50
+ ```
51
+
52
+ Then configure the channel (run `openclaw onboard`, pick **iMessage (Photon)**)
53
+ or edit `openclaw.json` directly:
54
+
55
+ ```json5
56
+ {
57
+ channels: {
58
+ "imessage-photon": {
59
+ enabled: true,
60
+ projectId: "...", // or env SPECTRUM_PROJECT_ID
61
+ projectSecret: "...", // or env SPECTRUM_PROJECT_SECRET
62
+ allowFrom: ["+8613800138000"], // DM allowlist (E.164). [] = open
63
+ },
64
+ },
65
+ }
66
+ ```
67
+
68
+ Register at [photon.codes](https://photon.codes), create a project with the
69
+ iMessage provider, and copy the Project ID / Secret.
70
+
71
+ ## Configuration
72
+
73
+ | Key | Default | Description |
74
+ |---|---|---|
75
+ | `projectId` / `projectSecret` | env `SPECTRUM_PROJECT_ID` / `SPECTRUM_PROJECT_SECRET` | Photon project credentials |
76
+ | `allowFrom` | `[]` (open) | DM allowlist, E.164 numbers |
77
+ | `ackReaction` | `👀` | Seen/processing reaction on inbound text; removed after reply. `""` disables |
78
+ | `tapbackNotifications` | `all` | `all` forward tapbacks to agent; `off` ignores them |
79
+ | `enableMedia` | `false` | Agent can send/receive media attachments |
80
+ | `enablePoll` | `false` | Agent can create polls |
81
+ | `enableEffects` | `false` | Full-screen effects (confetti, lasers, ...) |
82
+ | `enableContact` | `false` | Agent can share contacts |
83
+ | `enableVoice` | `false` | Agent can send voice (via media + audio content type) |
84
+ | `enableGroups` | `false` | Handle group chats |
85
+ | `enableTyping` | `false` | Typing indicator while processing |
86
+ | `enableReadReceipts` | `false` | Mark inbound as read |
87
+
88
+ ### Voice transcription
89
+
90
+ Configure any OpenClaw media-audio STT provider, e.g. Deepgram:
91
+
92
+ ```json5
93
+ {
94
+ tools: { media: { audio: { models: [{ provider: "deepgram", model: "nova-3", language: "zh" }] } } },
95
+ }
96
+ ```
97
+
98
+ Requires the provider's env key (e.g. `DEEPGRAM_API_KEY`) in the gateway
99
+ environment. Inbound voice is saved to the media store and transcribed by the
100
+ core media pipeline; the agent then sees the transcript.
101
+
102
+ ## Capability matrix
103
+
104
+ | iMessage capability | This plugin | [Mouxy/openclaw-photon](https://github.com/Mouxy/openclaw-photon) |
105
+ |---|---|---|
106
+ | Text / reply / active send | ✅ | ✅ |
107
+ | Media (images/files, both ways) | ✅ (`enableMedia`) | ✅ |
108
+ | Voice send + inbound transcription | ✅ (`enableVoice` + `tools.media.audio`) | ✅ |
109
+ | Polls (create) | ✅ (`enablePoll`) | ✅ |
110
+ | Poll vote events | ⚠️ blocked by upstream spectrum-ts bug | ✅ |
111
+ | Full-screen effects | ✅ (`enableEffects`) | ✅ |
112
+ | Contacts | ✅ (`enableContact`) | ✅ |
113
+ | Group chats | ✅ (`enableGroups`, no mention gating) | ✅ |
114
+ | Tapbacks (inbound) | ✅ | ✅ |
115
+ | Seen-ack reaction | ✅ (👀) | — |
116
+ | Typing indicators | ✅ (`enableTyping`) | ✅ |
117
+ | Read receipts | ✅ (`enableReadReceipts`) | ✅ |
118
+ | Stickers / text animations | ❌ | ✅ (needs `@photon-ai/advanced-imessage`) |
119
+ | Mini-app / status cards | ❌ (business-account only) | ✅ |
120
+ | photonDoctor diagnostic tool | ❌ (use `openclaw channels status`) | ✅ |
121
+ | Onboarding | manual credentials / env | device-code auto-provisioning |
122
+ | License | **MIT** | UNLICENSED |
123
+
124
+ ### Attribution
125
+
126
+ This project is an **independent implementation** inspired by
127
+ [Mouxy/openclaw-photon](https://github.com/Mouxy/openclaw-photon)'s design and
128
+ its pointer that OpenClaw's shared message tool + channel runtime are the right
129
+ integration surface. No code from that repository is used — its license is
130
+ UNLICENSED, and this project is written from scratch against the OpenClaw
131
+ plugin SDK and `spectrum-ts`. Thank you, Mouxy, for the inspiration.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ npm run build # tsc -> dist/
137
+ npm test # (planned) config/inbound/dedupe unit tests
138
+ openclaw plugins install ./imessage-photon --force
139
+ systemctl --user restart openclaw-gateway
140
+ ```
141
+
142
+ Local install lives under `~/.openclaw/extensions/imessage-photon/`; rebuild
143
+ and `cp -r dist/*` there, or re-run `plugins install --force`.
144
+
145
+ ## Known issues
146
+
147
+ - **Poll vote events**: spectrum-ts's `toCachedPoll` rejects events whose poll
148
+ title is an empty string, crashing and dropping the event in the library
149
+ before the plugin can see it (issue draft:
150
+ `spectrum-ts-poll-bug-issue-draft.md`). Poll *creation* works.
151
+
152
+ ## Roadmap / TODO
153
+
154
+ - [ ] **Message dedupe**: 48h window + restart protection (avoid duplicate
155
+ replies after a gateway restart)
156
+ - [ ] **Unit tests**: config parsing / inbound classification / dedupe
157
+ - [ ] **Device-code onboarding**: auto-login + auto-provision the Photon
158
+ project via Photon's public API (no manual credential copying)
159
+ - [ ] **Group mention gating**: `requireMention` config (only reply in groups
160
+ when mentioned)
161
+ - [ ] **Edit/unsend notifications**: `enableEditUnsend` switch (agent reacts
162
+ to inbound edit/unsend events)
163
+ - [ ] Multi-account / remote iMessage line refinement
164
+
165
+ ## License
166
+
167
+ [MIT](LICENSE)
@@ -0,0 +1,141 @@
1
+ # openclaw-imessage-photon
2
+
3
+ 基于 **Photon Spectrum Cloud** 的 [OpenClaw](https://github.com/contaxy/openclaw) iMessage 渠道插件 —— **无需 Mac、无需 iMessage 服务器、无独立 bridge 进程**。
4
+
5
+ 在你的 VPS/云服务器上直接跑 iMessage:你的 agent 通过 Photon 云中继与 iMessage 联系人对话,以 OpenClaw 原生渠道插件形式运行(在 gateway 进程内 —— 没有每次消息临时起 CLI 进程、没有瞬时 agent 进程)。
6
+
7
+ ## 亮点
8
+
9
+ - **原生渠道插件** —— 运行于 OpenClaw gateway 进程内;消息直接进入内嵌 agent 运行时。
10
+ - **无需 Mac** —— Photon Spectrum Cloud 中继 iMessage。
11
+ - **文本、图片、语音、投票、特效、名片** —— 双向。
12
+ - **语音转写** —— 通过普通 OpenClaw 配置接入任意 `tools.media.audio` 转录 provider(Deepgram、ElevenLabs、Groq……)。
13
+ - **对用户友好的默认行为** —— DM 白名单、👀 已读反应、tapback、自动重连、已读回执、输入中指示。
14
+ - **引导式配置向导** —— `openclaw onboard` 引导完成 Photon 项目配置(凭据通过 `SPECTRUM_PROJECT_ID` / `SPECTRUM_PROJECT_SECRET` 环境变量,或从控制台粘贴)。
15
+
16
+ ## 安装
17
+
18
+ 要求:OpenClaw ≥ 2026.7.1,Node 22+。
19
+
20
+ **通过 npm 安装(推荐)** —— 让任意 AI agent 帮你执行,或手动:
21
+
22
+ ```bash
23
+ npx -y openclaw-imessage-photon-cli@latest install
24
+ ```
25
+
26
+ 或直接安装插件包:
27
+
28
+ ```bash
29
+ openclaw plugins install npm:openclaw-imessage-photon
30
+ ```
31
+
32
+ **从源码安装:**
33
+
34
+ ```bash
35
+ git clone https://github.com/ethanjtch/openclaw-imessage-photon
36
+ cd openclaw-imessage-photon
37
+ npm install
38
+ npm run build
39
+ openclaw plugins install ./imessage-photon
40
+ openclaw gateway restart
41
+ ```
42
+
43
+ 然后配置渠道(运行 `openclaw onboard` 选择 **iMessage (Photon)**),或直接编辑 `openclaw.json`:
44
+
45
+ ```json5
46
+ {
47
+ channels: {
48
+ "imessage-photon": {
49
+ enabled: true,
50
+ projectId: "...", // 或环境变量 SPECTRUM_PROJECT_ID
51
+ projectSecret: "...", // 或环境变量 SPECTRUM_PROJECT_SECRET
52
+ allowFrom: ["+8613800138000"], // DM 白名单(E.164)。[] = 所有人
53
+ },
54
+ },
55
+ }
56
+ ```
57
+
58
+ 在 [photon.codes](https://photon.codes) 注册、创建 iMessage provider 项目,复制 Project ID / Secret 填入。
59
+
60
+ ## 配置项
61
+
62
+ | 配置项 | 默认值 | 说明 |
63
+ |---|---|---|
64
+ | `projectId` / `projectSecret` | 环境变量 `SPECTRUM_PROJECT_ID` / `SPECTRUM_PROJECT_SECRET` | Photon 项目凭据 |
65
+ | `allowFrom` | `[]`(所有人) | DM 白名单,E.164 号码 |
66
+ | `ackReaction` | `👀` | 收到文本消息时的"已读/处理中"反应;回复后移除。`""` 关闭 |
67
+ | `tapbackNotifications` | `all` | `all` 把 tapback 转发给 agent;`off` 忽略 |
68
+ | `enableMedia` | `false` | agent 收发媒体附件 |
69
+ | `enablePoll` | `false` | agent 发起投票 |
70
+ | `enableEffects` | `false` | 全屏特效(彩带、激光……) |
71
+ | `enableContact` | `false` | agent 分享名片 |
72
+ | `enableVoice` | `false` | agent 发送语音(走媒体 + audio 内容类型) |
73
+ | `enableGroups` | `false` | 处理群聊 |
74
+ | `enableTyping` | `false` | 处理中显示输入中指示 |
75
+ | `enableReadReceipts` | `false` | 入站消息标记已读 |
76
+
77
+ ### 语音转写
78
+
79
+ 配置任一 OpenClaw 媒体音频转录 provider,例如 Deepgram:
80
+
81
+ ```json5
82
+ {
83
+ tools: { media: { audio: { models: [{ provider: "deepgram", model: "nova-3", language: "zh" }] } } },
84
+ }
85
+ ```
86
+
87
+ 需在 gateway 环境中配置对应 provider 的 key(如 `DEEPGRAM_API_KEY`)。入站语音会被保存到媒体库,由核心媒体管线转写,agent 随后能看到转录文本。
88
+
89
+ ## 能力对比矩阵
90
+
91
+ | iMessage 能力 | 本插件 | [Mouxy/openclaw-photon](https://github.com/Mouxy/openclaw-photon) |
92
+ |---|---|---|
93
+ | 文本 / 引用回复 / 主动发送 | ✅ | ✅ |
94
+ | 媒体(图片/文件,双向) | ✅(`enableMedia`) | ✅ |
95
+ | 语音发送 + 入站转写 | ✅(`enableVoice` + `tools.media.audio`) | ✅ |
96
+ | 投票(发起) | ✅(`enablePoll`) | ✅ |
97
+ | 投票事件通知 | ⚠️ 被上游 spectrum-ts bug 阻断 | ✅ |
98
+ | 全屏特效 | ✅(`enableEffects`) | ✅ |
99
+ | 名片 | ✅(`enableContact`) | ✅ |
100
+ | 群聊 | ✅(`enableGroups`,无 @ 门控) | ✅ |
101
+ | Tapback(收到点赞) | ✅ | ✅ |
102
+ | 👀 已读反应 | ✅(👀) | — |
103
+ | 输入中指示 | ✅(`enableTyping`) | ✅ |
104
+ | 已读回执 | ✅(`enableReadReceipts`) | ✅ |
105
+ | 贴纸 / 文字动画 | ❌ | ✅(需 `@photon-ai/advanced-imessage`) |
106
+ | mini-app / 状态卡片 | ❌(仅 business 账号) | ✅ |
107
+ | photonDoctor 诊断工具 | ❌(用 `openclaw channels status`) | ✅ |
108
+ | 引导配置 | 手动凭据 / 环境变量 | 设备码自动开通 |
109
+ | 许可证 | **MIT** | UNLICENSED |
110
+
111
+ ### 致谢(Attribution)
112
+
113
+ 本项目是**独立实现**,受 [Mouxy/openclaw-photon](https://github.com/Mouxy/openclaw-photon) 的设计及其"OpenClaw 共享 message 工具 + channel runtime 是正确的集成入口"这一洞察启发。**未使用该仓库的任何代码** —— 其许可证为 UNLICENSED;本项目基于 OpenClaw 插件 SDK 与 `spectrum-ts` 从零编写。感谢 Mouxy 提供的灵感。
114
+
115
+ ## 开发
116
+
117
+ ```bash
118
+ npm run build # tsc -> dist/
119
+ npm test # (计划中)config/inbound/dedupe 单元测试
120
+ openclaw plugins install ./imessage-photon --force
121
+ systemctl --user restart openclaw-gateway
122
+ ```
123
+
124
+ 本地安装位于 `~/.openclaw/extensions/imessage-photon/`;重新构建后 `cp -r dist/*` 到该目录,或重新执行 `plugins install --force`。
125
+
126
+ ## 已知问题
127
+
128
+ - **投票事件通知**:spectrum-ts 的 `toCachedPoll` 会拒绝 poll 标题为空字符串的事件,在库内崩溃并丢弃事件,插件无法看到(issue 草稿:`spectrum-ts-poll-bug-issue-draft.md`)。投票**发起**正常。
129
+
130
+ ## 路线图(Roadmap / TODO)
131
+
132
+ - [ ] **消息去重**:48 小时窗口去重 + 重启后防止重复处理(防重启重复回复)
133
+ - [ ] **测试文件**:config 解析 / inbound 分类 / 去重逻辑(防回归)
134
+ - [ ] **设备码 onboarding**:调用 Photon 公开 API(app.photon.codes)自动登录 + 自动开通项目,免手动复制凭据
135
+ - [ ] **群组 @ 门控**:`requireMention` 配置(群聊中只有被 @ 才回复)
136
+ - [ ] **edit/unsend 通知 agent**:`enableEditUnsend` 开关(收到用户的编辑/撤回事件并回应)
137
+ - [ ] multi-account / 远程 iMessage 线路支持细化
138
+
139
+ ## 许可证
140
+
141
+ [MIT](LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ import { imessagePhotonPlugin } from "./src/channel.js";
3
+ export default defineChannelPluginEntry({
4
+ id: "imessage-photon",
5
+ name: "iMessage (Photon)",
6
+ description: "iMessage channel via Photon Spectrum Cloud — no Mac required.",
7
+ plugin: imessagePhotonPlugin,
8
+ });
@@ -0,0 +1,3 @@
1
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ import { imessagePhotonPlugin } from "./src/channel.js";
3
+ export default defineSetupPluginEntry(imessagePhotonPlugin);
@@ -0,0 +1,331 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
3
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
4
+ import { text as textContent, markdown as markdownContent, richlink as richlinkContent, attachment as attachmentContent, poll as pollContent, read as readContent, reply as replyContent, voice as voiceContent, contact as contactContent, } from "spectrum-ts";
5
+ import { effect as effectContent } from "@spectrum-ts/imessage";
6
+ import { resolveAccount } from "./channel.js";
7
+ import { resolveSpace, resolveMessage } from "./spectrum.js";
8
+ // Full-screen iMessage effects (spectrum-ts exposes the builder but not the
9
+ // id table, so we keep the canonical ids here).
10
+ const MESSAGE_EFFECTS = {
11
+ balloons: "com.apple.messages.effect.CKBalloonEffect",
12
+ celebration: "com.apple.messages.effect.CKHappyBirthdayEffect",
13
+ confetti: "com.apple.messages.effect.CKConfettiEffect",
14
+ echo: "com.apple.messages.effect.CKEchoEffect",
15
+ fireworks: "com.apple.messages.effect.CKFireworksEffect",
16
+ gentle: "com.apple.MobileSMS.expressivesend.gentle",
17
+ heart: "com.apple.messages.effect.CKHeartEffect",
18
+ invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
19
+ lasers: "com.apple.messages.effect.CKLasersEffect",
20
+ loud: "com.apple.MobileSMS.expressivesend.loud",
21
+ slam: "com.apple.MobileSMS.expressivesend.impact",
22
+ sparkles: "com.apple.messages.effect.CKSparklesEffect",
23
+ spotlight: "com.apple.messages.effect.CKSpotlightEffect",
24
+ };
25
+ // Base chat actions always available through the shared message tool.
26
+ const BASE_ACTIONS = ["send", "react", "read", "edit", "unsend", "reply"];
27
+ // Feature-gated actions (default off; enable via channels.imessage-photon.*).
28
+ const MEDIA_ACTION = "sendAttachment";
29
+ const POLL_ACTION = "poll";
30
+ const EFFECT_ACTION = "sendWithEffect";
31
+ const CONTACT_ACTION = "sendContact";
32
+ // Note: there is no custom "sendVoice" action. OpenClaw's message-tool layer
33
+ // hard-codes which actions accept a target (MESSAGE_ACTION_TARGET_MODE), and
34
+ // unknown actions are rejected with "Action X does not accept a target".
35
+ // Voice is sent via sendAttachment with contentType audio/* instead.
36
+ const SUPPORTED = new Set([
37
+ ...BASE_ACTIONS,
38
+ MEDIA_ACTION,
39
+ POLL_ACTION,
40
+ EFFECT_ACTION,
41
+ CONTACT_ACTION,
42
+ ]);
43
+ function readString(params, ...keys) {
44
+ for (const key of keys) {
45
+ const v = params[key];
46
+ if (typeof v === "string" && v.trim())
47
+ return v.trim();
48
+ }
49
+ return undefined;
50
+ }
51
+ function readStringArray(params, ...keys) {
52
+ for (const key of keys) {
53
+ const v = params[key];
54
+ if (Array.isArray(v))
55
+ return v.map(String).map((s) => s.trim()).filter(Boolean);
56
+ }
57
+ return [];
58
+ }
59
+ function readTarget(params) {
60
+ return readString(params, "to", "target", "phone", "phoneNumber", "number");
61
+ }
62
+ function readMessageId(params) {
63
+ return readString(params, "messageId", "message_id", "targetMessageId", "id");
64
+ }
65
+ /** Normalize an effect name to the iMessage effect id, or throw. */
66
+ function resolveEffect(name) {
67
+ const key = (name ?? "").trim().toLowerCase();
68
+ if (!key)
69
+ throw new Error(`sendWithEffect requires effect (one of: ${Object.keys(MESSAGE_EFFECTS).join(", ")})`);
70
+ const id = MESSAGE_EFFECTS[key];
71
+ if (!id)
72
+ throw new Error(`unsupported effect "${key}" (one of: ${Object.keys(MESSAGE_EFFECTS).join(", ")})`);
73
+ return id;
74
+ }
75
+ /**
76
+ * Read a media source from the agent's params into a sendable attachment.
77
+ *
78
+ * OpenClaw's message-tool layer can deliver media as either:
79
+ * - `params.buffer` (base64) — from local files / hosted hydration
80
+ * - `params.mediaUrl` / `media` / `filePath` / `path` — URL or sandbox path
81
+ * We support both. Buffer input REQUIRES an explicit mimeType (no extension
82
+ * to inspect), so we resolve contentType from params, then fall back to
83
+ * extension/magic-byte sniffing via guessMimeType.
84
+ */
85
+ async function readMediaContent(ctx, params) {
86
+ // Render-friendly metadata
87
+ const name = readString(params, "filename", "name", "fileName") ?? "attachment";
88
+ const contentType = readString(params, "contentType", "mimeType");
89
+ // Our own param names; accept Mouxy-style aliases only for tool compatibility
90
+ const asVoice = params.asVoiceMessage === true ||
91
+ params.asVoice === true ||
92
+ params.as_voice === true;
93
+ // 1) Hydrated base64 buffer
94
+ const bufferB64 = typeof params.buffer === "string" ? params.buffer : undefined;
95
+ if (bufferB64) {
96
+ const buf = Buffer.from(bufferB64, "base64");
97
+ const mime = contentType ?? guessMimeType(name, buf);
98
+ if (!mime) {
99
+ throw new Error("Unable to resolve MIME type for attachment. Pass contentType/mimeType explicitly.");
100
+ }
101
+ const builder = asVoice ? voiceContent : attachmentContent;
102
+ return builder(buf, { name, mimeType: mime });
103
+ }
104
+ // 2) mediaUrl / media / filePath / path (URL or sandbox path)
105
+ const source = readString(params, "mediaUrl", "media", "media_url") ??
106
+ readString(params, "filePath", "path", "file", "mediaPath");
107
+ if (!source)
108
+ throw new Error("sendAttachment requires buffer, mediaUrl/media, or filePath/path");
109
+ const loaded = await loadOutboundMediaFromUrl(source, {
110
+ maxBytes: typeof params.maxBytes === "number" ? params.maxBytes : 20 * 1024 * 1024,
111
+ mediaAccess: ctx.mediaAccess,
112
+ mediaLocalRoots: ctx.mediaLocalRoots,
113
+ mediaReadFile: ctx.mediaReadFile,
114
+ });
115
+ const mime = contentType ??
116
+ loaded.contentType ??
117
+ guessMimeType(loaded.fileName ?? name, loaded.buffer);
118
+ if (!mime) {
119
+ throw new Error("Unable to resolve MIME type for attachment. Pass contentType/mimeType explicitly.");
120
+ }
121
+ const builder = asVoice ? voiceContent : attachmentContent;
122
+ return builder(loaded.buffer, {
123
+ name: loaded.fileName ?? name,
124
+ mimeType: mime,
125
+ });
126
+ }
127
+ /** Best-effort MIME detection from a filename extension (or buffer magic bytes). */
128
+ function guessMimeType(filename, buf) {
129
+ const ext = (filename ?? "").toLowerCase().split(".").pop() ?? "";
130
+ const byExt = {
131
+ png: "image/png",
132
+ jpg: "image/jpeg",
133
+ jpeg: "image/jpeg",
134
+ gif: "image/gif",
135
+ webp: "image/webp",
136
+ heic: "image/heic",
137
+ heif: "image/heif",
138
+ svg: "image/svg+xml",
139
+ mp3: "audio/mpeg",
140
+ m4a: "audio/mp4",
141
+ aac: "audio/aac",
142
+ wav: "audio/wav",
143
+ ogg: "audio/ogg",
144
+ mp4: "video/mp4",
145
+ mov: "video/quicktime",
146
+ m4v: "video/mp4",
147
+ pdf: "application/pdf",
148
+ txt: "text/plain",
149
+ md: "text/markdown",
150
+ zip: "application/zip",
151
+ };
152
+ if (ext && byExt[ext])
153
+ return byExt[ext];
154
+ if (buf && buf.length >= 8) {
155
+ const hex = buf.subarray(0, 8).toString("hex");
156
+ if (hex.startsWith("89504e47"))
157
+ return "image/png";
158
+ if (hex.startsWith("ffd8ff"))
159
+ return "image/jpeg";
160
+ if (hex.startsWith("47494638"))
161
+ return "image/gif";
162
+ if (hex.startsWith("52494646") && buf.subarray(8, 12).toString() === "WAVE")
163
+ return "audio/wav";
164
+ if (hex.startsWith("494433") || hex.startsWith("fffb") || hex.startsWith("fff3"))
165
+ return "audio/mpeg";
166
+ if (hex.startsWith("25504446"))
167
+ return "application/pdf";
168
+ }
169
+ return undefined;
170
+ }
171
+ function actionOk(data) {
172
+ return jsonResult({ ok: true, ...data });
173
+ }
174
+ function actionError(message) {
175
+ return jsonResult({ ok: false, error: message });
176
+ }
177
+ /** Build the channel message-action adapter for iMessage via Photon. */
178
+ export function createMessageActions() {
179
+ return {
180
+ describeMessageTool: ({ cfg, accountId }) => {
181
+ let account;
182
+ try {
183
+ account = resolveAccount(cfg, accountId);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ const actions = [...BASE_ACTIONS];
189
+ if (account.enableMedia)
190
+ actions.push(MEDIA_ACTION);
191
+ if (account.enablePoll)
192
+ actions.push(POLL_ACTION);
193
+ if (account.enableEffects)
194
+ actions.push(EFFECT_ACTION);
195
+ if (account.enableContact)
196
+ actions.push(CONTACT_ACTION);
197
+ return {
198
+ actions,
199
+ mediaSourceParams: {
200
+ sendAttachment: ["media", "mediaUrl", "filePath", "path", "file"],
201
+ },
202
+ };
203
+ },
204
+ supportsAction: ({ action }) => SUPPORTED.has(action),
205
+ resolveExecutionMode: () => "gateway",
206
+ handleAction: async (ctx) => {
207
+ const { action, params } = ctx;
208
+ const actionName = action;
209
+ try {
210
+ const to = readTarget(params);
211
+ const space = to ? resolveSpace(to) : undefined;
212
+ if (!space) {
213
+ return actionError(`no known iMessage space for target "${to ?? "(none)"}". The user must message the bot first.`);
214
+ }
215
+ switch (actionName) {
216
+ case "send": {
217
+ const text = readString(params, "text", "message", "content");
218
+ const url = readString(params, "url", "link");
219
+ const md = readString(params, "markdown", "md");
220
+ if (url) {
221
+ await space.send(richlinkContent(url));
222
+ return actionOk({ to, type: "richlink", url });
223
+ }
224
+ if (md) {
225
+ await space.send(markdownContent(md));
226
+ return actionOk({ to, type: "markdown" });
227
+ }
228
+ if (!text)
229
+ throw new Error("send requires text/message/content (or url/link, or markdown/md)");
230
+ await space.send(textContent(text));
231
+ return actionOk({ to, type: "text" });
232
+ }
233
+ case "sendAttachment": {
234
+ const content = await readMediaContent(ctx, params);
235
+ await space.send(content);
236
+ return actionOk({ to, type: "attachment" });
237
+ }
238
+ case "poll": {
239
+ const question = readString(params, "pollQuestion", "question", "title", "text", "message");
240
+ const options = readStringArray(params, "pollOptions", "options", "pollOption", "choices");
241
+ if (!question)
242
+ throw new Error("poll requires pollQuestion/question/title");
243
+ if (options.length < 2)
244
+ throw new Error("poll requires at least two options");
245
+ await space.send(pollContent(question, ...options));
246
+ return actionOk({ to, type: "poll", question, optionCount: options.length });
247
+ }
248
+ case "sendWithEffect": {
249
+ const text = readString(params, "text", "message", "content");
250
+ const effectName = readString(params, "effect", "effectId");
251
+ if (!text)
252
+ throw new Error("sendWithEffect requires text/message/content");
253
+ await space.send(effectContent(markdownContent(text), resolveEffect(effectName)));
254
+ return actionOk({ to, type: "effect", effect: effectName });
255
+ }
256
+ case "sendContact": {
257
+ const input = readString(params, "name", "contactName") ??
258
+ readString(params, "phone", "contactPhone") ??
259
+ readString(params, "email", "contactEmail");
260
+ if (!input)
261
+ throw new Error("sendContact requires name, phone, or email");
262
+ const contactValue = readString(params, "name", "contactName") ?? input;
263
+ const phone = readString(params, "phone", "contactPhone");
264
+ const email = readString(params, "email", "contactEmail");
265
+ // contact() accepts a phone/email string or ContactInput; build
266
+ // a phone-number contact when possible.
267
+ await space.send(contactContent(phone ?? email ?? contactValue));
268
+ return actionOk({ to, type: "contact" });
269
+ }
270
+ case "sendVoice": {
271
+ // Kept for backward compatibility: voice is sent through the media
272
+ // path (audio/* contentType).
273
+ const content = await readMediaContent(ctx, params);
274
+ await space.send(content);
275
+ return actionOk({ to, type: "voice" });
276
+ }
277
+ case "react": {
278
+ const target = resolveMessage(readMessageId(params));
279
+ if (!target)
280
+ return actionError("react requires messageId of a recent message");
281
+ const emoji = readString(params, "emoji", "reaction");
282
+ if (!emoji)
283
+ throw new Error("react requires emoji");
284
+ await target.react(emoji);
285
+ return actionOk({ to, emoji });
286
+ }
287
+ case "read": {
288
+ const target = resolveMessage(readMessageId(params));
289
+ if (target)
290
+ await target.read();
291
+ else
292
+ await space.send(readContent(target ?? {}));
293
+ return actionOk({ to });
294
+ }
295
+ case "edit": {
296
+ const target = resolveMessage(readMessageId(params));
297
+ if (!target)
298
+ return actionError("edit requires messageId of a recent message");
299
+ const text = readString(params, "text", "message", "content");
300
+ if (!text)
301
+ throw new Error("edit requires text/message/content");
302
+ await target.edit(textContent(text));
303
+ return actionOk({ to });
304
+ }
305
+ case "unsend": {
306
+ const target = resolveMessage(readMessageId(params));
307
+ if (!target)
308
+ return actionError("unsend requires messageId of a recent message");
309
+ await target.unsend();
310
+ return actionOk({ to });
311
+ }
312
+ case "reply": {
313
+ const target = resolveMessage(readMessageId(params));
314
+ const text = readString(params, "text", "message", "content");
315
+ if (!target)
316
+ return actionError("reply requires messageId of a recent message");
317
+ if (!text)
318
+ throw new Error("reply requires text/message/content");
319
+ await space.send(replyContent(textContent(text), target));
320
+ return actionOk({ to });
321
+ }
322
+ default:
323
+ return actionError(`unsupported action: ${actionName}`);
324
+ }
325
+ }
326
+ catch (err) {
327
+ return actionError(`imessage-photon ${actionName}: ${err.message}`);
328
+ }
329
+ },
330
+ };
331
+ }