From 6d54ea648650c6d7e2dc50fadf750e1cf476f165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Misi=C5=82o?= Date: Mon, 27 Jul 2026 18:19:23 +0200 Subject: [PATCH] feat: add Comms provider --- .changeset/add-comms-provider.md | 8 + .env.example | 3 + README.md | 3 +- RELEASING.md | 11 +- packages/cli/README.md | 5 +- packages/cli/package.json | 1 + .../src/commands/provider-options-command.ts | 6 +- packages/cli/src/config.ts | 1 + packages/cli/src/help.ts | 2 +- packages/cli/src/provider-names.ts | 2 +- packages/cli/src/providers.ts | 56 ++ packages/cli/src/runtime.ts | 2 + packages/cli/test/cli.integration.test.ts | 18 +- packages/cli/test/cli.test.ts | 2 +- packages/cli/test/providers.test.ts | 35 + packages/providers/README.md | 60 +- packages/providers/comms/LICENSE | 21 + packages/providers/comms/README.md | 77 ++ packages/providers/comms/package.json | 59 ++ packages/providers/comms/src/index.ts | 871 ++++++++++++++++++ .../comms/test/comms.integration.test.ts | 56 ++ packages/providers/comms/test/comms.test.ts | 421 +++++++++ packages/providers/comms/tsconfig.json | 5 + packages/providers/comms/tsup.config.ts | 13 + pnpm-lock.yaml | 12 + scripts/check-packages.sh | 2 + test/package-consumer/index.ts | 31 + 27 files changed, 1743 insertions(+), 40 deletions(-) create mode 100644 .changeset/add-comms-provider.md create mode 100644 packages/providers/comms/LICENSE create mode 100644 packages/providers/comms/README.md create mode 100644 packages/providers/comms/package.json create mode 100644 packages/providers/comms/src/index.ts create mode 100644 packages/providers/comms/test/comms.integration.test.ts create mode 100644 packages/providers/comms/test/comms.test.ts create mode 100644 packages/providers/comms/tsconfig.json create mode 100644 packages/providers/comms/tsup.config.ts diff --git a/.changeset/add-comms-provider.md b/.changeset/add-comms-provider.md new file mode 100644 index 0000000..cc2b7a8 --- /dev/null +++ b/.changeset/add-comms-provider.md @@ -0,0 +1,8 @@ +--- +'@imessage-sdk/comms': minor +'imessage-cli': minor +--- + +Add Comms by Osis as an official provider with direct text sending, idempotency, conversation +continuation, provider-specific read APIs, and webhook endpoint management. Bundle the provider +with `imessage-cli`. diff --git a/.env.example b/.env.example index aa2578b..a28796f 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ BLOOIO_TEST_IMAGE_URL= BLOOIO_TEST_VIDEO_URL= BLOOIO_TEST_FILE_URL= +COMMS_API_KEY= +COMMS_TEST_RECIPIENT= + PHOTON_PROJECT_ID= PHOTON_PROJECT_SECRET= PHOTON_PHONE_NUMBER= diff --git a/README.md b/README.md index a010794..0032927 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ const client = createIMessageClient({ P.S. remember to create your own [Blooio account](https://app.blooio.com/signup?ref=BLOO-2NS4AJM8) and configure the provider with your credentials. -The stable v0.1 providers are Blooio, Photon, and Sendblue. See the +The official providers are Blooio, Comms by Osis, Photon, and Sendblue. See the [provider feature matrix](./packages/providers/README.md) for their verified surfaces and current limitations. @@ -60,6 +60,7 @@ packages/ ├── imessage-sdk/ Provider-neutral core package ├── providers/ │ ├── blooio/ @imessage-sdk/blooio +│ ├── comms/ @imessage-sdk/comms │ ├── photon/ @imessage-sdk/photon │ └── sendblue/ @imessage-sdk/sendblue ├── chat-adapter/ @imessage-sdk/chat-adapter diff --git a/RELEASING.md b/RELEASING.md index 54f4009..62aa335 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,6 +9,7 @@ requests, npm trusted publishing, and package-specific GitHub Releases. | ----------------------------- | ---------------------------- | ------ | | `packages/imessage-sdk` | `imessage-sdk` | Public | | `packages/providers/blooio` | `@imessage-sdk/blooio` | Public | +| `packages/providers/comms` | `@imessage-sdk/comms` | Public | | `packages/providers/photon` | `@imessage-sdk/photon` | Public | | `packages/providers/sendblue` | `@imessage-sdk/sendblue` | Public | | `packages/chat-adapter` | `@imessage-sdk/chat-adapter` | Public | @@ -101,8 +102,8 @@ changelogs. ## Trusted publishing on npm Configure trusted publishing separately in the settings for `imessage-sdk`, -`@imessage-sdk/blooio`, `@imessage-sdk/photon`, `@imessage-sdk/sendblue`, and -`@imessage-sdk/chat-adapter`, and `imessage-cli`: +`@imessage-sdk/blooio`, `@imessage-sdk/comms`, `@imessage-sdk/photon`, +`@imessage-sdk/sendblue`, `@imessage-sdk/chat-adapter`, and `imessage-cli`: ```text Provider: GitHub Actions @@ -173,12 +174,12 @@ npm publish "$PACKAGE_DIR/imessage-cli-0.1.0-beta.0.tgz" \ --provenance=false ``` -Add `--tag beta` when bootstrapping a prerelease. For the initial stable Sendblue release, use: +Add `--tag beta` when bootstrapping a prerelease. For an initial stable provider release, use: ```bash PACKAGE_DIR=$(mktemp -d) -pnpm --filter @imessage-sdk/sendblue pack --pack-destination "$PACKAGE_DIR" -npm publish "$PACKAGE_DIR/imessage-sdk-sendblue-0.1.0.tgz" \ +pnpm --filter @imessage-sdk/ pack --pack-destination "$PACKAGE_DIR" +npm publish "$PACKAGE_DIR/imessage-sdk--0.1.0.tgz" \ --access public \ --provenance=false ``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 3fbb24c..d36a97e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,13 +30,14 @@ provider accounts. It sends messages and performs supported provider mutations, start a webhook server or create a tunnel. Create an ignored repository-root `.env.cli-test` file with the credentials and dedicated test -recipient/assets for all three providers. It must explicitly opt in: +recipient/assets for all four providers. It must explicitly opt in: ```dotenv IMESSAGE_CLI_RUN_LIVE=1 BLOOIO_API_KEY= BLOOIO_FROM_NUMBER= +COMMS_API_KEY= IMESSAGE_CLI_TEST_RECIPIENT= IMESSAGE_CLI_TEST_IMAGE_URL= IMESSAGE_CLI_TEST_VIDEO_URL= @@ -78,6 +79,7 @@ Every provider published from `packages/providers/*` is bundled with the CLI: ```text blooio +comms photon sendblue ``` @@ -147,6 +149,7 @@ imessage-cli send \ Current provider restrictions still apply: - Blooio requires public attachment URLs. +- Comms currently supports plain-text messages only. - Photon accepts URLs and local files. - Sendblue accepts one URL or local file per message. - Group messaging remains disabled for the current official provider configurations. diff --git a/packages/cli/package.json b/packages/cli/package.json index 80e95a6..caaa64d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,6 +51,7 @@ "dependencies": { "@hono/node-server": "^2.0.10", "@imessage-sdk/blooio": "workspace:^", + "@imessage-sdk/comms": "workspace:^", "@imessage-sdk/photon": "workspace:^", "@imessage-sdk/sendblue": "workspace:^", "@inquirer/prompts": "^8.5.2", diff --git a/packages/cli/src/commands/provider-options-command.ts b/packages/cli/src/commands/provider-options-command.ts index 11d4f75..85c0ff7 100644 --- a/packages/cli/src/commands/provider-options-command.ts +++ b/packages/cli/src/commands/provider-options-command.ts @@ -6,14 +6,16 @@ import { BaseCommand } from './base-command.js'; export abstract class ProviderOptionsCommand extends BaseCommand { provider = Option.String('--provider', { - description: 'Use Blooio, Photon, or Sendblue (and its configured default connection).', + description: 'Use Blooio, Comms, Photon, or Sendblue (and its configured default connection).', }); noInput = Option.Boolean('--no-input', false, { description: 'Never prompt for missing credentials or settings.', }); - apiKey = Option.String('--api-key', { description: 'One-time Blooio or Sendblue API key.' }); + apiKey = Option.String('--api-key', { + description: 'One-time Blooio, Comms, or Sendblue API key.', + }); apiSecret = Option.String('--api-secret', { description: 'One-time Sendblue API secret.' }); projectId = Option.String('--project-id', { description: 'One-time Photon project ID.' }); projectSecret = Option.String('--project-secret', { diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 41a83ba..af54736 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -54,6 +54,7 @@ const ConnectionConfigSchema = z const DefaultConnectionsSchema = z .object({ blooio: ConnectionNameSchema.optional(), + comms: ConnectionNameSchema.optional(), photon: ConnectionNameSchema.optional(), sendblue: ConnectionNameSchema.optional(), }) diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8ef5362..b30863f 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -1,7 +1,7 @@ export function rootHelp(): string { return `Usage: imessage-cli [options] -Send and interact with iMessage through Blooio, Photon, or Sendblue. +Send and interact with iMessage through Blooio, Comms, Photon, or Sendblue. Options: -V, -v, --version output the version number diff --git a/packages/cli/src/provider-names.ts b/packages/cli/src/provider-names.ts index 40aaab9..edbeaee 100644 --- a/packages/cli/src/provider-names.ts +++ b/packages/cli/src/provider-names.ts @@ -1,3 +1,3 @@ -export const BUILT_IN_PROVIDER_NAMES = ['blooio', 'photon', 'sendblue'] as const; +export const BUILT_IN_PROVIDER_NAMES = ['blooio', 'comms', 'photon', 'sendblue'] as const; export type BuiltInProviderName = (typeof BUILT_IN_PROVIDER_NAMES)[number]; diff --git a/packages/cli/src/providers.ts b/packages/cli/src/providers.ts index 665f0d2..1734a92 100644 --- a/packages/cli/src/providers.ts +++ b/packages/cli/src/providers.ts @@ -1,8 +1,10 @@ import type { BlooioProvider } from '@imessage-sdk/blooio'; +import type { CommsProvider } from '@imessage-sdk/comms'; import type { PhotonProvider } from '@imessage-sdk/photon'; import type { SendblueProvider } from '@imessage-sdk/sendblue'; import type { AnyIMessageProvider, IMessageCapabilities } from 'imessage-sdk'; import { blooio, BLOOIO_CAPABILITIES } from '@imessage-sdk/blooio'; +import { comms, COMMS_CAPABILITIES } from '@imessage-sdk/comms'; import { photon, PHOTON_CAPABILITIES } from '@imessage-sdk/photon'; import { sendblue, SENDBLUE_CAPABILITIES } from '@imessage-sdk/sendblue'; import { IMessageSDKError, ValidationError } from 'imessage-sdk'; @@ -47,6 +49,7 @@ export interface ProviderDoctorResult { interface BuiltInProviderMap { readonly blooio: BlooioProvider; + readonly comms: CommsProvider; readonly photon: PhotonProvider; readonly sendblue: SendblueProvider | SendblueProvider; } @@ -158,6 +161,16 @@ function createPhoton(values: ProviderValues): PhotonProvider { }); } +function createComms(values: ProviderValues): CommsProvider { + const apiKey = optionalString('comms', values, 'apiKey'); + const baseUrl = optionalString('comms', values, 'baseUrl'); + + return comms({ + ...(apiKey === undefined ? {} : { apiKey }), + ...(baseUrl === undefined ? {} : { baseUrl }), + }); +} + function createSendblue(values: ProviderValues): SendblueProvider | SendblueProvider { const apiKey = optionalString('sendblue', values, 'apiKey'); const apiSecret = optionalString('sendblue', values, 'apiSecret'); @@ -272,6 +285,23 @@ async function doctorPhoton(values: ProviderValues): Promise { + try { + const provider = createComms(values); + const messages = await provider.messages.list({ + since: new Date(0), + limit: 1, + }); + return { + status: 'ok', + message: 'Comms credentials and read access were verified.', + details: { sampledMessageCount: messages.length }, + }; + } catch (error) { + return doctorError(error, values, ['apiKey']); + } +} + async function doctorSendblue(values: ProviderValues): Promise { try { createSendblue(values); @@ -350,6 +380,32 @@ export const providerRegistry: ProviderRegistry = { create: createBlooio, doctor: doctorBlooio, }, + comms: { + name: 'comms', + displayName: 'Comms by Osis', + packageName: '@imessage-sdk/comms', + description: 'Comms hosted iMessage and SMS API by Osis.', + capabilities: COMMS_CAPABILITIES, + fields: [ + { + key: 'apiKey', + label: 'API key', + kind: 'secret', + env: 'COMMS_API_KEY', + requiredFor: ['api', 'doctor'], + description: 'Bearer credential for Comms API operations.', + }, + { + key: 'baseUrl', + label: 'API base URL', + kind: 'setting', + requiredFor: [], + description: 'Trusted Comms API endpoint override.', + }, + ], + create: createComms, + doctor: doctorComms, + }, photon: { name: 'photon', displayName: 'Photon Cloud', diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 034b54a..d86e23b 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -133,6 +133,8 @@ export function providerValuesFromOverrides( switch (provider) { case 'blooio': return { ...shared, sender: overrides.fromNumber }; + case 'comms': + return shared; case 'photon': return { projectId: overrides.projectId, diff --git a/packages/cli/test/cli.integration.test.ts b/packages/cli/test/cli.integration.test.ts index 19ce21f..612adec 100644 --- a/packages/cli/test/cli.integration.test.ts +++ b/packages/cli/test/cli.integration.test.ts @@ -14,6 +14,7 @@ const PROVIDER_CREDENTIAL_ENVIRONMENT_VARIABLES = [ 'BLOOIO_API_KEY', 'BLOOIO_FROM_NUMBER', 'BLOOIO_WEBHOOK_SECRET', + 'COMMS_API_KEY', 'PHOTON_PROJECT_ID', 'PHOTON_PROJECT_SECRET', 'PHOTON_PHONE_NUMBER', @@ -40,6 +41,7 @@ interface SentMessage { const secretValues = [ process.env['BLOOIO_API_KEY'], process.env['BLOOIO_WEBHOOK_SECRET'], + process.env['COMMS_API_KEY'], process.env['PHOTON_PROJECT_SECRET'], process.env['PHOTON_WEBHOOK_SECRET'], process.env['SENDBLUE_API_KEY'], @@ -130,6 +132,15 @@ describe.skipIf(!enabled)('imessage-cli live provider API', () => { await testCommonInteractions('photon', text); }, 180_000); + it('exercises Comms through the built CLI', async () => { + const recipient = required('IMESSAGE_CLI_TEST_RECIPIENT'); + const conversation = await openConversation('comms', recipient); + const text = await sendText('comms', conversation, 'Comms text'); + + expect(text.providerMessageId).toBeTruthy(); + expect(text.conversationId).toBeTruthy(); + }, 60_000); + it('exercises Sendblue through the built CLI', async () => { const recipient = required('IMESSAGE_CLI_TEST_RECIPIENT'); const imageUrl = required('IMESSAGE_CLI_TEST_IMAGE_URL'); @@ -205,7 +216,10 @@ describe.skipIf(!enabled)('imessage-cli live provider API', () => { }, 180_000); }); -async function openConversation(provider: 'blooio' | 'photon' | 'sendblue', recipient: string) { +async function openConversation( + provider: 'blooio' | 'comms' | 'photon' | 'sendblue', + recipient: string, +) { const result = await command([ 'conversation', 'open', @@ -220,7 +234,7 @@ async function openConversation(provider: 'blooio' | 'photon' | 'sendblue', reci } async function sendText( - provider: 'blooio' | 'photon' | 'sendblue', + provider: 'blooio' | 'comms' | 'photon' | 'sendblue', conversation: string, label: string, ): Promise { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 3d52b08..9647443 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -168,7 +168,7 @@ describe('imessage-cli', () => { schemaVersion: 1, ok: true, command: 'provider.list', - data: [{ name: 'blooio' }, { name: 'photon' }, { name: 'sendblue' }], + data: [{ name: 'blooio' }, { name: 'comms' }, { name: 'photon' }, { name: 'sendblue' }], }); expect(test.stderr.text()).toBe(''); }); diff --git a/packages/cli/test/providers.test.ts b/packages/cli/test/providers.test.ts index 675598e..066faf7 100644 --- a/packages/cli/test/providers.test.ts +++ b/packages/cli/test/providers.test.ts @@ -56,6 +56,7 @@ describe('built-in provider registry', () => { }; expect(requiredWebhookFields('blooio')).toEqual(['webhookSecret']); + expect(requiredWebhookFields('comms')).toEqual([]); expect(requiredWebhookFields('photon')).toEqual(['webhookSecret']); expect(requiredWebhookFields('sendblue')).toEqual(['fromNumber', 'webhookSecret']); }); @@ -73,6 +74,9 @@ describe('built-in provider registry', () => { projectSecret: 'project-secret', phone: '+15550000002', }); + const comms = createProvider('comms', { + apiKey: 'comms-key', + }); const sendblue = createProvider('sendblue', { apiKey: 'sendblue-key', apiSecret: 'sendblue-secret', @@ -81,6 +85,7 @@ describe('built-in provider registry', () => { }); expect(blooio.name).toBe('blooio'); + expect(comms.name).toBe('comms'); expect(photon.name).toBe('photon'); expect(sendblue.name).toBe('sendblue'); expect(sendblue.capabilities.conversations.markRead).toBe(true); @@ -160,6 +165,36 @@ describe('built-in provider registry', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('verifies Comms credentials through a non-mutating message lookup', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return new Response(JSON.stringify({ messages: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await providerRegistry.comms.doctor({ + apiKey: 'comms-key', + baseUrl: 'https://comms.test/api/v1/comms', + }); + + expect(result).toMatchObject({ + status: 'ok', + details: { sampledMessageCount: 0 }, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://comms.test/api/v1/comms/messages?since=1970-01-01T00%3A00%3A00.000Z&limit=1', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get('authorization')).toBe('Bearer comms-key'); + }); + it('returns safe doctor failures without exposing secret values', async () => { const secret = 'blooio-secret-value'; vi.stubGlobal( diff --git a/packages/providers/README.md b/packages/providers/README.md index 0dcb82e..ed810ac 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -6,30 +6,30 @@ Provider adapters are independently installable packages built on the public All providers in this directory are also bundled with [`imessage-cli`](../cli). A workspace test fails when a provider package is added without a corresponding CLI registry entry. -| Capability | Blooio | Photon Cloud | Sendblue | -| ----------------------------- | ---------------------- | --------------------------------- | ------------------------ | -| Package | `@imessage-sdk/blooio` | `@imessage-sdk/photon` | `@imessage-sdk/sendblue` | -| Send text | ✅ | ✅ | ✅ | -| Send public URL attachments | ✅ | ✅ | ✅ (one per message) | -| Send `Blob` attachments | — | ✅ | ✅ (one per message) | -| Send `Uint8Array` attachments | — | ✅ | ✅ (one per message) | -| Access inbound attachments | Public URL | Authenticated byte download | Expiring public URL | -| Reply to a message | ✅ | ✅ | — | -| Get a message | ✅ | ✅ | ✅ | -| Edit a message | — | — | — | -| Delete or unsend a message | — | — | — | -| Open a direct conversation | ✅ | ✅ | ✅ | -| Group conversations | — | Experimental, provider-level only | — | -| Get a conversation | ✅ | ✅ | — | -| Mark a conversation as read | ✅ | ✅ | Account-dependent | -| Add and remove reactions | ✅ | ✅ | — | -| Provider-level tapback add | — | — | ✅ | -| Start and stop typing | ✅ | ✅ | ✅ | -| Read receipts | ✅ | ✅ | — | -| Authenticated webhooks | Signed | Signed | Shared-secret header | -| Normalized event stream | — | — | — | -| Provider-level event stream | — | Experimental | — | -| Sender or line discovery | Linked numbers | Connected line | Configured sender | +| Capability | Blooio | Comms by Osis | Photon Cloud | Sendblue | +| ----------------------------- | ---------------------- | --------------------- | --------------------------------- | ------------------------ | +| Package | `@imessage-sdk/blooio` | `@imessage-sdk/comms` | `@imessage-sdk/photon` | `@imessage-sdk/sendblue` | +| Send text | ✅ | ✅ | ✅ | ✅ | +| Send public URL attachments | ✅ | — | ✅ | ✅ (one per message) | +| Send `Blob` attachments | — | — | ✅ | ✅ (one per message) | +| Send `Uint8Array` attachments | — | — | ✅ | ✅ (one per message) | +| Access inbound attachments | Public URL | — | Authenticated byte download | Expiring public URL | +| Reply to a message | ✅ | — | ✅ | — | +| Get a message | ✅ | — | ✅ | ✅ | +| Edit a message | — | — | — | — | +| Delete or unsend a message | — | — | — | — | +| Open a direct conversation | ✅ | ✅ | ✅ | ✅ | +| Group conversations | — | — | Experimental, provider-level only | — | +| Get a conversation | ✅ | — | ✅ | — | +| Mark a conversation as read | ✅ | — | ✅ | Account-dependent | +| Add and remove reactions | ✅ | — | ✅ | — | +| Provider-level tapback add | — | — | — | ✅ | +| Start and stop typing | ✅ | — | ✅ | ✅ | +| Read receipts | ✅ | — | ✅ | — | +| Authenticated webhooks | Signed | — | Signed | Shared-secret header | +| Normalized event stream | — | — | — | — | +| Provider-level event stream | — | — | Experimental | — | +| Sender or line discovery | Linked numbers | — | Connected line | Configured sender | `—` means the normalized v0.1 capability is unavailable. Unsupported normalized operations throw `UnsupportedCapabilityError` rather than silently @@ -44,8 +44,14 @@ remains disabled because the documented API can add a tapback but cannot reliabl add-only operation remains available through the concrete Sendblue provider. The Sendblue mark-read endpoint depends on account support and must be explicitly enabled. -The published Blooio, Photon, and Sendblue v0.1 operations are stable. Their live integration suites -remain opt-in because they contact real provider accounts, send messages, and mutate provider state. +Comms currently exposes direct text sends through the normalized client. Its documented message, +conversation, delivery-event, and webhook-endpoint APIs are available through +`client.providers.comms`. Normalized webhooks remain disabled because the public documentation +does not specify a signed request format and normalized event payload schema. + +The documented provider operations shown above are treated as stable. Their live integration +suites remain opt-in because they contact real provider accounts, send messages, and mutate +provider state. ## Installation @@ -53,6 +59,7 @@ Install the core together with exactly the providers an application uses: ```bash pnpm add imessage-sdk @imessage-sdk/blooio +pnpm add imessage-sdk @imessage-sdk/comms pnpm add imessage-sdk @imessage-sdk/photon pnpm add imessage-sdk @imessage-sdk/sendblue ``` @@ -69,6 +76,7 @@ const client = createIMessageClient({ See each provider package for configuration and live integration-test details: - [`@imessage-sdk/blooio`](./blooio) +- [`@imessage-sdk/comms`](./comms) - [`@imessage-sdk/photon`](./photon) - [`@imessage-sdk/sendblue`](./sendblue) diff --git a/packages/providers/comms/LICENSE b/packages/providers/comms/LICENSE new file mode 100644 index 0000000..6bb425f --- /dev/null +++ b/packages/providers/comms/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 imessage-sdk contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/providers/comms/README.md b/packages/providers/comms/README.md new file mode 100644 index 0000000..2fe6421 --- /dev/null +++ b/packages/providers/comms/README.md @@ -0,0 +1,77 @@ +# `@imessage-sdk/comms` + +[Comms by Osis](https://comms.osis.co/) provider for +[`imessage-sdk`](https://www.npmjs.com/package/imessage-sdk). + +## Install + +```bash +pnpm add imessage-sdk @imessage-sdk/comms +``` + +## Send a message + +Set `COMMS_API_KEY`, then create a provider: + +```ts +import { comms } from '@imessage-sdk/comms'; +import { createIMessageClient } from 'imessage-sdk'; + +const client = createIMessageClient({ + provider: comms(), +}); + +await client.messages.send({ + to: { kind: 'phone', value: '+15551234567' }, + text: 'Hello from Comms', + idempotencyKey: crypto.randomUUID(), +}); +``` + +Explicit options take precedence over environment variables: + +```ts +comms({ apiKey: process.env.MY_COMMS_API_KEY }); +``` + +## Provider-specific APIs + +Documented Comms read and webhook-management APIs are available on the concrete provider: + +```ts +const messages = await client.providers.comms.messages.list({ + since: new Date(Date.now() - 60_000), + limit: 20, +}); +const conversations = await client.providers.comms.conversations.list({ limit: 20 }); +const contacts = await client.providers.comms.contacts.list({ limit: 20 }); +const events = await client.providers.comms.deliveryEvents.list({ limit: 20 }); + +const endpoints = await client.providers.comms.webhookEndpoints.list(); + +await client.providers.comms.messages.send({ + to: { kind: 'phone', value: '+15551234567' }, + text: 'Force iMessage for this provider-specific send', + channel: 'imessage', +}); +``` + +Comms requires message-history reads to be bounded by either `conversationId` or `since`; the +TypeScript API enforces that requirement. + +These methods require their corresponding Comms API-key scopes. + +## Current support + +- Direct plain-text messages +- Provider conversation continuation +- Idempotent sends +- Message, conversation, and delivery-event listing +- Contact listing and upsert +- Webhook endpoint listing and creation through the provider-specific API + +Attachments, replies, group conversations, reactions, typing, read receipts, edits, deletes, +event streams, and normalized signed webhooks are disabled because they are not documented by the +current Comms API. + +See the [Comms API documentation](https://docs.osis.co/messages-api/overview). diff --git a/packages/providers/comms/package.json b/packages/providers/comms/package.json new file mode 100644 index 0000000..19f93d7 --- /dev/null +++ b/packages/providers/comms/package.json @@ -0,0 +1,59 @@ +{ + "name": "@imessage-sdk/comms", + "version": "0.0.0", + "description": "Comms by Osis provider for imessage-sdk", + "author": "jmisilo", + "keywords": [ + "imessage", + "comms", + "osis", + "messaging", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/jmisilo/imessage-sdk.git", + "directory": "packages/providers/comms" + }, + "homepage": "https://github.com/jmisilo/imessage-sdk/tree/main/packages/providers/comms#readme", + "bugs": { + "url": "https://github.com/jmisilo/imessage-sdk/issues" + }, + "license": "MIT", + "type": "module", + "sideEffects": false, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:integration": "COMMS_LIVE_TEST=1 vitest run test/comms.integration.test.ts", + "test:watch": "vitest", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "dependencies": { + "imessage-sdk": "workspace:^", + "zod": "^4.4.3" + } +} diff --git a/packages/providers/comms/src/index.ts b/packages/providers/comms/src/index.ts new file mode 100644 index 0000000..c096caf --- /dev/null +++ b/packages/providers/comms/src/index.ts @@ -0,0 +1,871 @@ +import { z } from 'zod'; + +import type { + IMessageAddress, + IMessageCapabilities, + IMessageDirection, + IMessageProvider, + IMessageService, + IMessageStatus, + OpenConversationInput, + ProviderConversation, + ProviderConversations, + ProviderMessages, + ProviderSentMessage, + SendMessageInput, +} from 'imessage-sdk'; +import { + AmbiguousDeliveryError, + AuthenticationError, + ConflictError, + defineProvider, + IMessageSDKError, + NotFoundError, + ProviderUnavailableError, + RateLimitError, + ValidationError, +} from 'imessage-sdk'; + +const DEFAULT_BASE_URL = 'https://osis.co/api/v1/comms'; + +export const COMMS_CAPABILITIES = { + attachments: { + download: false, + }, + messages: { + text: true, + attachments: false, + replies: false, + get: false, + edit: false, + delete: false, + }, + conversations: { + direct: true, + groups: false, + get: false, + markRead: false, + }, + interactions: { + reactions: false, + typingStart: false, + typingStop: false, + readReceipts: false, + }, + events: { + webhooks: false, + stream: false, + }, +} as const satisfies IMessageCapabilities; + +export interface CommsOptions { + readonly apiKey?: string; + readonly baseUrl?: string; +} + +export type CommsChannel = 'imessage' | 'sms'; + +export type CommsSendMessageInput = SendMessageInput & { + /** Prefer a specific Comms channel. Omit to let Comms choose. */ + readonly channel?: CommsChannel; +}; + +interface CommsListMessagesFilterOptions { + readonly direction?: IMessageDirection; + readonly limit?: number; +} + +export type CommsListMessagesOptions = CommsListMessagesFilterOptions & + ( + | { + readonly conversationId: string; + readonly since?: Date | string; + } + | { + readonly conversationId?: string; + readonly since: Date | string; + } + ); + +export interface CommsMessage { + readonly id: string; + readonly body: string; + readonly direction: IMessageDirection; + readonly conversationId?: string; + readonly to?: string; + readonly from?: string; + readonly channel?: string; + readonly status?: string; + readonly createdAt?: Date; + readonly raw: unknown; +} + +export interface CommsListConversationsOptions { + readonly state?: string; + readonly query?: string; + readonly limit?: number; +} + +export interface CommsConversation { + readonly id: string; + readonly state?: string; + readonly raw: unknown; +} + +export interface CommsListDeliveryEventsOptions { + readonly limit?: number; +} + +export interface CommsListContactsOptions { + readonly query?: string; + readonly limit?: number; +} + +export interface CommsUpsertContactInput { + readonly phone?: string; + readonly name?: string; + readonly email?: string; + readonly tags?: readonly string[]; +} + +export interface CommsContact { + readonly id: string; + readonly phone?: string; + readonly name?: string; + readonly email?: string; + readonly tags?: readonly string[]; + readonly raw: unknown; +} + +export interface CommsDeliveryEvent { + readonly id: string; + readonly event?: string; + readonly status?: string; + readonly attempts?: number; + readonly lastStatus?: string; + readonly createdAt?: Date; + readonly raw: unknown; +} + +export interface CommsCreateWebhookEndpointInput { + readonly url: string; + readonly events: readonly [string, ...string[]]; +} + +export interface CommsWebhookEndpoint { + readonly id: string; + readonly url: string; + readonly events: readonly string[]; + readonly raw: unknown; +} + +export interface CommsMessages extends Omit { + send(input: CommsSendMessageInput): Promise; + list(options: CommsListMessagesOptions): Promise; +} + +export interface CommsConversations extends Omit { + list(options?: CommsListConversationsOptions): Promise; +} + +export interface CommsDeliveryEvents { + list(options?: CommsListDeliveryEventsOptions): Promise; +} + +export interface CommsContacts { + list(options?: CommsListContactsOptions): Promise; + upsert(input: CommsUpsertContactInput): Promise; +} + +export interface CommsWebhookEndpoints { + list(): Promise; + create(input: CommsCreateWebhookEndpointInput): Promise; +} + +export interface CommsProvider extends IMessageProvider<'comms', typeof COMMS_CAPABILITIES> { + readonly messages: CommsMessages; + readonly conversations: CommsConversations; + readonly contacts: CommsContacts; + readonly deliveryEvents: CommsDeliveryEvents; + readonly webhookEndpoints: CommsWebhookEndpoints; +} + +const E164Schema = z.string().regex(/^\+[1-9]\d{6,14}$/u, 'Expected an E.164 phone number.'); +const OptionalStringSchema = z + .string() + .nullable() + .optional() + .transform((value) => (value === null || value === '' ? undefined : value)); +const OptionalDateSchema = z + .union([z.string(), z.number().finite()]) + .nullable() + .optional() + .transform((value) => (value === null || value === '' ? undefined : value)); + +const MessageSchema = z + .object({ + id: z.string().min(1), + body: z.string(), + direction: z.enum(['inbound', 'outbound']), + conversation_id: OptionalStringSchema, + to: OptionalStringSchema, + from: OptionalStringSchema, + channel: OptionalStringSchema, + status: OptionalStringSchema, + created_at: OptionalDateSchema, + sent_at: OptionalDateSchema, + delivered_at: OptionalDateSchema, + read_at: OptionalDateSchema, + }) + .loose(); +const SendResponseSchema = z + .object({ + message: MessageSchema, + duplicate: z.boolean().optional(), + }) + .loose(); +const MessagesResponseSchema = z.object({ messages: z.array(MessageSchema) }).loose(); +const ConversationSchema = z + .object({ + id: z.string().min(1), + state: OptionalStringSchema, + }) + .loose(); +const ConversationsResponseSchema = z + .object({ conversations: z.array(ConversationSchema) }) + .loose(); +const DeliveryEventSchema = z + .object({ + id: z.string().min(1), + event: OptionalStringSchema, + status: OptionalStringSchema, + attempts: z.number().int().nonnegative().nullable().optional(), + last_status: OptionalStringSchema, + created_at: OptionalDateSchema, + }) + .loose(); +const DeliveryEventsResponseSchema = z.object({ deliveries: z.array(DeliveryEventSchema) }).loose(); +const ContactSchema = z + .object({ + id: z.string().min(1), + phone: OptionalStringSchema, + name: OptionalStringSchema, + email: OptionalStringSchema, + tags: z.array(z.string()).nullable().optional(), + }) + .loose(); +const ContactsResponseSchema = z.object({ contacts: z.array(ContactSchema) }).loose(); +const ContactResponseSchema = z.object({ contact: ContactSchema }).loose(); +const WebhookEndpointSchema = z + .object({ + id: z.string().min(1), + url: z.url(), + events: z.array(z.string()), + }) + .loose(); +const WebhookEndpointResponseSchema = z.object({ webhook: WebhookEndpointSchema }).loose(); +const WebhookEndpointsResponseSchema = z + .object({ webhooks: z.array(WebhookEndpointSchema) }) + .loose(); +const ApiErrorSchema = z + .object({ + error: OptionalStringSchema, + message: OptionalStringSchema, + retry_after: z.union([z.number().nonnegative(), z.string()]).nullable().optional(), + }) + .loose(); + +type MessagePayload = z.infer; +type ConversationPayload = z.infer; +type DeliveryEventPayload = z.infer; +type ContactPayload = z.infer; +type WebhookEndpointPayload = z.infer; + +interface RequestOptions { + readonly operation: string; + readonly send?: boolean; + readonly idempotencyKey?: string; +} + +function address(value: string): IMessageAddress { + return { kind: 'phone', value }; +} + +function unknownAddress(): IMessageAddress { + return address('unknown'); +} + +function parseDate(value: string | number | null | undefined): Date | undefined { + if (value === undefined || value === null) return undefined; + const result = new Date(value); + return Number.isNaN(result.valueOf()) ? undefined : result; +} + +function mapStatus(value: string | null | undefined): IMessageStatus { + switch (value?.toLowerCase()) { + case 'accepted': + return 'accepted'; + case 'sent': + return 'sent'; + case 'delivered': + return 'delivered'; + case 'read': + return 'read'; + case 'failed': + case 'error': + return 'failed'; + default: + return 'pending'; + } +} + +function mapService(value: string | null | undefined): IMessageService { + switch (value?.toLowerCase()) { + case 'imessage': + return 'imessage'; + case 'sms': + return 'sms'; + default: + return 'unknown'; + } +} + +function mapMessage(payload: MessagePayload, raw: unknown): CommsMessage { + const createdAt = parseDate(payload.created_at); + return { + id: payload.id, + body: payload.body, + direction: payload.direction, + ...(payload.conversation_id === undefined ? {} : { conversationId: payload.conversation_id }), + ...(payload.to === undefined ? {} : { to: payload.to }), + ...(payload.from === undefined ? {} : { from: payload.from }), + ...(payload.channel === undefined ? {} : { channel: payload.channel }), + ...(payload.status === undefined ? {} : { status: payload.status }), + ...(createdAt === undefined ? {} : { createdAt }), + raw, + }; +} + +function mapConversation(payload: ConversationPayload, raw: unknown): CommsConversation { + return { + id: payload.id, + ...(payload.state === undefined ? {} : { state: payload.state }), + raw, + }; +} + +function mapDeliveryEvent(payload: DeliveryEventPayload, raw: unknown): CommsDeliveryEvent { + const createdAt = parseDate(payload.created_at); + return { + id: payload.id, + ...(payload.event === undefined ? {} : { event: payload.event }), + ...(payload.status === undefined ? {} : { status: payload.status }), + ...(payload.attempts === undefined || payload.attempts === null + ? {} + : { attempts: payload.attempts }), + ...(payload.last_status === undefined ? {} : { lastStatus: payload.last_status }), + ...(createdAt === undefined ? {} : { createdAt }), + raw, + }; +} + +function mapContact(payload: ContactPayload, raw: unknown): CommsContact { + return { + id: payload.id, + ...(payload.phone === undefined ? {} : { phone: payload.phone }), + ...(payload.name === undefined ? {} : { name: payload.name }), + ...(payload.email === undefined ? {} : { email: payload.email }), + ...(payload.tags === undefined || payload.tags === null ? {} : { tags: payload.tags }), + raw, + }; +} + +function mapWebhookEndpoint(payload: WebhookEndpointPayload, raw: unknown): CommsWebhookEndpoint { + return { + id: payload.id, + url: payload.url, + events: payload.events, + raw, + }; +} + +function rawArray(raw: unknown, key: string): readonly unknown[] { + return (raw as Readonly>)[key] ?? []; +} + +function rawObject(raw: unknown, key: string): unknown { + return (raw as Readonly>)[key]; +} + +function appendQuery( + path: string, + values: Readonly>, +): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) search.set(key, String(value)); + } + const query = search.toString(); + return query.length === 0 ? path : `${path}?${query}`; +} + +function retryAfterSeconds(response: Response, raw: unknown): number | undefined { + const parsed = ApiErrorSchema.safeParse(raw); + const value = parsed.success ? parsed.data.retry_after : undefined; + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + } + + const header = response.headers.get('retry-after'); + if (header === null) return undefined; + const numeric = Number(header); + return Number.isFinite(numeric) ? numeric : undefined; +} + +function errorMessage(raw: unknown, fallback: string): string { + const parsed = ApiErrorSchema.safeParse(raw); + if (!parsed.success) return fallback; + return parsed.data.error ?? parsed.data.message ?? fallback; +} + +function resolveDestination(input: SendMessageInput): { + readonly to?: string; + readonly conversationId?: string; +} { + if (input.conversationId !== undefined) { + if (input.conversationId.trim().length === 0) { + throw new ValidationError('Comms conversationId must not be empty.', { + provider: 'comms', + code: 'invalid_conversation_id', + }); + } + const phone = E164Schema.safeParse(input.conversationId); + return phone.success ? { to: phone.data } : { conversationId: input.conversationId }; + } + + const recipients = Array.isArray(input.to) ? input.to : [input.to]; + if (recipients.length !== 1 || recipients[0]?.kind !== 'phone') { + throw new ValidationError('Comms requires exactly one phone recipient.', { + provider: 'comms', + code: 'invalid_recipient', + }); + } + + const parsed = E164Schema.safeParse(recipients[0].value); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid phone recipient.', { + provider: 'comms', + code: 'invalid_recipient', + raw: parsed.error, + }); + } + return { to: parsed.data }; +} + +export function comms(options: CommsOptions = {}): CommsProvider { + const apiKey = options.apiKey ?? process.env['COMMS_API_KEY']; + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/u, ''); + + const request = async ( + path: string, + init: RequestInit = {}, + requestOptions: RequestOptions, + ): Promise => { + if (apiKey === undefined || apiKey.length === 0) { + throw new AuthenticationError( + 'Comms API key is required. Pass apiKey or set COMMS_API_KEY.', + { + provider: 'comms', + code: 'missing_api_key', + }, + ); + } + + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${apiKey}`); + headers.set('accept', 'application/json'); + if (init.body !== undefined) headers.set('content-type', 'application/json'); + if (requestOptions.idempotencyKey !== undefined) { + headers.set('idempotency-key', requestOptions.idempotencyKey); + } + + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { ...init, headers }); + } catch (error) { + const ErrorType = requestOptions.send ? AmbiguousDeliveryError : ProviderUnavailableError; + throw new ErrorType(`Comms ${requestOptions.operation} request failed.`, { + provider: 'comms', + code: 'network_error', + retryable: true, + raw: error, + }); + } + + const text = await response.text(); + let raw: unknown; + try { + raw = text.length === 0 ? {} : JSON.parse(text); + } catch { + raw = text; + } + + if (response.ok) return raw; + + const message = errorMessage(raw, `Comms ${requestOptions.operation} failed.`); + const common = { + provider: 'comms', + statusCode: response.status, + raw, + } as const; + + if (response.status === 400 || response.status === 422) { + throw new ValidationError(message, { ...common, code: 'validation_error' }); + } + if (response.status === 401 || response.status === 403) { + throw new AuthenticationError(message, { ...common, code: 'authentication_error' }); + } + if (response.status === 404) { + throw new NotFoundError(message, { ...common, code: 'not_found' }); + } + if (response.status === 409) { + throw new ConflictError(message, { ...common, code: 'conflict' }); + } + if (response.status === 429) { + const retryAfter = retryAfterSeconds(response, raw); + throw new RateLimitError(message, { + ...common, + code: 'rate_limited', + retryable: true, + ...(retryAfter === undefined ? {} : { retryAfter }), + }); + } + if (requestOptions.send && (response.status === 408 || response.status >= 500)) { + throw new AmbiguousDeliveryError(message, { + ...common, + code: 'ambiguous_delivery', + retryable: true, + }); + } + if (response.status >= 500) { + throw new ProviderUnavailableError(message, { + ...common, + code: 'provider_unavailable', + retryable: true, + }); + } + throw new IMessageSDKError(message, { + ...common, + code: 'provider_error', + }); + }; + + const messages: CommsMessages = { + async send(input): Promise { + if (input.attachments !== undefined && input.attachments.length > 0) { + throw new ValidationError('Comms does not currently document attachment sends.', { + provider: 'comms', + code: 'unsupported_attachment', + }); + } + if (input.replyTo !== undefined) { + throw new ValidationError('Comms does not currently document message replies.', { + provider: 'comms', + code: 'unsupported_reply', + }); + } + if (input.text === undefined || input.text.trim().length === 0) { + throw new ValidationError('Comms requires non-empty message text.', { + provider: 'comms', + code: 'missing_text', + }); + } + + const destination = resolveDestination(input); + const raw = await request( + '/messages', + { + method: 'POST', + body: JSON.stringify({ + ...(destination.to === undefined ? {} : { to: destination.to }), + ...(destination.conversationId === undefined + ? {} + : { conversation_id: destination.conversationId }), + body: input.text, + ...(input.channel === undefined ? {} : { channel: input.channel }), + }), + }, + { + operation: 'send message', + send: true, + ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), + }, + ); + const parsed = SendResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new AmbiguousDeliveryError('Comms returned an invalid send response.', { + provider: 'comms', + code: 'invalid_provider_response', + retryable: false, + raw, + }); + } + + const message = parsed.data.message; + const createdAt = parseDate(message.created_at) ?? new Date(); + const status = mapStatus(message.status ?? 'accepted'); + const sentAt = parseDate(message.sent_at); + const deliveredAt = parseDate(message.delivered_at); + const readAt = parseDate(message.read_at); + const recipient = message.to ?? destination.to; + const conversationId = + message.conversation_id ?? destination.conversationId ?? destination.to; + + return { + providerMessageId: message.id, + ...(conversationId === undefined ? {} : { conversationId }), + direction: 'outbound', + sender: message.from === undefined ? unknownAddress() : address(message.from), + recipients: recipient === undefined ? [] : [address(recipient)], + text: message.body, + attachments: [], + service: mapService(message.channel), + status, + providerStatus: parsed.data.duplicate ? 'duplicate' : (message.status ?? 'accepted'), + createdAt, + ...(sentAt === undefined ? {} : { sentAt }), + ...(deliveredAt === undefined ? {} : { deliveredAt }), + ...(readAt === undefined ? {} : { readAt }), + raw, + }; + }, + async list(options) { + if ( + (options.conversationId === undefined || options.conversationId.trim().length === 0) && + (options.since === undefined || + (typeof options.since === 'string' && options.since.trim().length === 0)) + ) { + throw new ValidationError('Comms message listing requires conversationId or since.', { + provider: 'comms', + code: 'missing_message_list_bound', + }); + } + const raw = await request( + appendQuery('/messages', { + conversation_id: options.conversationId, + since: options.since instanceof Date ? options.since.toISOString() : options.since, + direction: options.direction, + limit: options.limit, + }), + {}, + { operation: 'list messages' }, + ); + const parsed = MessagesResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid messages response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + const rawMessages = rawArray(raw, 'messages'); + return parsed.data.messages.map((message, index) => mapMessage(message, rawMessages[index])); + }, + }; + + const conversations: CommsConversations = { + async open(input: OpenConversationInput): Promise { + if (input.participants.length !== 1 || input.participants[0].kind !== 'phone') { + throw new ValidationError('Comms supports one direct phone participant.', { + provider: 'comms', + code: 'invalid_participants', + }); + } + const parsed = E164Schema.safeParse(input.participants[0].value); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid participant.', { + provider: 'comms', + code: 'invalid_participant', + raw: parsed.error, + }); + } + return { + providerConversationId: parsed.data, + participants: [address(parsed.data)], + raw: input, + }; + }, + async list(options = {}) { + const raw = await request( + appendQuery('/conversations', { + state: options.state, + q: options.query, + limit: options.limit, + }), + {}, + { operation: 'list conversations' }, + ); + const parsed = ConversationsResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid conversations response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + const rawConversations = rawArray(raw, 'conversations'); + return parsed.data.conversations.map((conversation, index) => + mapConversation(conversation, rawConversations[index]), + ); + }, + }; + + const deliveryEvents: CommsDeliveryEvents = { + async list(options = {}) { + const raw = await request( + appendQuery('/events', { limit: options.limit }), + {}, + { operation: 'list delivery events' }, + ); + const parsed = DeliveryEventsResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid delivery events response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + const rawDeliveries = rawArray(raw, 'deliveries'); + return parsed.data.deliveries.map((event, index) => + mapDeliveryEvent(event, rawDeliveries[index]), + ); + }, + }; + + const contacts: CommsContacts = { + async list(options = {}) { + const raw = await request( + appendQuery('/contacts', { q: options.query, limit: options.limit }), + {}, + { operation: 'list contacts' }, + ); + const parsed = ContactsResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid contacts response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + const rawContacts = rawArray(raw, 'contacts'); + return parsed.data.contacts.map((contact, index) => mapContact(contact, rawContacts[index])); + }, + async upsert(input) { + if ( + input.phone === undefined && + input.name === undefined && + input.email === undefined && + input.tags === undefined + ) { + throw new ValidationError('Comms contact upsert requires at least one field.', { + provider: 'comms', + code: 'invalid_contact', + }); + } + if (input.phone !== undefined && !E164Schema.safeParse(input.phone).success) { + throw new ValidationError('Comms contact phone must be an E.164 phone number.', { + provider: 'comms', + code: 'invalid_contact_phone', + }); + } + if (input.email !== undefined && !z.email().safeParse(input.email).success) { + throw new ValidationError('Comms contact email must be a valid email address.', { + provider: 'comms', + code: 'invalid_contact_email', + }); + } + + const raw = await request( + '/contacts', + { + method: 'POST', + body: JSON.stringify(input), + }, + { operation: 'upsert contact' }, + ); + const parsed = ContactResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid contact response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + return mapContact(parsed.data.contact, rawObject(raw, 'contact')); + }, + }; + + const webhookEndpoints: CommsWebhookEndpoints = { + async list() { + const raw = await request('/webhooks', {}, { operation: 'list webhook endpoints' }); + const parsed = WebhookEndpointsResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid webhooks response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + const rawWebhooks = rawArray(raw, 'webhooks'); + return parsed.data.webhooks.map((webhook, index) => + mapWebhookEndpoint(webhook, rawWebhooks[index]), + ); + }, + async create(input) { + const url = z.url().safeParse(input.url); + if (!url.success || new URL(input.url).protocol !== 'https:') { + throw new ValidationError('Comms webhook endpoint URLs must use HTTPS.', { + provider: 'comms', + code: 'invalid_webhook_url', + raw: url.success ? input.url : url.error, + }); + } + if (input.events.length === 0 || input.events.some((event) => event.length === 0)) { + throw new ValidationError('Comms webhook endpoints require at least one event.', { + provider: 'comms', + code: 'invalid_webhook_events', + }); + } + + const raw = await request( + '/webhooks', + { + method: 'POST', + body: JSON.stringify({ url: input.url, events: input.events }), + }, + { operation: 'create webhook endpoint' }, + ); + const parsed = WebhookEndpointResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Comms returned an invalid webhook response.', { + provider: 'comms', + code: 'invalid_provider_response', + raw, + }); + } + return mapWebhookEndpoint(parsed.data.webhook, rawObject(raw, 'webhook')); + }, + }; + + return defineProvider({ + name: 'comms', + capabilities: COMMS_CAPABILITIES, + messages, + conversations, + contacts, + deliveryEvents, + webhookEndpoints, + }); +} diff --git a/packages/providers/comms/test/comms.integration.test.ts b/packages/providers/comms/test/comms.integration.test.ts new file mode 100644 index 0000000..24490e6 --- /dev/null +++ b/packages/providers/comms/test/comms.integration.test.ts @@ -0,0 +1,56 @@ +import process from 'node:process'; + +import { describe, expect, it } from 'vitest'; + +import { createIMessageClient } from 'imessage-sdk'; + +import { comms } from '../src/index.js'; + +const enabled = process.env['COMMS_LIVE_TEST'] === '1'; + +describe.skipIf(!enabled)('Comms live API', () => { + it('exercises documented Comms text and read operations', async () => { + const recipientNumber = required('COMMS_TEST_RECIPIENT'); + const provider = comms(); + const client = createIMessageClient({ + connectionId: 'comms-live', + provider, + }); + const conversation = await client.conversations.open({ + participants: [{ kind: 'phone', value: recipientNumber }], + }); + const startedAt = new Date(); + const run = String(Date.now()); + + const sent = await client.messages.send({ + to: { kind: 'phone', value: recipientNumber }, + text: `Hello from imessage-sdk ${run}`, + idempotencyKey: `imessage-sdk-comms-${run}`, + }); + const messages = await provider.messages.list({ + since: startedAt, + direction: 'outbound', + limit: 20, + }); + const conversations = await provider.conversations.list({ limit: 20 }); + const contacts = await provider.contacts.list({ limit: 20 }); + const events = await provider.deliveryEvents.list({ limit: 20 }); + const webhooks = await provider.webhookEndpoints.list(); + + expect(conversation.providerConversationId).toBe(recipientNumber); + expect(sent.providerMessageId).toBeTruthy(); + expect(messages.some((message) => message.id === sent.providerMessageId)).toBe(true); + expect(conversations).toBeInstanceOf(Array); + expect(contacts).toBeInstanceOf(Array); + expect(events).toBeInstanceOf(Array); + expect(webhooks).toBeInstanceOf(Array); + }, 60_000); +}); + +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required when COMMS_LIVE_TEST=1.`); + } + return value; +} diff --git a/packages/providers/comms/test/comms.test.ts b/packages/providers/comms/test/comms.test.ts new file mode 100644 index 0000000..064add7 --- /dev/null +++ b/packages/providers/comms/test/comms.test.ts @@ -0,0 +1,421 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { + AmbiguousDeliveryError, + AuthenticationError, + createIMessageClient, + RateLimitError, + UnsupportedCapabilityError, + ValidationError, +} from 'imessage-sdk'; + +import type { CommsProvider } from '../src/index.js'; +import { comms } from '../src/index.js'; + +const apiBaseUrl = 'https://comms.test/api/v1/comms'; +const recipientNumber = '+15551111111'; +const recipient = { kind: 'phone', value: recipientNumber } as const; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function configuredProvider(): CommsProvider { + return comms({ apiKey: 'test-key', baseUrl: apiBaseUrl }); +} + +function message(overrides: Readonly> = {}) { + return { + id: 'msg_123', + body: 'Hello', + direction: 'outbound', + conversation_id: 'conv_123', + to: recipientNumber, + channel: 'imessage', + status: 'accepted', + created_at: '2026-07-27T10:00:00.000Z', + ...overrides, + }; +} + +describe('Comms provider', () => { + it('preserves its literal name, capabilities, and provider-specific methods', () => { + const provider = configuredProvider(); + + expectTypeOf(provider.name).toEqualTypeOf<'comms'>(); + expectTypeOf(provider.capabilities.messages.text).toEqualTypeOf(); + expectTypeOf(provider.capabilities.messages.attachments).toEqualTypeOf(); + expectTypeOf(provider.capabilities.messages.replies).toEqualTypeOf(); + expectTypeOf(provider.capabilities.conversations.direct).toEqualTypeOf(); + expectTypeOf(provider.capabilities.conversations.groups).toEqualTypeOf(); + expectTypeOf(provider.capabilities.events.webhooks).toEqualTypeOf(); + expectTypeOf(provider.messages.list).toBeFunction(); + expectTypeOf(provider.deliveryEvents.list).toBeFunction(); + expectTypeOf(provider.webhookEndpoints.create).toBeFunction(); + }); + + it('reads the API key from COMMS_API_KEY', async () => { + vi.stubEnv('COMMS_API_KEY', 'environment-key'); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ message: message(), duplicate: false }, 202); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ + provider: comms({ baseUrl: apiBaseUrl }), + }); + + await client.messages.send({ to: recipient, text: 'Environment' }); + + const [, init] = fetchMock.mock.calls[0] ?? []; + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer environment-key'); + }); + + it('sends direct text with an idempotency key', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ message: message(), duplicate: false }, 202); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ + connectionId: 'comms-line', + provider: configuredProvider(), + }); + + const sent = await client.messages.send({ + to: recipient, + text: 'Hello', + idempotencyKey: 'send-123', + }); + + expect(sent).toMatchObject({ + id: 'msg_123', + provider: 'comms', + connectionId: 'comms-line', + providerMessageId: 'msg_123', + conversationId: 'conv_123', + direction: 'outbound', + text: 'Hello', + service: 'imessage', + status: 'accepted', + providerStatus: 'accepted', + }); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe(`${apiBaseUrl}/messages`); + expect(init?.method).toBe('POST'); + expect(new Headers(init?.headers).get('idempotency-key')).toBe('send-123'); + expect(JSON.parse(String(init?.body))).toEqual({ + to: recipientNumber, + body: 'Hello', + }); + }); + + it('continues native conversations and treats direct open IDs as phone destinations', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ message: message(), duplicate: true }); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + + const conversation = await client.conversations.open({ + participants: [recipient], + }); + await client.messages.send({ + conversationId: conversation.id, + text: 'First', + }); + await client.messages.send({ + conversationId: 'conv_123', + text: 'Continue', + }); + + expect(conversation.id).toBe(recipientNumber); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + to: recipientNumber, + body: 'First', + }); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ + conversation_id: 'conv_123', + body: 'Continue', + }); + }); + + it('allows provider-specific channel selection', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ + message: message({ channel: 'sms' }), + }); + }); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + + const sent = await provider.messages.send({ + to: recipient, + text: 'Use SMS', + channel: 'sms', + }); + + expect(sent.service).toBe('sms'); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + to: recipientNumber, + body: 'Use SMS', + channel: 'sms', + }); + }); + + it('lists messages, conversations, contacts, delivery events, and webhook endpoints', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ messages: [message()] })) + .mockResolvedValueOnce(jsonResponse({ conversations: [{ id: 'conv_123', state: 'open' }] })) + .mockResolvedValueOnce( + jsonResponse({ + contacts: [ + { + id: 'contact_123', + phone: recipientNumber, + name: 'Alex', + }, + ], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + deliveries: [ + { + id: 'evt_123', + event: 'message.delivered', + status: 'delivered', + attempts: 1, + last_status: '200', + created_at: '2026-07-27T10:01:00.000Z', + }, + ], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + webhooks: [ + { + id: 'webhook_123', + url: 'https://example.com/comms', + events: ['message.received'], + }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + + const messages = await provider.messages.list({ + conversationId: 'conv_123', + direction: 'outbound', + since: new Date('2026-07-27T09:00:00.000Z'), + limit: 10, + }); + const conversations = await provider.conversations.list({ + state: 'open', + query: recipientNumber, + limit: 5, + }); + const contacts = await provider.contacts.list({ query: 'Alex', limit: 5 }); + const events = await provider.deliveryEvents.list({ limit: 3 }); + const webhooks = await provider.webhookEndpoints.list(); + + expect(messages[0]).toMatchObject({ + id: 'msg_123', + conversationId: 'conv_123', + direction: 'outbound', + }); + expect(conversations[0]).toMatchObject({ id: 'conv_123', state: 'open' }); + expect(contacts[0]).toMatchObject({ + id: 'contact_123', + phone: recipientNumber, + name: 'Alex', + }); + expect(events[0]).toMatchObject({ + id: 'evt_123', + event: 'message.delivered', + attempts: 1, + }); + expect(webhooks[0]).toMatchObject({ + id: 'webhook_123', + url: 'https://example.com/comms', + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + `${apiBaseUrl}/messages?conversation_id=conv_123&since=2026-07-27T09%3A00%3A00.000Z&direction=outbound&limit=10`, + `${apiBaseUrl}/conversations?state=open&q=%2B15551111111&limit=5`, + `${apiBaseUrl}/contacts?q=Alex&limit=5`, + `${apiBaseUrl}/events?limit=3`, + `${apiBaseUrl}/webhooks`, + ]); + }); + + it('upserts contacts through the provider-specific API', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse( + { + contact: { + id: 'contact_123', + phone: recipientNumber, + name: 'Alex', + email: 'alex@example.com', + tags: ['vip'], + }, + }, + 201, + ); + }); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + + const contact = await provider.contacts.upsert({ + phone: recipientNumber, + name: 'Alex', + email: 'alex@example.com', + tags: ['vip'], + }); + + expect(contact).toMatchObject({ + id: 'contact_123', + phone: recipientNumber, + name: 'Alex', + tags: ['vip'], + }); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + phone: recipientNumber, + name: 'Alex', + email: 'alex@example.com', + tags: ['vip'], + }); + }); + + it('creates provider-specific webhook endpoints without enabling normalized webhooks', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse( + { + webhook: { + id: 'webhook_123', + url: 'https://example.com/comms', + events: ['message.received'], + }, + }, + 201, + ); + }); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + const client = createIMessageClient({ provider }); + + const endpoint = await provider.webhookEndpoints.create({ + url: 'https://example.com/comms', + events: ['message.received'], + }); + + expect(endpoint.id).toBe('webhook_123'); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + url: 'https://example.com/comms', + events: ['message.received'], + }); + await expect(client.webhooks.handle(new Request('https://example.com'))).rejects.toBeInstanceOf( + UnsupportedCapabilityError, + ); + }); + + it('rejects unsupported content and invalid direct destinations before calling Comms', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.send({ + to: recipient, + attachments: [ + { + kind: 'image', + source: { type: 'url', url: 'https://example.com/image.png' }, + }, + ], + }), + ).rejects.toBeInstanceOf(UnsupportedCapabilityError); + await expect( + client.conversations.open({ + participants: [recipient, { kind: 'phone', value: '+15552222222' }], + }), + ).rejects.toBeInstanceOf(UnsupportedCapabilityError); + await expect( + configuredProvider().messages.send({ + to: { kind: 'email', value: 'person@example.com' }, + text: 'Hello', + }), + ).rejects.toBeInstanceOf(ValidationError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('maps authentication and rate-limit failures', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ error: 'Invalid API key' }, 401)) + .mockResolvedValueOnce(jsonResponse({ error: 'Slow down', retry_after: 30 }, 429)); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + + await expect( + provider.messages.list({ since: '2026-07-27T00:00:00.000Z' }), + ).rejects.toBeInstanceOf(AuthenticationError); + await expect( + provider.messages.list({ since: '2026-07-27T00:00:00.000Z' }), + ).rejects.toMatchObject({ + name: 'RateLimitError', + retryAfter: 30, + retryable: true, + } satisfies Partial); + }); + + it('rejects unbounded message-list queries', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const provider = configuredProvider(); + + await expect( + provider.messages.list( + // Runtime validation protects JavaScript consumers as well. + {} as { since: string }, + ), + ).rejects.toMatchObject({ + name: 'ValidationError', + code: 'missing_message_list_bound', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses AmbiguousDeliveryError for uncertain sends', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse({ error: 'Unavailable' }, 503)), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect(client.messages.send({ to: recipient, text: 'Uncertain' })).rejects.toBeInstanceOf( + AmbiguousDeliveryError, + ); + }); +}); diff --git a/packages/providers/comms/tsconfig.json b/packages/providers/comms/tsconfig.json new file mode 100644 index 0000000..b61eac9 --- /dev/null +++ b/packages/providers/comms/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/providers/comms/tsup.config.ts b/packages/providers/comms/tsup.config.ts new file mode 100644 index 0000000..150d7e8 --- /dev/null +++ b/packages/providers/comms/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'es2022', + platform: 'neutral', + dts: true, + clean: true, + sourcemap: true, + splitting: false, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c1edf0..d7213c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@imessage-sdk/blooio': specifier: workspace:^ version: link:../providers/blooio + '@imessage-sdk/comms': + specifier: workspace:^ + version: link:../providers/comms '@imessage-sdk/photon': specifier: workspace:^ version: link:../providers/photon @@ -129,6 +132,15 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/providers/comms: + dependencies: + imessage-sdk: + specifier: workspace:^ + version: link:../../imessage-sdk + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/providers/photon: dependencies: '@photon-ai/advanced-imessage': diff --git a/scripts/check-packages.sh b/scripts/check-packages.sh index 7d2bf10..69c3287 100644 --- a/scripts/check-packages.sh +++ b/scripts/check-packages.sh @@ -12,6 +12,7 @@ trap cleanup EXIT pnpm --filter imessage-sdk pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/blooio pack --pack-destination "${PACKAGE_DIR}" +pnpm --filter @imessage-sdk/comms pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/photon pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/sendblue pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/chat-adapter pack --pack-destination "${PACKAGE_DIR}" @@ -35,6 +36,7 @@ pnpm exec tsc --project "${CONSUMER_DIR}/tsconfig.json" node --input-type=module -e ' await import("imessage-sdk"); await import("@imessage-sdk/blooio"); + await import("@imessage-sdk/comms"); await import("@imessage-sdk/photon"); await import("@imessage-sdk/sendblue"); await import("@imessage-sdk/chat-adapter"); diff --git a/test/package-consumer/index.ts b/test/package-consumer/index.ts index be7f072..20bd142 100644 --- a/test/package-consumer/index.ts +++ b/test/package-consumer/index.ts @@ -2,6 +2,7 @@ import type { Adapter } from 'chat'; import { blooio } from '@imessage-sdk/blooio'; import { createIMessageAdapter } from '@imessage-sdk/chat-adapter'; +import { comms } from '@imessage-sdk/comms'; import { photon } from '@imessage-sdk/photon'; import { sendblue } from '@imessage-sdk/sendblue'; import { createIMessageClient } from 'imessage-sdk'; @@ -16,6 +17,11 @@ const photonClient = createIMessageClient({ provider: photon({ projectId: 'test', projectSecret: 'test' }), }); +const commsClient = createIMessageClient({ + connectionId: 'comms-consumer', + provider: comms({ apiKey: 'test' }), +}); + const sendblueClient = createIMessageClient({ connectionId: 'sendblue-consumer', provider: sendblue({ apiKey: 'test', apiSecret: 'test', fromNumber: '+15555550100' }), @@ -32,18 +38,33 @@ const sendblueMarkReadClient = createIMessageClient({ }); const blooioProvider: 'blooio' = blooioClient.provider; +const commsProvider: 'comms' = commsClient.provider; const photonProvider: 'photon' = photonClient.provider; const sendblueProvider: 'sendblue' = sendblueClient.provider; void blooioProvider; +void commsProvider; void photonProvider; void sendblueProvider; void blooioClient.providers.blooio.numbers.list; +void commsClient.providers.comms.messages.list({ + since: new Date(0), + limit: 1, +}); +void commsClient.providers.comms.contacts.list; +void commsClient.providers.comms.deliveryEvents.list; +void commsClient.providers.comms.messages.send({ + to: { kind: 'phone', value: '+15555550100' }, + text: 'Provider-specific channel preference', + channel: 'imessage', +}); void photonClient.providers.photon.connection.getLine; void sendblueClient.providers.sendblue.tapbacks.add; void photonClient.attachments.download; const photonDownloadsAttachments: true = photonClient.capabilities.attachments.download; const blooioDownloadsAttachments: false = blooioClient.capabilities.attachments.download; +const commsSendsText: true = commsClient.capabilities.messages.text; +const commsSendsAttachments: false = commsClient.capabilities.messages.attachments; const sendblueDownloadsAttachments: false = sendblueClient.capabilities.attachments.download; const sendblueSendsAttachments: true = sendblueClient.capabilities.messages.attachments; const sendblueDefaultMarkRead: false = sendblueClient.capabilities.conversations.markRead; @@ -52,6 +73,8 @@ const sendblueReadReceipts: false = sendblueMarkReadClient.capabilities.interact const sendblueMarkRead: true = sendblueMarkReadClient.capabilities.conversations.markRead; void photonDownloadsAttachments; void blooioDownloadsAttachments; +void commsSendsText; +void commsSendsAttachments; void sendblueDownloadsAttachments; void sendblueSendsAttachments; void sendblueDefaultMarkRead; @@ -67,14 +90,22 @@ const sendblueImessage = createIMessageAdapter({ connectionId: 'sendblue-chat-consumer', provider: sendblue({ apiKey: 'test', apiSecret: 'test', fromNumber: '+15555550100' }), }); +const commsImessage = createIMessageAdapter({ + connectionId: 'comms-chat-consumer', + provider: comms({ apiKey: 'test' }), +}); const chatAdapter: Adapter = imessage; const chatProvider: 'blooio' = imessage.client.provider; const sendblueChatAdapter: Adapter = sendblueImessage; +const commsChatAdapter: Adapter = commsImessage; +const commsChatProvider: 'comms' = commsImessage.client.provider; const sendblueChatProvider: 'sendblue' = sendblueImessage.client.provider; void chatAdapter; void chatProvider; void sendblueChatAdapter; +void commsChatAdapter; +void commsChatProvider; void sendblueChatProvider; void imessage.client.providers.blooio.numbers.list; void sendblueImessage.client.providers.sendblue.tapbacks.add;