From eb492dede3f665c0434cf1ba26203ee7f30e6416 Mon Sep 17 00:00:00 2001 From: sarveshpatil-plivo Date: Thu, 9 Jul 2026 23:40:07 +0530 Subject: [PATCH 1/5] feat(channels): add Plivo SMS channel adapter Fill the `sms` channel slot with a Plivo-backed adapter using Plivo's Messaging API. Outbound sends via POST /v1/Account/{authId}/Message/; inbound messages arrive on a webhook the host forwards to handleIncomingWebhook, which verifies the X-Plivo-Signature-V3 signature and fails closed before emitting. Register the adapter in adapters/index.ts and document setup in docs/features/CHANNELS.md. Unit tests cover outbound send, inbound emit/drop, and a golden-fixture signature check against the Plivo SDK. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: sarveshpatil-plivo --- docs/features/CHANNELS.md | 45 ++- .../adapters/PlivoSmsChannelAdapter.ts | 379 ++++++++++++++++++ .../__tests__/PlivoSmsChannelAdapter.test.ts | 216 ++++++++++ src/io/channels/adapters/index.ts | 3 + 4 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/io/channels/adapters/PlivoSmsChannelAdapter.ts create mode 100644 src/io/channels/adapters/__tests__/PlivoSmsChannelAdapter.test.ts diff --git a/docs/features/CHANNELS.md b/docs/features/CHANNELS.md index 5bdc931ed8a..bc88334be7d 100644 --- a/docs/features/CHANNELS.md +++ b/docs/features/CHANNELS.md @@ -44,7 +44,7 @@ Channels are registered as `messaging-channel` extensions and managed by the | `imessage` | Chat | macOS only — no env vars | | `matrix` | Chat | `MATRIX_HOMESERVER_URL`, `MATRIX_ACCESS_TOKEN` | | `webchat` | Chat | `WEBCHAT_SECRET` (for webhook validation) | -| `sms` | Messaging | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE` | +| `sms` | Messaging | `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_PHONE` | | `email` | Messaging | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` | | `line` | Chat | `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET` | | `zalo` | Chat | `ZALO_APP_ID`, `ZALO_APP_SECRET` | @@ -286,6 +286,49 @@ router.registerAdapter(whatsapp); --- +### SMS (Plivo) + +SMS is backed by [Plivo](https://www.plivo.com). Get your Auth ID and Auth Token from the Plivo console at [cx.plivo.com](https://cx.plivo.com), and use one of your Plivo numbers as the sender. + +```bash +export PLIVO_AUTH_ID=your-auth-id +export PLIVO_AUTH_TOKEN=your-auth-token +export PLIVO_PHONE=+14150000000 +``` + +```typescript +import { PlivoSmsChannelAdapter } from '@framers/agentos'; // src/io/channels/adapters + +const sms = new PlivoSmsChannelAdapter(); +await sms.initialize({ + platform: 'sms', + credential: process.env.PLIVO_AUTH_TOKEN!, // Auth Token + params: { + authId: process.env.PLIVO_AUTH_ID!, + phoneNumber: process.env.PLIVO_PHONE!, + // The externally-visible URL you set as the number's Message URL in Plivo. + webhookUrl: 'https://your-host.example/plivo/inbound', + }, +}); + +router.registerAdapter(sms); +``` + +**Inbound messages.** Point your Plivo number's Message URL at a route on your host and forward the request to the adapter. Plivo signs inbound webhooks, so pass the method, the exact URL Plivo posted to, and the headers — the adapter verifies `X-Plivo-Signature-V3` and drops anything unsigned or tampered: + +```typescript +app.post('/plivo/inbound', (req, res) => { + sms.handleIncomingWebhook(req.body, { + method: 'POST', + url: 'https://your-host.example/plivo/inbound', + headers: req.headers, + }); + res.sendStatus(200); +}); +``` + +--- + ## Custom Channel Adapter Implement [`IChannelAdapter`](https://github.com/framerslab/agentos/blob/master/src/io/channels/IChannelAdapter.ts) to add any platform not in the built-in set: diff --git a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts new file mode 100644 index 00000000000..0ceef56b614 --- /dev/null +++ b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts @@ -0,0 +1,379 @@ +/** + * @fileoverview Plivo SMS Channel Adapter for AgentOS. + * + * Fills the `sms` channel slot using Plivo's Messaging API. Bidirectional: + * + * 1. **Outbound** — sends SMS via `POST /v1/Account/{authId}/Message/` using + * HTTP Basic auth (Auth ID / Auth Token). + * 2. **Inbound** — Plivo POSTs incoming messages to a configured message URL. + * The host application forwards the request to {@link handleIncomingWebhook}, + * which verifies the `X-Plivo-Signature-V3` signature before emitting. + * + * The adapter does NOT start its own HTTP server; the host wires a route + * (Express/Fastify/etc.) that forwards inbound requests here — the same + * pattern used by {@link WhatsAppChannelAdapter}. + * + * Voice for Plivo already ships separately under `telephony/providers/plivo.ts`; + * this adapter is SMS only. + * + * @example + * ```typescript + * const sms = new PlivoSmsChannelAdapter(); + * await sms.initialize({ + * platform: 'sms', + * credential: process.env.PLIVO_AUTH_TOKEN!, // Auth Token + * params: { + * authId: process.env.PLIVO_AUTH_ID!, + * phoneNumber: '+14150000002', // Plivo sender number + * webhookUrl: 'https://myhost.example/plivo/inbound', // signed message URL + * }, + * }); + * ``` + * + * @module @framers/agentos/channels/adapters/PlivoSmsChannelAdapter + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { + ChannelAuthConfig, + ChannelCapability, + ChannelMessage, + ChannelPlatform, + ChannelSendResult, + MessageContent, + MessageContentBlock, +} from '../types.js'; +import { BaseChannelAdapter } from './BaseChannelAdapter.js'; +import type { RetryConfig } from './BaseChannelAdapter.js'; + +// ============================================================================ +// PlivoSmsChannelAdapter +// ============================================================================ + +/** + * Channel adapter for SMS backed by Plivo. + * + * Capabilities: text. (MMS media is out of scope for this adapter.) + */ +export class PlivoSmsChannelAdapter extends BaseChannelAdapter { + readonly platform: ChannelPlatform = 'sms'; + readonly displayName = 'Plivo SMS'; + readonly capabilities: readonly ChannelCapability[] = ['text'] as const; + + /** Plivo Auth ID (account id, used in the API path and Basic auth). */ + private authId: string | undefined; + /** Plivo Auth Token (Basic auth password + inbound-webhook HMAC key). */ + private authToken: string | undefined; + /** Sender number / short code / sender id used as `src`. */ + private phoneNumber: string | undefined; + /** Externally-visible message URL Plivo signs, for inbound verification. */ + private webhookUrl: string | undefined; + /** When true (default), inbound webhooks must carry a valid V3 signature. */ + private verifySignatureEnabled = true; + /** Pre-computed `Authorization: Basic ...` header value. */ + private authHeader: string | undefined; + /** Fetch implementation (injectable for tests). */ + private readonly fetchImpl: typeof fetch; + + /** + * @param opts.fetchImpl - Override the global fetch (inject a mock in tests). + * @param opts.retryConfig - Connection retry tuning (see BaseChannelAdapter). + */ + constructor(opts?: { fetchImpl?: typeof fetch; retryConfig?: Partial }) { + super(opts?.retryConfig); + this.fetchImpl = opts?.fetchImpl ?? globalThis.fetch; + } + + // ── Abstract hook implementations ── + + protected async doConnect( + auth: ChannelAuthConfig & { params?: PlivoSmsAuthParams }, + ): Promise { + const params = auth.params ?? ({} as PlivoSmsAuthParams); + + this.authId = params.authId; + this.authToken = params.authToken ?? auth.credential; + this.phoneNumber = params.phoneNumber; + this.webhookUrl = params.webhookUrl; + this.verifySignatureEnabled = params.verifySignature !== 'false'; + + if (!this.authId) { + throw new Error('Plivo authId is required for SMS.'); + } + if (!this.authToken) { + throw new Error( + 'Plivo Auth Token is required. Provide it as credential or params.authToken.', + ); + } + if (!this.phoneNumber) { + throw new Error('A Plivo sender number (params.phoneNumber) is required.'); + } + + this.authHeader = + 'Basic ' + Buffer.from(`${this.authId}:${this.authToken}`).toString('base64'); + + // Verify credentials by fetching the account. Tolerate failure — the + // credentials may still be valid for messaging even if this GET fails. + try { + const resp = await this.fetchImpl( + `https://api.plivo.com/v1/Account/${this.authId}/`, + { headers: { Authorization: this.authHeader } }, + ); + if (resp.ok) { + const data = (await resp.json()) as Record; + this.platformInfo = { + provider: 'plivo', + authId: this.authId, + phoneNumber: this.phoneNumber, + accountName: data.name, + }; + console.log(`[Plivo SMS] Connected (${data.name ?? this.authId}, ${this.phoneNumber})`); + return; + } + console.warn(`[Plivo SMS] Account verification returned HTTP ${resp.status}.`); + } catch (err) { + console.warn(`[Plivo SMS] Account verification failed: ${err}`); + } + this.platformInfo = { provider: 'plivo', authId: this.authId, phoneNumber: this.phoneNumber }; + console.log(`[Plivo SMS] Connected (${this.phoneNumber})`); + } + + protected async doSendMessage( + conversationId: string, + content: MessageContent, + ): Promise { + if (!this.authHeader || !this.authId || !this.phoneNumber) { + throw new Error('[Plivo SMS] Adapter is not connected.'); + } + + // SMS carries text only; collapse text blocks into one message body. + const text = content.blocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n') + .trim(); + + if (!text) { + throw new Error('[Plivo SMS] Only text content is supported and none was provided.'); + } + + const resp = await this.fetchImpl( + `https://api.plivo.com/v1/Account/${this.authId}/Message/`, + { + method: 'POST', + headers: { + Authorization: this.authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + src: this.phoneNumber, + dst: conversationId, + text, + type: 'sms', + }), + }, + ); + + if (!resp.ok) { + const errText = await resp.text().catch(() => String(resp.status)); + throw new Error(`[Plivo SMS] Send failed — HTTP ${resp.status}: ${errText}`); + } + + const data = (await resp.json()) as { message_uuid?: string[]; api_id?: string }; + const messageId = data.message_uuid?.[0] ?? data.api_id ?? ''; + + return { messageId, timestamp: new Date().toISOString() }; + } + + protected async doShutdown(): Promise { + this.authHeader = undefined; + this.authToken = undefined; + this.authId = undefined; + this.phoneNumber = undefined; + console.log('[Plivo SMS] Adapter shut down.'); + } + + // ── Public: inbound webhook ── + + /** + * Handle an inbound Plivo SMS webhook. The host forwards Plivo's POST here. + * + * When signature verification is enabled (the default), the request must + * carry valid `X-Plivo-Signature-V3` / `-Nonce` headers and the URL Plivo + * signed; otherwise the message is dropped (fail closed). + * + * @param body - Parsed form/JSON body of Plivo's inbound-message POST. + * @param meta - Request metadata needed to verify the V3 signature. + */ + handleIncomingWebhook( + body: Record, + meta?: { + method?: string; + /** The exact externally-visible URL Plivo POSTed to (must byte-match). */ + url?: string; + headers?: Record; + }, + ): void { + if (this.status !== 'connected') return; + + if (this.verifySignatureEnabled && !this.isFromPlivo(body, meta)) { + console.warn('[Plivo SMS] Dropping inbound webhook — signature missing or invalid.'); + return; + } + + const from = String(body.From ?? ''); + const text = String(body.Text ?? ''); + const messageUuid = String(body.MessageUUID ?? ''); + + if (!from || !messageUuid) { + console.warn('[Plivo SMS] Dropping inbound webhook — missing From or MessageUUID.'); + return; + } + + const channelMessage: ChannelMessage = { + messageId: messageUuid, + platform: 'sms', + conversationId: from, + conversationType: 'direct', + sender: { id: from }, + content: [{ type: 'text', text }], + text, + timestamp: new Date().toISOString(), + rawEvent: body, + }; + + this.emit({ + type: 'message', + platform: 'sms', + conversationId: from, + timestamp: channelMessage.timestamp, + data: channelMessage, + }); + } + + // ── Private: signature verification ── + + /** + * Verify an inbound request carries a valid Plivo X-Plivo-Signature-V3. + * Fails closed: missing headers/URL/token → not from Plivo. + */ + private isFromPlivo( + body: Record, + meta?: { method?: string; url?: string; headers?: Record }, + ): boolean { + const headers = meta?.headers ?? {}; + const signature = headerValue(headers, 'x-plivo-signature-v3'); + const nonce = headerValue(headers, 'x-plivo-signature-v3-nonce'); + const url = meta?.url ?? this.webhookUrl; + const method = (meta?.method ?? 'POST').toUpperCase(); + + if (!signature || !nonce || !url || !this.authToken) return false; + + let expected: string; + try { + expected = computePlivoV3Signature({ method, url, nonce, authToken: this.authToken, params: body }); + } catch { + return false; // malformed URL/input is untrusted, not a crash + } + + const expectedBuf = Buffer.from(expected); + return signature.split(',').some((candidate) => { + const candBuf = Buffer.from(candidate.trim()); + return candBuf.length === expectedBuf.length && timingSafeEqual(candBuf, expectedBuf); + }); + } +} + +// ============================================================================ +// Signature helpers (exported for testing against the Plivo SDK golden fixture) +// ============================================================================ + +/** + * Compute Plivo's X-Plivo-Signature-V3 for a callback, matching the algorithm + * in `plivo-python`'s `signature_v3.py`. + * + * For a POST callback the signed string is: + * `{scheme}://{host}{path}?` + (query as sorted `k=v&…` + `.` if present) + * + sorted, separator-less `key`+`value` body params + `.` + nonce + * then `base64(HMAC_SHA256(authToken, signedString))`. + */ +export function computePlivoV3Signature(input: { + method: string; + url: string; + nonce: string; + authToken: string; + params: Record; +}): string { + const { method, url, nonce, authToken, params } = input; + const parsed = new URL(url); + const base = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + const isPost = method.toUpperCase() === 'POST'; + + let signed = base + '?'; + + // If the URL carried a query string, append it as sorted k=v&k=v. + if (parsed.search && parsed.search.length > 1) { + signed += [...parsed.searchParams.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + if (isPost) signed += '.'; // separator between query and the POST params + } + + // POST callbacks append sorted key+value body params, then '.' + nonce. + // GET callbacks are signed over the query string alone (params ARE the query). + if (isPost) { + signed += sortedParamsString(params) + '.' + nonce; + } + + return createHmac('sha256', authToken).update(signed).digest('base64'); +} + +/** Sorted, separator-less `key`+`value` concatenation (recurses dicts, sorts lists). */ +function sortedParamsString(params: Record): string { + let out = ''; + for (const key of Object.keys(params).sort()) { + const value = params[key]; + if (Array.isArray(value)) { + for (const item of [...value].map(String).sort()) out += key + item; + } else if (value !== null && typeof value === 'object') { + out += key + sortedParamsString(value as Record); + } else { + out += key + String(value); + } + } + return out; +} + +/** Case-insensitive single-header lookup; for array-valued headers, takes the first. */ +function headerValue( + headers: Record, + name: string, +): string | undefined { + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() !== name) continue; + if (typeof v === 'string') return v; + // Node/Express surface duplicated headers as arrays — use the first value. + if (Array.isArray(v) && typeof v[0] === 'string') return v[0]; + } + return undefined; +} + +// ============================================================================ +// Plivo SMS Auth Params +// ============================================================================ + +/** Platform-specific parameters for a Plivo SMS connection. */ +export interface PlivoSmsAuthParams extends Record { + /** Plivo Auth ID (account identifier). Required. */ + authId?: string; + /** Plivo Auth Token. If omitted, the `credential` field is used. */ + authToken?: string; + /** Plivo sender number / short code / sender id used as `src`. Required. */ + phoneNumber?: string; + /** Externally-visible message URL Plivo signs; used to verify inbound webhooks. */ + webhookUrl?: string; + /** Set to the string `'false'` to disable inbound signature verification. */ + verifySignature?: string; +} diff --git a/src/io/channels/adapters/__tests__/PlivoSmsChannelAdapter.test.ts b/src/io/channels/adapters/__tests__/PlivoSmsChannelAdapter.test.ts new file mode 100644 index 00000000000..a40fa14e261 --- /dev/null +++ b/src/io/channels/adapters/__tests__/PlivoSmsChannelAdapter.test.ts @@ -0,0 +1,216 @@ +/** + * @fileoverview Unit tests for {@link PlivoSmsChannelAdapter}. + * + * Covers: + * - X-Plivo-Signature-V3 computation against the Plivo SDK golden fixture. + * - Outbound SMS send (request shape + returned message id). + * - Inbound webhook: valid signature emits a message; invalid/missing drops it. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { + PlivoSmsChannelAdapter, + computePlivoV3Signature, +} from '../PlivoSmsChannelAdapter.js'; +import type { ChannelEvent } from '../../types.js'; + +// --------------------------------------------------------------------------- +// Golden fixture — from plivo-python@989c589 signature_v3.py. A correct +// validator MUST reproduce SIG_EXPECTED for these inputs. +// --------------------------------------------------------------------------- + +const FIXTURE = { + method: 'POST', + url: 'https://example.com/plivo/inbound', + nonce: 'f4b1c2d3e5', + authToken: 'FAKE_AUTH_TOKEN_1234567890', + params: { + From: '+14150000001', + To: '+14150000002', + Text: 'test', + Type: 'sms', + MessageUUID: '11111111-2222-3333-4444-555555555555', + }, +} as const; +const SIG_EXPECTED = 'BsYEsmZvb8pj7+RQtDfliZnZKIBlAmvq4t8a3d6MkXU='; + +describe('computePlivoV3Signature', () => { + it('reproduces the Plivo SDK golden fixture', () => { + expect(computePlivoV3Signature({ ...FIXTURE, params: { ...FIXTURE.params } })).toBe( + SIG_EXPECTED, + ); + }); + + it('is order-independent on params (keys are sorted before signing)', () => { + const shuffled = { + Type: 'sms', + MessageUUID: '11111111-2222-3333-4444-555555555555', + From: '+14150000001', + Text: 'test', + To: '+14150000002', + }; + expect(computePlivoV3Signature({ ...FIXTURE, params: shuffled })).toBe(SIG_EXPECTED); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function connect( + adapter: PlivoSmsChannelAdapter, + overrides: Record = {}, +): Promise { + await adapter.initialize({ + platform: 'sms', + credential: FIXTURE.authToken, + params: { + authId: 'test-auth-id', + phoneNumber: '+14150000002', + webhookUrl: FIXTURE.url, + ...overrides, + }, + }); +} + +/** A fetch stub that returns the account GET, then the send response. */ +function makeFetch(sendResponse: unknown, sendOk = true): typeof fetch { + return vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const isSend = init?.method === 'POST'; + const bodyObj = isSend ? sendResponse : { name: 'Test Account' }; + return { + ok: isSend ? sendOk : true, + status: isSend && !sendOk ? 400 : 200, + json: async () => bodyObj, + text: async () => JSON.stringify(bodyObj), + } as Response; + }) as unknown as typeof fetch; +} + +// --------------------------------------------------------------------------- +// Outbound +// --------------------------------------------------------------------------- + +describe('PlivoSmsChannelAdapter — outbound', () => { + it('POSTs to the Message endpoint with {src,dst,text,type} and returns the message uuid', async () => { + const fetchImpl = makeFetch({ message: 'message(s) queued', message_uuid: ['uuid-123'], api_id: 'api-1' }); + const adapter = new PlivoSmsChannelAdapter({ fetchImpl }); + await connect(adapter); + + const result = await adapter.sendMessage('+14150000001', { + blocks: [{ type: 'text', text: 'hello' }], + }); + + expect(result.messageId).toBe('uuid-123'); + + const calls = (fetchImpl as unknown as ReturnType).mock.calls; + const sendCall = calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST'); + expect(sendCall?.[0]).toBe('https://api.plivo.com/v1/Account/test-auth-id/Message/'); + expect(JSON.parse((sendCall?.[1] as RequestInit).body as string)).toEqual({ + src: '+14150000002', + dst: '+14150000001', + text: 'hello', + type: 'sms', + }); + }); + + it('throws when no text content is provided', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter); + await expect( + adapter.sendMessage('+14150000001', { blocks: [{ type: 'image', url: 'x' }] }), + ).rejects.toThrow(/text/i); + }); +}); + +// --------------------------------------------------------------------------- +// Inbound +// --------------------------------------------------------------------------- + +describe('PlivoSmsChannelAdapter — inbound', () => { + const inboundBody = { ...FIXTURE.params }; + const validHeaders = { + 'x-plivo-signature-v3': SIG_EXPECTED, + 'x-plivo-signature-v3-nonce': FIXTURE.nonce, + }; + + it('emits a message event when the signature is valid', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter); + + const events: ChannelEvent[] = []; + adapter.on((e) => void events.push(e), ['message']); + + adapter.handleIncomingWebhook(inboundBody, { + method: 'POST', + url: FIXTURE.url, + headers: validHeaders, + }); + + expect(events).toHaveLength(1); + expect(events[0].platform).toBe('sms'); + const msg = events[0].data as { conversationId: string; text: string }; + expect(msg.conversationId).toBe('+14150000001'); + expect(msg.text).toBe('test'); + }); + + it('accepts array-valued signature headers (Node/Express duplicate-header shape)', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter); + + const events: ChannelEvent[] = []; + adapter.on((e) => void events.push(e), ['message']); + + adapter.handleIncomingWebhook(inboundBody, { + method: 'POST', + url: FIXTURE.url, + headers: { + 'x-plivo-signature-v3': [SIG_EXPECTED], + 'x-plivo-signature-v3-nonce': [FIXTURE.nonce], + }, + }); + + expect(events).toHaveLength(1); + }); + + it('drops the message when the signature is invalid', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter); + + const events: ChannelEvent[] = []; + adapter.on((e) => void events.push(e), ['message']); + + adapter.handleIncomingWebhook(inboundBody, { + method: 'POST', + url: FIXTURE.url, + headers: { ...validHeaders, 'x-plivo-signature-v3': 'WRONGSIGNATURE=' }, + }); + + expect(events).toHaveLength(0); + }); + + it('drops the message when signature headers are absent (fail closed)', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter); + + const events: ChannelEvent[] = []; + adapter.on((e) => void events.push(e), ['message']); + + adapter.handleIncomingWebhook(inboundBody, { method: 'POST', url: FIXTURE.url, headers: {} }); + + expect(events).toHaveLength(0); + }); + + it('emits without verification when verifySignature is disabled', async () => { + const adapter = new PlivoSmsChannelAdapter({ fetchImpl: makeFetch({}) }); + await connect(adapter, { verifySignature: 'false' }); + + const events: ChannelEvent[] = []; + adapter.on((e) => void events.push(e), ['message']); + + adapter.handleIncomingWebhook(inboundBody); + + expect(events).toHaveLength(1); + }); +}); diff --git a/src/io/channels/adapters/index.ts b/src/io/channels/adapters/index.ts index 925e199f059..4860ed6340d 100644 --- a/src/io/channels/adapters/index.ts +++ b/src/io/channels/adapters/index.ts @@ -27,6 +27,9 @@ export type { SlackAuthParams } from './SlackChannelAdapter.js'; export { WhatsAppChannelAdapter } from './WhatsAppChannelAdapter.js'; export type { WhatsAppAuthParams } from './WhatsAppChannelAdapter.js'; +export { PlivoSmsChannelAdapter, computePlivoV3Signature } from './PlivoSmsChannelAdapter.js'; +export type { PlivoSmsAuthParams } from './PlivoSmsChannelAdapter.js'; + export { WebChatChannelAdapter } from './WebChatChannelAdapter.js'; export type { WebChatAuthParams } from './WebChatChannelAdapter.js'; From 5d98c3c6c3a00ded94f89f8e626ea25bb186fc7d Mon Sep 17 00:00:00 2001 From: sarveshpatil-plivo Date: Fri, 10 Jul 2026 22:02:29 +0530 Subject: [PATCH 2/5] refactor(channels): register Plivo as its own 'plivo' provider Register the adapter under a dedicated 'plivo' ChannelPlatform instead of taking over the shared 'sms' slot, and restore the original Twilio entry in the channels doc (Plivo is added as a separate row/section, not a swap). Signed-off-by: sarveshpatil-plivo --- docs/features/CHANNELS.md | 9 +++++---- src/io/channels/adapters/PlivoSmsChannelAdapter.ts | 8 ++++---- .../adapters/__tests__/PlivoSmsChannelAdapter.test.ts | 4 ++-- src/io/channels/types.ts | 1 + 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/features/CHANNELS.md b/docs/features/CHANNELS.md index bc88334be7d..dc740a0f86f 100644 --- a/docs/features/CHANNELS.md +++ b/docs/features/CHANNELS.md @@ -44,7 +44,8 @@ Channels are registered as `messaging-channel` extensions and managed by the | `imessage` | Chat | macOS only — no env vars | | `matrix` | Chat | `MATRIX_HOMESERVER_URL`, `MATRIX_ACCESS_TOKEN` | | `webchat` | Chat | `WEBCHAT_SECRET` (for webhook validation) | -| `sms` | Messaging | `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_PHONE` | +| `sms` | Messaging | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE` | +| `plivo` | Messaging | `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_PHONE` | | `email` | Messaging | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` | | `line` | Chat | `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET` | | `zalo` | Chat | `ZALO_APP_ID`, `ZALO_APP_SECRET` | @@ -286,9 +287,9 @@ router.registerAdapter(whatsapp); --- -### SMS (Plivo) +### Plivo (SMS) -SMS is backed by [Plivo](https://www.plivo.com). Get your Auth ID and Auth Token from the Plivo console at [cx.plivo.com](https://cx.plivo.com), and use one of your Plivo numbers as the sender. +Plivo is available as its own messaging channel for SMS. Get your Auth ID and Auth Token from the Plivo console at [cx.plivo.com](https://cx.plivo.com), and use one of your Plivo numbers as the sender. ```bash export PLIVO_AUTH_ID=your-auth-id @@ -301,7 +302,7 @@ import { PlivoSmsChannelAdapter } from '@framers/agentos'; // src/io/channels/ad const sms = new PlivoSmsChannelAdapter(); await sms.initialize({ - platform: 'sms', + platform: 'plivo', credential: process.env.PLIVO_AUTH_TOKEN!, // Auth Token params: { authId: process.env.PLIVO_AUTH_ID!, diff --git a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts index 0ceef56b614..dbb544b8cbe 100644 --- a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts +++ b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts @@ -20,7 +20,7 @@ * ```typescript * const sms = new PlivoSmsChannelAdapter(); * await sms.initialize({ - * platform: 'sms', + * platform: 'plivo', * credential: process.env.PLIVO_AUTH_TOKEN!, // Auth Token * params: { * authId: process.env.PLIVO_AUTH_ID!, @@ -57,7 +57,7 @@ import type { RetryConfig } from './BaseChannelAdapter.js'; * Capabilities: text. (MMS media is out of scope for this adapter.) */ export class PlivoSmsChannelAdapter extends BaseChannelAdapter { - readonly platform: ChannelPlatform = 'sms'; + readonly platform: ChannelPlatform = 'plivo'; readonly displayName = 'Plivo SMS'; readonly capabilities: readonly ChannelCapability[] = ['text'] as const; @@ -233,7 +233,7 @@ export class PlivoSmsChannelAdapter extends BaseChannelAdapter = {}, ): Promise { await adapter.initialize({ - platform: 'sms', + platform: 'plivo', credential: FIXTURE.authToken, params: { authId: 'test-auth-id', @@ -149,7 +149,7 @@ describe('PlivoSmsChannelAdapter — inbound', () => { }); expect(events).toHaveLength(1); - expect(events[0].platform).toBe('sms'); + expect(events[0].platform).toBe('plivo'); const msg = events[0].data as { conversationId: string; text: string }; expect(msg.conversationId).toBe('+14150000001'); expect(msg.text).toBe('test'); diff --git a/src/io/channels/types.ts b/src/io/channels/types.ts index aa2efcd8c84..da9f749dc31 100644 --- a/src/io/channels/types.ts +++ b/src/io/channels/types.ts @@ -32,6 +32,7 @@ export type ChannelPlatform = | 'zalo' | 'email' | 'sms' + | 'plivo' | 'nostr' | 'twitch' | 'line' From 77ae9c9132e32ed874e41e8494729c3ce187a3c7 Mon Sep 17 00:00:00 2001 From: sarveshpatil-plivo Date: Sat, 11 Jul 2026 00:32:14 +0530 Subject: [PATCH 3/5] Harden Plivo SMS adapter (review fixes) - fail fast on 401/403 account-verification (auth is known-bad), while still tolerating transient/network errors at connect - log when an inbound webhook is dropped because the adapter is not connected Signed-off-by: sarveshpatil-plivo --- src/io/channels/adapters/PlivoSmsChannelAdapter.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts index dbb544b8cbe..21e9f51fe09 100644 --- a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts +++ b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts @@ -131,8 +131,14 @@ export class PlivoSmsChannelAdapter extends BaseChannelAdapter; }, ): void { - if (this.status !== 'connected') return; + if (this.status !== 'connected') { + console.warn('[Plivo SMS] Dropping inbound webhook — adapter not connected.'); + return; + } if (this.verifySignatureEnabled && !this.isFromPlivo(body, meta)) { console.warn('[Plivo SMS] Dropping inbound webhook — signature missing or invalid.'); From 7687712c16c0954fa75d28f3c59db0ff2eb7ec5e Mon Sep 17 00:00:00 2001 From: Sarvesh Patil Date: Tue, 21 Jul 2026 13:05:47 +0530 Subject: [PATCH 4/5] fix(channels): address review on Plivo SMS adapter - Re-export PlivoSmsChannelAdapter and PlivoSmsAuthParams from the channels barrel so the documented import from '@framers/agentos' resolves. - Use PLIVO_PHONE_NUMBER in the docs to match the configured secret name, and note the sender must be E.164. - Log a clear warning when an inbound webhook cannot be verified because no request URL is available, instead of a silent failure that looks like a bad signature. --- docs/features/CHANNELS.md | 6 +++--- src/io/channels/adapters/PlivoSmsChannelAdapter.ts | 9 ++++++++- src/io/channels/index.ts | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/features/CHANNELS.md b/docs/features/CHANNELS.md index dc740a0f86f..85e0076b1d3 100644 --- a/docs/features/CHANNELS.md +++ b/docs/features/CHANNELS.md @@ -45,7 +45,7 @@ Channels are registered as `messaging-channel` extensions and managed by the | `matrix` | Chat | `MATRIX_HOMESERVER_URL`, `MATRIX_ACCESS_TOKEN` | | `webchat` | Chat | `WEBCHAT_SECRET` (for webhook validation) | | `sms` | Messaging | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE` | -| `plivo` | Messaging | `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_PHONE` | +| `plivo` | Messaging | `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, `PLIVO_PHONE_NUMBER` | | `email` | Messaging | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` | | `line` | Chat | `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET` | | `zalo` | Chat | `ZALO_APP_ID`, `ZALO_APP_SECRET` | @@ -294,7 +294,7 @@ Plivo is available as its own messaging channel for SMS. Get your Auth ID and Au ```bash export PLIVO_AUTH_ID=your-auth-id export PLIVO_AUTH_TOKEN=your-auth-token -export PLIVO_PHONE=+14150000000 +export PLIVO_PHONE_NUMBER=+14150000000 # your Plivo sender number, E.164 format ``` ```typescript @@ -306,7 +306,7 @@ await sms.initialize({ credential: process.env.PLIVO_AUTH_TOKEN!, // Auth Token params: { authId: process.env.PLIVO_AUTH_ID!, - phoneNumber: process.env.PLIVO_PHONE!, + phoneNumber: process.env.PLIVO_PHONE_NUMBER!, // The externally-visible URL you set as the number's Message URL in Plivo. webhookUrl: 'https://your-host.example/plivo/inbound', }, diff --git a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts index 21e9f51fe09..fcc6167fdab 100644 --- a/src/io/channels/adapters/PlivoSmsChannelAdapter.ts +++ b/src/io/channels/adapters/PlivoSmsChannelAdapter.ts @@ -277,7 +277,14 @@ export class PlivoSmsChannelAdapter extends BaseChannelAdapter Date: Wed, 22 Jul 2026 13:15:29 +0530 Subject: [PATCH 5/5] docs: embed the Plivo signup link on console references Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/features/CHANNELS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/CHANNELS.md b/docs/features/CHANNELS.md index 85e0076b1d3..3f2c6101e00 100644 --- a/docs/features/CHANNELS.md +++ b/docs/features/CHANNELS.md @@ -289,7 +289,7 @@ router.registerAdapter(whatsapp); ### Plivo (SMS) -Plivo is available as its own messaging channel for SMS. Get your Auth ID and Auth Token from the Plivo console at [cx.plivo.com](https://cx.plivo.com), and use one of your Plivo numbers as the sender. +Plivo is available as its own messaging channel for SMS. Get your Auth ID and Auth Token from the Plivo console at [cx.plivo.com](https://cx.plivo.com/?utm_source=github&utm_medium=oss&utm_campaign=agentos), and use one of your Plivo numbers as the sender. ```bash export PLIVO_AUTH_ID=your-auth-id