@mapier/imsg-sdk 0.6.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.

@@ -66,6 +66,7 @@ interface Gateway {
66
66
  locationRequest: boolean;
67
67
  extensionCardSend: boolean;
68
68
  extensionCardUpdate: boolean;
69
+ multipartSend: boolean;
69
70
  };
70
71
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
71
72
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -117,6 +118,11 @@ interface Gateway {
117
118
  card: ExtensionCard,
118
119
  opts?: { update?: ExtensionCardHandle },
119
120
  ): Promise<ExtensionCardResult>;
121
+ sendMultipart(
122
+ chatId: number,
123
+ parts: readonly MultipartPart[],
124
+ opts?: { replyToGuid?: string; effect?: string; subject?: string },
125
+ ): Promise<MultipartResult>;
120
126
  editMessage(chatId: number, targetGuid: string, text: string): Promise<{
121
127
  ok: boolean;
122
128
  skipped?: 'unchanged';
@@ -266,6 +272,22 @@ interface ExtensionCardResult {
266
272
  effectStarted?: boolean;
267
273
  reason?: GroupMutationFailure; // only unsupported / refused / fire-failed are reachable
268
274
  }
275
+
276
+ // sendMultipart (M2B-55). One message made of several parts, in the order
277
+ // given — that order is each part's NUMBER, the same number tapback's
278
+ // `partIndex` names. Audio is not a part: the voice flag is message-wide, so a
279
+ // voice note still goes alone through sendAttachment.
280
+ type MultipartPart =
281
+ | { kind: 'text'; text: string; textFormatting?: TextFormatRange[] } // the same ranges sendRich takes
282
+ | { kind: 'file'; filePath: string }; // a path on the HOST Mac
283
+
284
+ interface MultipartResult {
285
+ ok: boolean;
286
+ guid?: string; // absent when Messages had not exposed the row yet; never synthesized
287
+ partCount?: number; // the host's own count of the parts it built
288
+ effectStarted?: boolean;
289
+ reason?: GroupMutationFailure; // only unsupported / refused / fire-failed are reachable
290
+ }
269
291
  ```
270
292
 
271
293
  ### Tier 2 (bridge-backed)
@@ -305,6 +327,11 @@ Part-level tapbacks require `tapbackPart`, which the helper computes from the
305
327
  part's own chat item plus an associated-message initializer taking a range —
306
328
  and which no other tapback marker may stand in for, because a helper without it
307
329
  does not reject `part_index`, it writes a corrupt row (M2B-45, above).
330
+ Multipart sends require `multipartSend`, composed by the helper from the
331
+ file-transfer centre plus the attachment IMMessage initializer, and equally
332
+ un-substitutable: a helper predating file parts reads only each part's `text`,
333
+ so it DROPS every file part without a word and answers ok with a short
334
+ `parts_count` (M2B-55, below).
308
335
  Missing status, malformed JSON, or absent markers fail closed.
309
336
  `FakeGateway.capabilities` mirrors this gate and can disable the same surface
310
337
  for parity tests. A remote adapter (e.g. imsg-agent's connector) must gate on
@@ -592,6 +619,67 @@ prevent.
592
619
  builder's returns above the IMChat call; `send-extension-card failed:` is
593
620
  deliberately **not** among them, because that `@try` wraps the whole
594
621
  build-and-send block. FakeGateway reaches only the proven half.
622
+ - **`sendMultipart(chatId, parts, opts?)`** — `send.multipart`
623
+ (`RPCServer+MultipartHandler.swift` → `handleSendMultipart` in
624
+ `IMsgInjected.m`). Several parts as **one message**, targeting an **existing**
625
+ chat only. Gated on `multipartSend`, and the gate covers the whole verb
626
+ including a text-only list: the marker guards a silent LOSS rather than a
627
+ rejection (a helper predating file parts drops them and answers ok), so a
628
+ caller must never be left to discover mid-list that this host eats photos.
629
+
630
+ **What "one message" means on a phone** — proven on a real iPhone from the
631
+ lab Mac (macOS 26.6.2) on 2026-09-21, through the native CLI:
632
+ one notification and one `chat.db` row, **not** one bubble. Each part draws as
633
+ its own bubble, in the order given, and consecutive photos are gathered by the
634
+ receiving phone into a stack (five photos arrived as one stack labelled
635
+ "5 photos"). Two text parts in a row are fine — two bubbles — so, unlike
636
+ Linq, this verb allows them. A text part with a **trailing newline draws an
637
+ empty last line** in its bubble, so callers must not add one (the
638
+ single-attachment path's caption does, which is a native wart, not a model to
639
+ copy). **Part number = index in `parts`**, and that is exactly the
640
+ `partIndex` a later `tapback` names. Audio is not a part: the voice flag is
641
+ message-wide, so a voice note still goes alone through `sendAttachment`.
642
+
643
+ **`effect`, `subject` and `replyToGuid` are message-wide**, read by the
644
+ native handler exactly as `send.rich` reads them — IMCore has one of each per
645
+ message, never one per part. Per-part `textFormatting` is the `sendRich`
646
+ range vocabulary scoped to that part, offsets counted in that part's own
647
+ text; a mention still needs `mentionFormatting` and is `unsupported` without
648
+ it. Formatting on a **file** part is refused rather than dropped.
649
+
650
+ **Validation happens before the RPC**, mirroring the native pass that runs
651
+ before a single transfer is staged: a non-empty array, every entry an object
652
+ of a known `kind`, non-empty `text`, non-empty `filePath` carrying no newline
653
+ or NUL, per-part formatting through the same `textFormattingRefusal` sendRich
654
+ uses, and the Mac-side caps **100 parts / 40 file parts**
655
+ — plus one rule that exists only because of how this verb fails: a part
656
+ carrying **both** a `text` and a `filePath` is refused *before* its `kind` is
657
+ consulted, never resolved by the label. Whichever branch the label chose would
658
+ drop the other key without a word, and losing part content in silence is the
659
+ failure the whole verb exists to remove; it must not come back in through a
660
+ caller that names a part wrong. The native handler refuses it by index for the
661
+ same reason (an empty string counts as absent on both keys, on both sides)
662
+ (`MULTIPART_MAX_PARTS` / `MULTIPART_MAX_FILE_PARTS`, mirroring
663
+ `MultipartLimits` and `kMaxMultipartParts`). Each is a proven no-op
664
+ (`effectStarted:false`, `reason:'refused'`). Two native checks are
665
+ deliberately **not** mirrored: the 100 MiB total-byte cap and whether each
666
+ file exists. This SDK never touches the filesystem — the path belongs to the
667
+ Mac — so a local check would make the fake and the real path disagree in both
668
+ directions; a missing file is the helper's refusal, classified through
669
+ `MULTIPART_PROVEN_REJECTIONS`.
670
+
671
+ **No verification pass exists**, for the reason `sendAttachment` has none:
672
+ the synchronous response carries the message's guid, which is the proof to
673
+ read `sendStatus()` on, while the row Messages writes at dispatch says
674
+ nothing about the upload that follows (`is_sent` stays 0 until the bytes are
675
+ out). So only `unsupported` / `refused` / `fire-failed` are reachable.
676
+ `send-multipart failed:` is deliberately **not** a proven rejection: that
677
+ `@try` wraps the whole build-and-dispatch block. `partCount` is the **host's**
678
+ count of the parts it built and is never replaced with the caller's list
679
+ length — a count shorter than the list is precisely how a helper that dropped
680
+ file parts would show up. There is deliberately no numeric `id`: the native
681
+ result sets `message_id` to the message GUID, so no rowid exists on the real
682
+ path and FakeGateway must not hand back one either.
595
683
  - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
596
684
  (`handleMessageEdit`; imsg `docs/edit.md`). Capability-gated on the
597
685
  `editMessage` marker (see above): on macOS 27 the helper calls the
@@ -1209,6 +1297,49 @@ both markers read `true` on the current build — a host with `extensionCardSend
1209
1297
  and not `extensionCardUpdate` is possible by construction but has never been
1210
1298
  seen. There is no smoke leg for this verb yet; the host proof is driven by hand.
1211
1299
 
1300
+ **HOST-VERIFIED 2026-09-21 (lab Mac, macOS 26.6.2) — `sendMultipart` (M2B-55),
1301
+ through this SDK verb.** The native layer was already proven on a real iPhone
1302
+ the same day through the native CLI (Mapier-Labs/imsg#28): ten shapes — caption
1303
+ + photo, photo + caption, caption + 3 photos, alternating text and photos, two
1304
+ text parts, five photos, a 93 MiB ten-photo message — each arriving as a single
1305
+ notification with the parts in the order given and the photos stacked, which is
1306
+ where the "no trailing newline" and "consecutive text parts are fine" rules come
1307
+ from, and where the phone RENDERING of these shapes is settled. This run drove
1308
+ the gateway verb itself: the packed tarball of SDK `43b6454` installed into a
1309
+ scratch dir, `IMSG_BIN` pointed at the native build of imsg#28 head `f2a8bc6`
1310
+ (helper sha256 `3229d9dd…`). The phone rendering was not re-judged per SDK
1311
+ send — these legs prove the SDK's half, and `chat.db` is the oracle for it:
1312
+
1313
+ - `capabilities.multipartSend` and `capabilities.mentionFormatting` both read
1314
+ `true` off that build.
1315
+ - `[text, file, file, text]` → `{ok:true, guid, partCount:4}`, and that guid is
1316
+ **ONE** `chat.db` row with `part_count` 4, **two attachment joins**,
1317
+ `is_sent` 1, `is_delivered` 1, `error` 0. That pair is the load-bearing
1318
+ observation of the whole verb: a `partCount` of 4 arriving with both photos
1319
+ actually joined is the direct evidence that the file parts were not silently
1320
+ dropped — the failure the `multipartSend` marker exists to prevent, until now
1321
+ reasoned from the helper's source rather than seen.
1322
+ - `[text with a bold range, file]` with `effect` and `subject` →
1323
+ `{ok:true, guid, partCount:2}`; ONE row, `part_count` 2, one attachment,
1324
+ the subject set, and `expressive_send_style_id`
1325
+ `com.apple.messages.effect.CKConfettiEffect`, sent and delivered, `error` 0.
1326
+ So the per-part `text_formatting` and the message-wide `effect` / `subject`
1327
+ survive this SDK's wire shape into the fields the native handler's
1328
+ `send.rich` parsers write.
1329
+ - An empty list, a 41-file list, and `textFormatting` on a file part each
1330
+ answered `{ok:false, effectStarted:false, reason:'refused'}` — and the run
1331
+ wrote **exactly two rows in total**, so all three refusals fired nothing.
1332
+ That row count is the only way to prove `effectStarted:false`, which is a
1333
+ claim about what did NOT happen.
1334
+
1335
+ **Still NOT host-verified for M2B-55:** a confirmed mention inside a multipart
1336
+ text part (it needs a group thread, and the marker reading `true` is not the
1337
+ same as a mention landing); `replyToGuid` as an inline-reply target; and every
1338
+ string in `MULTIPART_PROVEN_REJECTIONS` — no send failed on the host, so the
1339
+ fire-failure classifier stays read-from-source and unit-tested only, unlike
1340
+ M2B-45's `'has no part '`, which its run happened to provoke. There is still no
1341
+ smoke leg for this verb; the host proof is driven by hand.
1342
+
1212
1343
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
1213
1344
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
1214
1345
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -1576,6 +1707,22 @@ a different product.
1576
1707
  implementations also refuse to invent a guid: when Messages exposes none, the
1577
1708
  success carries `updatable:false` and no handle rather than a synthesized one.
1578
1709
 
1710
+ - **A multipart send is ONE row, and the fake may under-count its parts —
1711
+ never over-count** (M2B-55). `sendMultipart` emits a single outbound row on
1712
+ both implementations, whatever the part list holds: one notification, one
1713
+ `chat.db` row, several bubbles. What the fake does not do is synthesize that
1714
+ row's inner shape. The real row's `text` is Apple's own concatenation of the
1715
+ parts with object-replacement placeholders where the files sit, and its
1716
+ `attachments[]` only appears on a later history/watch row — the send response
1717
+ reports neither, and inventing them is exactly what `sendAttachment` already
1718
+ refuses to do. The consequence is deliberate: `FakeGateway.partCount()` still
1719
+ counts attachments, so a fake-sent multipart row reports 1 part however many
1720
+ it really had, and a part-level tapback on it is refused where a host would
1721
+ accept it. That is the allowed direction (the fake must not succeed at what
1722
+ the real path refuses), and counting `(text ? 1 : 0) + attachments.length`
1723
+ instead is a contract change that needs the host run to prove the real part
1724
+ layout first.
1725
+
1579
1726
  - **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
1580
1727
  be able to emit the exact attachment metadata with `text: undefined`/empty so
1581
1728
  this path gets exercised — the agent currently ignores attachments gracefully
@@ -1737,6 +1884,12 @@ harness (`tsx --test`, see `package.json`). Required cases:
1737
1884
  `extensionCardSend` but not `extensionCardUpdate` sends but refuses the update. One case
1738
1885
  asserts a NEGATIVE: an update aimed at a previous update's guid is **not** caught — the
1739
1886
  fake must not be safer than the Mac, which cannot detect it either
1887
+ - `sendMultipart` lands text and file parts as ONE outbound row (not one per part), reports
1888
+ the host's `partCount` and a guid, and preserves an inline-reply guid alongside the
1889
+ message-wide effect and subject; an empty list, an empty text part, formatting on a file
1890
+ part, 41 file parts and an unknown chat are each `refused` with no row written, and a
1891
+ missing `multipartSend` marker is `unsupported` — including for a text-only list, which
1892
+ such a host could actually have sent
1740
1893
  - Fake-only restart coverage snapshots and restores a world, then proves
1741
1894
  `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1742
1895
  guid sequence), Find My shares, current group metadata, fixture state, and monotonic IDs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapier/imsg-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "TypeScript SDK for iMessage automation on macOS: the Gateway contract, ImsgGateway (real), and a device-free FakeGateway.",
5
5
  "license": "MIT",
6
6
  "repository": {