@mapier/imsg-sdk 0.5.0 → 0.6.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 +10 -9
- package/dist/gateway/fake.js +110 -17
- package/dist/gateway/fake.js.map +1 -1
- package/dist/gateway/imsg.d.ts +9 -9
- package/dist/gateway/imsg.js +108 -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/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 +48 -9
- package/dist/gateway/types.js +16 -0
- package/dist/gateway/types.js.map +1 -1
- package/dist/imsg/rpc.d.ts +4 -2
- package/dist/imsg/rpc.js +35 -4
- package/dist/imsg/rpc.js.map +1 -1
- package/dist/imsg/status.js +15 -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 +2 -0
- package/docs/gateway-contract.md +397 -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, 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,12 @@ 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>;
|
|
98
102
|
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
|
-
}>;
|
|
103
|
+
unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
|
|
104
|
+
deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
|
|
105
105
|
setTyping(chatId: number, _on: boolean): Promise<{
|
|
106
106
|
ok: boolean;
|
|
107
107
|
}>;
|
|
@@ -141,6 +141,7 @@ export declare class FakeGateway implements Gateway {
|
|
|
141
141
|
reaction?: Reaction;
|
|
142
142
|
emoji?: string;
|
|
143
143
|
targetGuid?: string;
|
|
144
|
+
partIndex?: number;
|
|
144
145
|
}): ImsgMessage;
|
|
145
146
|
injectPollCreate(params: {
|
|
146
147
|
chatId?: number;
|
package/dist/gateway/fake.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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 { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, extensionCardFailure, groupMutationFailure, messageRemovalFailure, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, removalWouldBreakGroupMinimum, sendRichPreflight, tapbackFailure, TIER2_HISTORY_SCAN_WINDOW, } from './types.js';
|
|
5
|
+
import { reactionOnPart, tapbackPartRefusal, toReactionNote } from './tapback.js';
|
|
3
6
|
import { locationUpdateFromWire, sharedLocationToWire } from './locations.js';
|
|
4
7
|
import { classifyGroupChatResolution } from './portable-chat.js';
|
|
5
8
|
// How far back recentReactions() scans, mirroring ImsgGateway's
|
|
@@ -145,12 +148,20 @@ export class FakeGateway {
|
|
|
145
148
|
// Maintains the target message's reactions[] aggregate (the state the real
|
|
146
149
|
// gateway reads from history) and emits the reaction event (the row the
|
|
147
150
|
// real gateway sees on the watch stream).
|
|
148
|
-
|
|
151
|
+
//
|
|
152
|
+
// `partIndex` (M2B-45) is part of a reaction's IDENTITY, not a detail on it:
|
|
153
|
+
// the same type on part 0 and part 1 is two separate reactions on the host,
|
|
154
|
+
// so the match below compares the part and the entry records it.
|
|
155
|
+
toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji, partIndex = 0) {
|
|
149
156
|
const target = this.messages.find((m) => m.guid === targetGuid);
|
|
150
157
|
if (!target)
|
|
151
158
|
throw new Error(`toggleReaction: no message with guid ${targetGuid}`);
|
|
152
159
|
target.reactions ??= [];
|
|
153
|
-
const existingIdx = target.reactions.findIndex((r) => r.type === type &&
|
|
160
|
+
const existingIdx = target.reactions.findIndex((r) => r.type === type &&
|
|
161
|
+
r.emoji === emoji &&
|
|
162
|
+
r.is_from_me === isFromMe &&
|
|
163
|
+
reactionOnPart(r, partIndex) &&
|
|
164
|
+
(isFromMe || r.sender === senderHandle));
|
|
154
165
|
const nowActive = existingIdx === -1;
|
|
155
166
|
const id = this.allocId();
|
|
156
167
|
const createdAt = new Date().toISOString();
|
|
@@ -162,6 +173,9 @@ export class FakeGateway {
|
|
|
162
173
|
is_from_me: isFromMe,
|
|
163
174
|
sender: senderHandle,
|
|
164
175
|
created_at: createdAt,
|
|
176
|
+
// Absent for part 0, exactly as the native emits it — a whole-message
|
|
177
|
+
// reaction carries no `target_part` and must not grow one here.
|
|
178
|
+
...(partIndex ? { target_part: partIndex } : {}),
|
|
165
179
|
};
|
|
166
180
|
target.reactions.push(entry);
|
|
167
181
|
}
|
|
@@ -183,6 +197,9 @@ export class FakeGateway {
|
|
|
183
197
|
reaction_type: type,
|
|
184
198
|
reaction_emoji: emoji,
|
|
185
199
|
is_reaction_add: nowActive,
|
|
200
|
+
// The event row carries the part under its own name, and
|
|
201
|
+
// `reacted_to_guid` stays the bare message guid (contract §2).
|
|
202
|
+
...(partIndex ? { reaction_target_part: partIndex } : {}),
|
|
186
203
|
reacted_to_guid: targetGuid,
|
|
187
204
|
created_at: createdAt,
|
|
188
205
|
};
|
|
@@ -336,7 +353,7 @@ export class FakeGateway {
|
|
|
336
353
|
continue;
|
|
337
354
|
for (const r of m.reactions) {
|
|
338
355
|
if (!r.is_from_me)
|
|
339
|
-
notes.push(
|
|
356
|
+
notes.push(toReactionNote(r));
|
|
340
357
|
}
|
|
341
358
|
}
|
|
342
359
|
return notes.sort((a, b) => b.id - a.id).slice(0, limit);
|
|
@@ -444,33 +461,59 @@ export class FakeGateway {
|
|
|
444
461
|
.slice(-HISTORY_SCAN_WINDOW)
|
|
445
462
|
.find((m) => m.guid === targetGuid);
|
|
446
463
|
}
|
|
464
|
+
// How many parts a fake message has (M2B-45). The host counts the message's
|
|
465
|
+
// real chat items; the fake counts its attachments, so a two-photo message
|
|
466
|
+
// has parts 0 and 1 and anything else has the single part 0. That is
|
|
467
|
+
// deliberately CONSERVATIVE: a mixed text+photos message really has more
|
|
468
|
+
// parts than this says, so the fake refuses some parts the host would
|
|
469
|
+
// accept, and never the other way round (parity rule 2 — the fake must not
|
|
470
|
+
// succeed at something the real path refuses).
|
|
471
|
+
partCount(target) {
|
|
472
|
+
return Math.max(1, target.attachments?.length ?? 0);
|
|
473
|
+
}
|
|
447
474
|
// Targeted tapback: any message guid (within the scan window above),
|
|
448
475
|
// explicit add/remove, works in groups. Same closed Reaction set as
|
|
449
476
|
// react(); the patched custom-emoji path is modeled separately below.
|
|
450
|
-
async tapback(chatId, targetGuid, reaction, remove = false) {
|
|
477
|
+
async tapback(chatId, targetGuid, reaction, remove = false, partIndex) {
|
|
451
478
|
await this.delay();
|
|
479
|
+
const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
|
|
480
|
+
if (partRefusal)
|
|
481
|
+
return partRefusal;
|
|
482
|
+
// Every failure the fake can reach is a proven no-op (same rule as the
|
|
483
|
+
// group verbs below): nothing fired, so it says so.
|
|
452
484
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
453
485
|
if (!target)
|
|
454
|
-
return
|
|
455
|
-
|
|
486
|
+
return tapbackFailure('refused');
|
|
487
|
+
// The bridge refuses a part the message does not have before sending
|
|
488
|
+
// anything (`Message <guid> has no part <n>`), so the fake must too.
|
|
489
|
+
if (partIndex !== undefined && partIndex >= this.partCount(target))
|
|
490
|
+
return tapbackFailure('refused');
|
|
491
|
+
const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction && reactionOnPart(r, partIndex)));
|
|
456
492
|
if (remove ? !active : active) {
|
|
457
493
|
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
458
494
|
}
|
|
459
|
-
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
|
|
495
|
+
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction], partIndex);
|
|
460
496
|
return { ok: true };
|
|
461
497
|
}
|
|
462
|
-
async emojiTapback(chatId, targetGuid, emoji, remove = false) {
|
|
498
|
+
async emojiTapback(chatId, targetGuid, emoji, remove = false, partIndex) {
|
|
463
499
|
await this.delay();
|
|
464
|
-
if (!this.capabilities.emojiTapback
|
|
465
|
-
return
|
|
500
|
+
if (!this.capabilities.emojiTapback)
|
|
501
|
+
return tapbackFailure('unsupported');
|
|
502
|
+
if (!emoji)
|
|
503
|
+
return tapbackFailure('refused');
|
|
504
|
+
const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
|
|
505
|
+
if (partRefusal)
|
|
506
|
+
return partRefusal;
|
|
466
507
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
467
508
|
if (!target)
|
|
468
|
-
return
|
|
469
|
-
|
|
509
|
+
return tapbackFailure('refused');
|
|
510
|
+
if (partIndex !== undefined && partIndex >= this.partCount(target))
|
|
511
|
+
return tapbackFailure('refused');
|
|
512
|
+
const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji && reactionOnPart(r, partIndex)) ?? false;
|
|
470
513
|
if (remove ? !active : active) {
|
|
471
514
|
return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
|
|
472
515
|
}
|
|
473
|
-
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji);
|
|
516
|
+
this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji, partIndex);
|
|
474
517
|
return { ok: true };
|
|
475
518
|
}
|
|
476
519
|
// Targets an EXISTING chat only — no find-or-create `to:` form (contract §2).
|
|
@@ -585,6 +628,44 @@ export class FakeGateway {
|
|
|
585
628
|
this.emit(this.buildMessage(chat, { is_from_me: true }));
|
|
586
629
|
return { ok: true };
|
|
587
630
|
}
|
|
631
|
+
async sendExtensionCard(chatId, card, opts = {}) {
|
|
632
|
+
await this.delay();
|
|
633
|
+
// The same shared preflight ImsgGateway runs, so the capability gates and
|
|
634
|
+
// every field rule answer identically on both implementations.
|
|
635
|
+
const refusal = extensionCardPreflight(this.capabilities, card, opts.update);
|
|
636
|
+
if (refusal)
|
|
637
|
+
return refusal.result;
|
|
638
|
+
const chat = this.chats.get(chatId);
|
|
639
|
+
// No find-or-create, like every other tier-2 send. On the host this is the
|
|
640
|
+
// helper's "Chat not found", a proven pre-invoke rejection.
|
|
641
|
+
if (!chat)
|
|
642
|
+
return extensionCardFailure('refused');
|
|
643
|
+
// NOT a UUID by the fake's usual `fake-msg-<id>` convention: a real card's
|
|
644
|
+
// guid is a Messages UUID and the update path validates it as one, so a
|
|
645
|
+
// fake guid would make the update leg unreachable here while it works on a
|
|
646
|
+
// host. The fake must be neither easier nor harder than the Mac.
|
|
647
|
+
const guid = randomUUID().toUpperCase();
|
|
648
|
+
// The card lands as its own balloon row; ImsgMessage models nothing of the
|
|
649
|
+
// payload, so the fake emits the bare outbound row like sendLocationRequest
|
|
650
|
+
// and lets the shared result builder do the rest. An update emits a row
|
|
651
|
+
// too — on a host it is a real associated-message row, and the card it
|
|
652
|
+
// replaces is restamped rather than removed.
|
|
653
|
+
this.emit(this.buildMessage(chat, { is_from_me: true, guid }));
|
|
654
|
+
// Deliberately NOT checked: that `update` names a card this gateway sent,
|
|
655
|
+
// that it is the FIRST card of its session, or that the session exists.
|
|
656
|
+
// The Mac cannot check any of it — an update aimed at a previous update's
|
|
657
|
+
// guid arrives as a separate card with `ok:true` and a real guid — and a
|
|
658
|
+
// fake that caught it would hide the one mistake this verb is shaped
|
|
659
|
+
// around (see ExtensionCardHandle).
|
|
660
|
+
return extensionCardResultFromResponse({
|
|
661
|
+
ok: true,
|
|
662
|
+
guid,
|
|
663
|
+
session_id: opts.update?.sessionId ?? randomUUID(),
|
|
664
|
+
balloon_bundle_id: `com.apple.messages.MSMessageExtensionBalloonPlugin:${card.teamId}:${card.extensionBundleId}`,
|
|
665
|
+
live_layout: card.liveLayout ?? true,
|
|
666
|
+
...(opts.update ? { updated_message_guid: opts.update.firstCardMessageGuid } : {}),
|
|
667
|
+
}, card, opts.update);
|
|
668
|
+
}
|
|
588
669
|
// Edits mutate the target row's text IN PLACE — no new row, no subscribe()
|
|
589
670
|
// event (contract §2: this is what breaks the "same guid = immutable
|
|
590
671
|
// content" assumption elsewhere in the codebase).
|
|
@@ -603,11 +684,18 @@ export class FakeGateway {
|
|
|
603
684
|
return { ok: true };
|
|
604
685
|
}
|
|
605
686
|
// Live parity: unsend leaves a tombstone row whose text is cleared.
|
|
687
|
+
//
|
|
688
|
+
// The fake has no bridge to time out on, so it can only ever reach the
|
|
689
|
+
// proven half of the vocabulary. A guid outside the scan window is the one
|
|
690
|
+
// failure it can model — and it is `refused` here, not the `unverified` the
|
|
691
|
+
// real path reports for the same input: the real verb fires before it
|
|
692
|
+
// discovers the ceiling, the fake never fires at all. That is the fake
|
|
693
|
+
// staying INSIDE the real gateway's certainty, not exceeding it.
|
|
606
694
|
async unsendMessage(chatId, targetGuid) {
|
|
607
695
|
await this.delay();
|
|
608
696
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
609
697
|
if (!target)
|
|
610
|
-
return
|
|
698
|
+
return messageRemovalFailure('refused');
|
|
611
699
|
target.text = undefined;
|
|
612
700
|
return { ok: true };
|
|
613
701
|
}
|
|
@@ -618,7 +706,7 @@ export class FakeGateway {
|
|
|
618
706
|
await this.delay();
|
|
619
707
|
const target = this.findInScanWindow(chatId, targetGuid);
|
|
620
708
|
if (!target)
|
|
621
|
-
return
|
|
709
|
+
return messageRemovalFailure('refused');
|
|
622
710
|
return { ok: true };
|
|
623
711
|
}
|
|
624
712
|
// Fire-and-forget: no observable effect through history() or this
|
|
@@ -855,6 +943,11 @@ export class FakeGateway {
|
|
|
855
943
|
// Simulate another participant tapback-reacting (their side can target any
|
|
856
944
|
// message, same as real Messages.app — only OUR react() is most-recent-
|
|
857
945
|
// incoming-only).
|
|
946
|
+
// `partIndex` reacts to one part of the target (M2B-45), the way a phone
|
|
947
|
+
// reacts to the second photo of two. Unlike our own tapback() this is a
|
|
948
|
+
// fixture, not a verb: it models what the other side's device did, so it is
|
|
949
|
+
// not capability-gated — the reaction exists in the thread whatever THIS
|
|
950
|
+
// host can send.
|
|
858
951
|
injectReaction(params) {
|
|
859
952
|
if ((params.reaction === undefined) === (params.emoji === undefined)) {
|
|
860
953
|
throw new Error('injectReaction: provide exactly one of reaction or emoji');
|
|
@@ -864,7 +957,7 @@ export class FakeGateway {
|
|
|
864
957
|
throw new Error('injectReaction: chat has no message to react to');
|
|
865
958
|
const type = params.reaction ?? 'custom';
|
|
866
959
|
const emoji = params.emoji ?? REACTION_EMOJI_BY_TYPE[params.reaction];
|
|
867
|
-
return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji);
|
|
960
|
+
return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji, params.partIndex);
|
|
868
961
|
}
|
|
869
962
|
// ---- native poll fixtures (contract §2 "Native poll readback") ----
|
|
870
963
|
// Polls are created through sendPoll(), a manual host-Mac `imsg poll send`
|