openclaw-imessage-photon 0.1.2 → 0.1.3
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/README.md +24 -3
- package/README.zh-CN.md +15 -2
- package/dist/src/actions.js +31 -3
- package/dist/src/channel.js +56 -5
- package/dist/src/inbound.js +37 -4
- package/dist/src/spectrum.js +27 -0
- package/openclaw.plugin.json +49 -12
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ agent processes).
|
|
|
16
16
|
dispatch straight into the embedded agent runtime.
|
|
17
17
|
- **No Mac required** — Photon Spectrum Cloud relays iMessage.
|
|
18
18
|
- **Text, images, voice, polls, effects, contacts** — both directions.
|
|
19
|
+
- **Rich link previews & Mini App cards** — send a URL as an iMessage link
|
|
20
|
+
card (`url`), a sheet-style Mini App card (`appUrl`, opens inside the
|
|
21
|
+
Spectrum App — no Safari jump), or a live in-transcript card (`appUrl` +
|
|
22
|
+
`appLive`).
|
|
19
23
|
- **Voice transcription** — plug any `tools.media.audio` STT provider
|
|
20
24
|
(Deepgram, ElevenLabs, Groq, ...) via ordinary OpenClaw config.
|
|
21
25
|
- **Green-friendly defaults** — DM allowlist, seen-ack reaction, tapbacks,
|
|
@@ -85,6 +89,21 @@ iMessage provider, and copy the Project ID / Secret.
|
|
|
85
89
|
| `enableTyping` | `false` | Typing indicator while processing |
|
|
86
90
|
| `enableReadReceipts` | `false` | Mark inbound as read |
|
|
87
91
|
|
|
92
|
+
### Rich links & Mini App cards
|
|
93
|
+
|
|
94
|
+
Plain `send` accepts three URL flavors:
|
|
95
|
+
|
|
96
|
+
| send param | iMessage rendering |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `url` / `link` | Rich link preview card (tap opens in Safari) |
|
|
99
|
+
| `appUrl` | Mini App card: compact title card; tap opens the full page in the Spectrum iMessage App — no Safari jump |
|
|
100
|
+
| `appUrl` + `appLive: true` | Live card: interactive page rendered inline in the transcript (swipe/click, no tap-to-open) |
|
|
101
|
+
|
|
102
|
+
App cards use Photon's **Spectrum App** host (Apple-approved iMessage
|
|
103
|
+
launcher) — no business plan needed for `app()` cards; recipients install the
|
|
104
|
+
Spectrum App once. Custom `customizedMiniApp()` (bring-your-own extension) is
|
|
105
|
+
business-plan only.
|
|
106
|
+
|
|
88
107
|
### Voice transcription
|
|
89
108
|
|
|
90
109
|
Configure any OpenClaw media-audio STT provider, e.g. Deepgram:
|
|
@@ -116,7 +135,7 @@ core media pipeline; the agent then sees the transcript.
|
|
|
116
135
|
| Typing indicators | ✅ (`enableTyping`) | ✅ |
|
|
117
136
|
| Read receipts | ✅ (`enableReadReceipts`) | ✅ |
|
|
118
137
|
| Stickers / text animations | ❌ | ✅ (needs `@photon-ai/advanced-imessage`) |
|
|
119
|
-
| Mini-app / status cards |
|
|
138
|
+
| Mini-app / status cards | ✅ `appUrl`/`appLive` via send (Spectrum App host); custom `customizedMiniApp()` business-only | ✅ (needs `@photon-ai/advanced-imessage`) |
|
|
120
139
|
| photonDoctor diagnostic tool | ❌ (use `openclaw channels status`) | ✅ |
|
|
121
140
|
| Onboarding | manual credentials / env | device-code auto-provisioning |
|
|
122
141
|
| License | **MIT** | UNLICENSED |
|
|
@@ -158,8 +177,10 @@ and `cp -r dist/*` there, or re-run `plugins install --force`.
|
|
|
158
177
|
project via Photon's public API (no manual credential copying)
|
|
159
178
|
- [ ] **Group mention gating**: `requireMention` config (only reply in groups
|
|
160
179
|
when mentioned)
|
|
161
|
-
- [ ] **
|
|
162
|
-
|
|
180
|
+
- [ ] **Plugin config schema**: declare plugin-owned `channels.imessage-photon.*`
|
|
181
|
+
keys via `configSchema` (blocked-ish today: openclaw's validator rejects
|
|
182
|
+
unknown keys, which is why extra switches like edit/unsend can't be added
|
|
183
|
+
without it)
|
|
163
184
|
- [ ] Multi-account / remote iMessage line refinement
|
|
164
185
|
|
|
165
186
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
- **原生渠道插件** —— 运行于 OpenClaw gateway 进程内;消息直接进入内嵌 agent 运行时。
|
|
10
10
|
- **无需 Mac** —— Photon Spectrum Cloud 中继 iMessage。
|
|
11
11
|
- **文本、图片、语音、投票、特效、名片** —— 双向。
|
|
12
|
+
- **富链接预览 & Mini App 卡片** —— 用 `url` 发链接卡片;用 `appUrl` 发 sheet 式 Mini App 卡片(在 Spectrum App 内打开,不跳 Safari);用 `appUrl` + `appLive` 发 live 卡片(对话内直接交互)。
|
|
12
13
|
- **语音转写** —— 通过普通 OpenClaw 配置接入任意 `tools.media.audio` 转录 provider(Deepgram、ElevenLabs、Groq……)。
|
|
13
14
|
- **对用户友好的默认行为** —— DM 白名单、👀 已读反应、tapback、自动重连、已读回执、输入中指示。
|
|
14
15
|
- **引导式配置向导** —— `openclaw onboard` 引导完成 Photon 项目配置(凭据通过 `SPECTRUM_PROJECT_ID` / `SPECTRUM_PROJECT_SECRET` 环境变量,或从控制台粘贴)。
|
|
@@ -74,6 +75,18 @@ openclaw gateway restart
|
|
|
74
75
|
| `enableTyping` | `false` | 处理中显示输入中指示 |
|
|
75
76
|
| `enableReadReceipts` | `false` | 入站消息标记已读 |
|
|
76
77
|
|
|
78
|
+
### 富链接 & Mini App 卡片
|
|
79
|
+
|
|
80
|
+
`send` 支持三种 URL 形态:
|
|
81
|
+
|
|
82
|
+
| send 参数 | iMessage 渲染 |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `url` / `link` | 富链接预览卡片(点击打开 Safari) |
|
|
85
|
+
| `appUrl` | Mini App 卡片:紧凑标题卡片;点击在 Spectrum App 内打开完整页面——不跳 Safari |
|
|
86
|
+
| `appUrl` + `appLive: true` | Live 卡片:页面内嵌在对话中直接交互(滑动/点击,无需点开) |
|
|
87
|
+
|
|
88
|
+
App 卡片使用 Photon 的 **Spectrum App** 宿主(Apple 已批准的 iMessage 启动器)——`app()` 卡片**无需 business 套餐**;接收方装一次 Spectrum App 即可。自定义 `customizedMiniApp()`(自带扩展)才需要 business。
|
|
89
|
+
|
|
77
90
|
### 语音转写
|
|
78
91
|
|
|
79
92
|
配置任一 OpenClaw 媒体音频转录 provider,例如 Deepgram:
|
|
@@ -103,7 +116,7 @@ openclaw gateway restart
|
|
|
103
116
|
| 输入中指示 | ✅(`enableTyping`) | ✅ |
|
|
104
117
|
| 已读回执 | ✅(`enableReadReceipts`) | ✅ |
|
|
105
118
|
| 贴纸 / 文字动画 | ❌ | ✅(需 `@photon-ai/advanced-imessage`) |
|
|
106
|
-
| mini-app / 状态卡片 |
|
|
119
|
+
| mini-app / 状态卡片 | ✅ `appUrl`/`appLive`(通过 send,Spectrum App 宿主);自定义 `customizedMiniApp()` 仅 business | ✅(需 `@photon-ai/advanced-imessage`) |
|
|
107
120
|
| photonDoctor 诊断工具 | ❌(用 `openclaw channels status`) | ✅ |
|
|
108
121
|
| 引导配置 | 手动凭据 / 环境变量 | 设备码自动开通 |
|
|
109
122
|
| 许可证 | **MIT** | UNLICENSED |
|
|
@@ -133,7 +146,7 @@ systemctl --user restart openclaw-gateway
|
|
|
133
146
|
- [ ] **测试文件**:config 解析 / inbound 分类 / 去重逻辑(防回归)
|
|
134
147
|
- [ ] **设备码 onboarding**:调用 Photon 公开 API(app.photon.codes)自动登录 + 自动开通项目,免手动复制凭据
|
|
135
148
|
- [ ] **群组 @ 门控**:`requireMention` 配置(群聊中只有被 @ 才回复)
|
|
136
|
-
- [ ]
|
|
149
|
+
- [ ] **插件 config schema**:通过 `configSchema` 声明插件自有 `channels.imessage-photon.*` 配置键(当前 openclaw 校验器拒绝未知键,这正是 edit/unsend 等附加开关无法加入的原因)
|
|
137
150
|
- [ ] multi-account / 远程 iMessage 线路支持细化
|
|
138
151
|
|
|
139
152
|
## 许可证
|
package/dist/src/actions.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Type } from "typebox";
|
|
2
3
|
import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
|
|
3
4
|
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 { text as textContent, markdown as markdownContent, richlink as richlinkContent, app as appContent, attachment as attachmentContent, poll as pollContent, read as readContent, reply as replyContent, voice as voiceContent, contact as contactContent, } from "spectrum-ts";
|
|
5
6
|
import { effect as effectContent } from "@spectrum-ts/imessage";
|
|
6
7
|
import { resolveAccount } from "./channel.js";
|
|
7
8
|
import { resolveSpace, resolveMessage } from "./spectrum.js";
|
|
@@ -199,6 +200,27 @@ export function createMessageActions() {
|
|
|
199
200
|
mediaSourceParams: {
|
|
200
201
|
sendAttachment: ["media", "mediaUrl", "filePath", "path", "file"],
|
|
201
202
|
},
|
|
203
|
+
// Expose rich-link and App-card capabilities on the shared send action.
|
|
204
|
+
schema: [
|
|
205
|
+
{
|
|
206
|
+
properties: {
|
|
207
|
+
url: Type.String({
|
|
208
|
+
description: "HTTPS URL to send as a rich link-preview message (iMessage renders it as a link card). Mutually exclusive with text/markdown/appUrl.",
|
|
209
|
+
}),
|
|
210
|
+
link: Type.String({
|
|
211
|
+
description: "Alias for url: HTTPS URL to send as a rich link-preview message.",
|
|
212
|
+
}),
|
|
213
|
+
appUrl: Type.String({
|
|
214
|
+
description: "HTTPS URL to send as a native iMessage App card (Spectrum App host). Recipient opens it in the Spectrum iMessage App without leaving Messages; degrades to a link preview on unsupported platforms. Mutually exclusive with text/markdown/url.",
|
|
215
|
+
}),
|
|
216
|
+
appLive: Type.Optional(Type.Boolean({
|
|
217
|
+
description: "When sending an App card (appUrl set), request live in-transcript rendering (MSMessageLiveLayout). Requires the recipient to have the Spectrum App extension installed.",
|
|
218
|
+
})),
|
|
219
|
+
},
|
|
220
|
+
actions: ["send"],
|
|
221
|
+
visibility: "current-channel",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
202
224
|
};
|
|
203
225
|
},
|
|
204
226
|
supportsAction: ({ action }) => SUPPORTED.has(action),
|
|
@@ -216,17 +238,23 @@ export function createMessageActions() {
|
|
|
216
238
|
case "send": {
|
|
217
239
|
const text = readString(params, "text", "message", "content");
|
|
218
240
|
const url = readString(params, "url", "link");
|
|
219
|
-
const
|
|
241
|
+
const appUrl = readString(params, "appUrl", "app_url", "cardUrl");
|
|
242
|
+
if (appUrl) {
|
|
243
|
+
const live = params.appLive === true || params.appLive === "true" || params.live === true || params.live === "true";
|
|
244
|
+
await space.send(appContent(appUrl, live ? { live: true } : undefined));
|
|
245
|
+
return actionOk({ to, type: "app", url: appUrl, live });
|
|
246
|
+
}
|
|
220
247
|
if (url) {
|
|
221
248
|
await space.send(richlinkContent(url));
|
|
222
249
|
return actionOk({ to, type: "richlink", url });
|
|
223
250
|
}
|
|
251
|
+
const md = readString(params, "markdown", "md");
|
|
224
252
|
if (md) {
|
|
225
253
|
await space.send(markdownContent(md));
|
|
226
254
|
return actionOk({ to, type: "markdown" });
|
|
227
255
|
}
|
|
228
256
|
if (!text)
|
|
229
|
-
throw new Error("send requires text/message/content (or url/link, or
|
|
257
|
+
throw new Error("send requires text/message/content (or url/link, or appUrl)");
|
|
230
258
|
await space.send(textContent(text));
|
|
231
259
|
return actionOk({ to, type: "text" });
|
|
232
260
|
}
|
package/dist/src/channel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createChatChannelPlugin, } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
-
import { connectSpectrum, resetSpectrum, rememberSpace, rememberMessage } from "./spectrum.js";
|
|
2
|
+
import { connectSpectrum, resetSpectrum, rememberSpace, rememberMessage, resolveOrOpenSpace, sendText } from "./spectrum.js";
|
|
3
3
|
import { handleInbound } from "./inbound.js";
|
|
4
4
|
import { createMessageActions } from "./actions.js";
|
|
5
5
|
function channelSection(cfg) {
|
|
@@ -36,6 +36,7 @@ export function resolveAccount(cfg, accountId) {
|
|
|
36
36
|
enableGroups: Boolean(section?.enableGroups),
|
|
37
37
|
enableTyping: Boolean(section?.enableTyping),
|
|
38
38
|
enableReadReceipts: Boolean(section?.enableReadReceipts),
|
|
39
|
+
contactNames: section?.contactNames ?? {},
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
const setupWizard = {
|
|
@@ -160,6 +161,29 @@ export const imessagePhotonPlugin = createChatChannelPlugin({
|
|
|
160
161
|
reply: true,
|
|
161
162
|
},
|
|
162
163
|
actions: createMessageActions(),
|
|
164
|
+
agentPrompt: {
|
|
165
|
+
// Text guidance layered on top of the fixed openclaw message-tool
|
|
166
|
+
// schema: keep replies as plain text by default, and only use
|
|
167
|
+
// reply-quoting when the user themselves quoted a message.
|
|
168
|
+
messageToolHints: ({ cfg }) => {
|
|
169
|
+
const account = resolveAccount(cfg);
|
|
170
|
+
const hints = [
|
|
171
|
+
"Prefer plain text (text) over markdown (markdown) unless the recipient explicitly asked for rich formatting. Plain text renders best in iMessage.",
|
|
172
|
+
"For everyday replies use the plain send action without quotes.",
|
|
173
|
+
];
|
|
174
|
+
if (account.enableMedia) {
|
|
175
|
+
hints.push("Images/audio/video/files are sent with sendAttachment (media/buffer/filePath).");
|
|
176
|
+
}
|
|
177
|
+
return hints;
|
|
178
|
+
},
|
|
179
|
+
inboundFormattingHints: () => ({
|
|
180
|
+
text_markup: "plain text",
|
|
181
|
+
rules: [
|
|
182
|
+
"When the user's message is a reply-to (quoted) message, reply by quoting that message back using the reply action with its messageId — this keeps the quoted conversation thread visible in iMessage.",
|
|
183
|
+
"When the user's message is NOT a reply, reply with a plain send (no quote).",
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
163
187
|
setupWizard,
|
|
164
188
|
config: {
|
|
165
189
|
listAccountIds: () => ["default"],
|
|
@@ -176,6 +200,21 @@ export const imessagePhotonPlugin = createChatChannelPlugin({
|
|
|
176
200
|
};
|
|
177
201
|
},
|
|
178
202
|
},
|
|
203
|
+
// Directory: give iMessage contacts a friendly display name so sessions
|
|
204
|
+
// show "iMessage <last4>" instead of the raw E.164 number.
|
|
205
|
+
directory: {
|
|
206
|
+
listPeers: async ({ cfg }) => {
|
|
207
|
+
const account = resolveAccount(cfg);
|
|
208
|
+
const peers = account.allowFrom ?? [];
|
|
209
|
+
return peers.map((raw) => {
|
|
210
|
+
const id = String(raw).trim();
|
|
211
|
+
if (!id)
|
|
212
|
+
return null;
|
|
213
|
+
const tail = id.slice(-4);
|
|
214
|
+
return { kind: "user", id, name: `iMessage ${tail}` };
|
|
215
|
+
}).filter((e) => e !== null);
|
|
216
|
+
},
|
|
217
|
+
},
|
|
179
218
|
setup: {
|
|
180
219
|
applyAccountConfig: ({ cfg, input }) => ({
|
|
181
220
|
...cfg,
|
|
@@ -239,9 +278,21 @@ export const imessagePhotonPlugin = createChatChannelPlugin({
|
|
|
239
278
|
},
|
|
240
279
|
threading: { topLevelReplyToMode: "reply" },
|
|
241
280
|
outbound: {
|
|
242
|
-
//
|
|
243
|
-
// (
|
|
244
|
-
//
|
|
245
|
-
deliveryMode: "
|
|
281
|
+
// Real active-outbound surface: lets the core deliver proactive sends
|
|
282
|
+
// (cron announcements, heartbeat, control-UI new message) to iMessage via
|
|
283
|
+
// Spectrum. sendText resolves/creates the 1:1 Space by phone number.
|
|
284
|
+
deliveryMode: "direct",
|
|
285
|
+
sendText: async ({ to, text }) => {
|
|
286
|
+
const space = await resolveOrOpenSpace(to);
|
|
287
|
+
if (!space)
|
|
288
|
+
throw new Error(`imessage-photon: no reachable space for ${to}`);
|
|
289
|
+
await sendText(space, text);
|
|
290
|
+
return {
|
|
291
|
+
channel: "imessage-photon",
|
|
292
|
+
messageId: "",
|
|
293
|
+
target: { kind: "conversation", id: to },
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
};
|
|
296
|
+
},
|
|
246
297
|
},
|
|
247
298
|
});
|
package/dist/src/inbound.js
CHANGED
|
@@ -36,8 +36,7 @@ export async function handleInbound(runtime, cfg, space, message, log) {
|
|
|
36
36
|
await dispatchPollEvent(runtime, cfg, space, message, account, log);
|
|
37
37
|
}
|
|
38
38
|
else if (message.content.type === "edit" || message.content.type === "unsend") {
|
|
39
|
-
//
|
|
40
|
-
// until then, these are system events: log only.
|
|
39
|
+
// edit/unsend from the user are system events: log only (no agent turn).
|
|
41
40
|
log?.(`[imessage-photon] system event type=${message.content.type} from ${space.id}`);
|
|
42
41
|
}
|
|
43
42
|
else {
|
|
@@ -49,7 +48,16 @@ export async function handleInbound(runtime, cfg, space, message, log) {
|
|
|
49
48
|
/** Shared turn dispatch: build session route + context, then run the agent. */
|
|
50
49
|
async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
51
50
|
const phone = senderPhone(space, message) ?? space.id;
|
|
52
|
-
|
|
51
|
+
// Sender display name, separate from the conversation label:
|
|
52
|
+
// 1. explicit per-number contact name from config (contactNames) — bubbles show
|
|
53
|
+
// this instead of a phone-derived label, e.g. "Ethan".
|
|
54
|
+
// 2. any real name the platform delivers (spectrum currently never sends one).
|
|
55
|
+
// 3. stable fallback "iMessage <last4 digits>" so the label never fluctuates.
|
|
56
|
+
const platformName = message.sender?.name;
|
|
57
|
+
const senderName = account.contactNames[phone] ?? platformName?.trim() ?? `iMessage ${phone.slice(-4)}`;
|
|
58
|
+
// Conversation title stays a stable, human-meaningful label independent of the
|
|
59
|
+
// per-message sender display name.
|
|
60
|
+
const conversationLabel = `iMessage ${phone.slice(-4)}`;
|
|
53
61
|
// Read receipt: mark the conversation read (iMessage marks the whole chat).
|
|
54
62
|
if (account.enableReadReceipts) {
|
|
55
63
|
message.read().catch((err) => log?.(`[imessage-photon] read receipt failed: ${String(err)}`));
|
|
@@ -92,6 +100,7 @@ async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
|
92
100
|
});
|
|
93
101
|
await runtime.inbound.run({
|
|
94
102
|
channel: CHANNEL,
|
|
103
|
+
accountId: "default",
|
|
95
104
|
raw: message,
|
|
96
105
|
adapter: {
|
|
97
106
|
ingest: (raw) => ({
|
|
@@ -102,9 +111,17 @@ async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
|
102
111
|
raw,
|
|
103
112
|
}),
|
|
104
113
|
resolveTurn: async (input) => {
|
|
114
|
+
// Pass the configured DM scope through so iMessage DMs get their own
|
|
115
|
+
// session (agent:main:imessage-photon:direct:<phone>) instead of being
|
|
116
|
+
// merged into the main session (agent:main:main). The default
|
|
117
|
+
// dmScope in core is "main"; without this, every channel merges.
|
|
118
|
+
const s = cfg.session;
|
|
105
119
|
const routeSessionKey = runtime.routing.buildAgentSessionKey({
|
|
106
120
|
agentId: AGENT_ID,
|
|
107
121
|
channel: CHANNEL,
|
|
122
|
+
mainKey: s?.mainKey ?? "main",
|
|
123
|
+
dmScope: s?.dmScope ?? "main",
|
|
124
|
+
identityLinks: s?.identityLinks,
|
|
108
125
|
peer: { kind: "direct", id: phone },
|
|
109
126
|
});
|
|
110
127
|
const storePath = runtime.session.resolveStorePath(cfg.session?.store, {
|
|
@@ -112,9 +129,10 @@ async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
|
112
129
|
});
|
|
113
130
|
const ctxPayload = await runtime.inbound.buildContext({
|
|
114
131
|
channel: CHANNEL,
|
|
132
|
+
accountId: "default",
|
|
115
133
|
from: phone,
|
|
116
134
|
sender: { id: phone, name: senderName },
|
|
117
|
-
conversation: { kind: "direct", id: space.id },
|
|
135
|
+
conversation: { kind: "direct", id: space.id, label: conversationLabel },
|
|
118
136
|
route: { agentId: AGENT_ID, routeSessionKey },
|
|
119
137
|
reply: { to: phone, sourceReplyDeliveryMode: "reply" },
|
|
120
138
|
message: { rawBody: opts.rawText, bodyForAgent: opts.textForAgent },
|
|
@@ -128,6 +146,21 @@ async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
|
128
146
|
storePath,
|
|
129
147
|
ctxPayload,
|
|
130
148
|
recordInboundSession: runtime.session.recordInboundSession,
|
|
149
|
+
// openclaw ≥ 2026.8.1 requires prepared turns to declare a dispatch
|
|
150
|
+
// lifecycle: turnAdoptionLifecycle is undefined for this non-durable
|
|
151
|
+
// direct-DM turn; onDispatchSkipped releases what runDispatch would
|
|
152
|
+
// have settled (typing indicator + seen-ack) when dispatch is
|
|
153
|
+
// skipped (outboundEcho / botLoopProtection / observeOnly).
|
|
154
|
+
runDispatchLifecycle: {
|
|
155
|
+
turnAdoptionLifecycle: undefined,
|
|
156
|
+
onDispatchSkipped: async () => {
|
|
157
|
+
await stopTyping();
|
|
158
|
+
if (ackHandle) {
|
|
159
|
+
const h = ackHandle;
|
|
160
|
+
await h.remove().catch((err) => log?.(`[imessage-photon] ack remove failed (skipped): ${String(err)}`));
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
},
|
|
131
164
|
runDispatch: () => runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
132
165
|
ctx: ctxPayload,
|
|
133
166
|
cfg,
|
package/dist/src/spectrum.js
CHANGED
|
@@ -39,6 +39,33 @@ export function resolveSpace(target) {
|
|
|
39
39
|
return undefined;
|
|
40
40
|
return spacesByPhone.get(phone);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a Space for an outbound target, falling back to resolving/creating
|
|
44
|
+
* the 1:1 conversation via the platform when the target was never observed on
|
|
45
|
+
* the inbound stream (active outbound sends).
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveOrOpenSpace(target) {
|
|
48
|
+
const cached = resolveSpace(target);
|
|
49
|
+
if (cached)
|
|
50
|
+
return cached;
|
|
51
|
+
const explicitPhone = normalizePhone(target);
|
|
52
|
+
if (!explicitPhone || !app)
|
|
53
|
+
return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const namespace = app.space;
|
|
56
|
+
const create = namespace?.["imessage"]
|
|
57
|
+
?.create;
|
|
58
|
+
if (!create)
|
|
59
|
+
return undefined;
|
|
60
|
+
const space = await create(explicitPhone);
|
|
61
|
+
if (space)
|
|
62
|
+
rememberSpace(space);
|
|
63
|
+
return space;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
42
69
|
/** Number of known spaces (used in diagnostics). */
|
|
43
70
|
export function knownSpaceCount() {
|
|
44
71
|
return spacesByPhone.size;
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
"name": "iMessage (Photon)",
|
|
4
4
|
"description": "iMessage channel via Photon Spectrum Cloud — no Mac required. Register at https://photon.codes, create a project, and paste the Project ID and Secret.",
|
|
5
5
|
"icon": "https://cdn.simpleicons.org/imessage",
|
|
6
|
-
"channels": [
|
|
6
|
+
"channels": [
|
|
7
|
+
"imessage-photon"
|
|
8
|
+
],
|
|
7
9
|
"channelEnvVars": {
|
|
8
10
|
"imessage-photon": [
|
|
9
11
|
"SPECTRUM_PROJECT_ID",
|
|
@@ -22,20 +24,38 @@
|
|
|
22
24
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
23
25
|
"type": "object",
|
|
24
26
|
"properties": {
|
|
25
|
-
"enabled": {
|
|
27
|
+
"enabled": {
|
|
28
|
+
"type": "boolean"
|
|
29
|
+
},
|
|
26
30
|
"dmPolicy": {
|
|
27
31
|
"type": "string",
|
|
28
|
-
"enum": [
|
|
32
|
+
"enum": [
|
|
33
|
+
"allowlist",
|
|
34
|
+
"pairing",
|
|
35
|
+
"open",
|
|
36
|
+
"disabled"
|
|
37
|
+
]
|
|
29
38
|
},
|
|
30
39
|
"dmSecurity": {
|
|
31
40
|
"type": "string",
|
|
32
|
-
"enum": [
|
|
41
|
+
"enum": [
|
|
42
|
+
"allowlist",
|
|
43
|
+
"pairing",
|
|
44
|
+
"open",
|
|
45
|
+
"disabled"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"projectId": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
},
|
|
51
|
+
"projectSecret": {
|
|
52
|
+
"type": "string"
|
|
33
53
|
},
|
|
34
|
-
"projectId": { "type": "string" },
|
|
35
|
-
"projectSecret": { "type": "string" },
|
|
36
54
|
"allowFrom": {
|
|
37
55
|
"type": "array",
|
|
38
|
-
"items": {
|
|
56
|
+
"items": {
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
39
59
|
"description": "Allowed sender phone numbers in E.164 form, e.g. +8613800138000. Empty = allow everyone."
|
|
40
60
|
},
|
|
41
61
|
"ackReaction": {
|
|
@@ -45,7 +65,10 @@
|
|
|
45
65
|
},
|
|
46
66
|
"tapbackNotifications": {
|
|
47
67
|
"type": "string",
|
|
48
|
-
"enum": [
|
|
68
|
+
"enum": [
|
|
69
|
+
"off",
|
|
70
|
+
"all"
|
|
71
|
+
],
|
|
49
72
|
"default": "all",
|
|
50
73
|
"description": "off = ignore inbound tapbacks; all = forward them to the agent as system events."
|
|
51
74
|
},
|
|
@@ -88,15 +111,29 @@
|
|
|
88
111
|
"type": "boolean",
|
|
89
112
|
"default": false,
|
|
90
113
|
"description": "Mark inbound messages as read (read receipts)."
|
|
114
|
+
},
|
|
115
|
+
"contactNames": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"additionalProperties": {
|
|
118
|
+
"type": "string"
|
|
119
|
+
},
|
|
120
|
+
"description": "Map of E.164 phone numbers to human-readable display names shown as the message sender in conversation bubbles, e.g. {\"+8613800138000\": \"Ethan\"}. Falls back to \"iMessage <last4 digits>\" and then to any name provided by the platform."
|
|
91
121
|
}
|
|
92
122
|
},
|
|
93
123
|
"additionalProperties": false
|
|
94
124
|
},
|
|
95
125
|
"uiHints": {
|
|
96
|
-
"projectId": {
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
"projectId": {
|
|
127
|
+
"label": "Photon project ID"
|
|
128
|
+
},
|
|
129
|
+
"projectSecret": {
|
|
130
|
+
"label": "Photon project secret",
|
|
131
|
+
"sensitive": true
|
|
132
|
+
},
|
|
133
|
+
"allowFrom": {
|
|
134
|
+
"label": "Allowed phone numbers"
|
|
135
|
+
}
|
|
99
136
|
}
|
|
100
137
|
}
|
|
101
138
|
}
|
|
102
|
-
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-imessage-photon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "iMessage channel for OpenClaw via Photon Spectrum Cloud (no Mac required).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"spectrum-ts": "^12.8.0"
|
|
44
|
+
"spectrum-ts": "^12.8.0",
|
|
45
|
+
"typebox": "1.3.3"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^22",
|