diff --git a/docs-src/webhooks/events.mdx.vel b/docs-src/webhooks/events.mdx.vel index fffb3d1..05e4959 100644 --- a/docs-src/webhooks/events.mdx.vel +++ b/docs-src/webhooks/events.mdx.vel @@ -168,22 +168,22 @@ type Content = **Text** is what you'll see for the vast majority of inbound messages. -**Attachment** is what you'll see for any non-text content from iMessage — photos, voice memos, audio files, videos, documents. The `mimeType` field is the discriminator for *what kind* of attachment it is: +**Attachment** is what you'll see for non-text file content from iMessage — photos, audio files, videos, documents. The `mimeType` field is the discriminator for *what kind* of attachment it is: | `mimeType` prefix | Kind | Example values | | --- | --- | --- | | `image/*` | Photo or image attachment | `image/heic`, `image/jpeg`, `image/png` | -| `audio/*` | Voice memo or audio file | `audio/mp4`, `audio/x-m4a` | +| `audio/*` | Audio file | `audio/mp4`, `audio/x-m4a` | | `video/*` | Video clip | `video/mp4`, `video/quicktime` | | `application/*` | Document or file | `application/pdf`, `application/zip` | -**Byte-bearing arms ship metadata, not bytes.** `attachment` and `contact.photo` both carry `mimeType` / `size` / filename — never the raw bytes themselves and never a download URL. The `attachment` arm carries an `id` you use to fetch the file out of band — see [Retrieving an attachment](#retrieving-an-attachment). +**Byte-bearing arms ship metadata, not bytes.** `attachment`, `voice`, and `contact.photo` all carry `mimeType` / `size` / filename — never the raw bytes themselves and never a download URL. The `attachment` and `voice` arms carry an `id` you use to fetch the file out of band — see [Retrieving an attachment](#retrieving-an-attachment). ##### Retrieving an attachment -The payload tells you a file *exists* and what it is — it never carries the bytes, and there's no HTTP endpoint to download them (the same constraint as [sending](#what-you-dont-get)). The `attachment` arm's `id` is a stable, provider-native identifier — an iMessage GUID here; a Slack file id or WhatsApp media id on those platforms. To pull the bytes, run a [`spectrum-ts`](/spectrum-ts/getting-started) instance — the same one you'd use to reply — and hand the `id` to the provider's `getAttachment`: +The payload tells you a file *exists* and what it is — it never carries the bytes, and there's no HTTP endpoint to download them (the same constraint as [sending](#what-you-dont-get)). The `attachment` and `voice` arms' `id` is a stable, provider-native identifier — an iMessage GUID here; a Slack file id or WhatsApp media id on those platforms. To pull the bytes, run a [`spectrum-ts`](/spectrum-ts/getting-started) instance — the same one you'd use to reply — and hand the `id` to the provider's `getAttachment`: ```ts // `im` is the same iMessage instance you reply through: const im = imessage(app) @@ -206,7 +206,8 @@ The arms a current provider (iMessage, WhatsApp Business) delivers inbound today | `type` | What it is | Wire fields (post-projection) | | --- | --- | --- | | `text` | A plain text message — the most common arm. | `text: string` | -| `attachment` | A file attachment — photo, video, audio, voice memo, or document. | `id: string`, `name: string`, `mimeType: string`, `size?: number` | +| `attachment` | A file attachment — photo, video, audio, or document. | `id: string`, `name: string`, `mimeType: string`, `size?: number` | +| `voice` | A voice message — native Apple voice memos on iMessage. | `id?: string`, `name?: string`, `mimeType: string`, `duration?: number`, `size?: number` | | `contact` | A contact card — all fields optional. Full shape: [`Contact`](/spectrum-ts/content). | `name?: { formatted?, first?, last? }`, `phones?: [{ value, type? }]`, `photo?: { mimeType }`, `raw?` | | `richlink` | A link preview — URL only on the wire; OG metadata is not pre-fetched. | `url: string` | | `reaction` | An emoji reaction targeting another message. | `emoji: string`, [`target: MessageRef`](#target-refs) | @@ -214,7 +215,7 @@ The arms a current provider (iMessage, WhatsApp Business) delivers inbound today A few cross-cutting points the table doesn't surface: -- **Byte-bearing arms** (`attachment`, `contact.photo`) ship metadata only — see the [warning above](#content-shapes). The SDK's `read()` / `stream()` thunks and the internal filesystem `path` are dropped before delivery. iMessage voice memos arrive as `attachment` with an `audio/*` `mimeType`, not as a distinct arm. +- **Byte-bearing arms** (`attachment`, `voice`, `contact.photo`) ship metadata only — see the [warning above](#content-shapes). The SDK's `read()` / `stream()` thunks and the internal filesystem `path` are dropped before delivery. - **`target` on `reaction`** is the slim shape documented under [Target refs](#target-refs) below, not a full nested message. - **`richlink`** ships only `url` — OG fetches happen in your handler, since resolving them inline at delivery time would tie latency to the slowest target site. @@ -248,7 +249,7 @@ Each item has the same shape as the top-level [`message`](#message) and carries ##### Arms you won't receive inbound -Two arms you might expect arrive as something else: inbound **replies come as plain `text`** (there's no `reply` arm on the wire — the thread link isn't surfaced), and **voice memos come as `attachment`** (`audio/*`). Anything else in the SDK's [`Content`](/spectrum-ts/content) union is send-only or a rare provider-specific case — don't build on receiving it inbound. +One arm you might expect arrives as something else: inbound **replies come as plain `text`** (there's no `reply` arm on the wire — the thread link isn't surfaced). Anything else in the SDK's [`Content`](/spectrum-ts/content) union is send-only or a rare provider-specific case — don't build on receiving it inbound. Don't branch on arms you can't receive — but always keep a `default:` case, so an arm a future SDK/provider bump starts delivering (forwarded generically as `type: string` plus its JSON-safe fields) can't break your handler. @@ -297,6 +298,9 @@ switch (content.type) { else if (content.mimeType.startsWith('audio/')) handleAudio(content); else handleGenericAttachment(content); break; + case 'voice': + handleVoice(content); + break; case 'reaction': handleReaction(content.emoji, content.target.id); break; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11734b2..1414146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2341,8 +2341,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.14: - resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2464,6 +2464,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2481,8 +2485,8 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -3704,8 +3708,8 @@ snapshots: '@typescript-eslint/project-service@8.58.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -3919,7 +3923,7 @@ snapshots: '@vue/shared': 3.5.32 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.15 + postcss: 8.5.19 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.32': @@ -4637,6 +4641,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5217,7 +5225,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.14: {} + nanoid@3.3.16: {} napi-build-utils@2.0.0: optional: true @@ -5379,6 +5387,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -5402,9 +5412,9 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.15: + postcss@8.5.19: dependencies: - nanoid: 3.3.14 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5693,8 +5703,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {}