@mapier/imsg-sdk 0.5.0 → 0.7.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.
Potentially problematic release.
This version of @mapier/imsg-sdk might be problematic. Click here for more details.
- package/dist/gateway/extension-card.d.ts +34 -0
- package/dist/gateway/extension-card.js +182 -0
- package/dist/gateway/extension-card.js.map +1 -0
- package/dist/gateway/fake.d.ts +15 -9
- package/dist/gateway/fake.js +166 -17
- package/dist/gateway/fake.js.map +1 -1
- package/dist/gateway/imsg.d.ts +14 -9
- package/dist/gateway/imsg.js +130 -56
- package/dist/gateway/imsg.js.map +1 -1
- package/dist/gateway/message-removal.d.ts +16 -0
- package/dist/gateway/message-removal.js +94 -0
- package/dist/gateway/message-removal.js.map +1 -0
- package/dist/gateway/multipart.d.ts +38 -0
- package/dist/gateway/multipart.js +216 -0
- package/dist/gateway/multipart.js.map +1 -0
- package/dist/gateway/tapback.d.ts +16 -0
- package/dist/gateway/tapback.js +103 -0
- package/dist/gateway/tapback.js.map +1 -0
- package/dist/gateway/types.d.ts +70 -9
- package/dist/gateway/types.js +20 -0
- package/dist/gateway/types.js.map +1 -1
- package/dist/imsg/rpc.d.ts +6 -2
- package/dist/imsg/rpc.js +43 -4
- package/dist/imsg/rpc.js.map +1 -1
- package/dist/imsg/status.js +25 -0
- package/dist/imsg/status.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions/catalog.js +7 -3
- package/dist/interactions/catalog.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/docs/api.md +1 -1
- package/docs/capability-matrix.md +3 -0
- package/docs/gateway-contract.md +550 -16
- package/docs/interactions.md +21 -19
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExtensionCard, ExtensionCardHandle, ExtensionCardResult, GatewayCapabilities } from './types.js';
|
|
2
|
+
export declare const EXTENSION_CARD_MAX_FIELD_LENGTH = 4096;
|
|
3
|
+
export declare const EXTENSION_CARD_PROVEN_REJECTIONS: readonly string[];
|
|
4
|
+
export declare function classifyExtensionCardFireFailure(err: unknown): ExtensionCardResult;
|
|
5
|
+
export interface ExtensionCardWireParams {
|
|
6
|
+
chat_id: number;
|
|
7
|
+
team_id: string;
|
|
8
|
+
extension_bundle_id: string;
|
|
9
|
+
app_name: string;
|
|
10
|
+
caption: string;
|
|
11
|
+
url: string;
|
|
12
|
+
live_layout: boolean;
|
|
13
|
+
subcaption?: string;
|
|
14
|
+
summary_text?: string;
|
|
15
|
+
app_store_id?: number;
|
|
16
|
+
session_id?: string;
|
|
17
|
+
updates_message_guid?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ExtensionCardSendResponse {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
guid?: string;
|
|
22
|
+
message_id?: string;
|
|
23
|
+
session_id?: string;
|
|
24
|
+
balloon_bundle_id?: string;
|
|
25
|
+
live_layout?: boolean;
|
|
26
|
+
updated_message_guid?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function extensionCardRefusalReason(card: ExtensionCard, update?: ExtensionCardHandle): string | null;
|
|
29
|
+
export declare function extensionCardPreflight(capabilities: Pick<GatewayCapabilities, 'extensionCardSend' | 'extensionCardUpdate'>, card: ExtensionCard, update?: ExtensionCardHandle): {
|
|
30
|
+
result: ExtensionCardResult;
|
|
31
|
+
reason: string;
|
|
32
|
+
} | null;
|
|
33
|
+
export declare function extensionCardWireParams(chatId: number, card: ExtensionCard, update?: ExtensionCardHandle): ExtensionCardWireParams;
|
|
34
|
+
export declare function extensionCardResultFromResponse(response: ExtensionCardSendResponse, card: ExtensionCard, update?: ExtensionCardHandle): ExtensionCardResult;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { bridgeEffectStarted } from './chat-background.js';
|
|
2
|
+
import { extensionCardFailure } from './types.js';
|
|
3
|
+
// Counted in UTF-16 code units, which is what a JS string's `.length` counts
|
|
4
|
+
// and what `[NSString length]` counts on the helper's side of the bridge —
|
|
5
|
+
// the same field cap ExtensionCardRequest.swift enforces.
|
|
6
|
+
export const EXTENSION_CARD_MAX_FIELD_LENGTH = 4096;
|
|
7
|
+
// handleSendExtensionCard's returns BEFORE `dispatchIMMessageInChat` /
|
|
8
|
+
// `[chat sendMessage:]` (IMsgInjected.m), plus the builder's own refusals
|
|
9
|
+
// (IMsgExtensionCard.m), matched against `error.data` exactly as the group
|
|
10
|
+
// verbs match theirs. Deliberately absent: `send-extension-card failed:` —
|
|
11
|
+
// that wraps an NSException caught around the whole build-and-send block, so
|
|
12
|
+
// the text cannot prove which side of the send it was raised on and the result
|
|
13
|
+
// stays ambiguous.
|
|
14
|
+
export const EXTENSION_CARD_PROVEN_REJECTIONS = [
|
|
15
|
+
'Missing chatGuid',
|
|
16
|
+
'Extension balloon IMMessage initializer unavailable on this macOS',
|
|
17
|
+
'Associated-message IMMessage initializer unavailable on this macOS',
|
|
18
|
+
"teamId must be 10 characters of A-Z0-9 and bundleId reverse-DNS without ':'",
|
|
19
|
+
// Formatted with the guid appended, so the match has to be a prefix.
|
|
20
|
+
'Chat not found',
|
|
21
|
+
'Extension card spec is missing',
|
|
22
|
+
'Extension card url is required',
|
|
23
|
+
'Extension card appName is required',
|
|
24
|
+
'Extension card caption is required',
|
|
25
|
+
'Could not build extension card',
|
|
26
|
+
'Could not construct extension card IMMessage',
|
|
27
|
+
];
|
|
28
|
+
// The card call itself threw. `refused` only when the error proves the helper
|
|
29
|
+
// stopped before IMChat was invoked; everything else — a bridge timeout, an
|
|
30
|
+
// exception raised after the send, a dead rpc child — may already have put a
|
|
31
|
+
// card in the thread.
|
|
32
|
+
export function classifyExtensionCardFireFailure(err) {
|
|
33
|
+
return extensionCardFailure(bridgeEffectStarted(err, EXTENSION_CARD_PROVEN_REJECTIONS) ? 'fire-failed' : 'refused');
|
|
34
|
+
}
|
|
35
|
+
const TEAM_ID = /^[A-Z0-9]{10}$/;
|
|
36
|
+
// Reverse-DNS: the helper's own charset (letters, digits, '.', '-') plus at
|
|
37
|
+
// least one dot. A ':' would forge the `plugin:team:extension` structure of
|
|
38
|
+
// the balloon identifier and is excluded by the charset.
|
|
39
|
+
const EXTENSION_BUNDLE_ID = /^[A-Za-z0-9.-]+$/;
|
|
40
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
41
|
+
function requiredText(value, field) {
|
|
42
|
+
if (typeof value !== 'string' || !value)
|
|
43
|
+
return `${field} is required`;
|
|
44
|
+
return overlongText(value, field);
|
|
45
|
+
}
|
|
46
|
+
function overlongText(value, field) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return `${field} must be a string`;
|
|
49
|
+
// `.length` is UTF-16 code units, the unit the helper caps on.
|
|
50
|
+
if (value.length > EXTENSION_CARD_MAX_FIELD_LENGTH) {
|
|
51
|
+
return `${field} must be at most ${EXTENSION_CARD_MAX_FIELD_LENGTH} UTF-16 units`;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// Every field the native side would reject, checked here so a bad card is a
|
|
56
|
+
// proven no-op instead of a round trip. The one deliberate divergence is `url`:
|
|
57
|
+
// the helper accepts anything `URL(string:)` parses, which includes a
|
|
58
|
+
// scheme-relative string, while this requires an absolute URL. A card's URL is
|
|
59
|
+
// the extension's own state (a `data:` or `https:` URL in practice), never a
|
|
60
|
+
// relative path, so the stricter rule costs nothing real and keeps a caller
|
|
61
|
+
// from shipping a card whose URL the app cannot resolve.
|
|
62
|
+
export function extensionCardRefusalReason(card, update) {
|
|
63
|
+
if (card === null || typeof card !== 'object')
|
|
64
|
+
return 'card must be an object';
|
|
65
|
+
const teamIdProblem = requiredText(card.teamId, 'teamId');
|
|
66
|
+
if (teamIdProblem)
|
|
67
|
+
return teamIdProblem;
|
|
68
|
+
if (!TEAM_ID.test(card.teamId))
|
|
69
|
+
return 'teamId must be exactly 10 characters of A-Z and 0-9';
|
|
70
|
+
const bundleProblem = requiredText(card.extensionBundleId, 'extensionBundleId');
|
|
71
|
+
if (bundleProblem)
|
|
72
|
+
return bundleProblem;
|
|
73
|
+
if (!EXTENSION_BUNDLE_ID.test(card.extensionBundleId) || !card.extensionBundleId.includes('.')) {
|
|
74
|
+
return "extensionBundleId must be reverse-DNS (letters, digits, '-', at least one '.') and must not contain ':'";
|
|
75
|
+
}
|
|
76
|
+
for (const field of ['appName', 'caption', 'url']) {
|
|
77
|
+
const problem = requiredText(card[field], field);
|
|
78
|
+
if (problem)
|
|
79
|
+
return problem;
|
|
80
|
+
}
|
|
81
|
+
if (!URL.canParse(card.url))
|
|
82
|
+
return 'url must be an absolute URL';
|
|
83
|
+
for (const field of ['subcaption', 'summaryText']) {
|
|
84
|
+
const value = card[field];
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
const problem = overlongText(value, field);
|
|
88
|
+
if (problem)
|
|
89
|
+
return problem;
|
|
90
|
+
}
|
|
91
|
+
if (card.appStoreId !== undefined && (!Number.isSafeInteger(card.appStoreId) || card.appStoreId <= 0)) {
|
|
92
|
+
return 'appStoreId must be a positive integer';
|
|
93
|
+
}
|
|
94
|
+
// Strict, like the native handler: a live_layout that cannot be read must
|
|
95
|
+
// not quietly become the default, because the two values render differently
|
|
96
|
+
// on the recipient's phone.
|
|
97
|
+
if (card.liveLayout !== undefined && typeof card.liveLayout !== 'boolean') {
|
|
98
|
+
return 'liveLayout must be a boolean';
|
|
99
|
+
}
|
|
100
|
+
if (update === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
if (update === null || typeof update !== 'object')
|
|
103
|
+
return 'update must be an ExtensionCardHandle';
|
|
104
|
+
// Both halves or neither: the session id alone starts a second card in the
|
|
105
|
+
// same session rather than replacing anything, and a guid alone is refused
|
|
106
|
+
// by the helper.
|
|
107
|
+
if (typeof update.sessionId !== 'string' || !UUID.test(update.sessionId)) {
|
|
108
|
+
return 'update.sessionId must be the UUID a previous send returned';
|
|
109
|
+
}
|
|
110
|
+
if (typeof update.firstCardMessageGuid !== 'string' || !UUID.test(update.firstCardMessageGuid)) {
|
|
111
|
+
return "update.firstCardMessageGuid must be the FIRST card's message guid, as a UUID";
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// The whole sendExtensionCard preflight, shared by both gateways so parity is
|
|
116
|
+
// structural: the capability gates, then the field rules. Returns the result
|
|
117
|
+
// to answer with plus a line to log, or null to fire. Nothing has been sent at
|
|
118
|
+
// any exit here, so every one of them is `effectStarted:false`.
|
|
119
|
+
export function extensionCardPreflight(capabilities, card, update) {
|
|
120
|
+
if (!capabilities.extensionCardSend) {
|
|
121
|
+
return {
|
|
122
|
+
result: extensionCardFailure('unsupported'),
|
|
123
|
+
reason: 'bridge does not advertise extensionCardSend',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// An update is a send plus the associated-message initializer, so it needs
|
|
127
|
+
// both markers; a host can advertise the first without the second.
|
|
128
|
+
if (update !== undefined && !capabilities.extensionCardUpdate) {
|
|
129
|
+
return {
|
|
130
|
+
result: extensionCardFailure('unsupported'),
|
|
131
|
+
reason: 'bridge does not advertise extensionCardUpdate',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const refusal = extensionCardRefusalReason(card, update);
|
|
135
|
+
return refusal ? { result: extensionCardFailure('refused'), reason: refusal } : null;
|
|
136
|
+
}
|
|
137
|
+
export function extensionCardWireParams(chatId, card, update) {
|
|
138
|
+
return {
|
|
139
|
+
chat_id: chatId,
|
|
140
|
+
team_id: card.teamId,
|
|
141
|
+
extension_bundle_id: card.extensionBundleId,
|
|
142
|
+
app_name: card.appName,
|
|
143
|
+
caption: card.caption,
|
|
144
|
+
url: card.url,
|
|
145
|
+
// Sent explicitly rather than left to the native default, so the value
|
|
146
|
+
// that shaped the send is the value that comes back in the result.
|
|
147
|
+
live_layout: card.liveLayout ?? true,
|
|
148
|
+
...(card.subcaption !== undefined ? { subcaption: card.subcaption } : {}),
|
|
149
|
+
...(card.summaryText !== undefined ? { summary_text: card.summaryText } : {}),
|
|
150
|
+
...(card.appStoreId !== undefined ? { app_store_id: card.appStoreId } : {}),
|
|
151
|
+
...(update ? { session_id: update.sessionId, updates_message_guid: update.firstCardMessageGuid } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// Build the caller's result out of the host's response. Shared by both
|
|
155
|
+
// gateways, which is what keeps the first-card rule from drifting: on an
|
|
156
|
+
// update `result.handle` is the one that was passed IN, never one built from
|
|
157
|
+
// this row's guid, so `handle = result.handle` after every update still points
|
|
158
|
+
// at the session's first card.
|
|
159
|
+
export function extensionCardResultFromResponse(response, card, update) {
|
|
160
|
+
// The native handler answers `ok:true` on every path that reaches a result
|
|
161
|
+
// and raises a JSON-RPC error otherwise, so a falsy `ok` is a host the SDK
|
|
162
|
+
// cannot read. It was invoked, so it is ambiguous, not a refusal.
|
|
163
|
+
if (!response.ok)
|
|
164
|
+
return extensionCardFailure('fire-failed');
|
|
165
|
+
const messageGuid = response.guid || response.message_id || undefined;
|
|
166
|
+
const sessionId = response.session_id || update?.sessionId || undefined;
|
|
167
|
+
// An update re-uses the handle it was given. A first send can only mint one
|
|
168
|
+
// when Messages actually exposed the new row's guid; `updatable:false` says
|
|
169
|
+
// that card is unreachable forever rather than handing back a guid we do not
|
|
170
|
+
// have.
|
|
171
|
+
const handle = update ?? (sessionId && messageGuid ? { sessionId, firstCardMessageGuid: messageGuid } : undefined);
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
...(sessionId ? { sessionId } : {}),
|
|
175
|
+
...(messageGuid ? { messageGuid } : {}),
|
|
176
|
+
...(handle ? { handle } : {}),
|
|
177
|
+
updatable: handle !== undefined,
|
|
178
|
+
...(response.balloon_bundle_id ? { balloonBundleId: response.balloon_bundle_id } : {}),
|
|
179
|
+
liveLayout: response.live_layout ?? card.liveLayout ?? true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=extension-card.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extension-card.js","sourceRoot":"","sources":["../../src/gateway/extension-card.ts"],"names":[],"mappings":"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"}
|
package/dist/gateway/fake.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MessageAttachment } from '../types.js';
|
|
2
|
-
import type { ChatBackgroundPreset, ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus, EditMessageResult, Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, GroupMutationResult, HandleCheck, ImsgMessage, LocationUpdate, MessageService, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, SharedLocation, TextFormatRange } from './types.js';
|
|
2
|
+
import type { ChatBackgroundPreset, ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus, EditMessageResult, ExtensionCard, ExtensionCardHandle, ExtensionCardResult, Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, GroupMutationResult, HandleCheck, ImsgMessage, LocationUpdate, MessageRemovalResult, MessageService, MultipartPart, MultipartResult, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, SharedLocation, TextFormatRange } from './types.js';
|
|
3
3
|
export interface FakeGatewayOptions {
|
|
4
4
|
latencyMs?: number;
|
|
5
5
|
capabilities?: Partial<GatewayCapabilities>;
|
|
@@ -76,8 +76,9 @@ export declare class FakeGateway implements Gateway {
|
|
|
76
76
|
aliasType?: 'phone' | 'email';
|
|
77
77
|
}): Promise<HandleCheck>;
|
|
78
78
|
private findInScanWindow;
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
private partCount;
|
|
80
|
+
tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean, partIndex?: number): Promise<ReactResult>;
|
|
81
|
+
emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean, partIndex?: number): Promise<ReactResult>;
|
|
81
82
|
sendRich(chatId: number, text: string, opts?: {
|
|
82
83
|
effect?: string;
|
|
83
84
|
replyToGuid?: string;
|
|
@@ -95,13 +96,17 @@ export declare class FakeGateway implements Gateway {
|
|
|
95
96
|
sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
|
|
96
97
|
votePoll(chatId: number, pollGuid: string, optionId: string): Promise<ReactResult>;
|
|
97
98
|
sendRichLink(chatId: number, _url: string): Promise<SendResult>;
|
|
99
|
+
sendExtensionCard(chatId: number, card: ExtensionCard, opts?: {
|
|
100
|
+
update?: ExtensionCardHandle;
|
|
101
|
+
}): Promise<ExtensionCardResult>;
|
|
102
|
+
sendMultipart(chatId: number, parts: readonly MultipartPart[], opts?: {
|
|
103
|
+
replyToGuid?: string;
|
|
104
|
+
effect?: string;
|
|
105
|
+
subject?: string;
|
|
106
|
+
}): Promise<MultipartResult>;
|
|
98
107
|
editMessage(chatId: number, targetGuid: string, text: string): Promise<EditMessageResult>;
|
|
99
|
-
unsendMessage(chatId: number, targetGuid: string): Promise<
|
|
100
|
-
|
|
101
|
-
}>;
|
|
102
|
-
deleteMessage(chatId: number, targetGuid: string): Promise<{
|
|
103
|
-
ok: boolean;
|
|
104
|
-
}>;
|
|
108
|
+
unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
|
|
109
|
+
deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
|
|
105
110
|
setTyping(chatId: number, _on: boolean): Promise<{
|
|
106
111
|
ok: boolean;
|
|
107
112
|
}>;
|
|
@@ -141,6 +146,7 @@ export declare class FakeGateway implements Gateway {
|
|
|
141
146
|
reaction?: Reaction;
|
|
142
147
|
emoji?: string;
|
|
143
148
|
targetGuid?: string;
|
|
149
|
+
partIndex?: number;
|
|
144
150
|
}): ImsgMessage;
|
|
145
151
|
injectPollCreate(params: {
|
|
146
152
|
chatId?: number;
|
package/dist/gateway/fake.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { aggregatePolls } from '../polls.js';
|
|
2
|
-
import {
|
|
3
|
+
import { extensionCardPreflight, extensionCardResultFromResponse } from './extension-card.js';
|
|
4
|
+
import { multipartPreflight, multipartResultFromResponse } from './multipart.js';
|
|
5
|
+
import { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, extensionCardFailure, groupMutationFailure, messageRemovalFailure, multipartFailure, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, removalWouldBreakGroupMinimum, sendRichPreflight, tapbackFailure, TIER2_HISTORY_SCAN_WINDOW, } from './types.js';
|
|
6
|
+
import { reactionOnPart, tapbackPartRefusal, toReactionNote } from './tapback.js';
|
|
3
7
|
import { locationUpdateFromWire, sharedLocationToWire } from './locations.js';
|
|
4
8
|
import { classifyGroupChatResolution } from './portable-chat.js';
|
|
5
9
|
// How far back recentReactions() scans, mirroring ImsgGateway's
|
|
@@ -145,12 +149,20 @@ export class FakeGateway {
|
|
|
145
149
|
// Maintains the target message's reactions[] aggregate (the state the real
|
|
146
150
|
// gateway reads from history) and emits the reaction event (the row the
|
|
147
151
|
// real gateway sees on the watch stream).
|
|
148
|
-
|
|
152
|
+
//
|
|
153
|
+
// `partIndex` (M2B-45) is part of a reaction's IDENTITY, not a detail on it:
|
|
154
|
+
// the same type on part 0 and part 1 is two separate reactions on the host,
|
|
155
|
+
// so the match below compares the part and the entry records it.
|
|
156
|
+
toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji, partIndex = 0) {
|
|
149
157
|
const target = this.messages.find((m) => m.guid === targetGuid);
|
|
150
158
|
if (!target)
|
|
151
159
|
throw new Error(`toggleReaction: no message with guid ${targetGuid}`);
|
|
152
160
|
target.reactions ??= [];
|
|
153
|
-
const existingIdx = target.reactions.findIndex((r) => r.type === type &&
|
|
161
|
+
const existingIdx = target.reactions.findIndex((r) => r.type === type &&
|
|
162
|
+
r.emoji === emoji &&
|
|
163
|
+
r.is_from_me === isFromMe &&
|
|
164
|
+
reactionOnPart(r, partIndex) &&
|
|
165
|
+
(isFromMe || r.sender === senderHandle));
|
|
154
166
|
const nowActive = existingIdx === -1;
|
|
155
167
|
const id = this.allocId();
|
|
156
168
|
const createdAt = new Date().toISOString();
|
|
@@ -162,6 +174,9 @@ export class FakeGateway {
|
|
|
162
174
|
is_from_me: isFromMe,
|
|
163
175
|
sender: senderHandle,
|
|
164
176
|
created_at: createdAt,
|
|
177
|
+
// Absent for part 0, exactly as the native emits it — a whole-message
|
|
178
|
+
// reaction carries no `target_part` and must not grow one here.
|
|
179
|
+
...(partIndex ? { target_part: partIndex } : {}),
|
|
165
180
|
};
|
|
166
181
|
target.reactions.push(entry);
|
|
167
182
|
}
|
|
@@ -183,6 +198,9 @@ export class FakeGateway {
|
|
|
183
198
|
reaction_type: type,
|
|
184
199
|
reaction_emoji: emoji,
|
|
185
200
|
is_reaction_add: nowActive,
|
|
201
|
+
// The event row carries the part under its own name, and
|
|
202
|
+
// `reacted_to_guid` stays the bare message guid (contract §2).
|
|
203
|
+
...(partIndex ? { reaction_target_part: partIndex } : {}),
|
|
186
204
|
reacted_to_guid: targetGuid,
|
|
187
205
|
created_at: createdAt,
|
|
188
206
|
};
|
|
@@ -336,7 +354,7 @@ export class FakeGateway {
|
|
|
336
354
|
continue;
|
|
337
355
|
for (const r of m.reactions) {
|
|
338
356
|
if (!r.is_from_me)
|
|
339
|
-
notes.push(
|
|
357
|
+
notes.push(toReactionNote(r));
|
|
340
358
|
}
|
|
341
359
|
}
|
|
342
360
|
return notes.sort((a, b) => b.id - a.id).slice(0, limit);
|
|
@@ -444,33 +462,69 @@ export class FakeGateway {
|
|
|
444
462
|
.slice(-HISTORY_SCAN_WINDOW)
|
|
445
463
|
.find((m) => m.guid === targetGuid);
|
|
446
464
|
}
|
|
465
|
+
// How many parts a fake message has (M2B-45). The host counts the message's
|
|
466
|
+
// real chat items; the fake counts its attachments, so a two-photo message
|
|
467
|
+
// has parts 0 and 1 and anything else has the single part 0. That is
|
|
468
|
+
// deliberately CONSERVATIVE: a mixed text+photos message really has more
|
|
469
|
+
// parts than this says, so the fake refuses some parts the host would
|
|
470
|
+
// accept, and never the other way round (parity rule 2 — the fake must not
|
|
471
|
+
// succeed at something the real path refuses).
|
|
472
|
+
//
|
|
473
|
+
// Left exactly as it was by M2B-55, deliberately. A row this gateway sent
|
|
474
|
+
// through sendMultipart carries no `attachments[]` — the send response
|
|
475
|
+
// reports none, and inventing them is what sendAttachment already refuses to
|
|
476
|
+
// do — so such a row counts 1 part however many it really had. That
|
|
477
|
+
// under-counts, which is the allowed direction: a part-level tapback on a
|
|
478
|
+
// fake-sent multipart message is refused where a host would accept it, and
|
|
479
|
+
// never the reverse. Counting `(text ? 1 : 0) + attachments.length` instead
|
|
480
|
+
// would be a contract change, and it needs the host run to prove the real
|
|
481
|
+
// part layout first.
|
|
482
|
+
partCount(target) {
|
|
483
|
+
return Math.max(1, target.attachments?.length ?? 0);
|
|
484
|
+
}
|
|
447
485
|
// Targeted tapback: any message guid (within the scan window above),
|
|
448
486
|
// explicit add/remove, works in groups. Same closed Reaction set as
|
|
449
487
|
// react(); the patched custom-emoji path is modeled separately below.
|
|
450
|
-
async tapback(chatId, targetGuid, reaction, remove = false) {
|
|
488
|
+
async tapback(chatId, targetGuid, reaction, remove = false, partIndex) {
|
|
451
489
|
await this.delay();
|
|
490
|
+
const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
|
|
491
|
+
if (partRefusal)
|
|
492
|
+
return partRefusal;
|
|
493
|
+
// Every failure the fake can reach is a proven no-op (same rule as the
|
|
494
|
+
// group verbs below): nothing fired, so it says so.
|
|
452
495
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
453
496
|
if (!target)
|
|
454
|
-
return
|
|
455
|
-
|
|
497
|
+
return tapbackFailure('refused');
|
|
498
|
+
// The bridge refuses a part the message does not have before sending
|
|
499
|
+
// anything (`Message <guid> has no part <n>`), so the fake must too.
|
|
500
|
+
if (partIndex !== undefined && partIndex >= this.partCount(target))
|
|
501
|
+
return tapbackFailure('refused');
|
|
502
|
+
const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction && reactionOnPart(r, partIndex)));
|
|
456
503
|
if (remove ? !active : active) {
|
|
457
504
|
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
458
505
|
}
|
|
459
|
-
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
|
|
506
|
+
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction], partIndex);
|
|
460
507
|
return { ok: true };
|
|
461
508
|
}
|
|
462
|
-
async emojiTapback(chatId, targetGuid, emoji, remove = false) {
|
|
509
|
+
async emojiTapback(chatId, targetGuid, emoji, remove = false, partIndex) {
|
|
463
510
|
await this.delay();
|
|
464
|
-
if (!this.capabilities.emojiTapback
|
|
465
|
-
return
|
|
511
|
+
if (!this.capabilities.emojiTapback)
|
|
512
|
+
return tapbackFailure('unsupported');
|
|
513
|
+
if (!emoji)
|
|
514
|
+
return tapbackFailure('refused');
|
|
515
|
+
const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
|
|
516
|
+
if (partRefusal)
|
|
517
|
+
return partRefusal;
|
|
466
518
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
467
519
|
if (!target)
|
|
468
|
-
return
|
|
469
|
-
|
|
520
|
+
return tapbackFailure('refused');
|
|
521
|
+
if (partIndex !== undefined && partIndex >= this.partCount(target))
|
|
522
|
+
return tapbackFailure('refused');
|
|
523
|
+
const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji && reactionOnPart(r, partIndex)) ?? false;
|
|
470
524
|
if (remove ? !active : active) {
|
|
471
525
|
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
472
526
|
}
|
|
473
|
-
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji);
|
|
527
|
+
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji, partIndex);
|
|
474
528
|
return { ok: true };
|
|
475
529
|
}
|
|
476
530
|
// Targets an EXISTING chat only — no find-or-create `to:` form (contract §2).
|
|
@@ -585,6 +639,89 @@ export class FakeGateway {
|
|
|
585
639
|
this.emit(this.buildMessage(chat, { is_from_me: true }));
|
|
586
640
|
return { ok: true };
|
|
587
641
|
}
|
|
642
|
+
async sendExtensionCard(chatId, card, opts = {}) {
|
|
643
|
+
await this.delay();
|
|
644
|
+
// The same shared preflight ImsgGateway runs, so the capability gates and
|
|
645
|
+
// every field rule answer identically on both implementations.
|
|
646
|
+
const refusal = extensionCardPreflight(this.capabilities, card, opts.update);
|
|
647
|
+
if (refusal)
|
|
648
|
+
return refusal.result;
|
|
649
|
+
const chat = this.chats.get(chatId);
|
|
650
|
+
// No find-or-create, like every other tier-2 send. On the host this is the
|
|
651
|
+
// helper's "Chat not found", a proven pre-invoke rejection.
|
|
652
|
+
if (!chat)
|
|
653
|
+
return extensionCardFailure('refused');
|
|
654
|
+
// NOT a UUID by the fake's usual `fake-msg-<id>` convention: a real card's
|
|
655
|
+
// guid is a Messages UUID and the update path validates it as one, so a
|
|
656
|
+
// fake guid would make the update leg unreachable here while it works on a
|
|
657
|
+
// host. The fake must be neither easier nor harder than the Mac.
|
|
658
|
+
const guid = randomUUID().toUpperCase();
|
|
659
|
+
// The card lands as its own balloon row; ImsgMessage models nothing of the
|
|
660
|
+
// payload, so the fake emits the bare outbound row like sendLocationRequest
|
|
661
|
+
// and lets the shared result builder do the rest. An update emits a row
|
|
662
|
+
// too — on a host it is a real associated-message row, and the card it
|
|
663
|
+
// replaces is restamped rather than removed.
|
|
664
|
+
this.emit(this.buildMessage(chat, { is_from_me: true, guid }));
|
|
665
|
+
// Deliberately NOT checked: that `update` names a card this gateway sent,
|
|
666
|
+
// that it is the FIRST card of its session, or that the session exists.
|
|
667
|
+
// The Mac cannot check any of it — an update aimed at a previous update's
|
|
668
|
+
// guid arrives as a separate card with `ok:true` and a real guid — and a
|
|
669
|
+
// fake that caught it would hide the one mistake this verb is shaped
|
|
670
|
+
// around (see ExtensionCardHandle).
|
|
671
|
+
return extensionCardResultFromResponse({
|
|
672
|
+
ok: true,
|
|
673
|
+
guid,
|
|
674
|
+
session_id: opts.update?.sessionId ?? randomUUID(),
|
|
675
|
+
balloon_bundle_id: `com.apple.messages.MSMessageExtensionBalloonPlugin:${card.teamId}:${card.extensionBundleId}`,
|
|
676
|
+
live_layout: card.liveLayout ?? true,
|
|
677
|
+
...(opts.update ? { updated_message_guid: opts.update.firstCardMessageGuid } : {}),
|
|
678
|
+
}, card, opts.update);
|
|
679
|
+
}
|
|
680
|
+
// Several parts as ONE message (M2B-55) — one row, not one row per part,
|
|
681
|
+
// which is the whole point of the verb.
|
|
682
|
+
async sendMultipart(chatId, parts, opts = {}) {
|
|
683
|
+
await this.delay();
|
|
684
|
+
// The same shared preflight ImsgGateway runs, so the capability gates and
|
|
685
|
+
// every list rule answer identically on both implementations. A file part
|
|
686
|
+
// whose path does not exist is NOT checked here or there: the path belongs
|
|
687
|
+
// to the Mac, so a missing file is the helper's refusal.
|
|
688
|
+
const refusal = multipartPreflight(this.capabilities, parts);
|
|
689
|
+
if (refusal)
|
|
690
|
+
return refusal.result;
|
|
691
|
+
const chat = this.chats.get(chatId);
|
|
692
|
+
// No find-or-create, like every other tier-2 send. On the host this is the
|
|
693
|
+
// helper's "Chat not found", a proven pre-invoke rejection.
|
|
694
|
+
if (!chat)
|
|
695
|
+
return multipartFailure('refused');
|
|
696
|
+
// The helper validates every text part's mentions against the LIVE
|
|
697
|
+
// participant list before a single transfer is staged, and refuses them all
|
|
698
|
+
// when that list is unreadable — the same rule sendRich models above, so a
|
|
699
|
+
// mention that the Mac would refuse must not go through here either.
|
|
700
|
+
const members = this.groupParticipantsUnreported.has(chatId) ? [] : chat.participants;
|
|
701
|
+
const mentionsAStranger = parts.some((part) => part.kind === 'text' &&
|
|
702
|
+
part.textFormatting?.some((r) => r.mention !== undefined && !members.includes(r.mention)));
|
|
703
|
+
if (mentionsAStranger)
|
|
704
|
+
return multipartFailure('refused');
|
|
705
|
+
// ONE bare outbound row. The real row's text is Apple's own concatenation
|
|
706
|
+
// of the parts, object-replacement placeholders for the files included, and
|
|
707
|
+
// its `attachments[]` only appears on a later history/watch row — the send
|
|
708
|
+
// response reports neither, so neither is invented here (the rule
|
|
709
|
+
// sendAttachment states). Effect, subject and formatting have no fields in
|
|
710
|
+
// imsg's JSON message shape at all: write-only on the real path too.
|
|
711
|
+
const text = parts
|
|
712
|
+
.filter((part) => part.kind === 'text')
|
|
713
|
+
.map((part) => part.text)
|
|
714
|
+
.join('\n');
|
|
715
|
+
const msg = this.buildMessage(chat, {
|
|
716
|
+
is_from_me: true,
|
|
717
|
+
...(text ? { text } : {}),
|
|
718
|
+
reply_to_guid: opts.replyToGuid,
|
|
719
|
+
});
|
|
720
|
+
this.emit(msg);
|
|
721
|
+
// Minted like the host's own response and mapped by the shared builder, so
|
|
722
|
+
// neither implementation can grow a field the other cannot report.
|
|
723
|
+
return multipartResultFromResponse({ ok: true, guid: msg.guid, parts_count: parts.length });
|
|
724
|
+
}
|
|
588
725
|
// Edits mutate the target row's text IN PLACE — no new row, no subscribe()
|
|
589
726
|
// event (contract §2: this is what breaks the "same guid = immutable
|
|
590
727
|
// content" assumption elsewhere in the codebase).
|
|
@@ -603,11 +740,18 @@ export class FakeGateway {
|
|
|
603
740
|
return { ok: true };
|
|
604
741
|
}
|
|
605
742
|
// Live parity: unsend leaves a tombstone row whose text is cleared.
|
|
743
|
+
//
|
|
744
|
+
// The fake has no bridge to time out on, so it can only ever reach the
|
|
745
|
+
// proven half of the vocabulary. A guid outside the scan window is the one
|
|
746
|
+
// failure it can model — and it is `refused` here, not the `unverified` the
|
|
747
|
+
// real path reports for the same input: the real verb fires before it
|
|
748
|
+
// discovers the ceiling, the fake never fires at all. That is the fake
|
|
749
|
+
// staying INSIDE the real gateway's certainty, not exceeding it.
|
|
606
750
|
async unsendMessage(chatId, targetGuid) {
|
|
607
751
|
await this.delay();
|
|
608
752
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
609
753
|
if (!target)
|
|
610
|
-
return
|
|
754
|
+
return messageRemovalFailure('refused');
|
|
611
755
|
target.text = undefined;
|
|
612
756
|
return { ok: true };
|
|
613
757
|
}
|
|
@@ -618,7 +762,7 @@ export class FakeGateway {
|
|
|
618
762
|
await this.delay();
|
|
619
763
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
620
764
|
if (!target)
|
|
621
|
-
return
|
|
765
|
+
return messageRemovalFailure('refused');
|
|
622
766
|
return { ok: true };
|
|
623
767
|
}
|
|
624
768
|
// Fire-and-forget: no observable effect through history() or this
|
|
@@ -855,6 +999,11 @@ export class FakeGateway {
|
|
|
855
999
|
// Simulate another participant tapback-reacting (their side can target any
|
|
856
1000
|
// message, same as real Messages.app — only OUR react() is most-recent-
|
|
857
1001
|
// incoming-only).
|
|
1002
|
+
// `partIndex` reacts to one part of the target (M2B-45), the way a phone
|
|
1003
|
+
// reacts to the second photo of two. Unlike our own tapback() this is a
|
|
1004
|
+
// fixture, not a verb: it models what the other side's device did, so it is
|
|
1005
|
+
// not capability-gated — the reaction exists in the thread whatever THIS
|
|
1006
|
+
// host can send.
|
|
858
1007
|
injectReaction(params) {
|
|
859
1008
|
if ((params.reaction === undefined) === (params.emoji === undefined)) {
|
|
860
1009
|
throw new Error('injectReaction: provide exactly one of reaction or emoji');
|
|
@@ -864,7 +1013,7 @@ export class FakeGateway {
|
|
|
864
1013
|
throw new Error('injectReaction: chat has no message to react to');
|
|
865
1014
|
const type = params.reaction ?? 'custom';
|
|
866
1015
|
const emoji = params.emoji ?? REACTION_EMOJI_BY_TYPE[params.reaction];
|
|
867
|
-
return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji);
|
|
1016
|
+
return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji, params.partIndex);
|
|
868
1017
|
}
|
|
869
1018
|
// ---- native poll fixtures (contract §2 "Native poll readback") ----
|
|
870
1019
|
// Polls are created through sendPoll(), a manual host-Mac `imsg poll send`
|