@mapier/imsg-sdk 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 +68 -0
- package/dist/gateway/external-ids.d.ts +3 -0
- package/dist/gateway/external-ids.js +37 -0
- package/dist/gateway/external-ids.js.map +1 -0
- package/dist/gateway/fake.d.ts +161 -0
- package/dist/gateway/fake.js +870 -0
- package/dist/gateway/fake.js.map +1 -0
- package/dist/gateway/imsg.d.ts +77 -0
- package/dist/gateway/imsg.js +676 -0
- package/dist/gateway/imsg.js.map +1 -0
- package/dist/gateway/portable-chat.d.ts +65 -0
- package/dist/gateway/portable-chat.js +118 -0
- package/dist/gateway/portable-chat.js.map +1 -0
- package/dist/gateway/types.d.ts +169 -0
- package/dist/gateway/types.js +16 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/imsg/binary.d.ts +1 -0
- package/dist/imsg/binary.js +4 -0
- package/dist/imsg/binary.js.map +1 -0
- package/dist/imsg/react.d.ts +2 -0
- package/dist/imsg/react.js +24 -0
- package/dist/imsg/react.js.map +1 -0
- package/dist/imsg/rpc.d.ts +145 -0
- package/dist/imsg/rpc.js +227 -0
- package/dist/imsg/rpc.js.map +1 -0
- package/dist/imsg/status.d.ts +9 -0
- package/dist/imsg/status.js +37 -0
- package/dist/imsg/status.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions/catalog.d.ts +2 -0
- package/dist/interactions/catalog.js +98 -0
- package/dist/interactions/catalog.js.map +1 -0
- package/dist/interactions/harness.d.ts +25 -0
- package/dist/interactions/harness.js +69 -0
- package/dist/interactions/harness.js.map +1 -0
- package/dist/interactions/index.d.ts +5 -0
- package/dist/interactions/index.js +5 -0
- package/dist/interactions/index.js.map +1 -0
- package/dist/interactions/registry.d.ts +9 -0
- package/dist/interactions/registry.js +33 -0
- package/dist/interactions/registry.js.map +1 -0
- package/dist/interactions/types.d.ts +28 -0
- package/dist/interactions/types.js +5 -0
- package/dist/interactions/types.js.map +1 -0
- package/dist/polls.d.ts +12 -0
- package/dist/polls.js +102 -0
- package/dist/polls.js.map +1 -0
- package/dist/runtime-report.d.ts +3 -0
- package/dist/runtime-report.js +10 -0
- package/dist/runtime-report.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { imsgBinary } from '../imsg/binary.js';
|
|
4
|
+
import { sendReaction } from '../imsg/react.js';
|
|
5
|
+
import { ImsgRpc } from '../imsg/rpc.js';
|
|
6
|
+
import { readPatchedGatewayCapabilities } from '../imsg/status.js';
|
|
7
|
+
import { toReactionNoteType } from './types.js';
|
|
8
|
+
import { classifyGroupChatResolution } from './portable-chat.js';
|
|
9
|
+
const CREATE_GROUP_SCRIPT = fileURLToPath(new URL('../../scripts/create-group.applescript', import.meta.url));
|
|
10
|
+
// `imsg react` exit 0 does NOT mean the tapback landed (observed live
|
|
11
|
+
// 2026-07-02: one react silently no-oped, another landed in the wrong chat).
|
|
12
|
+
// After firing we poll history until the reaction shows on the target
|
|
13
|
+
// message, or give up and report ok:false.
|
|
14
|
+
const REACT_VERIFY_ATTEMPTS = 8;
|
|
15
|
+
const REACT_VERIFY_INTERVAL_MS = 1000;
|
|
16
|
+
// createGroup: how long we poll for the new thread to materialize in
|
|
17
|
+
// chats.list + history after the compose script exits (which can also lie —
|
|
18
|
+
// observed silent failure 2026-07-02 when a recipient flapped to SMS).
|
|
19
|
+
const GROUP_VERIFY_ATTEMPTS = 6;
|
|
20
|
+
const GROUP_VERIFY_INTERVAL_MS = 2000;
|
|
21
|
+
// How many recent chats resolveDmChat scans. chats.list is newest-first by
|
|
22
|
+
// last_message_at, so the DM of anyone mid-onboarding is comfortably inside a
|
|
23
|
+
// generous window; a completion for a long-dormant thread that has fallen out
|
|
24
|
+
// of it re-warms on the user's next inbound anyway (the pre-existing backstop).
|
|
25
|
+
const CHAT_SCAN_LIMIT = 40;
|
|
26
|
+
// Group resolution is a boot/control operation, not a hot path. chats.list
|
|
27
|
+
// reads chat.db locally and already returns each row's stable GUID and members,
|
|
28
|
+
// so one high-ceiling directory read is both faster and more complete than
|
|
29
|
+
// opening message history for every group. Request one extra row so an account
|
|
30
|
+
// beyond the ceiling still fails closed instead of claiming uniqueness from a
|
|
31
|
+
// partial directory.
|
|
32
|
+
const GROUP_CHAT_SCAN_LIMIT = 100_000;
|
|
33
|
+
// Tier-2 (bridge) verify-after-fire polling. Bridge calls are async IMCore
|
|
34
|
+
// dispatch, not synchronous AppleScript — same "exit 0 lies" discipline as
|
|
35
|
+
// react()/createGroup(), just a different underlying mechanism (a private
|
|
36
|
+
// API queued through openclaw's v2 IPC, not UI automation), so these do NOT
|
|
37
|
+
// take uiMutex: the bridge is built for concurrent access, unlike Messages.app's
|
|
38
|
+
// single GUI focus.
|
|
39
|
+
const TIER2_VERIFY_ATTEMPTS = 6;
|
|
40
|
+
const TIER2_VERIFY_INTERVAL_MS = 1000;
|
|
41
|
+
// Group metadata propagation is materially slower than message/reaction
|
|
42
|
+
// readback on the host (live-observed at 10-15s on macOS 26).
|
|
43
|
+
const GROUP_MUTATION_VERIFY_ATTEMPTS = 20;
|
|
44
|
+
// How far back verify-after-fire scans history for an explicitly-targeted
|
|
45
|
+
// message guid (tapback/edit/unsend/delete can target ANY message, not just
|
|
46
|
+
// the newest — contract §2 — so this window is generous, matching the
|
|
47
|
+
// existing recentReactions/resolveDmChat scan-window convention).
|
|
48
|
+
const TIER2_HISTORY_SCAN = 30;
|
|
49
|
+
// Serializes react() and createGroup() — both drive Messages.app UI
|
|
50
|
+
// automation and share one GUI session (docs/gateway-contract.md §2 "UI
|
|
51
|
+
// automation mutex"). send() does not take this lock; it's AppleScript
|
|
52
|
+
// events, no UI focus needed, and parallelizes fine.
|
|
53
|
+
class Mutex {
|
|
54
|
+
tail = Promise.resolve();
|
|
55
|
+
run(fn) {
|
|
56
|
+
const result = this.tail.then(fn, fn);
|
|
57
|
+
this.tail = result.then(() => undefined, () => undefined);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function delay(ms) {
|
|
62
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
63
|
+
}
|
|
64
|
+
function normalizeHandle(h) {
|
|
65
|
+
return h.trim().toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
function sameParticipants(participants, wanted) {
|
|
68
|
+
if (!participants || participants.length !== wanted.size)
|
|
69
|
+
return false;
|
|
70
|
+
return participants.every((h) => wanted.has(normalizeHandle(h)));
|
|
71
|
+
}
|
|
72
|
+
// The react CLI targets the most recent INCOMING message (its own help text,
|
|
73
|
+
// imsg 0.12.0) — our own bubbles are never the target.
|
|
74
|
+
function newestIncoming(history) {
|
|
75
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
76
|
+
const m = history[i];
|
|
77
|
+
if (m && !m.is_from_me && !m.is_reaction)
|
|
78
|
+
return m;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function hasOwnReaction(msg, reaction) {
|
|
83
|
+
return Boolean(msg?.reactions?.some((r) => r.is_from_me && r.type === reaction));
|
|
84
|
+
}
|
|
85
|
+
function runCreateGroupScript(handles, firstMessage) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
execFile('osascript', [CREATE_GROUP_SCRIPT, ...handles, firstMessage], { timeout: 30_000 }, (err) => err ? reject(err) : resolve());
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// The long-lived RPC process's chats.list view stays stale after its own
|
|
91
|
+
// group mutations on macOS 26. A fresh CLI process sees the update, so group
|
|
92
|
+
// verify-after-fire uses this read-only subprocess with the same configured
|
|
93
|
+
// binary. Output is one JSON object per line.
|
|
94
|
+
function readFreshChats(limit) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
execFile(imsgBinary(), ['chats', '--limit', String(limit), '--json'], { timeout: 10_000 }, (err, stdout) => {
|
|
97
|
+
if (err) {
|
|
98
|
+
reject(err);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
resolve(stdout
|
|
103
|
+
.split('\n')
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.map((line) => JSON.parse(line)));
|
|
106
|
+
}
|
|
107
|
+
catch (parseErr) {
|
|
108
|
+
reject(parseErr);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Real Gateway: wraps the long-lived ImsgRpc child (send/history/subscribe)
|
|
114
|
+
// plus the UI-automation primitives (react, group create) behind the
|
|
115
|
+
// hardening docs/gateway-contract.md and the B1 task spec require. See
|
|
116
|
+
// gateway/types.ts for the react()/ReactResult shape.
|
|
117
|
+
//
|
|
118
|
+
// All reaction STATE comes from history's per-message `reactions[]` aggregate
|
|
119
|
+
// (verified live on imsg 0.12.0, 2026-07-02) — authoritative and restart-safe,
|
|
120
|
+
// unlike stream-tracking, which is blind to anything before boot. The
|
|
121
|
+
// subscribe stream still delivers reaction EVENTS for realtime wake-ups.
|
|
122
|
+
export class ImsgGateway {
|
|
123
|
+
capabilities;
|
|
124
|
+
rpc;
|
|
125
|
+
uiMutex = new Mutex();
|
|
126
|
+
eventQueue = [];
|
|
127
|
+
waiters = [];
|
|
128
|
+
subscribed = false;
|
|
129
|
+
constructor(onExit) {
|
|
130
|
+
this.capabilities = readPatchedGatewayCapabilities();
|
|
131
|
+
this.rpc = new ImsgRpc((msg) => this.pushEvent(msg), onExit);
|
|
132
|
+
}
|
|
133
|
+
pushEvent(msg) {
|
|
134
|
+
this.eventQueue.push(msg);
|
|
135
|
+
this.waiters.shift()?.();
|
|
136
|
+
}
|
|
137
|
+
async *subscribe(sinceId) {
|
|
138
|
+
// Host-verified 2026-07-14: watch.subscribe requests `attachments:true`,
|
|
139
|
+
// enriching every live event with local-file metadata.
|
|
140
|
+
// Single-consumer by contract (§1): eventQueue/waiters are instance-shared,
|
|
141
|
+
// so a second iterator would silently steal events and double-register the
|
|
142
|
+
// underlying watch. One long-lived subscription per process (src/index.ts);
|
|
143
|
+
// reconnect = new gateway.
|
|
144
|
+
if (this.subscribed) {
|
|
145
|
+
throw new Error('ImsgGateway.subscribe: already subscribed — one consumer per gateway');
|
|
146
|
+
}
|
|
147
|
+
this.subscribed = true;
|
|
148
|
+
await this.rpc.subscribe(sinceId);
|
|
149
|
+
for (;;) {
|
|
150
|
+
const next = this.eventQueue.shift();
|
|
151
|
+
if (next) {
|
|
152
|
+
yield next;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
history(chatId, limit = 30) {
|
|
160
|
+
// Host-verified 2026-07-14: messages.history requests `attachments:true`;
|
|
161
|
+
// rows pass through without reshaping.
|
|
162
|
+
return this.rpc.history(chatId, limit);
|
|
163
|
+
}
|
|
164
|
+
async listChats(limit = 10_000) {
|
|
165
|
+
const chats = await this.rpc.chats(limit);
|
|
166
|
+
return chats.flatMap((chat) => {
|
|
167
|
+
if (!chat.guid || !chat.participants)
|
|
168
|
+
return [];
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
chatId: chat.id,
|
|
172
|
+
conversationExternalId: chat.guid,
|
|
173
|
+
isGroup: chat.is_group,
|
|
174
|
+
participantExternalIds: [...chat.participants].sort(),
|
|
175
|
+
lastMessageAt: chat.last_message_at ?? null,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
historyRange(chatId, range) {
|
|
181
|
+
return this.rpc.historyRange(chatId, range);
|
|
182
|
+
}
|
|
183
|
+
send(target, text) {
|
|
184
|
+
return this.rpc.send(target, text);
|
|
185
|
+
}
|
|
186
|
+
async react(chatId, reaction, expectedGuid) {
|
|
187
|
+
return this.uiMutex.run(async () => {
|
|
188
|
+
const recent = await this.rpc.history(chatId, 10);
|
|
189
|
+
const target = newestIncoming(recent);
|
|
190
|
+
if (!target || (expectedGuid !== undefined && target.guid !== expectedGuid)) {
|
|
191
|
+
return { ok: true, skipped: 'stale-target' };
|
|
192
|
+
}
|
|
193
|
+
// Toggle guard: same reaction from us already on the target means a new
|
|
194
|
+
// fire would REMOVE it (live incident 2026-07-01) — skip instead.
|
|
195
|
+
if (hasOwnReaction(target, reaction)) {
|
|
196
|
+
return { ok: true, skipped: 'already-reacted' };
|
|
197
|
+
}
|
|
198
|
+
// DM-only: the automation verifies the focused thread by handle digits,
|
|
199
|
+
// which group titles don't carry (contract §2).
|
|
200
|
+
const handle = target.sender ?? target.chat_identifier;
|
|
201
|
+
if (target.is_group || !handle)
|
|
202
|
+
return { ok: false };
|
|
203
|
+
try {
|
|
204
|
+
await sendReaction(handle, reaction);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// Wrong-chat abort or osascript failure — nothing was sent.
|
|
208
|
+
console.error(`[gateway] react aborted before firing: ${String(err)}`);
|
|
209
|
+
return { ok: false };
|
|
210
|
+
}
|
|
211
|
+
// Verify it actually landed on the intended message — exit 0 lies.
|
|
212
|
+
// Deliberately holds uiMutex through the poll: the next react()'s toggle
|
|
213
|
+
// guard needs this reaction visible in history, or it would double-fire.
|
|
214
|
+
for (let attempt = 0; attempt < REACT_VERIFY_ATTEMPTS; attempt++) {
|
|
215
|
+
await delay(REACT_VERIFY_INTERVAL_MS);
|
|
216
|
+
const after = await this.rpc.history(chatId, 10);
|
|
217
|
+
if (hasOwnReaction(after.find((m) => m.guid === target.guid), reaction)) {
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { ok: false };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async createGroup(handles, firstMessage) {
|
|
225
|
+
return this.uiMutex.run(async () => {
|
|
226
|
+
await runCreateGroupScript(handles, firstMessage);
|
|
227
|
+
const wanted = new Set(handles.map(normalizeHandle));
|
|
228
|
+
// Locate the thread by CONTENT, not just participant set: an existing
|
|
229
|
+
// group with the same members must only match if our first message
|
|
230
|
+
// actually landed there (Messages may reuse the existing thread), and
|
|
231
|
+
// a compose that silently failed (observed 2026-07-02, SMS-flapped
|
|
232
|
+
// recipient) must surface as an error, not a stale chatId.
|
|
233
|
+
for (let attempt = 0; attempt < GROUP_VERIFY_ATTEMPTS; attempt++) {
|
|
234
|
+
await delay(GROUP_VERIFY_INTERVAL_MS);
|
|
235
|
+
const chats = await this.rpc.chats(20);
|
|
236
|
+
const candidates = chats.filter((c) => c.is_group && sameParticipants(c.participants, wanted));
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
const recent = await this.rpc.history(candidate.id, 3);
|
|
239
|
+
if (recent.some((m) => m.is_from_me && m.text === firstMessage)) {
|
|
240
|
+
return { chatId: candidate.id };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error('createGroup: first message never appeared in any matching group thread — compose likely failed silently ' +
|
|
245
|
+
'(check recipients are iMessage-reachable; SMS handles cannot join an iMessage group)');
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Inbound tapbacks on OUR messages, newest-first — read from history's
|
|
249
|
+
// reactions[] aggregate, so it survives restarts.
|
|
250
|
+
async recentReactions(chatId, limit = 10) {
|
|
251
|
+
const recent = await this.rpc.history(chatId, 30);
|
|
252
|
+
const notes = [];
|
|
253
|
+
for (const m of recent) {
|
|
254
|
+
if (!m.is_from_me || !m.reactions)
|
|
255
|
+
continue;
|
|
256
|
+
for (const r of m.reactions) {
|
|
257
|
+
if (!r.is_from_me)
|
|
258
|
+
notes.push({ id: r.id, reaction: toReactionNoteType(r.type), emoji: r.emoji });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return notes.sort((a, b) => b.id - a.id).slice(0, limit);
|
|
262
|
+
}
|
|
263
|
+
// Recover a handle→chat_id the runtime cache lost (pod restart) by reading
|
|
264
|
+
// it back from chat.db via chats.list — the same source createGroup's
|
|
265
|
+
// content-verify uses (this.rpc.chats). A DM row is the non-group chat whose
|
|
266
|
+
// sole participant is this handle. Read-only: returns null when no thread
|
|
267
|
+
// exists, never composes one.
|
|
268
|
+
async resolveDmChat(handle) {
|
|
269
|
+
const wanted = normalizeHandle(handle);
|
|
270
|
+
const chats = await this.rpc.chats(CHAT_SCAN_LIMIT);
|
|
271
|
+
const dm = chats.find((c) => !c.is_group && c.participants?.length === 1 && normalizeHandle(c.participants[0] ?? '') === wanted);
|
|
272
|
+
return dm?.id ?? null;
|
|
273
|
+
}
|
|
274
|
+
async resolveGroupChat(request) {
|
|
275
|
+
const chats = await this.rpc.chats(GROUP_CHAT_SCAN_LIMIT + 1);
|
|
276
|
+
const directoryComplete = chats.length <= GROUP_CHAT_SCAN_LIMIT;
|
|
277
|
+
const groupChats = chats.slice(0, GROUP_CHAT_SCAN_LIMIT).filter((chat) => chat.is_group);
|
|
278
|
+
const groups = [];
|
|
279
|
+
let everyGroupReadable = true;
|
|
280
|
+
for (const chat of groupChats) {
|
|
281
|
+
if (!chat.guid) {
|
|
282
|
+
everyGroupReadable = false;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
groups.push({
|
|
286
|
+
chatId: chat.id,
|
|
287
|
+
conversationExternalId: chat.guid,
|
|
288
|
+
// `participants` is optional on the imsg wire. Report the unknown as
|
|
289
|
+
// unknown: `?? []` would read as "this group has no members" and turn
|
|
290
|
+
// an unreadable directory into a confident participant mismatch.
|
|
291
|
+
participantExternalIds: chat.participants ?? null,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return classifyGroupChatResolution({
|
|
295
|
+
request,
|
|
296
|
+
directoryComplete: directoryComplete && everyGroupReadable,
|
|
297
|
+
groups,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
// Host-verified 2026-07-14 against the patched bridge.
|
|
301
|
+
async sendStatus(guid) {
|
|
302
|
+
try {
|
|
303
|
+
const result = await this.rpc.messageSendStatus(guid);
|
|
304
|
+
return {
|
|
305
|
+
ok: result.ok,
|
|
306
|
+
guid: result.guid,
|
|
307
|
+
send_state: result.send_state,
|
|
308
|
+
service: result.service,
|
|
309
|
+
delivered_at: result.delivered_at,
|
|
310
|
+
date_read: result.status_fields?.date_read ?? null,
|
|
311
|
+
is_read: result.status_fields?.date_read !== null && result.status_fields?.date_read !== undefined,
|
|
312
|
+
status_fields: result.status_fields,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
console.error(`[gateway] sendStatus failed: ${String(err)}`);
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
guid,
|
|
320
|
+
send_state: 'pending',
|
|
321
|
+
service: null,
|
|
322
|
+
delivered_at: null,
|
|
323
|
+
date_read: null,
|
|
324
|
+
is_read: false,
|
|
325
|
+
status_fields: null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Host-verified 2026-07-14 for reachable and unavailable addresses.
|
|
330
|
+
async checkHandle(address, opts) {
|
|
331
|
+
try {
|
|
332
|
+
const result = await this.rpc.checkHandle(address, opts);
|
|
333
|
+
return {
|
|
334
|
+
ok: result.ok,
|
|
335
|
+
available: result.available,
|
|
336
|
+
idStatus: result.id_status,
|
|
337
|
+
destination: result.destination,
|
|
338
|
+
service: result.service,
|
|
339
|
+
aliasType: result.alias_type,
|
|
340
|
+
address: result.address,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
console.error(`[gateway] checkHandle failed: ${String(err)}`);
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
available: false,
|
|
348
|
+
idStatus: 0,
|
|
349
|
+
destination: '',
|
|
350
|
+
service: 'iMessage',
|
|
351
|
+
aliasType: opts?.aliasType ?? null,
|
|
352
|
+
address,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// ---- Tier 2 (bridge-backed). Patched-fork verbs are fail-closed through
|
|
357
|
+
// capabilities read from `imsg status --json`; stock imsg omits those
|
|
358
|
+
// selector markers even when it exposes the same RPC method names. ----
|
|
359
|
+
// Targeted tapback via the bridge: any message guid, explicit add/remove,
|
|
360
|
+
// works in group chats. Same closed 6-type Reaction set as react(); custom
|
|
361
|
+
// emoji is the separately gated emojiTapback() verb below.
|
|
362
|
+
async tapback(chatId, targetGuid, reaction, remove = false) {
|
|
363
|
+
const recent = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
364
|
+
const target = recent.find((m) => m.guid === targetGuid);
|
|
365
|
+
if (!target)
|
|
366
|
+
return { ok: false };
|
|
367
|
+
const active = hasOwnReaction(target, reaction);
|
|
368
|
+
if (remove ? !active : active) {
|
|
369
|
+
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
await this.rpc.tapback(chatId, targetGuid, reaction, remove);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error(`[gateway] tapback failed: ${String(err)}`);
|
|
376
|
+
return { ok: false };
|
|
377
|
+
}
|
|
378
|
+
for (let attempt = 0; attempt < TIER2_VERIFY_ATTEMPTS; attempt++) {
|
|
379
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
380
|
+
const after = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
381
|
+
const found = after.find((m) => m.guid === targetGuid);
|
|
382
|
+
const nowActive = hasOwnReaction(found, reaction);
|
|
383
|
+
if (remove ? !nowActive : nowActive)
|
|
384
|
+
return { ok: true };
|
|
385
|
+
}
|
|
386
|
+
return { ok: false };
|
|
387
|
+
}
|
|
388
|
+
async emojiTapback(chatId, targetGuid, emoji, remove = false) {
|
|
389
|
+
if (!this.capabilities.emojiTapback || !emoji)
|
|
390
|
+
return { ok: false };
|
|
391
|
+
const recent = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
392
|
+
const target = recent.find((m) => m.guid === targetGuid);
|
|
393
|
+
if (!target)
|
|
394
|
+
return { ok: false };
|
|
395
|
+
const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji) ?? false;
|
|
396
|
+
if (remove ? !active : active) {
|
|
397
|
+
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await this.rpc.emojiTapback(chatId, targetGuid, emoji, remove);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
console.error(`[gateway] emojiTapback failed: ${String(err)}`);
|
|
404
|
+
return { ok: false };
|
|
405
|
+
}
|
|
406
|
+
for (let attempt = 0; attempt < TIER2_VERIFY_ATTEMPTS; attempt++) {
|
|
407
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
408
|
+
const after = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
409
|
+
const found = after.find((m) => m.guid === targetGuid);
|
|
410
|
+
const nowActive = found?.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji) ?? false;
|
|
411
|
+
if (remove ? !nowActive : nowActive)
|
|
412
|
+
return { ok: true };
|
|
413
|
+
}
|
|
414
|
+
return { ok: false };
|
|
415
|
+
}
|
|
416
|
+
// Rich send to an EXISTING chat only (no find-or-create `to:` form, unlike
|
|
417
|
+
// send()). No extra verification beyond the RPC's own report — mirrors
|
|
418
|
+
// send(), which relies on the same `{ ok, id?, guid? }` contract and the
|
|
419
|
+
// natural echo via subscribe().
|
|
420
|
+
// Subject, text/effect, and reply paths are host-verified.
|
|
421
|
+
async sendRich(chatId, text, opts) {
|
|
422
|
+
try {
|
|
423
|
+
return await this.rpc.sendRich(chatId, text, opts);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
console.error(`[gateway] sendRich failed: ${String(err)}`);
|
|
427
|
+
return { ok: false };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Host run 2026-07-14: subject + poll confirmed receiver-visible on the test
|
|
431
|
+
// phone; poll.send returns the balloon guid as `guid` (not `messageGuid`).
|
|
432
|
+
// send.attachment returns a guid synchronously; send.rich url mode is async
|
|
433
|
+
// (`{ok, queued}`) and returns NO guid — see sendRichLink.
|
|
434
|
+
async sendAttachment(chatId, filePath, opts) {
|
|
435
|
+
try {
|
|
436
|
+
return await this.rpc.sendAttachment(chatId, filePath, opts);
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
console.error(`[gateway] sendAttachment failed: ${String(err)}`);
|
|
440
|
+
return { ok: false };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Host-verified delivered after the patched fork's secure staging fix.
|
|
444
|
+
async sendSticker(chatId, filePath, opts) {
|
|
445
|
+
if (!this.capabilities.stickerSend)
|
|
446
|
+
return { ok: false };
|
|
447
|
+
try {
|
|
448
|
+
const result = await this.rpc.sendSticker(chatId, filePath, opts);
|
|
449
|
+
return { ok: result.ok, guid: result.transfer_guid, id: result.message_id };
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
console.error(`[gateway] sendSticker failed: ${String(err)}`);
|
|
453
|
+
return { ok: false };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async sendPoll(chatId, question, options) {
|
|
457
|
+
if (options.length < 2)
|
|
458
|
+
return { ok: false };
|
|
459
|
+
try {
|
|
460
|
+
const result = await this.rpc.sendPoll(chatId, question, options);
|
|
461
|
+
return { ok: true, guid: result.guid };
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
console.error(`[gateway] sendPoll failed: ${String(err)}`);
|
|
465
|
+
return { ok: false };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// The url submode returns `{ok, queued}` with NO guid. Host-verified delivered
|
|
469
|
+
// after the patched fork moved the deferred bridge send onto its synchronous
|
|
470
|
+
// path; callers still cannot use the queued response itself as delivery proof.
|
|
471
|
+
async sendRichLink(chatId, url) {
|
|
472
|
+
try {
|
|
473
|
+
return await this.rpc.sendRichLink(chatId, url);
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
console.error(`[gateway] sendRichLink failed: ${String(err)}`);
|
|
477
|
+
return { ok: false };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Edits mutate the target row's text IN PLACE — no new row, no subscribe()
|
|
481
|
+
// event (contract §2). Verified by re-reading history for the changed text.
|
|
482
|
+
async editMessage(chatId, targetGuid, text) {
|
|
483
|
+
try {
|
|
484
|
+
await this.rpc.editMessage(chatId, targetGuid, text);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
console.error(`[gateway] editMessage failed: ${String(err)}`);
|
|
488
|
+
return { ok: false };
|
|
489
|
+
}
|
|
490
|
+
for (let attempt = 0; attempt < TIER2_VERIFY_ATTEMPTS; attempt++) {
|
|
491
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
492
|
+
const after = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
493
|
+
if (after.find((m) => m.guid === targetGuid)?.text === text)
|
|
494
|
+
return { ok: true };
|
|
495
|
+
}
|
|
496
|
+
return { ok: false };
|
|
497
|
+
}
|
|
498
|
+
// Live-verified on macOS 26: retractMessagePart: leaves the row in history
|
|
499
|
+
// with its text cleared. There is no separate is_unsent marker.
|
|
500
|
+
async unsendMessage(chatId, targetGuid) {
|
|
501
|
+
try {
|
|
502
|
+
await this.rpc.unsendMessage(chatId, targetGuid);
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
console.error(`[gateway] unsend failed: ${String(err)}`);
|
|
506
|
+
return { ok: false };
|
|
507
|
+
}
|
|
508
|
+
for (let attempt = 0; attempt < TIER2_VERIFY_ATTEMPTS; attempt++) {
|
|
509
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
510
|
+
const after = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
511
|
+
const found = after.find((m) => m.guid === targetGuid);
|
|
512
|
+
if (!found?.text)
|
|
513
|
+
return { ok: true };
|
|
514
|
+
}
|
|
515
|
+
return { ok: false };
|
|
516
|
+
}
|
|
517
|
+
// Live-verified on macOS 26: deleteChatItems: is a local-view mutation with
|
|
518
|
+
// no stable history postcondition (the row persisted in one run and was
|
|
519
|
+
// absent in another; no is_deleted marker exists). Pre-check the guid so an
|
|
520
|
+
// unknown target still fails closed, then treat a successful RPC dispatch
|
|
521
|
+
// as the only observable success signal. The pre-check POLLS (unlike
|
|
522
|
+
// tapback's single-shot scan) because delete's common caller targets a
|
|
523
|
+
// just-sent guid that can lag history by a beat — a one-shot read would
|
|
524
|
+
// fail closed on a message that is about to appear.
|
|
525
|
+
async deleteMessage(chatId, targetGuid) {
|
|
526
|
+
let targetFound = false;
|
|
527
|
+
for (let attempt = 0; attempt < TIER2_VERIFY_ATTEMPTS; attempt++) {
|
|
528
|
+
const recent = await this.rpc.history(chatId, TIER2_HISTORY_SCAN);
|
|
529
|
+
if (recent.some((m) => m.guid === targetGuid)) {
|
|
530
|
+
targetFound = true;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
534
|
+
}
|
|
535
|
+
if (!targetFound)
|
|
536
|
+
return { ok: false };
|
|
537
|
+
try {
|
|
538
|
+
await this.rpc.deleteMessage(chatId, targetGuid);
|
|
539
|
+
return { ok: true };
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
console.error(`[gateway] delete failed: ${String(err)}`);
|
|
543
|
+
return { ok: false };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Fire-and-forget: no observable verification surface through this
|
|
547
|
+
// interface (contract §2). `ok: true` means dispatched without error,
|
|
548
|
+
// never confirmed.
|
|
549
|
+
async setTyping(chatId, on) {
|
|
550
|
+
try {
|
|
551
|
+
await this.rpc.setTyping(chatId, on);
|
|
552
|
+
return { ok: true };
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
console.error(`[gateway] setTyping failed: ${String(err)}`);
|
|
556
|
+
return { ok: false };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async markRead(chatId) {
|
|
560
|
+
try {
|
|
561
|
+
await this.rpc.markRead(chatId);
|
|
562
|
+
return { ok: true };
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
console.error(`[gateway] markRead failed: ${String(err)}`);
|
|
566
|
+
return { ok: false };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Verified against chats.list's display_name field (the same source
|
|
570
|
+
// createGroup/resolveDmChat already read via this.rpc.chats).
|
|
571
|
+
async renameGroup(chatId, name) {
|
|
572
|
+
try {
|
|
573
|
+
await this.rpc.renameGroup(chatId, name);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error(`[gateway] renameGroup failed: ${String(err)}`);
|
|
577
|
+
return { ok: false };
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
for (let attempt = 0; attempt < GROUP_MUTATION_VERIFY_ATTEMPTS; attempt++) {
|
|
581
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
582
|
+
const chats = await readFreshChats(CHAT_SCAN_LIMIT);
|
|
583
|
+
if (chats.find((c) => c.id === chatId)?.display_name === name)
|
|
584
|
+
return { ok: true };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
console.error(`[gateway] renameGroup verification failed: ${String(err)}`);
|
|
589
|
+
}
|
|
590
|
+
return { ok: false };
|
|
591
|
+
}
|
|
592
|
+
// No photo field exists anywhere in imsg's JSON output (docs/json.md) —
|
|
593
|
+
// fire-and-forget, same as setTyping/markRead.
|
|
594
|
+
async setGroupPhoto(chatId, filePath) {
|
|
595
|
+
if (!this.capabilities.groupPhoto)
|
|
596
|
+
return { ok: false };
|
|
597
|
+
try {
|
|
598
|
+
await this.rpc.setGroupPhoto(chatId, filePath);
|
|
599
|
+
return { ok: true };
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
console.error(`[gateway] setGroupPhoto failed: ${String(err)}`);
|
|
603
|
+
return { ok: false };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async addParticipant(chatId, handle) {
|
|
607
|
+
if (!this.capabilities.groupParticipants)
|
|
608
|
+
return { ok: false };
|
|
609
|
+
return this.fireAndVerifyParticipant(chatId, handle, true, () => this.rpc.addParticipant(chatId, handle));
|
|
610
|
+
}
|
|
611
|
+
async removeParticipant(chatId, handle) {
|
|
612
|
+
if (!this.capabilities.groupParticipants)
|
|
613
|
+
return { ok: false };
|
|
614
|
+
return this.fireAndVerifyParticipant(chatId, handle, false, () => this.rpc.removeParticipant(chatId, handle));
|
|
615
|
+
}
|
|
616
|
+
async fireAndVerifyParticipant(chatId, handle, wantPresent, fire) {
|
|
617
|
+
const wanted = normalizeHandle(handle);
|
|
618
|
+
try {
|
|
619
|
+
await fire();
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
console.error(`[gateway] ${wantPresent ? 'addParticipant' : 'removeParticipant'} failed: ${String(err)}`);
|
|
623
|
+
return { ok: false };
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
for (let attempt = 0; attempt < GROUP_MUTATION_VERIFY_ATTEMPTS; attempt++) {
|
|
627
|
+
await delay(TIER2_VERIFY_INTERVAL_MS);
|
|
628
|
+
const chats = await readFreshChats(CHAT_SCAN_LIMIT);
|
|
629
|
+
const participants = chats.find((c) => c.id === chatId)?.participants ?? [];
|
|
630
|
+
const present = participants.some((h) => normalizeHandle(h) === wanted);
|
|
631
|
+
if (present === wantPresent)
|
|
632
|
+
return { ok: true };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
console.error(`[gateway] participant verification failed: ${String(err)}`);
|
|
637
|
+
}
|
|
638
|
+
return { ok: false };
|
|
639
|
+
}
|
|
640
|
+
// participants[] excludes the local user always (contract §2 "Participants
|
|
641
|
+
// exclude the local user") — leaving never changes it, so there is no field
|
|
642
|
+
// to verify against. Fire-and-forget, same as setGroupPhoto.
|
|
643
|
+
async leaveGroup(chatId) {
|
|
644
|
+
try {
|
|
645
|
+
await this.rpc.leaveGroup(chatId);
|
|
646
|
+
return { ok: true };
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
console.error(`[gateway] leaveGroup failed: ${String(err)}`);
|
|
650
|
+
return { ok: false };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async shareNamePhoto(chatId) {
|
|
654
|
+
if (!this.capabilities.namePhotoSharing)
|
|
655
|
+
return { ok: false, effectStarted: false };
|
|
656
|
+
let effectStarted = false;
|
|
657
|
+
try {
|
|
658
|
+
const status = await this.rpc.namePhotoStatus(chatId);
|
|
659
|
+
if (!status.ok || !status.available)
|
|
660
|
+
return { ok: false, effectStarted: false };
|
|
661
|
+
if (status.should_offer !== true)
|
|
662
|
+
return { ok: true, skipped: 'not-offered' };
|
|
663
|
+
effectStarted = true;
|
|
664
|
+
const shared = await this.rpc.shareNamePhoto(chatId);
|
|
665
|
+
return shared.ok && shared.requested ? { ok: true } : { ok: false, effectStarted: true };
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
console.error(`[gateway] shareNamePhoto failed: ${String(err)}`);
|
|
669
|
+
return { ok: false, effectStarted };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
stop() {
|
|
673
|
+
this.rpc.stop();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
//# sourceMappingURL=imsg.js.map
|