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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createChatChannelPlugin, } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
+
import { connectSpectrum, resetSpectrum, rememberSpace, rememberMessage } from "./spectrum.js";
|
|
3
|
+
import { handleInbound } from "./inbound.js";
|
|
4
|
+
import { createMessageActions } from "./actions.js";
|
|
5
|
+
function channelSection(cfg) {
|
|
6
|
+
return cfg.channels?.["imessage-photon"];
|
|
7
|
+
}
|
|
8
|
+
function readAllowFrom(section) {
|
|
9
|
+
const fromConfig = section?.allowFrom;
|
|
10
|
+
if (Array.isArray(fromConfig))
|
|
11
|
+
return fromConfig.map(String);
|
|
12
|
+
const fromEnv = process.env.SPECTRUM_ALLOWED_NUMBERS ?? "";
|
|
13
|
+
return fromEnv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
export function resolveAccount(cfg, accountId) {
|
|
16
|
+
const section = channelSection(cfg);
|
|
17
|
+
const projectId = section?.projectId ?? process.env.SPECTRUM_PROJECT_ID ?? "";
|
|
18
|
+
const projectSecret = section?.projectSecret ?? process.env.SPECTRUM_PROJECT_SECRET ?? "";
|
|
19
|
+
if (!projectId || !projectSecret) {
|
|
20
|
+
throw new Error("imessage-photon: SPECTRUM_PROJECT_ID and SPECTRUM_PROJECT_SECRET are required " +
|
|
21
|
+
"(set channels.imessage-photon.projectId/projectSecret or the env vars)");
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
accountId: accountId ?? null,
|
|
25
|
+
projectId,
|
|
26
|
+
projectSecret,
|
|
27
|
+
allowFrom: readAllowFrom(section),
|
|
28
|
+
dmPolicy: section?.dmSecurity,
|
|
29
|
+
ackReaction: section?.ackReaction ?? "👀",
|
|
30
|
+
tapbackNotifications: section?.tapbackNotifications ?? "all",
|
|
31
|
+
enableMedia: Boolean(section?.enableMedia),
|
|
32
|
+
enablePoll: Boolean(section?.enablePoll),
|
|
33
|
+
enableEffects: Boolean(section?.enableEffects),
|
|
34
|
+
enableContact: Boolean(section?.enableContact),
|
|
35
|
+
enableVoice: Boolean(section?.enableVoice),
|
|
36
|
+
enableGroups: Boolean(section?.enableGroups),
|
|
37
|
+
enableTyping: Boolean(section?.enableTyping),
|
|
38
|
+
enableReadReceipts: Boolean(section?.enableReadReceipts),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const setupWizard = {
|
|
42
|
+
channel: "imessage-photon",
|
|
43
|
+
status: {
|
|
44
|
+
configuredLabel: "Connected",
|
|
45
|
+
unconfiguredLabel: "Not configured",
|
|
46
|
+
resolveConfigured: ({ cfg }) => {
|
|
47
|
+
const s = channelSection(cfg);
|
|
48
|
+
if (s?.projectId && s?.projectSecret)
|
|
49
|
+
return true;
|
|
50
|
+
return Boolean(process.env.SPECTRUM_PROJECT_ID && process.env.SPECTRUM_PROJECT_SECRET);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
credentials: [
|
|
54
|
+
{
|
|
55
|
+
inputKey: "token",
|
|
56
|
+
providerHint: "imessage-photon",
|
|
57
|
+
credentialLabel: "Photon project ID",
|
|
58
|
+
preferredEnvVar: "SPECTRUM_PROJECT_ID",
|
|
59
|
+
envPrompt: "Use SPECTRUM_PROJECT_ID from the environment?",
|
|
60
|
+
keepPrompt: "Keep the current project ID?",
|
|
61
|
+
inputPrompt: "Paste your Photon project ID:",
|
|
62
|
+
helpTitle: "Where do I find this?",
|
|
63
|
+
helpLines: [
|
|
64
|
+
"1. Sign up at https://photon.codes",
|
|
65
|
+
"2. Create a project and select the iMessage provider",
|
|
66
|
+
"3. Copy the Project ID from the project settings page",
|
|
67
|
+
],
|
|
68
|
+
inspect: ({ cfg }) => {
|
|
69
|
+
const s = channelSection(cfg);
|
|
70
|
+
const v = s?.projectId ?? process.env.SPECTRUM_PROJECT_ID;
|
|
71
|
+
return {
|
|
72
|
+
accountConfigured: Boolean(s?.projectId),
|
|
73
|
+
hasConfiguredValue: Boolean(v),
|
|
74
|
+
resolvedValue: v,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
applySet: ({ cfg, value }) => ({
|
|
78
|
+
...cfg,
|
|
79
|
+
channels: {
|
|
80
|
+
...cfg.channels,
|
|
81
|
+
"imessage-photon": { ...channelSection(cfg), projectId: String(value) },
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
inputKey: "secret",
|
|
87
|
+
providerHint: "imessage-photon",
|
|
88
|
+
credentialLabel: "Photon project secret",
|
|
89
|
+
preferredEnvVar: "SPECTRUM_PROJECT_SECRET",
|
|
90
|
+
envPrompt: "Use SPECTRUM_PROJECT_SECRET from the environment?",
|
|
91
|
+
keepPrompt: "Keep the current project secret?",
|
|
92
|
+
inputPrompt: "Paste your Photon project secret:",
|
|
93
|
+
helpTitle: "Where do I find this?",
|
|
94
|
+
helpLines: [
|
|
95
|
+
"The project secret is only shown once when you create the project.",
|
|
96
|
+
"If you lost it, rotate it from the project settings page.",
|
|
97
|
+
],
|
|
98
|
+
inspect: ({ cfg }) => {
|
|
99
|
+
const s = channelSection(cfg);
|
|
100
|
+
const v = s?.projectSecret ?? process.env.SPECTRUM_PROJECT_SECRET;
|
|
101
|
+
return {
|
|
102
|
+
accountConfigured: Boolean(s?.projectSecret),
|
|
103
|
+
hasConfiguredValue: Boolean(v),
|
|
104
|
+
resolvedValue: v,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
applySet: ({ cfg, value }) => ({
|
|
108
|
+
...cfg,
|
|
109
|
+
channels: {
|
|
110
|
+
...cfg.channels,
|
|
111
|
+
"imessage-photon": { ...channelSection(cfg), projectSecret: String(value) },
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
textInputs: [
|
|
117
|
+
{
|
|
118
|
+
inputKey: "dmAllowlist",
|
|
119
|
+
message: "Allowed sender phone numbers (comma-separated, E.164 like +8613800138000). Leave empty to allow everyone:",
|
|
120
|
+
placeholder: "+8613800138000,+14155550123",
|
|
121
|
+
required: false,
|
|
122
|
+
helpTitle: "Who can message the agent?",
|
|
123
|
+
helpLines: [
|
|
124
|
+
"Enter phone numbers in E.164 form. Empty = anyone can DM the agent.",
|
|
125
|
+
"This is the same as channels.imessage-photon.allowFrom.",
|
|
126
|
+
],
|
|
127
|
+
currentValue: ({ cfg }) => {
|
|
128
|
+
const s = channelSection(cfg);
|
|
129
|
+
if (Array.isArray(s?.allowFrom))
|
|
130
|
+
return s.allowFrom.join(",");
|
|
131
|
+
return process.env.SPECTRUM_ALLOWED_NUMBERS ?? "";
|
|
132
|
+
},
|
|
133
|
+
applySet: ({ cfg, value }) => ({
|
|
134
|
+
...cfg,
|
|
135
|
+
channels: {
|
|
136
|
+
...cfg.channels,
|
|
137
|
+
"imessage-photon": {
|
|
138
|
+
...channelSection(cfg),
|
|
139
|
+
allowFrom: value.split(",").map((s) => s.trim()).filter(Boolean),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
export const imessagePhotonPlugin = createChatChannelPlugin({
|
|
147
|
+
base: {
|
|
148
|
+
id: "imessage-photon",
|
|
149
|
+
meta: {
|
|
150
|
+
id: "imessage-photon",
|
|
151
|
+
label: "iMessage (Photon)",
|
|
152
|
+
selectionLabel: "iMessage via Photon Spectrum Cloud",
|
|
153
|
+
docsPath: "https://github.com/ethanjtch/openclaw-imessage-photon",
|
|
154
|
+
blurb: "iMessage through Photon Spectrum Cloud — no Mac required.",
|
|
155
|
+
markdownCapable: false,
|
|
156
|
+
},
|
|
157
|
+
capabilities: {
|
|
158
|
+
chatTypes: ["direct"],
|
|
159
|
+
reactions: true,
|
|
160
|
+
reply: true,
|
|
161
|
+
},
|
|
162
|
+
actions: createMessageActions(),
|
|
163
|
+
setupWizard,
|
|
164
|
+
config: {
|
|
165
|
+
listAccountIds: () => ["default"],
|
|
166
|
+
resolveAccount,
|
|
167
|
+
inspectAccount(cfg, _accountId) {
|
|
168
|
+
const s = channelSection(cfg);
|
|
169
|
+
const hasCfg = Boolean(s?.projectId && s?.projectSecret);
|
|
170
|
+
const hasEnv = Boolean(process.env.SPECTRUM_PROJECT_ID && process.env.SPECTRUM_PROJECT_SECRET);
|
|
171
|
+
return {
|
|
172
|
+
enabled: hasCfg || hasEnv,
|
|
173
|
+
configured: hasCfg || hasEnv,
|
|
174
|
+
projectIdStatus: s?.projectId || process.env.SPECTRUM_PROJECT_ID ? "available" : "missing",
|
|
175
|
+
projectSecretStatus: s?.projectSecret || process.env.SPECTRUM_PROJECT_SECRET ? "available" : "missing",
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
setup: {
|
|
180
|
+
applyAccountConfig: ({ cfg, input }) => ({
|
|
181
|
+
...cfg,
|
|
182
|
+
channels: {
|
|
183
|
+
...cfg.channels,
|
|
184
|
+
"imessage-photon": { ...channelSection(cfg), ...input },
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
gateway: {
|
|
189
|
+
startAccount: async (ctx) => {
|
|
190
|
+
const log = (msg) => ctx.log?.info?.(msg);
|
|
191
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
192
|
+
const channelRuntime = ctx.channelRuntime;
|
|
193
|
+
// Reconnect loop: if the Spectrum stream ever ends (transport drop,
|
|
194
|
+
// transient error), back off and re-create the connection instead of
|
|
195
|
+
// leaving the channel dead until a manual gateway restart.
|
|
196
|
+
let backoffMs = 1_000;
|
|
197
|
+
while (!ctx.abortSignal.aborted) {
|
|
198
|
+
try {
|
|
199
|
+
log("[imessage-photon] connecting to Spectrum Cloud...");
|
|
200
|
+
const app = await connectSpectrum(ctx.account.projectId, ctx.account.projectSecret);
|
|
201
|
+
log(`[imessage-photon] connected to Spectrum Cloud (allowlist: ${ctx.account.allowFrom.length ? ctx.account.allowFrom.join(", ") : "ALL"})`);
|
|
202
|
+
backoffMs = 1_000;
|
|
203
|
+
for await (const [space, message] of app.messages) {
|
|
204
|
+
if (ctx.abortSignal.aborted)
|
|
205
|
+
break;
|
|
206
|
+
rememberSpace(space);
|
|
207
|
+
rememberMessage(message);
|
|
208
|
+
handleInbound(channelRuntime, ctx.cfg, space, message, log).catch((err) => {
|
|
209
|
+
ctx.log?.error?.(`[imessage-photon] inbound error: ${String(err)}`);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (ctx.abortSignal.aborted)
|
|
213
|
+
break;
|
|
214
|
+
log("[imessage-photon] message stream ended; reconnecting...");
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
ctx.log?.error?.(`[imessage-photon] stream failed: ${String(err)}`);
|
|
218
|
+
}
|
|
219
|
+
if (ctx.abortSignal.aborted)
|
|
220
|
+
break;
|
|
221
|
+
await resetSpectrum();
|
|
222
|
+
await sleep(backoffMs + Math.random() * backoffMs * 0.2);
|
|
223
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
224
|
+
}
|
|
225
|
+
log("[imessage-photon] stopped");
|
|
226
|
+
},
|
|
227
|
+
stopAccount: async () => {
|
|
228
|
+
await resetSpectrum();
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
security: {
|
|
233
|
+
dm: {
|
|
234
|
+
channelKey: "imessage-photon",
|
|
235
|
+
resolvePolicy: (account) => account.dmPolicy,
|
|
236
|
+
resolveAllowFrom: (account) => account.allowFrom,
|
|
237
|
+
defaultPolicy: "allowlist",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
threading: { topLevelReplyToMode: "reply" },
|
|
241
|
+
outbound: {
|
|
242
|
+
// Outbound delivery runs inside the gateway via our message actions
|
|
243
|
+
// (handleAction). Declaring deliveryMode "gateway" makes the shared
|
|
244
|
+
// message tool treat this channel as send-capable.
|
|
245
|
+
deliveryMode: "gateway",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
|
2
|
+
import { sendText, phoneFromSpaceId, normalizePhone } from "./spectrum.js";
|
|
3
|
+
import { resolveAccount } from "./channel.js";
|
|
4
|
+
const CHANNEL = "imessage-photon";
|
|
5
|
+
const AGENT_ID = "main";
|
|
6
|
+
function senderPhone(space, message) {
|
|
7
|
+
const sender = message.sender?.id;
|
|
8
|
+
return normalizePhone(sender) ?? phoneFromSpaceId(space.id);
|
|
9
|
+
}
|
|
10
|
+
function isAllowed(space, message, allowFrom) {
|
|
11
|
+
if (allowFrom.length === 0)
|
|
12
|
+
return true;
|
|
13
|
+
const phone = senderPhone(space, message);
|
|
14
|
+
return Boolean(phone && allowFrom.includes(phone));
|
|
15
|
+
}
|
|
16
|
+
/** Handle one inbound Spectrum message: text -> agent turn, reaction -> tapback. */
|
|
17
|
+
export async function handleInbound(runtime, cfg, space, message, log) {
|
|
18
|
+
const account = resolveAccount(cfg);
|
|
19
|
+
if (!isAllowed(space, message, account.allowFrom)) {
|
|
20
|
+
log?.(`[imessage-photon] blocked (not allowlisted): ${space.id}`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (message.content.type === "text") {
|
|
24
|
+
await dispatchText(runtime, cfg, space, message, account, log);
|
|
25
|
+
}
|
|
26
|
+
else if (message.content.type === "reaction") {
|
|
27
|
+
await dispatchTapback(runtime, cfg, space, message, account, log);
|
|
28
|
+
}
|
|
29
|
+
else if (message.content.type === "voice" || message.content.type === "attachment") {
|
|
30
|
+
await dispatchMedia(runtime, cfg, space, message, account, log);
|
|
31
|
+
}
|
|
32
|
+
else if (message.content.type === "reply") {
|
|
33
|
+
await dispatchReply(runtime, cfg, space, message, account, log);
|
|
34
|
+
}
|
|
35
|
+
else if (message.content.type === "poll" || message.content.type === "poll_option") {
|
|
36
|
+
await dispatchPollEvent(runtime, cfg, space, message, account, log);
|
|
37
|
+
}
|
|
38
|
+
else if (message.content.type === "edit" || message.content.type === "unsend") {
|
|
39
|
+
// Agent-facing edit/unsend handling is opt-in (enableEditUnsend);
|
|
40
|
+
// until then, these are system events: log only.
|
|
41
|
+
log?.(`[imessage-photon] system event type=${message.content.type} from ${space.id}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// System events (read, typing, contact, group ops, rename, ...) and any
|
|
45
|
+
// unknown types are logged, never silently dropped.
|
|
46
|
+
log?.(`[imessage-photon] system event type=${message.content.type} from ${space.id}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Shared turn dispatch: build session route + context, then run the agent. */
|
|
50
|
+
async function runAgentTurn(runtime, cfg, space, message, account, log, opts) {
|
|
51
|
+
const phone = senderPhone(space, message) ?? space.id;
|
|
52
|
+
const senderName = message.sender?.name;
|
|
53
|
+
// Read receipt: mark the conversation read (iMessage marks the whole chat).
|
|
54
|
+
if (account.enableReadReceipts) {
|
|
55
|
+
message.read().catch((err) => log?.(`[imessage-photon] read receipt failed: ${String(err)}`));
|
|
56
|
+
}
|
|
57
|
+
// Typing indicator while the agent processes.
|
|
58
|
+
const stopTyping = async () => {
|
|
59
|
+
if (account.enableTyping) {
|
|
60
|
+
space.stopTyping().catch((err) => log?.(`[imessage-photon] stopTyping failed: ${String(err)}`));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
if (account.enableTyping) {
|
|
64
|
+
space.startTyping().catch((err) => log?.(`[imessage-photon] startTyping failed: ${String(err)}`));
|
|
65
|
+
}
|
|
66
|
+
// Seen-ack: native ack-reaction handle; removed after the reply is delivered.
|
|
67
|
+
const ackReaction = opts.ackEnabled === false
|
|
68
|
+
? ""
|
|
69
|
+
: account.ackReaction ||
|
|
70
|
+
cfg.messages?.ackReaction ||
|
|
71
|
+
"";
|
|
72
|
+
let ackReactionMsg;
|
|
73
|
+
let ackHandle = null;
|
|
74
|
+
if (ackReaction) {
|
|
75
|
+
ackHandle = runtime.reactions.createAckReactionHandle({
|
|
76
|
+
ackReactionValue: ackReaction,
|
|
77
|
+
send: async () => {
|
|
78
|
+
ackReactionMsg = await message.react(ackReaction);
|
|
79
|
+
if (!ackReactionMsg)
|
|
80
|
+
throw new Error("platform did not accept ack reaction");
|
|
81
|
+
},
|
|
82
|
+
remove: async () => {
|
|
83
|
+
await ackReactionMsg?.unsend();
|
|
84
|
+
},
|
|
85
|
+
onSendError: (err) => log?.(`[imessage-photon] ack send failed: ${String(err)}`),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const replyPipeline = createChannelMessageReplyPipeline({
|
|
89
|
+
cfg,
|
|
90
|
+
agentId: AGENT_ID,
|
|
91
|
+
channel: CHANNEL,
|
|
92
|
+
});
|
|
93
|
+
await runtime.inbound.run({
|
|
94
|
+
channel: CHANNEL,
|
|
95
|
+
raw: message,
|
|
96
|
+
adapter: {
|
|
97
|
+
ingest: (raw) => ({
|
|
98
|
+
id: opts.messageId,
|
|
99
|
+
timestamp: opts.timestamp,
|
|
100
|
+
rawText: opts.rawText,
|
|
101
|
+
textForAgent: opts.textForAgent,
|
|
102
|
+
raw,
|
|
103
|
+
}),
|
|
104
|
+
resolveTurn: async (input) => {
|
|
105
|
+
const routeSessionKey = runtime.routing.buildAgentSessionKey({
|
|
106
|
+
agentId: AGENT_ID,
|
|
107
|
+
channel: CHANNEL,
|
|
108
|
+
peer: { kind: "direct", id: phone },
|
|
109
|
+
});
|
|
110
|
+
const storePath = runtime.session.resolveStorePath(cfg.session?.store, {
|
|
111
|
+
agentId: AGENT_ID,
|
|
112
|
+
});
|
|
113
|
+
const ctxPayload = await runtime.inbound.buildContext({
|
|
114
|
+
channel: CHANNEL,
|
|
115
|
+
from: phone,
|
|
116
|
+
sender: { id: phone, name: senderName },
|
|
117
|
+
conversation: { kind: "direct", id: space.id },
|
|
118
|
+
route: { agentId: AGENT_ID, routeSessionKey },
|
|
119
|
+
reply: { to: phone, sourceReplyDeliveryMode: "reply" },
|
|
120
|
+
message: { rawBody: opts.rawText, bodyForAgent: opts.textForAgent },
|
|
121
|
+
media: opts.media,
|
|
122
|
+
timestamp: opts.timestamp,
|
|
123
|
+
messageId: opts.messageId,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
channel: CHANNEL,
|
|
127
|
+
routeSessionKey,
|
|
128
|
+
storePath,
|
|
129
|
+
ctxPayload,
|
|
130
|
+
recordInboundSession: runtime.session.recordInboundSession,
|
|
131
|
+
runDispatch: () => runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
132
|
+
ctx: ctxPayload,
|
|
133
|
+
cfg,
|
|
134
|
+
dispatcherOptions: {
|
|
135
|
+
...replyPipeline,
|
|
136
|
+
deliver: async (payload) => {
|
|
137
|
+
if (payload.text) {
|
|
138
|
+
await sendText(space, payload.text);
|
|
139
|
+
}
|
|
140
|
+
await stopTyping();
|
|
141
|
+
// Reply delivered: remove the seen-ack reaction.
|
|
142
|
+
if (ackHandle) {
|
|
143
|
+
runtime.reactions.removeAckReactionHandleAfterReply({
|
|
144
|
+
removeAfterReply: true,
|
|
145
|
+
ackReaction: ackHandle,
|
|
146
|
+
onError: (err) => log?.(`[imessage-photon] ack remove failed: ${String(err)}`),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Inbound text message -> agent turn. */
|
|
158
|
+
async function dispatchText(runtime, cfg, space, message, account, log) {
|
|
159
|
+
if (message.content.type !== "text")
|
|
160
|
+
return;
|
|
161
|
+
const text = message.content.text;
|
|
162
|
+
log?.(`[imessage-photon] ${space.id} -> agent: ${text.slice(0, 120)}`);
|
|
163
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
164
|
+
rawText: text,
|
|
165
|
+
messageId: message.id,
|
|
166
|
+
timestamp: message.timestamp?.getTime(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Inbound tapback (reaction) -> forwarded to the agent as a system event. */
|
|
170
|
+
async function dispatchTapback(runtime, cfg, space, message, account, log) {
|
|
171
|
+
if (account.tapbackNotifications === "off") {
|
|
172
|
+
log?.(`[imessage-photon] tapback ignored (notifications=off): ${space.id}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const c = message.content;
|
|
176
|
+
const emoji = c.emoji ?? c.kind ?? "?";
|
|
177
|
+
const targetText = c.target?.content?.text ?? c.targetGuid ?? "上一条消息";
|
|
178
|
+
const hint = `[Tapback] 用户对你发的消息「${targetText}」点了 ${emoji}。请回复一句简短的回应(可选)。`;
|
|
179
|
+
log?.(`[imessage-photon] tapback ${emoji} from ${space.id}`);
|
|
180
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
181
|
+
rawText: hint,
|
|
182
|
+
textForAgent: hint,
|
|
183
|
+
messageId: message.id,
|
|
184
|
+
timestamp: message.timestamp?.getTime(),
|
|
185
|
+
ackEnabled: false,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** Inbound media (voice / attachment) -> saved to the media store, then the agent is told where it landed. */
|
|
189
|
+
async function dispatchMedia(runtime, cfg, space, message, account, log) {
|
|
190
|
+
const c = message.content;
|
|
191
|
+
const kind = c.type === "voice" ? "语音" : "媒体";
|
|
192
|
+
const mime = c.mimeType ?? "application/octet-stream";
|
|
193
|
+
const name = c.name ?? (c.type === "voice" ? "voice.m4a" : "attachment.bin");
|
|
194
|
+
try {
|
|
195
|
+
if (typeof c.read !== "function") {
|
|
196
|
+
log?.(`[imessage-photon] inbound ${kind} has no read(): ${space.id}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const buf = await c.read();
|
|
200
|
+
const saved = await runtime.media.saveMediaBuffer(buf, mime, "inbound", undefined, name);
|
|
201
|
+
const mediaKind = c.type === "voice" ? "audio" : "image";
|
|
202
|
+
const hint = `[iMessage] 用户发来一条${kind}消息(已保存到 media store)。请查看内容并回应。`;
|
|
203
|
+
log?.(`[imessage-photon] inbound ${kind} saved to ${saved.path} (mediaKind=${mediaKind})`);
|
|
204
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
205
|
+
rawText: hint,
|
|
206
|
+
textForAgent: hint,
|
|
207
|
+
messageId: message.id,
|
|
208
|
+
timestamp: message.timestamp?.getTime(),
|
|
209
|
+
ackEnabled: false,
|
|
210
|
+
media: [
|
|
211
|
+
{
|
|
212
|
+
path: saved.path,
|
|
213
|
+
contentType: mime,
|
|
214
|
+
kind: mediaKind,
|
|
215
|
+
messageId: message.id,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
log?.(`[imessage-photon] inbound ${kind} handling failed: ${String(err)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Inbound reply (quoted message) -> forward the inner text to the agent. */
|
|
225
|
+
async function dispatchReply(runtime, cfg, space, message, account, log) {
|
|
226
|
+
const c = message.content;
|
|
227
|
+
const inner = c.content;
|
|
228
|
+
const innerType = inner?.type ?? "unknown";
|
|
229
|
+
if (innerType === "attachment" || innerType === "voice") {
|
|
230
|
+
// The quoted media isn't directly readable here; tell the agent about it.
|
|
231
|
+
const hint = `[iMessage] 用户引用了一条${innerType === "voice" ? "语音" : "媒体"}消息回复。请回应一句简短确认。`;
|
|
232
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
233
|
+
rawText: hint,
|
|
234
|
+
textForAgent: hint,
|
|
235
|
+
messageId: message.id,
|
|
236
|
+
timestamp: message.timestamp?.getTime(),
|
|
237
|
+
ackEnabled: false,
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const text = inner?.type === "markdown" ? inner.markdown : inner?.text;
|
|
242
|
+
const quoted = c.target?.content && c.target.content.type !== "reaction"
|
|
243
|
+
? c.target.content.text ??
|
|
244
|
+
c.target.content.markdown ??
|
|
245
|
+
""
|
|
246
|
+
: "";
|
|
247
|
+
if (!text) {
|
|
248
|
+
log?.(`[imessage-photon] reply with empty inner text (type=${innerType})`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const prompt = quoted
|
|
252
|
+
? `[iMessage 引用回复] 用户引用了你的消息「${quoted.slice(0, 200)}」并回复:${text}`
|
|
253
|
+
: `[iMessage 引用回复] ${text}`;
|
|
254
|
+
log?.(`[imessage-photon] reply -> agent: ${prompt.slice(0, 120)}`);
|
|
255
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
256
|
+
rawText: prompt,
|
|
257
|
+
textForAgent: prompt,
|
|
258
|
+
messageId: message.id,
|
|
259
|
+
timestamp: message.timestamp?.getTime(),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/** Inbound poll event (user voting / poll appearance) -> notify the agent. */
|
|
263
|
+
async function dispatchPollEvent(runtime, cfg, space, message, account, log) {
|
|
264
|
+
const c = message.content;
|
|
265
|
+
let hint;
|
|
266
|
+
if (c.type === "poll_option" && c.option?.title) {
|
|
267
|
+
const pollTitle = c.poll?.title ?? c.title ?? "某投票";
|
|
268
|
+
hint = `[iMessage 投票] 用户在投票「${pollTitle}」中选择了「${c.option.title}」。需要时可以简短确认或继续对话。`;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
hint = `[iMessage 投票] ${c.title ?? "用户发起/更新了一个投票"}${c.options?.length ? `(选项:${c.options.map((o) => o.title).join(" / ")})` : ""}`;
|
|
272
|
+
}
|
|
273
|
+
await runAgentTurn(runtime, cfg, space, message, account, log, {
|
|
274
|
+
rawText: hint,
|
|
275
|
+
textForAgent: hint,
|
|
276
|
+
messageId: message.id,
|
|
277
|
+
timestamp: message.timestamp?.getTime(),
|
|
278
|
+
ackEnabled: false,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Spectrum } from "spectrum-ts";
|
|
2
|
+
import { imessage } from "@spectrum-ts/imessage";
|
|
3
|
+
let app = null;
|
|
4
|
+
// Known spaces keyed by normalized phone (E.164 with +). Populated from the
|
|
5
|
+
// inbound message stream so outbound actions (message tool) can resolve a
|
|
6
|
+
// target phone to a live Space.
|
|
7
|
+
const spacesByPhone = new Map();
|
|
8
|
+
// Recent messages keyed by id, so actions like react/edit/unsend/reply can
|
|
9
|
+
// target a specific message.
|
|
10
|
+
const messagesById = new Map();
|
|
11
|
+
const MAX_CACHED_MESSAGES = 500;
|
|
12
|
+
/** Record a space observed on the inbound stream. */
|
|
13
|
+
export function rememberSpace(space) {
|
|
14
|
+
const phone = normalizePhone(phoneFromSpaceId(space.id));
|
|
15
|
+
if (phone)
|
|
16
|
+
spacesByPhone.set(phone, space);
|
|
17
|
+
}
|
|
18
|
+
/** Record a message observed on the inbound stream (for target resolution). */
|
|
19
|
+
export function rememberMessage(message) {
|
|
20
|
+
if (!message.id)
|
|
21
|
+
return;
|
|
22
|
+
messagesById.set(message.id, message);
|
|
23
|
+
if (messagesById.size > MAX_CACHED_MESSAGES) {
|
|
24
|
+
const oldest = messagesById.keys().next().value;
|
|
25
|
+
if (oldest !== undefined)
|
|
26
|
+
messagesById.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Resolve a cached Message by id (for react/edit/unsend/reply targets). */
|
|
30
|
+
export function resolveMessage(id) {
|
|
31
|
+
if (!id)
|
|
32
|
+
return undefined;
|
|
33
|
+
return messagesById.get(id);
|
|
34
|
+
}
|
|
35
|
+
/** Resolve a Space for an outbound target (phone number). */
|
|
36
|
+
export function resolveSpace(target) {
|
|
37
|
+
const phone = normalizePhone(target);
|
|
38
|
+
if (!phone)
|
|
39
|
+
return undefined;
|
|
40
|
+
return spacesByPhone.get(phone);
|
|
41
|
+
}
|
|
42
|
+
/** Number of known spaces (used in diagnostics). */
|
|
43
|
+
export function knownSpaceCount() {
|
|
44
|
+
return spacesByPhone.size;
|
|
45
|
+
}
|
|
46
|
+
/** Connect to Photon Spectrum Cloud. Reuses an existing connection. */
|
|
47
|
+
export async function connectSpectrum(projectId, projectSecret) {
|
|
48
|
+
if (app)
|
|
49
|
+
return app;
|
|
50
|
+
app = await Spectrum({
|
|
51
|
+
projectId,
|
|
52
|
+
projectSecret,
|
|
53
|
+
providers: [imessage.config()],
|
|
54
|
+
});
|
|
55
|
+
return app;
|
|
56
|
+
}
|
|
57
|
+
/** Close the current connection and clear it so the next connect re-creates it. */
|
|
58
|
+
export async function resetSpectrum() {
|
|
59
|
+
const current = app;
|
|
60
|
+
app = null;
|
|
61
|
+
if (current) {
|
|
62
|
+
try {
|
|
63
|
+
await current.stop();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// best-effort teardown
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Send a plain-text reply to a space (iMessage DM). */
|
|
71
|
+
export async function sendText(space, text) {
|
|
72
|
+
await space.send(text);
|
|
73
|
+
}
|
|
74
|
+
/** React to a message (iMessage tapback). New reactions replace the old one. */
|
|
75
|
+
export async function reactTo(message, emoji) {
|
|
76
|
+
const ok = await message.react(emoji);
|
|
77
|
+
return Boolean(ok);
|
|
78
|
+
}
|
|
79
|
+
/** Extract the E.164 phone number from an iMessage space id like "any;-;+8613800138000". */
|
|
80
|
+
export function phoneFromSpaceId(spaceId) {
|
|
81
|
+
const m = spaceId.match(/\+?\d{6,15}$/);
|
|
82
|
+
return m ? m[0] : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Normalize a phone candidate to E.164 with leading "+". */
|
|
85
|
+
export function normalizePhone(raw) {
|
|
86
|
+
if (!raw)
|
|
87
|
+
return undefined;
|
|
88
|
+
const digits = raw.replace(/[^\d]/g, "");
|
|
89
|
+
if (!digits)
|
|
90
|
+
return undefined;
|
|
91
|
+
return `+${digits}`;
|
|
92
|
+
}
|