@mapier/imsg-sdk 0.2.3 → 0.3.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.
@@ -0,0 +1,40 @@
1
+ import type { ChatBackgroundStatusResponse } from '../imsg/rpc.js';
2
+ import type { ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus } from './types.js';
3
+ export declare const CHAT_BACKGROUND_SET_DEADLINE_MS = 90000;
4
+ export declare const CHAT_BACKGROUND_REMOVE_DEADLINE_MS = 30000;
5
+ export declare const CHAT_BACKGROUND_POLL_INTERVAL_MS = 1000;
6
+ export declare const CHAT_BACKGROUND_HELPER_INTERVAL_MS = 15000;
7
+ export declare const CHAT_BACKGROUND_HELPER_BACKOFF_CAP_MS = 60000;
8
+ export declare function isBridgeTimeout(err: unknown): boolean;
9
+ export declare function chatBackgroundEffectStarted(err: unknown): boolean;
10
+ export declare function bridgeEffectStarted(err: unknown, provenRejections: readonly string[]): boolean;
11
+ export declare function chatBackgroundEventBaseline(status: ChatBackgroundStatusResponse): number;
12
+ export declare function didPersistRemove(current: ChatBackgroundStatusResponse, initialGuid: string | null, baseline: number): boolean;
13
+ export declare function toChatBackgroundStatus(status: ChatBackgroundStatusResponse): ChatBackgroundStatus;
14
+ export interface ChatBackgroundVerifyClock {
15
+ now(): number;
16
+ sleep(ms: number): Promise<void>;
17
+ }
18
+ export declare const MONOTONIC_CLOCK: ChatBackgroundVerifyClock;
19
+ export interface ChatBackgroundSetVerifyInput {
20
+ read(operationId?: string): Promise<ChatBackgroundStatusResponse>;
21
+ initialGuid: string | null;
22
+ operationId: string | undefined;
23
+ clock: ChatBackgroundVerifyClock;
24
+ log: (message: string) => void;
25
+ deadlineMs?: number;
26
+ intervalMs?: number;
27
+ helperIntervalMs?: number;
28
+ helperBackoffCapMs?: number;
29
+ }
30
+ export declare function verifyChatBackgroundSet(input: ChatBackgroundSetVerifyInput): Promise<ChatBackgroundSetResult>;
31
+ export interface ChatBackgroundRemoveVerifyInput {
32
+ read(): Promise<ChatBackgroundStatusResponse>;
33
+ initial: ChatBackgroundStatusResponse;
34
+ accepted: ChatBackgroundStatusResponse;
35
+ clock: ChatBackgroundVerifyClock;
36
+ log: (message: string) => void;
37
+ deadlineMs?: number;
38
+ intervalMs?: number;
39
+ }
40
+ export declare function verifyChatBackgroundRemove(input: ChatBackgroundRemoveVerifyInput): Promise<ChatBackgroundRemoveResult>;
@@ -0,0 +1,219 @@
1
+ // Chat-background verify-after-fire logic (Mapier-Labs/imsg#12), kept pure so
2
+ // it is unit-testable with mocked reads and a virtual clock. ImsgGateway wires
3
+ // it to the live `imsg rpc` child; nothing here touches a process.
4
+ //
5
+ // Native facts this encodes (imsg docs/chat-background.md,
6
+ // ChatBackgroundMutation.swift, RPCServer+Support.swift):
7
+ // - `chat.background.set`/`remove` return on helper acceptance; chat.db lags
8
+ // imagent, so persistence is proven by polling `chat.background.status`.
9
+ // - A status read WITHOUT `operation_id` is a local chat.db read. WITH it the
10
+ // native handler also round-trips the helper over the bridge: a Messages
11
+ // main-thread hop that competes with the pipeline itself and, because the
12
+ // native JSON-RPC loop is strictly serial, holds every other gateway verb
13
+ // for up to 10 s on a stall. Helper reads are therefore rare and backed off.
14
+ // - The helper record's `transfer_id` is what IMChat persists as the
15
+ // background guid. It is the ONLY evidence that correlates a chat.db guid
16
+ // with THIS operation; a lagging write from an earlier operation can satisfy
17
+ // every other signal (changed guid, newer set event).
18
+ // - Every failure is JSON-RPC -32603 with the reason only in `error.data`.
19
+ import { ImsgRpcError } from '../imsg/rpc.js';
20
+ // The native CLI's own windows (90 polls / 30 polls at 1 s). Here they are
21
+ // wall-clock deadlines, so a stalled bridge hop cannot stretch the wait.
22
+ export const CHAT_BACKGROUND_SET_DEADLINE_MS = 90_000;
23
+ export const CHAT_BACKGROUND_REMOVE_DEADLINE_MS = 30_000;
24
+ export const CHAT_BACKGROUND_POLL_INTERVAL_MS = 1000;
25
+ // Scheduled helper reads (early failure detection) at most this often; a
26
+ // helper timeout doubles the gap up to the cap.
27
+ export const CHAT_BACKGROUND_HELPER_INTERVAL_MS = 15_000;
28
+ export const CHAT_BACKGROUND_HELPER_BACKOFF_CAP_MS = 60_000;
29
+ // The helper's pre-queue refusals (IMsgChatBackground.m /
30
+ // IMsgInjected.m handleRemoveChatBackground) plus the coordinator's own
31
+ // "did not queue". Anything else under -32603 (bridge timeout, a dylib
32
+ // exception raised after IMChat was invoked, ...) may have acted.
33
+ const CHAT_BACKGROUND_PROVEN_REJECTIONS = [
34
+ 'did not queue the chat background operation',
35
+ 'Another chat background operation is already running',
36
+ 'does not match expected GUID',
37
+ 'Chat background selector unavailable',
38
+ 'Chat background GUID selector unavailable',
39
+ 'Chat background runtime unavailable',
40
+ 'Chat is required',
41
+ ];
42
+ // IMsgBridgeProtocol.swift `IMsgBridgeError.timeout`.
43
+ const BRIDGE_TIMEOUT_DETAIL = 'Timed out waiting for response';
44
+ export function isBridgeTimeout(err) {
45
+ return err instanceof ImsgRpcError && (err.data ?? '').includes(BRIDGE_TIMEOUT_DETAIL);
46
+ }
47
+ // Did the helper possibly act before the request failed? Proven "no": the
48
+ // server rejected the params (-32602: unknown chat, bad preset) or the helper
49
+ // refused before queueing (a known rejection reason in `data`). Everything
50
+ // else — a bridge timeout, an exception after IMChat was invoked, a
51
+ // non-structured rpc-child/transport failure — is ambiguous.
52
+ export function chatBackgroundEffectStarted(err) {
53
+ return bridgeEffectStarted(err, CHAT_BACKGROUND_PROVEN_REJECTIONS);
54
+ }
55
+ // The same rule for any bridge verb whose server folds every failure into
56
+ // -32603: `provenRejections` are the helper's pre-invoke refusal texts for
57
+ // that verb (matched against `error.data`).
58
+ export function bridgeEffectStarted(err, provenRejections) {
59
+ if (!(err instanceof ImsgRpcError))
60
+ return true;
61
+ if (err.code === -32602)
62
+ return false;
63
+ const detail = err.data ?? '';
64
+ return !provenRejections.some((reason) => detail.includes(reason));
65
+ }
66
+ // Highest known background-event ROWID before a mutation. Both the
67
+ // date-ordered and the ROWID-ordered newest event feed in, so a row imagent
68
+ // writes with an out-of-order date is still recognised as new.
69
+ export function chatBackgroundEventBaseline(status) {
70
+ return Math.max(status.latest_event?.row_id ?? 0, status.newest_event?.row_id ?? 0);
71
+ }
72
+ function hasNewChatBackgroundEvent(status, action, baseline) {
73
+ for (const event of [status.latest_event, status.newest_event]) {
74
+ if (event && event.action === action && event.row_id > baseline)
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ // Native didPersistRemove: no guid, plus either a clear event past the
80
+ // baseline, or — only when the pre-mutation snapshot recorded the background
81
+ // that is now gone — an events table that is empty. An already-lagging empty
82
+ // snapshot proves nothing: the clear event has to land first.
83
+ export function didPersistRemove(current, initialGuid, baseline) {
84
+ if (current.background_channel_guid)
85
+ return false;
86
+ if (hasNewChatBackgroundEvent(current, 'clear', baseline))
87
+ return true;
88
+ return initialGuid !== null && !current.latest_event && !current.newest_event;
89
+ }
90
+ export function toChatBackgroundStatus(status) {
91
+ const guid = status.background_channel_guid;
92
+ return {
93
+ chatId: status.chat_id,
94
+ backgroundSet: status.background_set,
95
+ backgroundGuid: guid ? guid : null,
96
+ };
97
+ }
98
+ // The production clock. `performance.now()` is monotonic: a wall-clock
99
+ // adjustment (NTP step, manual change) cannot move a deadline, whereas a
100
+ // `Date.now()`-based deadline would stretch the wait by the size of a
101
+ // backwards step — a 10-minute rollback turned a 90 s window into ~11 min.
102
+ export const MONOTONIC_CLOCK = {
103
+ now: () => performance.now(),
104
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
105
+ };
106
+ // Polls until chat.db shows a guid that the helper record correlates with
107
+ // THIS operation, the helper reports failure, the record is gone, a
108
+ // non-transient read error occurs, or the deadline passes. Only the
109
+ // `transfer_id` match is success; every other exit is either a proven failure
110
+ // with the effect started or ambiguity — never a guess.
111
+ export async function verifyChatBackgroundSet(input) {
112
+ const { read, initialGuid, operationId, clock, log, deadlineMs = CHAT_BACKGROUND_SET_DEADLINE_MS, intervalMs = CHAT_BACKGROUND_POLL_INTERVAL_MS, helperIntervalMs = CHAT_BACKGROUND_HELPER_INTERVAL_MS, helperBackoffCapMs = CHAT_BACKGROUND_HELPER_BACKOFF_CAP_MS, } = input;
113
+ const startedAt = clock.now();
114
+ const deadline = startedAt + deadlineMs;
115
+ // A candidate is a chat.db guid that differs from the pre-mutation
116
+ // snapshot. Seeing a NEW one pulls the next helper read forward once
117
+ // (`pulled`), so it is confirmed at the next opportunity; after that only
118
+ // the scheduled cadence and the timeout backoff decide when the helper is
119
+ // read again. `rejected` is the last candidate the record disproved.
120
+ let pulled = null;
121
+ let rejected = null;
122
+ let helperGapMs = helperIntervalMs;
123
+ let nextHelperAt = startedAt + helperIntervalMs;
124
+ let loggedTimeout = false;
125
+ for (;;) {
126
+ const consultHelper = operationId !== undefined && clock.now() >= nextHelperAt;
127
+ let current;
128
+ try {
129
+ current = await read(consultHelper ? operationId : undefined);
130
+ }
131
+ catch (err) {
132
+ // A bridge hop timing out while the pipeline holds the main thread is
133
+ // not evidence either way: back the helper off and keep polling chat.db.
134
+ // Anything else (dead rpc child, transport loss) ends the wait.
135
+ if (!isBridgeTimeout(err)) {
136
+ log(`setChatBackground verification failed: ${String(err)}`);
137
+ return { ok: false, effectStarted: true };
138
+ }
139
+ if (!loggedTimeout) {
140
+ loggedTimeout = true;
141
+ log(`setChatBackground verification read timed out: ${String(err)}`);
142
+ }
143
+ if (consultHelper) {
144
+ helperGapMs = Math.min(helperGapMs * 2, helperBackoffCapMs);
145
+ nextHelperAt = clock.now() + helperGapMs;
146
+ }
147
+ }
148
+ if (current) {
149
+ if (consultHelper)
150
+ nextHelperAt = clock.now() + helperGapMs;
151
+ const guid = current.background_channel_guid;
152
+ const candidate = guid && guid !== initialGuid ? guid : null;
153
+ const operation = current.operation;
154
+ if (operation?.found) {
155
+ if (operation.state === 'failed') {
156
+ log(`setChatBackground operation failed: ${operation.stage ?? 'unknown'}: ${operation.detail ?? 'unknown error'}`);
157
+ return { ok: false, effectStarted: true };
158
+ }
159
+ if (candidate) {
160
+ if (operation.transfer_id && candidate === operation.transfer_id) {
161
+ return { ok: true, backgroundGuid: candidate, effectStarted: true };
162
+ }
163
+ // A different guid is a lagging write of another operation's
164
+ // background, or the helper has not reached `invoked` yet and
165
+ // nothing in chat.db can be ours. Wait for the guid to change.
166
+ rejected = candidate;
167
+ }
168
+ }
169
+ else if (operation) {
170
+ // The helper no longer tracks the operation (bounded record table,
171
+ // Messages relaunch mid-flight). Nothing left can correlate a chat.db
172
+ // guid with THIS operation, so the outcome is unknowable from here.
173
+ log('setChatBackground operation record is gone; persistence cannot be correlated');
174
+ return { ok: false, effectStarted: true };
175
+ }
176
+ else if (candidate && candidate !== rejected && candidate !== pulled) {
177
+ pulled = candidate;
178
+ nextHelperAt = Math.min(nextHelperAt, clock.now() + intervalMs);
179
+ }
180
+ }
181
+ if (clock.now() + intervalMs >= deadline)
182
+ break;
183
+ await clock.sleep(intervalMs);
184
+ }
185
+ return { ok: false, effectStarted: true };
186
+ }
187
+ export async function verifyChatBackgroundRemove(input) {
188
+ const { read, initial, accepted, clock, log, deadlineMs = CHAT_BACKGROUND_REMOVE_DEADLINE_MS, intervalMs = CHAT_BACKGROUND_POLL_INTERVAL_MS, } = input;
189
+ const initialGuid = initial.background_channel_guid ?? null;
190
+ const baseline = chatBackgroundEventBaseline(initial);
191
+ if (didPersistRemove(accepted, initialGuid, baseline))
192
+ return { ok: true, effectStarted: true };
193
+ const deadline = clock.now() + deadlineMs;
194
+ let loggedTimeout = false;
195
+ for (;;) {
196
+ if (clock.now() + intervalMs > deadline)
197
+ break;
198
+ await clock.sleep(intervalMs);
199
+ let current;
200
+ try {
201
+ current = await read();
202
+ }
203
+ catch (err) {
204
+ if (!isBridgeTimeout(err)) {
205
+ log(`removeChatBackground verification failed: ${String(err)}`);
206
+ return { ok: false, effectStarted: true };
207
+ }
208
+ if (!loggedTimeout) {
209
+ loggedTimeout = true;
210
+ log(`removeChatBackground verification read timed out: ${String(err)}`);
211
+ }
212
+ continue;
213
+ }
214
+ if (didPersistRemove(current, initialGuid, baseline))
215
+ return { ok: true, effectStarted: true };
216
+ }
217
+ return { ok: false, effectStarted: true };
218
+ }
219
+ //# sourceMappingURL=chat-background.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-background.js","sourceRoot":"","sources":["../../src/gateway/chat-background.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { ImsgMessage } from '../types.js';
2
+ import { type EditMessageResult, type GatewayCapabilities } from './types.js';
3
+ export declare const EDIT_PREFLIGHT_LOCATE_ATTEMPTS = 3;
4
+ export declare const EDIT_VERIFY_ATTEMPTS = 6;
5
+ export declare const EDIT_POLL_INTERVAL_MS = 1000;
6
+ export declare const EDIT_MESSAGE_PROVEN_REJECTIONS: readonly string[];
7
+ export interface EditMessagePort {
8
+ capabilities: Pick<GatewayCapabilities, 'editMessage'>;
9
+ history(chatId: number): Promise<ImsgMessage[]>;
10
+ edit(chatId: number, targetGuid: string, text: string): Promise<{
11
+ ok: boolean;
12
+ }>;
13
+ sleep(ms: number): Promise<void>;
14
+ log(message: string): void;
15
+ }
16
+ export declare function awaitInHistory(port: Pick<EditMessagePort, 'history' | 'sleep'>, chatId: number, matches: (m: ImsgMessage) => boolean, attempts: number, delayFirst: boolean): Promise<ImsgMessage | undefined>;
17
+ export declare function runEditMessage(port: EditMessagePort, chatId: number, targetGuid: string, text: string): Promise<EditMessageResult>;
@@ -0,0 +1,70 @@
1
+ import { bridgeEffectStarted } from './chat-background.js';
2
+ import { decideEditMessagePreflight } from './types.js';
3
+ // How long a preflight waits for a just-sent guid to surface in history
4
+ // before calling it absent: "a beat", not the full post-fire verify budget.
5
+ export const EDIT_PREFLIGHT_LOCATE_ATTEMPTS = 3;
6
+ // Post-fire read-back budget (the Tier-2 verify convention: 6 × 1 s).
7
+ export const EDIT_VERIFY_ATTEMPTS = 6;
8
+ export const EDIT_POLL_INTERVAL_MS = 1000;
9
+ // handleEditMessage's pre-invoke refusals (IMsgInjected.m) — anything else
10
+ // under -32603 (bridge timeout, an exception after IMChat was invoked) may
11
+ // have edited the row.
12
+ export const EDIT_MESSAGE_PROVEN_REJECTIONS = [
13
+ 'Missing chatGuid',
14
+ 'Missing messageGuid',
15
+ 'Missing editedMessage',
16
+ 'Chat not found:',
17
+ 'No edit-message selector available',
18
+ 'Message not found:',
19
+ 'Message object not found:',
20
+ 'Unexpected signature for',
21
+ ];
22
+ // Poll history until a row matches, sleeping only BETWEEN attempts.
23
+ // `delayFirst` is for post-fire verification, where the first read would
24
+ // predate the effect; preflights read immediately.
25
+ export async function awaitInHistory(port, chatId, matches, attempts, delayFirst) {
26
+ for (let attempt = 0; attempt < attempts; attempt++) {
27
+ if (delayFirst || attempt > 0)
28
+ await port.sleep(EDIT_POLL_INTERVAL_MS);
29
+ const found = (await port.history(chatId)).find(matches);
30
+ if (found)
31
+ return found;
32
+ }
33
+ return undefined;
34
+ }
35
+ // Preflight (decideEditMessagePreflight, shared with FakeGateway) → fire →
36
+ // verify by history read-back. Every exit is a proven refusal
37
+ // (effectStarted:false), a confirmed edit, or an honest ambiguity
38
+ // (effectStarted:true) — never a guess.
39
+ export async function runEditMessage(port, chatId, targetGuid, text) {
40
+ if (!port.capabilities.editMessage || !text.trim())
41
+ return { ok: false, effectStarted: false };
42
+ let target;
43
+ try {
44
+ target = await awaitInHistory(port, chatId, (m) => m.guid === targetGuid, EDIT_PREFLIGHT_LOCATE_ATTEMPTS, false);
45
+ }
46
+ catch (err) {
47
+ port.log(`editMessage preflight failed: ${String(err)}`);
48
+ return { ok: false, effectStarted: false };
49
+ }
50
+ const decided = decideEditMessagePreflight(port.capabilities, target, text);
51
+ if (decided)
52
+ return decided;
53
+ try {
54
+ await port.edit(chatId, targetGuid, text);
55
+ }
56
+ catch (err) {
57
+ port.log(`editMessage failed: ${String(err)}`);
58
+ return { ok: false, effectStarted: bridgeEffectStarted(err, EDIT_MESSAGE_PROVEN_REJECTIONS) };
59
+ }
60
+ try {
61
+ const confirmed = await awaitInHistory(port, chatId, (m) => m.guid === targetGuid && m.text === text, EDIT_VERIFY_ATTEMPTS, true);
62
+ if (confirmed)
63
+ return { ok: true };
64
+ }
65
+ catch (err) {
66
+ port.log(`editMessage verification failed: ${String(err)}`);
67
+ }
68
+ return { ok: false, effectStarted: true };
69
+ }
70
+ //# sourceMappingURL=edit-message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-message.js","sourceRoot":"","sources":["../../src/gateway/edit-message.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ export declare function isBoundedId(value: string): boolean;
1
2
  export declare function isImessageParticipantExternalId(value: string): boolean;
2
3
  export declare function isImessageDmConversationExternalId(value: string): boolean;
3
4
  export declare function isImessageGroupConversationExternalId(value: string): boolean;
@@ -5,7 +5,9 @@
5
5
  // schemas wrap them.
6
6
  // Bounded-identifier envelope shared by every portable id: non-empty, capped,
7
7
  // no control characters, no surrounding whitespace.
8
- function isBoundedId(value) {
8
+ // The envelope every iMessage identifier must satisfy before any format
9
+ // check: non-empty, bounded, no surrounding whitespace, no control bytes.
10
+ export function isBoundedId(value) {
9
11
  if (value.length < 1 || value.length > 512)
10
12
  return false;
11
13
  if (value.trim() !== value)
@@ -1 +1 @@
1
- {"version":3,"file":"external-ids.js","sourceRoot":"","sources":["../../src/gateway/external-ids.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,6EAA6E;AAC7E,qBAAqB;AAErB,8EAA8E;AAC9E,oDAAoD;AACpD,SAAS,WAAW,CAAC,KAAa;IAChC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC;IACzC,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,EAAE,EAAE;QACpC,MAAM,SAAS,GAAG,SAAS,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAC3C,OAAO,SAAS,KAAK,SAAS,IAAI,SAAS,GAAG,EAAE,IAAI,SAAS,KAAK,GAAG,CAAC;IACxE,CAAC,CAAC,CAAC;AACL,CAAC;AAED,+EAA+E;AAC/E,kEAAkE;AAClE,2EAA2E;AAC3E,MAAM,UAAU,+BAA+B,CAAC,KAAa;IAC3D,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,OAAO,CACL,sBAAsB,CAAC,IAAI,CAAC,KAAK,CAAC;QAClC,CAAC,4BAA4B,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,KAAK,CAAC,WAAW,EAAE,IAAI,KAAK,KAAK,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAChH,CAAC;AACJ,CAAC;AAED,4EAA4E;AAC5E,yBAAyB;AACzB,MAAM,UAAU,kCAAkC,CAAC,KAAa;IAC9D,MAAM,MAAM,GAAG,QAAQ,CAAC;IACxB,OAAO,WAAW,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,+BAA+B,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACvH,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,qCAAqC,CAAC,KAAa;IACjE,OAAO,WAAW,CAAC,KAAK,CAAC,IAAI,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC"}
1
+ {"version":3,"file":"external-ids.js","sourceRoot":"","sources":["../../src/gateway/external-ids.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,6EAA6E;AAC7E,qBAAqB;AAErB,8EAA8E;AAC9E,oDAAoD;AACpD,wEAAwE;AACxE,0EAA0E;AAC1E,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC;IACzC,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,EAAE,EAAE;QACpC,MAAM,SAAS,GAAG,SAAS,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAC3C,OAAO,SAAS,KAAK,SAAS,IAAI,SAAS,GAAG,EAAE,IAAI,SAAS,KAAK,GAAG,CAAC;IACxE,CAAC,CAAC,CAAC;AACL,CAAC;AAED,+EAA+E;AAC/E,kEAAkE;AAClE,2EAA2E;AAC3E,MAAM,UAAU,+BAA+B,CAAC,KAAa;IAC3D,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,OAAO,CACL,sBAAsB,CAAC,IAAI,CAAC,KAAK,CAAC;QAClC,CAAC,4BAA4B,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,KAAK,CAAC,WAAW,EAAE,IAAI,KAAK,KAAK,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAChH,CAAC;AACJ,CAAC;AAED,4EAA4E;AAC5E,yBAAyB;AACzB,MAAM,UAAU,kCAAkC,CAAC,KAAa;IAC9D,MAAM,MAAM,GAAG,QAAQ,CAAC;IACxB,OAAO,WAAW,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,+BAA+B,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACvH,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,qCAAqC,CAAC,KAAa;IACjE,OAAO,WAAW,CAAC,KAAK,CAAC,IAAI,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import type { MessageAttachment } from '../types.js';
2
- import type { Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, HandleCheck, ImsgMessage, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, TextFormatRange } from './types.js';
2
+ import type { ChatBackgroundPreset, ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus, EditMessageResult, Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, HandleCheck, ImsgMessage, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, TextFormatRange } from './types.js';
3
3
  export interface FakeGatewayOptions {
4
4
  latencyMs?: number;
5
5
  capabilities?: Partial<GatewayCapabilities>;
@@ -20,6 +20,11 @@ export interface FakeGatewaySnapshotV1 {
20
20
  readonly namePhotoSharedChatIds: readonly number[];
21
21
  readonly groupDirectoryComplete: boolean;
22
22
  readonly groupParticipantsUnreportedChatIds: readonly number[];
23
+ readonly chatBackgrounds?: ReadonlyArray<{
24
+ readonly chatId: number;
25
+ readonly backgroundGuid: string;
26
+ }>;
27
+ readonly chatBackgroundSeq?: number;
23
28
  };
24
29
  }
25
30
  export type FakeGatewaySnapshot = FakeGatewaySnapshotV1;
@@ -30,6 +35,8 @@ export declare class FakeGateway implements Gateway {
30
35
  private groupDirectoryComplete;
31
36
  private groupParticipantsUnreported;
32
37
  private namePhotoSharedChats;
38
+ private chatBackgrounds;
39
+ private nextBackgroundSeq;
33
40
  private reachableAddresses;
34
41
  private sentGuids;
35
42
  private nextId;
@@ -84,9 +91,7 @@ export declare class FakeGateway implements Gateway {
84
91
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
85
92
  votePoll(chatId: number, pollGuid: string, optionId: string): Promise<ReactResult>;
86
93
  sendRichLink(chatId: number, _url: string): Promise<SendResult>;
87
- editMessage(chatId: number, targetGuid: string, text: string): Promise<{
88
- ok: boolean;
89
- }>;
94
+ editMessage(chatId: number, targetGuid: string, text: string): Promise<EditMessageResult>;
90
95
  unsendMessage(chatId: number, targetGuid: string): Promise<{
91
96
  ok: boolean;
92
97
  }>;
@@ -115,6 +120,9 @@ export declare class FakeGateway implements Gateway {
115
120
  ok: boolean;
116
121
  }>;
117
122
  shareNamePhoto(chatId: number): Promise<NamePhotoShareResult>;
123
+ chatBackgroundStatus(chatId: number): Promise<ChatBackgroundStatus | null>;
124
+ setChatBackground(chatId: number, preset?: ChatBackgroundPreset): Promise<ChatBackgroundSetResult>;
125
+ removeChatBackground(chatId: number, expectedGuid?: string): Promise<ChatBackgroundRemoveResult>;
118
126
  setGroupDirectoryComplete(complete: boolean): void;
119
127
  setGroupParticipantsUnreported(chatId: number, unreported: boolean): void;
120
128
  injectInbound(params: {
@@ -1,9 +1,9 @@
1
1
  import { aggregatePolls } from '../polls.js';
2
- import { PATCHED_GATEWAY_CAPABILITIES, toReactionNoteType } from './types.js';
2
+ import { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, sendRichPreflight, TIER2_HISTORY_SCAN_WINDOW, toReactionNoteType, } from './types.js';
3
3
  import { classifyGroupChatResolution } from './portable-chat.js';
4
4
  // How far back recentReactions() scans, mirroring ImsgGateway's
5
5
  // history(chatId, 30) read — keep the two in lockstep (contract §2).
6
- const HISTORY_SCAN_WINDOW = 30;
6
+ const HISTORY_SCAN_WINDOW = TIER2_HISTORY_SCAN_WINDOW;
7
7
  const REACTION_EMOJI_BY_TYPE = {
8
8
  love: '❤️',
9
9
  like: '👍',
@@ -37,6 +37,9 @@ function reactionEventText(type, emoji, isAdd, targetText) {
37
37
  // runtime.ts because is_reaction is handled first.
38
38
  return `Removed a ${type} from ${quotedTarget}`;
39
39
  }
40
+ // Fake background GUIDs are minted from a per-gateway sequence so a re-set
41
+ // always yields a NEW guid (the real path's persistence proof).
42
+ const FAKE_BACKGROUND_GUID_PREFIX = 'fake-chat-background-';
40
43
  // In-memory Gateway for unit tests and the simulator. No DB, no imsg binary.
41
44
  // Must reproduce every invariant in docs/gateway-contract.md §2 exactly —
42
45
  // see that doc before changing behavior here.
@@ -49,6 +52,8 @@ export class FakeGateway {
49
52
  groupDirectoryComplete = true;
50
53
  groupParticipantsUnreported = new Set();
51
54
  namePhotoSharedChats = new Set();
55
+ chatBackgrounds = new Map();
56
+ nextBackgroundSeq = 1;
52
57
  reachableAddresses = new Set();
53
58
  sentGuids = new Set();
54
59
  nextId = 1;
@@ -468,9 +473,22 @@ export class FakeGateway {
468
473
  const chat = this.chats.get(chatId);
469
474
  if (!chat)
470
475
  return { ok: false };
471
- // `effect` and `subject` have no fields in imsg's JSON message shape
472
- // (docs/json.md) they are write-only on the real path too, so the fake
473
- // has nothing to store.
476
+ // The real path's preflight (shared) plus the helper's membership rule:
477
+ // a mention must name a participant of the chat, matched exactly. A
478
+ // refused range sends nothing.
479
+ const preflight = sendRichPreflight(this.capabilities, text, opts.textFormatting);
480
+ if (preflight)
481
+ return preflight.result;
482
+ // The helper resolves the allowed set from the LIVE participant list and
483
+ // refuses every mention when that list is unreadable (empty set); the
484
+ // fake's unreported-directory state models exactly that.
485
+ const members = this.groupParticipantsUnreported.has(chatId) ? [] : chat.participants;
486
+ if (opts.textFormatting?.some((r) => r.mention !== undefined && !members.includes(r.mention))) {
487
+ return { ok: false, refused: 'mention-not-participant' };
488
+ }
489
+ // `effect`, `subject` and formatting have no fields in imsg's JSON message
490
+ // shape (docs/json.md) — they are write-only on the real path too, so the
491
+ // fake has nothing to store.
474
492
  const msg = this.buildMessage(chat, { is_from_me: true, text, reply_to_guid: opts.replyToGuid });
475
493
  this.emit(msg);
476
494
  return { ok: true, id: msg.id, guid: msg.guid };
@@ -564,12 +582,18 @@ export class FakeGateway {
564
582
  // Edits mutate the target row's text IN PLACE — no new row, no subscribe()
565
583
  // event (contract §2: this is what breaks the "same guid = immutable
566
584
  // content" assumption elsewhere in the codebase).
585
+ // The preflight is the real path's, verbatim (decideEditMessagePreflight):
586
+ // capability, non-blank text, scan window, own plain-text message only —
587
+ // so a retracted tombstone or an attachment row is refused here too — and
588
+ // unchanged text is an idempotent skip.
567
589
  async editMessage(chatId, targetGuid, text) {
568
590
  await this.delay();
569
591
  const target = this.findInScanWindow(chatId, targetGuid);
570
- if (!target)
571
- return { ok: false };
572
- target.text = text;
592
+ const decided = decideEditMessagePreflight(this.capabilities, target, text);
593
+ if (decided)
594
+ return decided;
595
+ if (target)
596
+ target.text = text;
573
597
  return { ok: true };
574
598
  }
575
599
  // Live parity: unsend leaves a tombstone row whose text is cleared.
@@ -656,6 +680,41 @@ export class FakeGateway {
656
680
  this.namePhotoSharedChats.add(chatId);
657
681
  return { ok: true };
658
682
  }
683
+ // ---- Chat backgrounds (contract §2 "Chat backgrounds"). Backgrounds are
684
+ // chat-scoped state, not message rows: no subscribe() event, no history
685
+ // row — the real side records them in chat.db's background tables. ----
686
+ async chatBackgroundStatus(chatId) {
687
+ await this.delay();
688
+ if (!this.chats.has(chatId))
689
+ return null;
690
+ const backgroundGuid = this.chatBackgrounds.get(chatId) ?? null;
691
+ return { chatId, backgroundSet: backgroundGuid !== null, backgroundGuid };
692
+ }
693
+ async setChatBackground(chatId, preset = 'gradient') {
694
+ await this.delay();
695
+ if (!this.capabilities.chatBackgroundSet || !this.chats.has(chatId))
696
+ return { ok: false, effectStarted: false };
697
+ if (!CHAT_BACKGROUND_PRESETS.includes(preset))
698
+ return { ok: false, effectStarted: false };
699
+ const backgroundGuid = `${FAKE_BACKGROUND_GUID_PREFIX}${this.nextBackgroundSeq++}`;
700
+ this.chatBackgrounds.set(chatId, backgroundGuid);
701
+ return { ok: true, backgroundGuid, effectStarted: true };
702
+ }
703
+ async removeChatBackground(chatId, expectedGuid) {
704
+ await this.delay();
705
+ if (!this.capabilities.chatBackgroundRemove || !this.chats.has(chatId))
706
+ return { ok: false, effectStarted: false };
707
+ const current = this.chatBackgrounds.get(chatId);
708
+ // Same order as the helper: the guard is compared against the live guid
709
+ // (nothing live counts as a mismatch too) BEFORE the no-background no-op.
710
+ const guard = normalizeChatBackgroundGuard(expectedGuid);
711
+ if (guard !== undefined && guard !== current)
712
+ return { ok: false, effectStarted: false };
713
+ if (current === undefined)
714
+ return { ok: true, skipped: 'no-background' };
715
+ this.chatBackgrounds.delete(chatId);
716
+ return { ok: true, effectStarted: true };
717
+ }
659
718
  // ---- fixture/test driver API (not part of Gateway) ----
660
719
  // Reproduce the real gateway's bounded-directory failure. ImsgGateway sets
661
720
  // directoryComplete=false when the chat scan hits GROUP_CHAT_SCAN_LIMIT or a
@@ -866,6 +925,10 @@ export class FakeGateway {
866
925
  namePhotoSharedChatIds: [...this.namePhotoSharedChats].sort((left, right) => left - right),
867
926
  groupDirectoryComplete: this.groupDirectoryComplete,
868
927
  groupParticipantsUnreportedChatIds: [...this.groupParticipantsUnreported].sort((left, right) => left - right),
928
+ chatBackgrounds: [...this.chatBackgrounds]
929
+ .map(([chatId, backgroundGuid]) => ({ chatId, backgroundGuid }))
930
+ .sort((left, right) => left.chatId - right.chatId),
931
+ chatBackgroundSeq: this.nextBackgroundSeq,
869
932
  },
870
933
  };
871
934
  }
@@ -912,10 +975,18 @@ export class FakeGateway {
912
975
  }
913
976
  const namePhotoSharedChats = new Set(input.gatewayState.namePhotoSharedChatIds);
914
977
  const groupParticipantsUnreported = new Set(input.gatewayState.groupParticipantsUnreportedChatIds);
978
+ const chatBackgroundEntries = input.gatewayState.chatBackgrounds ?? [];
979
+ const chatBackgrounds = new Map(chatBackgroundEntries.map((entry) => [entry.chatId, entry.backgroundGuid]));
980
+ const nextBackgroundSeq = input.gatewayState.chatBackgroundSeq ?? 1;
915
981
  if (namePhotoSharedChats.size !== input.gatewayState.namePhotoSharedChatIds.length ||
916
982
  [...namePhotoSharedChats].some((chatId) => !chats.has(chatId)) ||
917
983
  groupParticipantsUnreported.size !== input.gatewayState.groupParticipantsUnreportedChatIds.length ||
918
- [...groupParticipantsUnreported].some((chatId) => !chats.get(chatId)?.isGroup)) {
984
+ [...groupParticipantsUnreported].some((chatId) => !chats.get(chatId)?.isGroup) ||
985
+ chatBackgrounds.size !== chatBackgroundEntries.length ||
986
+ [...chatBackgrounds.keys()].some((chatId) => !chats.has(chatId)) ||
987
+ [...chatBackgrounds.values()].some((guid) => !guid) ||
988
+ !Number.isSafeInteger(nextBackgroundSeq) ||
989
+ nextBackgroundSeq < 1) {
919
990
  throw new Error('synthetic snapshot has invalid chat-scoped gateway state');
920
991
  }
921
992
  this.chats = chats;
@@ -923,6 +994,8 @@ export class FakeGateway {
923
994
  this.sentGuids = sentGuids;
924
995
  this.reachableAddresses = new Set(input.gatewayState.reachableAddresses);
925
996
  this.namePhotoSharedChats = namePhotoSharedChats;
997
+ this.chatBackgrounds = chatBackgrounds;
998
+ this.nextBackgroundSeq = nextBackgroundSeq;
926
999
  this.groupDirectoryComplete = input.gatewayState.groupDirectoryComplete;
927
1000
  this.groupParticipantsUnreported = groupParticipantsUnreported;
928
1001
  this.nextChatId = Math.max(0, ...chats.keys()) + 1;