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 +21 -0
- package/README.md +167 -0
- package/README.zh-CN.md +141 -0
- package/dist/index.js +8 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/actions.js +331 -0
- package/dist/src/channel.js +247 -0
- package/dist/src/inbound.js +280 -0
- package/dist/src/spectrum.js +92 -0
- package/openclaw.plugin.json +102 -0
- package/package.json +49 -0
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)
|
package/README.zh-CN.md
ADDED
|
@@ -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,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
|
+
}
|