diff --git a/README.md b/README.md index d2f03a4..55a26df 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ await textbee.sendSms({ | --- | --- | --- | | `deviceId` | `string` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. | | `simSubscriptionId` | `number` | Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. | -| `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future, up to 72 hours ahead. | +| `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future. | ```js await textbee.sendSms({ @@ -76,9 +76,9 @@ History is account-level: one call covers every device, and `deviceIds` narrows ```js // Paginated history across the whole account, filterable and searchable const { data, meta } = await textbee.getMessages({ - direction: 'received', // 'all' | 'sent' | 'received' + direction: 'sent', // 'all' | 'sent' | 'received' deviceIds: [deviceId], // omit for every device - status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends + status: 'delivered', // delivery state; status=failed lists failed sends search: 'invoice', from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count @@ -87,7 +87,7 @@ const { data, meta } = await textbee.getMessages({ }) // direction on each message is lowercase and feeds straight back into filters -data.filter((m) => m.direction === 'received') +data.filter((m) => m.direction === 'sent') // Which recipients of a bulk send failed: filter by the batch a send returned const { smsBatchId } = await textbee.sendSms({ recipients, message }) @@ -102,10 +102,14 @@ for await (const message of textbee.iterateMessages({ direction: 'received', ord // A single message and its current status const sms = await textbee.getSms(deviceId, smsId) -// A whole batch, using the smsBatchId returned by sendSms +// A whole batch, using the smsBatchId returned by sendSms. This needs the id +// of the device that sent it; when textbee picked the device, use +// getMessages({ smsBatchId }) instead. const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId) ``` +`getSms` and `getSmsBatch` return the `Sms` type. It differs from the `Message` that `getMessages` returns: `device` is the device id string, not an object with the phone's name and model, and `direction` is absent (read `type`). + `batch.status` and the batch counts come from the messages in the batch, so they change as each message reports. `successCount` counts messages sent or delivered, and `failureCount` counts failed ones. `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount` and `unknownCount` break the rest down by state. ## Verifying webhooks @@ -211,10 +215,22 @@ try { ```js new Textbee({ apiKey: 'your-api-key', - baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances + baseUrl: 'https://api.textbee.dev/api/v1', // the default }) ``` +### Self-hosted textbee + +Point `baseUrl` at your own instance. The `/api/v1` suffix is optional, so all of these reach the same API: + +```js +new Textbee({ apiKey, baseUrl: 'https://sms.example.com' }) +new Textbee({ apiKey, baseUrl: 'https://sms.example.com/api/v1' }) +new Textbee({ apiKey, baseUrl: 'http://localhost:3001' }) +``` + +If the API sits under a path prefix, give the full path, for example `https://example.com/textbee/api/v1`. Any path other than `/api/v1` is used as given, so a proxy that maps `/sms` onto the API keeps working with `https://example.com/sms`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, throws a `TypeError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev. + ## What is not covered yet The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at [textbee.dev/docs](https://textbee.dev/docs). diff --git a/package.json b/package.json index 5a53569..1305e92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@textbee/sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway", "license": "MIT", "packageManager": "pnpm@9.14.2", diff --git a/src/client.ts b/src/client.ts index df4d107..0624445 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7,6 +7,7 @@ import type { MessageList, SendSmsRequest, SendSmsResponse, + Sms, SmsBatchResult, TextbeeOptions, } from './types' @@ -31,7 +32,7 @@ export class Textbee { } this.#apiKey = options.apiKey - this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '') + this.#baseUrl = normalizeBaseUrl(options.baseUrl) } /** Send an SMS to one or more recipients. */ @@ -117,9 +118,10 @@ export class Textbee { /** * Iterate every message matching the filters, following the pagination - * cursor until the end. Use `order: 'asc'` with a `from` bound to drain - * forward when polling; keep the last message's `createdAt` (or track - * `meta.nextCursor` via `getMessages`) to resume the next poll. + * cursor until the end. `limit` is the page size, not a cap on the total. + * Use `order: 'asc'` with a `from` bound to drain forward when polling. + * `from` is inclusive, so a poll that resumes from the last `createdAt` sees + * that message again; skip ids you have already handled. * * ```ts * for await (const message of textbee.iterateMessages({ direction: 'received' })) { @@ -140,15 +142,19 @@ export class Textbee { } /** Fetch a single message, including its delivery status. */ - async getSms(deviceId: string, smsId: string): Promise { - const payload = await this.#request<{ data: Message }>( + async getSms(deviceId: string, smsId: string): Promise { + const payload = await this.#request<{ data: Sms }>( 'GET', `/gateway/devices/${encodeURIComponent(deviceId)}/sms/${encodeURIComponent(smsId)}`, ) return payload.data } - /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */ + /** + * Fetch a batch and every message in it, by the `smsBatchId` a send returns. + * Needs the id of the device that sent the batch. When textbee picked the + * device, use `getMessages({ smsBatchId })` instead. + */ async getSmsBatch( deviceId: string, smsBatchId: string, @@ -198,6 +204,66 @@ export class Textbee { } } +/** + * Resolve the API root from a base URL. A bare origin gets /api/v1, a path + * containing /api/v1 is cut there, and any other path is kept as given for + * proxies that map a prefix onto the API. Bad values throw rather than falling + * back, so a typo never sends a self-hoster's key to the public API. + */ +function normalizeBaseUrl(raw: string | undefined): string { + const value = raw?.trim() + if (!value) { + return DEFAULT_BASE_URL + } + + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) + ? value + : `https://${value}` + + let url: URL + try { + url = new URL(withScheme) + } catch { + throw new TypeError( + 'baseUrl is not a valid URL. Use your textbee api, ' + + 'for example https://sms.example.com. The /api/v1 suffix is added for you.', + ) + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new TypeError(`baseUrl must be http or https, got "${url.protocol}"`) + } + // textbee never uses basic auth, and https://api.textbee.dev@evil.com reads + // like textbee while sending the key to evil.com. + if (url.username || url.password) { + throw new TypeError('baseUrl must not contain a username or password') + } + + const segments = url.pathname.split('/').filter(Boolean) + let cut = -1 + for (let i = segments.length - 2; i >= 0; i--) { + if ( + segments[i]!.toLowerCase() === 'api' && + segments[i + 1]!.toLowerCase() === 'v1' + ) { + cut = i + 2 + break + } + } + + let path: string[] + if (segments.length === 0) { + path = ['api', 'v1'] + } else if (cut === -1) { + path = segments + } else { + // Anything after /api/v1 is a route, not part of the root, so it is dropped. + path = [...segments.slice(0, cut - 2), 'api', 'v1'] + } + + return [url.origin, ...path].join('/') +} + function toIsoString(value: string | Date | undefined): string | undefined { return value instanceof Date ? value.toISOString() : value } @@ -218,11 +284,21 @@ async function readBody(response: Response): Promise { function errorMessage(payload: unknown, status: number): string { if (payload && typeof payload === 'object') { const record = payload as Record - if (typeof record.error === 'string' && record.error) { - return record.error - } - if (typeof record.message === 'string' && record.message) { - return record.message + // Framework errors ({ statusCode, message, error }) carry the detail in + // message, and error is only the reason phrase such as "Not Found". + const keys = 'statusCode' in record ? ['message', 'error'] : ['error', 'message'] + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value) { + return value + } + if ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => typeof item === 'string') + ) { + return value.join('; ') + } } } diff --git a/src/index.ts b/src/index.ts index 3c39065..ee70575 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ export type { SendSmsQueuedResponse, SendSmsRequest, SendSmsResponse, + SendSmsWarning, + Sms, SmsBatch, SmsBatchResult, TextbeeOptions, diff --git a/src/types.ts b/src/types.ts index 264c024..6790fcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,15 @@ export interface TextbeeOptions { /** API key from the textbee dashboard. Sent as the `x-api-key` header. */ apiKey: string - /** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */ + /** + * Your self-hosted textbee API. Defaults to `https://api.textbee.dev/api/v1`. + * + * A bare origin such as `https://sms.example.com` gets `/api/v1` added. A URL + * that contains `/api/v1` is cut there, so a pasted endpoint URL still works. + * Any other path is used as given, for proxies that map a prefix onto the API. + * A value with no scheme gets `https://`. An invalid URL throws a `TypeError` + * rather than falling back, so a typo never sends your key to the public API. + */ baseUrl?: string } @@ -39,22 +47,35 @@ export interface SendSmsRequest { /** * Send later instead of now, as an ISO 8601 string or a `Date`. * - * Must be in the future, and up to 72 hours ahead. Omit for an immediate send. + * Must be in the future. Omit for an immediate send. */ scheduledAt?: string | Date } +/** A reason the send will probably fail, such as `SMS_PERMISSION_MISSING`. */ +export interface SendSmsWarning { + code: string + message: string +} + /** Returned when the message was accepted for delivery and queued. */ export interface SendSmsQueuedResponse { success: true message: string smsBatchId: string recipientCount: number + /** When a large send, spread over several waves, should finish. */ + estimatedCompletionAt?: string + /** Present when the phone last reported a problem, such as the SMS permission off. */ + warning?: SendSmsWarning } /** Returned when the message was dispatched to the device immediately. */ export interface SendSmsImmediateResponse { successCount: number + failureCount?: number + /** Present when the phone last reported a problem, such as the SMS permission off. */ + warning?: SendSmsWarning [key: string]: unknown } @@ -140,6 +161,7 @@ export interface Device { export interface MessageDevice { _id: string + name?: string brand?: string model?: string buildId?: string @@ -160,7 +182,7 @@ export interface Message { direction?: 'sent' | 'received' /** Message channel. Currently always `sms`; absent means `sms`. */ channel?: string - /** The sending or receiving device, populated by the history endpoints. */ + /** The sending or receiving device. */ device?: MessageDevice /** Lowercase, and absent on messages stored before status tracking. */ status?: @@ -241,6 +263,15 @@ export interface MessageListMeta { hasMore?: boolean } +/** + * A message as `getSms` and `getSmsBatch` return it: `device` is the id + * string, and `direction` is absent (read `type`). + */ +export interface Sms extends Omit { + /** The id of the sending or receiving device. */ + device?: string +} + export interface MessageList { data: Message[] meta: MessageListMeta @@ -248,7 +279,7 @@ export interface MessageList { export interface SmsBatchResult { batch: SmsBatch - messages: Message[] + messages: Sms[] } /** Event names textbee sends in a webhook payload. */ diff --git a/test/client.test.ts b/test/client.test.ts index 7ae9e2d..8c1009d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -161,6 +161,31 @@ describe('errors', () => { expect((error as TextbeeError).body).toEqual({ error: 'Unauthorized' }) }) + it('prefers message over the reason phrase in framework errors', async () => { + fetchMock.mockImplementation(async () => + respond(404, { + statusCode: 404, + message: 'Cannot GET /sms/gateway/devices', + error: 'Not Found', + }), + ) + await expect(client().getDevices()).rejects.toMatchObject({ + status: 404, + message: 'Cannot GET /sms/gateway/devices', + }) + + fetchMock.mockImplementation(async () => + respond(400, { + statusCode: 400, + message: ['recipients must be an array', 'message should not be empty'], + error: 'Bad Request', + }), + ) + await expect(client().getDevices()).rejects.toMatchObject({ + message: 'recipients must be an array; message should not be empty', + }) + }) + it('falls back to the status when the body carries no error text', async () => { fetchMock.mockImplementation( async () => new Response('gateway', { status: 502 }), @@ -334,6 +359,60 @@ describe('options', () => { expect(lastCall().url).toBe('http://localhost:3001/api/v1/gateway/devices') }) + // Same cases as textbee-mcp's normalizeBaseUrl, so all clients agree. + it.each([ + [undefined, 'https://api.textbee.dev/api/v1'], + ['', 'https://api.textbee.dev/api/v1'], + [' ', 'https://api.textbee.dev/api/v1'], + ['https://sms.example.com', 'https://sms.example.com/api/v1'], + ['https://sms.example.com/', 'https://sms.example.com/api/v1'], + ['https://sms.example.com/api/v1', 'https://sms.example.com/api/v1'], + ['https://sms.example.com/api/v1/', 'https://sms.example.com/api/v1'], + ['https://sms.example.com/textbee/api/v1', 'https://sms.example.com/textbee/api/v1'], + // A prefix without /api/v1 is kept as given, for proxies that rewrite it. + ['https://sms.example.com/textbee', 'https://sms.example.com/textbee'], + ['https://example.com/sms/', 'https://example.com/sms'], + ['https://api.textbee.dev/api/v1/gateway/send-sms', 'https://api.textbee.dev/api/v1'], + ['api.textbee.dev', 'https://api.textbee.dev/api/v1'], + ['localhost:3001', 'https://localhost:3001/api/v1'], + ['http://localhost:3001', 'http://localhost:3001/api/v1'], + ['http://127.0.0.1:3001/api/v1', 'http://127.0.0.1:3001/api/v1'], + ['http://[::1]:3001', 'http://[::1]:3001/api/v1'], + ['http://sms.example.com', 'http://sms.example.com/api/v1'], + ['https://sms.example.com/API/V1', 'https://sms.example.com/api/v1'], + ['https://sms.example.com/v1', 'https://sms.example.com/v1'], + ['https://example.com/my app/api/v1', 'https://example.com/my%20app/api/v1'], + ['https://b\u00fccher.example', 'https://xn--bcher-kva.example/api/v1'], + ['HTTPS://SMS.Example.com:443/?q=1#top', 'https://sms.example.com/api/v1'], + ])('normalizes baseUrl %j to %j', async (baseUrl, expected) => { + fetchMock.mockImplementation(async () => respond(200, { data: [] })) + + await client({ baseUrl }).getDevices() + + expect(lastCall().url).toBe(`${expected}/gateway/devices`) + }) + + it.each([ + 'not a url', + 'ftp://sms.example.com', + 'https://', + 'https://sms.example.com:99999', + 'https://user:pass@sms.example.com', + 'https://api.textbee.dev@evil.example', + ])( + 'rejects baseUrl %j instead of falling back to the public api', + (baseUrl) => { + expect(() => client({ baseUrl })).toThrow(TypeError) + expect(() => client({ baseUrl })).not.toThrow(/api\.textbee\.dev/) + }, + ) + + it('keeps credentials in an invalid baseUrl out of the error', () => { + expect(() => client({ baseUrl: 'https://user:secret@sms.example.com:99999' })).toThrow( + /^(?!.*secret)/s, + ) + }) + it('rejects a missing apiKey', () => { expect(() => new Textbee({ apiKey: '' })).toThrow(TypeError) // @ts-expect-error exercising the runtime guard for untyped callers