From 817d95b2ad5767bbadedc51b66b44061c8b51b73 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Sat, 7 Mar 2026 16:05:30 -0800 Subject: [PATCH 1/2] Initial draft --- packages/adapter-telnyx/package.json | 55 ++ packages/adapter-telnyx/src/index.test.ts | 690 +++++++++++++++++++ packages/adapter-telnyx/src/index.ts | 459 ++++++++++++ packages/adapter-telnyx/src/markdown.test.ts | 59 ++ packages/adapter-telnyx/src/markdown.ts | 48 ++ packages/adapter-telnyx/src/types.ts | 75 ++ packages/adapter-telnyx/tsconfig.json | 10 + packages/adapter-telnyx/tsup.config.ts | 9 + packages/adapter-telnyx/vitest.config.ts | 14 + pnpm-lock.yaml | 22 + turbo.json | 5 +- 11 files changed, 1445 insertions(+), 1 deletion(-) create mode 100644 packages/adapter-telnyx/package.json create mode 100644 packages/adapter-telnyx/src/index.test.ts create mode 100644 packages/adapter-telnyx/src/index.ts create mode 100644 packages/adapter-telnyx/src/markdown.test.ts create mode 100644 packages/adapter-telnyx/src/markdown.ts create mode 100644 packages/adapter-telnyx/src/types.ts create mode 100644 packages/adapter-telnyx/tsconfig.json create mode 100644 packages/adapter-telnyx/tsup.config.ts create mode 100644 packages/adapter-telnyx/vitest.config.ts diff --git a/packages/adapter-telnyx/package.json b/packages/adapter-telnyx/package.json new file mode 100644 index 00000000..98f6fe2a --- /dev/null +++ b/packages/adapter-telnyx/package.json @@ -0,0 +1,55 @@ +{ + "name": "@chat-adapter/telnyx", + "version": "0.1.0", + "description": "Telnyx SMS adapter for chat", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-telnyx" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "telnyx", + "sms", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-telnyx/src/index.test.ts b/packages/adapter-telnyx/src/index.test.ts new file mode 100644 index 00000000..f07d5325 --- /dev/null +++ b/packages/adapter-telnyx/src/index.test.ts @@ -0,0 +1,690 @@ +import { + AdapterRateLimitError, + AuthenticationError, + ValidationError, +} from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { NotImplementedError } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelnyxAdapter, TelnyxAdapter } from "./index"; +import type { TelnyxWebhookPayload } from "./types"; + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function createMockChat(options?: { userName?: string }): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue(options?.userName ?? "mybot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + processMemberJoinedChannel: vi.fn(), + } as unknown as ChatInstance; +} + +function sampleWebhookPayload( + overrides?: Partial +): TelnyxWebhookPayload { + return { + data: { + event_type: "message.received", + id: "evt-123", + occurred_at: "2025-01-01T00:00:00Z", + record_type: "event", + payload: { + direction: "inbound", + from: { phone_number: "+15551234567" }, + to: [{ phone_number: "+15559876543" }], + text: "Hello", + type: "SMS", + id: "msg-123", + ...overrides, + }, + }, + meta: { + attempt: 1, + delivered_to: "https://example.com/webhook", + }, + }; +} + +describe("TelnyxAdapter", () => { + describe("constructor", () => { + it("creates adapter with config params", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + expect(adapter.name).toBe("telnyx"); + expect(adapter.botUserId).toBe("+15559876543"); + }); + + it("reads config from env vars", () => { + process.env.TELNYX_API_KEY = "env-key"; + process.env.TELNYX_FROM_NUMBER = "+15550001111"; + try { + const adapter = new TelnyxAdapter({ logger: mockLogger }); + expect(adapter.botUserId).toBe("+15550001111"); + } finally { + Reflect.deleteProperty(process.env, "TELNYX_API_KEY"); + Reflect.deleteProperty(process.env, "TELNYX_FROM_NUMBER"); + } + }); + + it("throws if apiKey is missing", () => { + expect(() => new TelnyxAdapter({ phoneNumber: "+15550001111" })).toThrow( + ValidationError + ); + }); + + it("throws if phoneNumber is missing", () => { + expect(() => new TelnyxAdapter({ apiKey: "test-key" })).toThrow( + ValidationError + ); + }); + }); + + describe("createTelnyxAdapter", () => { + it("creates an adapter instance", () => { + const adapter = createTelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(TelnyxAdapter); + }); + }); + + describe("handleWebhook", () => { + it("processes message.received events", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = sampleWebhookPayload(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { "content-type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledOnce(); + }); + + it("ignores non-message events", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = sampleWebhookPayload(); + payload.data.event_type = "message.sent"; + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { "content-type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("returns 401 for missing signature when publicKey configured", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + publicKey: + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = sampleWebhookPayload(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { "content-type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("returns 401 for stale timestamp", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + publicKey: + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = sampleWebhookPayload(); + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 600); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { + "content-type": "application/json", + "telnyx-signature-ed25519": "dGVzdA==", + "telnyx-timestamp": staleTimestamp, + }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + expect(await response.text()).toBe("Stale timestamp"); + }); + + it("returns 400 for invalid JSON", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: "not json", + headers: { "content-type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); + }); + + describe("postMessage", () => { + it("sends SMS via API", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: "msg-456", + from: { phone_number: "+15559876543" }, + to: [{ phone_number: "+15551234567" }], + text: "Hello back", + type: "SMS", + direction: "outbound", + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + + const result = await adapter.postMessage( + "telnyx:+15559876543:+15551234567", + "Hello back" + ); + + expect(result.id).toBe("msg-456"); + expect(mockFetch).toHaveBeenCalledOnce(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.telnyx.com/v2/messages"); + const body = JSON.parse(init?.body as string); + expect(body.from).toBe("+15559876543"); + expect(body.to).toBe("+15551234567"); + expect(body.text).toBe("Hello back"); + }); + + it("sends MMS with media_urls when attachments have URLs", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: "msg-mms", + from: { phone_number: "+15559876543" }, + to: [{ phone_number: "+15551234567" }], + text: "Check this out", + type: "MMS", + direction: "outbound", + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + + await adapter.postMessage("telnyx:+15559876543:+15551234567", { + raw: "Check this out", + attachments: [ + { + type: "image", + mimeType: "image/jpeg", + url: "https://example.com/photo.jpg", + }, + ], + }); + + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.media_urls).toEqual(["https://example.com/photo.jpg"]); + expect(body.type).toBe("MMS"); + }); + + it("throws AdapterRateLimitError on 429", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response("Too Many Requests", { status: 429 }) + ); + + await expect( + adapter.postMessage("telnyx:+15559876543:+15551234567", "test") + ).rejects.toThrow(AdapterRateLimitError); + }); + + it("extracts retry-after from 429 response", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response("Too Many Requests", { + status: 429, + headers: { "retry-after": "30" }, + }) + ); + + try { + await adapter.postMessage("telnyx:+15559876543:+15551234567", "test"); + } catch (error) { + expect(error).toBeInstanceOf(AdapterRateLimitError); + expect((error as AdapterRateLimitError).retryAfter).toBe(30); + } + }); + + it("parses structured Telnyx error responses", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + errors: [ + { + title: "Invalid destination", + detail: "The destination number is not valid", + code: "40300", + }, + ], + }), + { status: 422 } + ) + ); + + await expect( + adapter.postMessage("telnyx:+15559876543:+15551234567", "test") + ).rejects.toThrow("Invalid destination"); + }); + + it("falls back to raw text for non-JSON error responses", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response("Internal Server Error", { status: 500 }) + ); + + await expect( + adapter.postMessage("telnyx:+15559876543:+15551234567", "test") + ).rejects.toThrow("Internal Server Error"); + }); + + it("throws AuthenticationError on 401", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "bad-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const chat = createMockChat(); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response("Unauthorized", { status: 401 }) + ); + + await expect( + adapter.postMessage("telnyx:+15559876543:+15551234567", "test") + ).rejects.toThrow(AuthenticationError); + }); + }); + + describe("editMessage / deleteMessage / addReaction / removeReaction", () => { + it("editMessage throws NotImplementedError", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + await expect( + adapter.editMessage("thread", "msg", "text") + ).rejects.toThrow(NotImplementedError); + }); + + it("deleteMessage throws NotImplementedError", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + await expect(adapter.deleteMessage("thread", "msg")).rejects.toThrow( + NotImplementedError + ); + }); + + it("addReaction throws NotImplementedError", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + await expect( + adapter.addReaction("thread", "msg", "thumbsup") + ).rejects.toThrow(NotImplementedError); + }); + + it("removeReaction throws NotImplementedError", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + await expect( + adapter.removeReaction("thread", "msg", "thumbsup") + ).rejects.toThrow(NotImplementedError); + }); + }); + + describe("startTyping", () => { + it("resolves without error (no-op)", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + await expect( + adapter.startTyping("telnyx:+15559876543:+15551234567") + ).resolves.toBeUndefined(); + }); + }); + + describe("encodeThreadId / decodeThreadId", () => { + it("round-trips thread IDs", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const data = { + telnyxNumber: "+15559876543", + recipientNumber: "+15551234567", + }; + const encoded = adapter.encodeThreadId(data); + expect(encoded).toBe("telnyx:+15559876543:+15551234567"); + + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual(data); + }); + + it("throws on invalid format", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + }); + + it("throws on non-E.164 format", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + expect(() => adapter.decodeThreadId("telnyx:123:456")).toThrow( + ValidationError + ); + }); + }); + + describe("parseMessage", () => { + it("extracts text and author", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-789", + text: "Test message", + from: { phone_number: "+15551234567" }, + to: [{ phone_number: "+15559876543" }], + direction: "inbound", + type: "SMS", + }); + + expect(message.text).toBe("Test message"); + expect(message.author.userId).toBe("+15551234567"); + expect(message.author.isMe).toBe(false); + }); + + it("detects bot messages", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-789", + text: "Bot reply", + from: { phone_number: "+15559876543" }, + to: [{ phone_number: "+15551234567" }], + direction: "outbound", + type: "SMS", + }); + + expect(message.author.isMe).toBe(true); + expect(message.author.isBot).toBe(true); + }); + + it("sets isMention true for inbound messages", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-789", + text: "Hello", + from: { phone_number: "+15551234567" }, + to: [{ phone_number: "+15559876543" }], + direction: "inbound", + type: "SMS", + }); + + expect(message.isMention).toBe(true); + }); + + it("sets isMention false for bot's own messages", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-789", + text: "Bot reply", + from: { phone_number: "+15559876543" }, + to: [{ phone_number: "+15551234567" }], + direction: "outbound", + type: "SMS", + }); + + expect(message.isMention).toBe(false); + }); + + it("extracts media attachments", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-789", + text: "Image", + from: { phone_number: "+15551234567" }, + to: [{ phone_number: "+15559876543" }], + direction: "inbound", + type: "MMS", + media: [ + { + content_type: "image/jpeg", + url: "https://example.com/image.jpg", + size: 1024, + }, + ], + }); + + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0].mimeType).toBe("image/jpeg"); + expect(message.attachments[0].url).toBe("https://example.com/image.jpg"); + }); + }); + + describe("isDM", () => { + it("always returns true", () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + expect(adapter.isDM()).toBe(true); + }); + }); + + describe("openDM", () => { + it("returns encoded thread ID", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const threadId = await adapter.openDM("+15551234567"); + expect(threadId).toBe("telnyx:+15559876543:+15551234567"); + }); + }); + + describe("fetchThread", () => { + it("returns thread info with isDM true", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const info = await adapter.fetchThread( + "telnyx:+15559876543:+15551234567" + ); + expect(info.isDM).toBe(true); + expect(info.metadata.recipientNumber).toBe("+15551234567"); + }); + }); + + describe("fetchMessages", () => { + it("returns empty results", async () => { + const adapter = new TelnyxAdapter({ + apiKey: "test-key", + phoneNumber: "+15559876543", + logger: mockLogger, + }); + const result = await adapter.fetchMessages( + "telnyx:+15559876543:+15551234567" + ); + expect(result.messages).toEqual([]); + }); + }); +}); diff --git a/packages/adapter-telnyx/src/index.ts b/packages/adapter-telnyx/src/index.ts new file mode 100644 index 00000000..25d43169 --- /dev/null +++ b/packages/adapter-telnyx/src/index.ts @@ -0,0 +1,459 @@ +import { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChatInstance, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message, NotImplementedError } from "chat"; +import { TelnyxFormatConverter } from "./markdown"; +import type { + TelnyxAdapterConfig, + TelnyxMedia, + TelnyxMessagePayload, + TelnyxRawMessage, + TelnyxThreadId, + TelnyxWebhookPayload, +} from "./types"; + +const TELNYX_API_BASE = "https://api.telnyx.com/v2"; +const SMS_MAX_LENGTH = 1600; +const THREAD_ID_PATTERN = /^telnyx:(\+\d+):(\+\d+)$/; +const TIMESTAMP_MAX_AGE_SECONDS = 300; + +export class TelnyxAdapter + implements Adapter +{ + readonly name = "telnyx"; + + private readonly apiKey: string; + private readonly publicKey?: string; + private readonly phoneNumber: string; + private readonly messagingProfileId?: string; + private readonly logger: Logger; + private readonly formatConverter = new TelnyxFormatConverter(); + + private chat: ChatInstance | null = null; + private readonly _userName: string; + + get botUserId(): string { + return this.phoneNumber; + } + + get userName(): string { + return this._userName; + } + + constructor(config: TelnyxAdapterConfig = {}) { + const apiKey = config.apiKey ?? process.env.TELNYX_API_KEY; + if (!apiKey) { + throw new ValidationError( + "telnyx", + "apiKey is required. Set TELNYX_API_KEY or provide it in config." + ); + } + + const phoneNumber = config.phoneNumber ?? process.env.TELNYX_FROM_NUMBER; + if (!phoneNumber) { + throw new ValidationError( + "telnyx", + "phoneNumber is required. Set TELNYX_FROM_NUMBER or provide it in config." + ); + } + + this.apiKey = apiKey; + this.publicKey = config.publicKey ?? process.env.TELNYX_PUBLIC_KEY; + this.phoneNumber = phoneNumber; + this.messagingProfileId = config.messagingProfileId; + this.logger = config.logger ?? new ConsoleLogger("info").child("telnyx"); + this._userName = config.userName ?? process.env.BOT_USERNAME ?? "bot"; + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + this.logger.info("Telnyx SMS adapter initialized", { + phoneNumber: this.phoneNumber, + }); + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + let body: TelnyxWebhookPayload; + try { + body = (await request.json()) as TelnyxWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + // Verify webhook signature if public key is configured + if (this.publicKey) { + const signature = request.headers.get("telnyx-signature-ed25519"); + const timestamp = request.headers.get("telnyx-timestamp"); + + if (!(signature && timestamp)) { + return new Response("Missing signature headers", { status: 401 }); + } + + // Replay attack prevention: reject stale timestamps + const now = Math.floor(Date.now() / 1000); + if ( + Math.abs(now - Number.parseInt(timestamp, 10)) > + TIMESTAMP_MAX_AGE_SECONDS + ) { + return new Response("Stale timestamp", { status: 401 }); + } + + const isValid = await this.verifySignature( + JSON.stringify(body), + signature, + timestamp + ); + + if (!isValid) { + return new Response("Invalid signature", { status: 401 }); + } + } + + const eventType = body.data?.event_type; + + // Only process inbound messages + if (eventType !== "message.received") { + this.logger.debug("Ignoring non-message event", { eventType }); + return new Response("OK", { status: 200 }); + } + + const payload = body.data.payload; + + if (!payload || payload.direction !== "inbound") { + return new Response("OK", { status: 200 }); + } + + if (!this.chat) { + this.logger.error("Chat instance not initialized"); + return new Response("Not initialized", { status: 500 }); + } + + const threadId = this.encodeThreadId({ + telnyxNumber: payload.to[0]?.phone_number ?? this.phoneNumber, + recipientNumber: payload.from.phone_number, + }); + + const message = this.parseMessage(payload); + + this.chat.processMessage(this, threadId, message, options); + + return new Response("OK", { status: 200 }); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { recipientNumber } = this.decodeThreadId(threadId); + const text = this.formatConverter.renderPostable(message); + + const requestBody: Record = { + from: this.phoneNumber, + to: recipientNumber, + text: text.slice(0, SMS_MAX_LENGTH), + }; + + if (this.messagingProfileId) { + requestBody.messaging_profile_id = this.messagingProfileId; + } + + // Extract media URLs for MMS support + const mediaUrls: string[] = []; + + // Collect URLs from attachments on the message + if ( + typeof message === "object" && + message !== null && + "attachments" in message + ) { + const attachments = (message as { attachments?: Attachment[] }) + .attachments; + if (attachments) { + for (const att of attachments) { + if (att.url) { + mediaUrls.push(att.url); + } + } + } + } + + if (mediaUrls.length > 0) { + requestBody.media_urls = mediaUrls; + requestBody.type = "MMS"; + } + + const response = await fetch(`${TELNYX_API_BASE}/messages`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorBody = await response.text(); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + throw new AdapterRateLimitError( + "telnyx", + retryAfter ? Number.parseInt(retryAfter, 10) : undefined + ); + } + + if (response.status === 401 || response.status === 403) { + throw new AuthenticationError("telnyx", `Auth failed: ${errorBody}`); + } + + // Try to parse structured Telnyx error response + let errorMessage = `Failed to send message: ${response.status} ${errorBody}`; + try { + const errorJson = JSON.parse(errorBody) as { + errors?: { title?: string; detail?: string; code?: string }[]; + }; + const firstError = errorJson.errors?.[0]; + if (firstError) { + const parts = [ + firstError.title, + firstError.detail, + firstError.code, + ].filter(Boolean); + if (parts.length > 0) { + errorMessage = `Failed to send message: ${parts.join(" — ")}`; + } + } + } catch { + // Fall back to raw text + } + + throw new NetworkError("telnyx", errorMessage); + } + + const result = (await response.json()) as { data: TelnyxMessagePayload }; + + return { + id: result.data.id, + threadId, + raw: result.data, + }; + } + + async editMessage(): Promise> { + throw new NotImplementedError("telnyx", "editMessage"); + } + + async deleteMessage(): Promise { + throw new NotImplementedError("telnyx", "deleteMessage"); + } + + parseMessage(raw: TelnyxRawMessage): Message { + const text = raw.text ?? ""; + const attachments: Attachment[] = (raw.media ?? []).map( + (media: TelnyxMedia) => ({ + type: inferAttachmentType(media.content_type), + mimeType: media.content_type, + url: media.url, + size: media.size, + }) + ); + + const fromNumber = raw.from.phone_number; + const isMe = fromNumber === this.phoneNumber; + + let dateSent = new Date(); + if (raw.received_at) { + dateSent = new Date(raw.received_at); + } else if (raw.sent_at) { + dateSent = new Date(raw.sent_at); + } + + const threadId = this.encodeThreadId({ + telnyxNumber: raw.to[0]?.phone_number ?? this.phoneNumber, + recipientNumber: isMe + ? (raw.to[0]?.phone_number ?? this.phoneNumber) + : fromNumber, + }); + + return new Message({ + id: raw.id, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw, + isMention: !isMe, + author: { + fullName: fromNumber, + userId: fromNumber, + isBot: isMe, + isMe, + userName: fromNumber, + }, + metadata: { + dateSent, + edited: false, + }, + attachments, + }); + } + + async fetchMessages( + _threadId: string, + _options?: FetchOptions + ): Promise> { + // Telnyx doesn't provide a thread-based message history API + return { messages: [] }; + } + + async fetchThread(threadId: string): Promise { + const { recipientNumber } = this.decodeThreadId(threadId); + return { + id: threadId, + channelId: `telnyx:${this.phoneNumber}`, + isDM: true, + metadata: { + recipientNumber, + telnyxNumber: this.phoneNumber, + }, + }; + } + + encodeThreadId(data: TelnyxThreadId): string { + return `telnyx:${data.telnyxNumber}:${data.recipientNumber}`; + } + + decodeThreadId(threadId: string): TelnyxThreadId { + const match = THREAD_ID_PATTERN.exec(threadId); + if (!match) { + throw new ValidationError( + "telnyx", + `Invalid thread ID format: ${threadId}. Expected telnyx:+:+` + ); + } + return { + telnyxNumber: match[1], + recipientNumber: match[2], + }; + } + + async startTyping(): Promise { + // SMS does not support typing indicators — no-op + } + + async addReaction(): Promise { + throw new NotImplementedError("telnyx", "addReaction"); + } + + async removeReaction(): Promise { + throw new NotImplementedError("telnyx", "removeReaction"); + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + isDM(): boolean { + return true; + } + + async openDM(phoneNumber: string): Promise { + return this.encodeThreadId({ + telnyxNumber: this.phoneNumber, + recipientNumber: phoneNumber, + }); + } + + private async verifySignature( + payload: string, + signature: string, + timestamp: string + ): Promise { + try { + const publicKeyBytes = hexToUint8Array(this.publicKey as string); + const signatureBytes = base64ToUint8Array(signature); + const messageBytes = new TextEncoder().encode(timestamp + payload); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + publicKeyBytes, + { name: "Ed25519", namedCurve: "Ed25519" }, + false, + ["verify"] + ); + + return await crypto.subtle.verify( + "Ed25519", + cryptoKey, + signatureBytes, + messageBytes + ); + } catch (error) { + this.logger.error("Signature verification failed", { error }); + return false; + } + } +} + +function hexToUint8Array(hex: string): Uint8Array { + const buffer = new ArrayBuffer(hex.length / 2); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function inferAttachmentType(mimeType: string): Attachment["type"] { + if (mimeType.startsWith("image/")) { + return "image"; + } + if (mimeType.startsWith("video/")) { + return "video"; + } + if (mimeType.startsWith("audio/")) { + return "audio"; + } + return "file"; +} + +export function createTelnyxAdapter( + config?: TelnyxAdapterConfig +): TelnyxAdapter { + return new TelnyxAdapter(config); +} + +export { TelnyxFormatConverter } from "./markdown"; +export type { + TelnyxAdapterConfig, + TelnyxRawMessage, + TelnyxThreadId, +} from "./types"; diff --git a/packages/adapter-telnyx/src/markdown.test.ts b/packages/adapter-telnyx/src/markdown.test.ts new file mode 100644 index 00000000..6ee68617 --- /dev/null +++ b/packages/adapter-telnyx/src/markdown.test.ts @@ -0,0 +1,59 @@ +import { parseMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import { TelnyxFormatConverter } from "./markdown"; + +describe("TelnyxFormatConverter", () => { + const converter = new TelnyxFormatConverter(); + + describe("fromAst", () => { + it("converts plain text", () => { + const ast = parseMarkdown("Hello world"); + expect(converter.fromAst(ast)).toBe("Hello world"); + }); + + it("strips bold/italic formatting into plain text output", () => { + const ast = parseMarkdown("**bold** and *italic*"); + const result = converter.fromAst(ast); + expect(result).toContain("bold"); + expect(result).toContain("italic"); + }); + + it("converts tables to ASCII", () => { + const ast = parseMarkdown( + "| Name | Value |\n| --- | --- |\n| a | 1 |\n| b | 2 |" + ); + const result = converter.fromAst(ast); + expect(result).toContain("Name"); + expect(result).toContain("Value"); + }); + }); + + describe("toAst", () => { + it("parses plain text to AST", () => { + const ast = converter.toAst("Hello world"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + }); + + describe("renderPostable", () => { + it("handles string messages", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + }); + + it("handles raw messages", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("handles markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toContain("bold"); + }); + + it("handles AST messages", () => { + const ast = parseMarkdown("test message"); + const result = converter.renderPostable({ ast }); + expect(result).toContain("test message"); + }); + }); +}); diff --git a/packages/adapter-telnyx/src/markdown.ts b/packages/adapter-telnyx/src/markdown.ts new file mode 100644 index 00000000..dc720db2 --- /dev/null +++ b/packages/adapter-telnyx/src/markdown.ts @@ -0,0 +1,48 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class TelnyxFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + // SMS has no formatting support — convert tables to ASCII and strip markdown + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + return stringifyMarkdown(transformed).trim(); + } + + toAst(text: string): Root { + return parseMarkdown(text); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } +} diff --git a/packages/adapter-telnyx/src/types.ts b/packages/adapter-telnyx/src/types.ts new file mode 100644 index 00000000..b4a379a9 --- /dev/null +++ b/packages/adapter-telnyx/src/types.ts @@ -0,0 +1,75 @@ +import type { Logger } from "chat"; + +export interface TelnyxAdapterConfig { + /** Telnyx API key. Defaults to TELNYX_API_KEY env var. */ + apiKey?: string; + /** Logger instance. Defaults to ConsoleLogger. */ + logger?: Logger; + /** Telnyx messaging profile ID (optional, for advanced routing). */ + messagingProfileId?: string; + /** Telnyx phone number to send from. Defaults to TELNYX_FROM_NUMBER env var. */ + phoneNumber?: string; + /** Telnyx webhook public key for Ed25519 signature verification. Defaults to TELNYX_PUBLIC_KEY env var. */ + publicKey?: string; + /** Override bot username. Defaults to BOT_USERNAME env var. */ + userName?: string; +} + +export interface TelnyxThreadId { + recipientNumber: string; + telnyxNumber: string; +} + +export interface TelnyxWebhookPayload { + data: { + event_type: string; + id: string; + occurred_at: string; + payload: TelnyxMessagePayload; + record_type: string; + }; + meta: { + attempt: number; + delivered_to: string; + }; +} + +export interface TelnyxMessagePayload { + completed_at?: string; + cost?: { amount: string; currency: string } | null; + direction: "inbound" | "outbound"; + encoding?: string; + errors?: unknown[]; + from: TelnyxPhoneNumber; + id: string; + media?: TelnyxMedia[]; + messaging_profile_id?: string; + organization_id?: string; + parts?: number; + received_at?: string; + record_type?: string; + sent_at?: string; + tags?: string[]; + text: string; + to: TelnyxPhoneNumber[]; + type: "SMS" | "MMS"; + valid_until?: string; + webhook_token?: string; + webhook_url?: string; +} + +export interface TelnyxPhoneNumber { + carrier?: string; + line_type?: string; + phone_number: string; + status?: string; +} + +export interface TelnyxMedia { + content_type: string; + hash_sha256?: string; + size?: number; + url: string; +} + +export type TelnyxRawMessage = TelnyxMessagePayload; diff --git a/packages/adapter-telnyx/tsconfig.json b/packages/adapter-telnyx/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-telnyx/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-telnyx/tsup.config.ts b/packages/adapter-telnyx/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-telnyx/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-telnyx/vitest.config.ts b/packages/adapter-telnyx/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-telnyx/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75df8373..d7ff14b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-telnyx: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/chat: dependencies: '@workflow/serde': diff --git a/turbo.json b/turbo.json index 98bf25f6..8921043e 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,10 @@ "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", "BOT_USERNAME", - "REDIS_URL" + "REDIS_URL", + "TELNYX_API_KEY", + "TELNYX_PUBLIC_KEY", + "TELNYX_FROM_NUMBER" ], "tasks": { "build": { From c3e9ae3cdac76dbf18f4d526d0d9f058e5c72c9a Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Sat, 7 Mar 2026 16:09:41 -0800 Subject: [PATCH 2/2] Update docs --- apps/docs/content/docs/adapters/index.mdx | 73 +++++----- apps/docs/content/docs/adapters/meta.json | 1 + apps/docs/content/docs/adapters/telnyx.mdx | 157 +++++++++++++++++++++ 3 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 apps/docs/content/docs/adapters/telnyx.mdx diff --git a/apps/docs/content/docs/adapters/index.mdx b/apps/docs/content/docs/adapters/index.mdx index c2d91091..7bc842c2 100644 --- a/apps/docs/content/docs/adapters/index.mdx +++ b/apps/docs/content/docs/adapters/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. +description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, Telnyx, GitHub, and Linear. type: overview prerequisites: - /docs/getting-started @@ -12,50 +12,50 @@ Adapters handle webhook verification, message parsing, and API calls for each pl ### Messaging -| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | -|---------|-------|-------|-------------|---------|---------|--------|--------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | +| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [Telnyx](/docs/adapters/telnyx) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | +|---------|-------|-------|-------------|---------|---------|--------|--------|--------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ⚠️ MMS URLs | ❌ | ❌ | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Telnyx | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | ❌ | GFM Markdown | Markdown | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ❌ | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ❌ | ✅ GFM | ✅ GFM | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Telnyx | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ⚠️ | ⚠️ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Telnyx | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ❌ | ✅ | ✅ | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ❌ | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ❌ | ✅ | ❌ | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. @@ -70,6 +70,7 @@ Adapters handle webhook verification, message parsing, and API calls for each pl | [Google Chat](/docs/adapters/gchat) | `@chat-adapter/gchat` | | [Discord](/docs/adapters/discord) | `@chat-adapter/discord` | | [Telegram](/docs/adapters/telegram) | `@chat-adapter/telegram` | +| [Telnyx](/docs/adapters/telnyx) | `@chat-adapter/telnyx` | | [GitHub](/docs/adapters/github) | `@chat-adapter/github` | | [Linear](/docs/adapters/linear) | `@chat-adapter/linear` | diff --git a/apps/docs/content/docs/adapters/meta.json b/apps/docs/content/docs/adapters/meta.json index 7767db24..f34314b6 100644 --- a/apps/docs/content/docs/adapters/meta.json +++ b/apps/docs/content/docs/adapters/meta.json @@ -7,6 +7,7 @@ "gchat", "discord", "telegram", + "telnyx", "github", "linear" ] diff --git a/apps/docs/content/docs/adapters/telnyx.mdx b/apps/docs/content/docs/adapters/telnyx.mdx new file mode 100644 index 00000000..4ce17d0c --- /dev/null +++ b/apps/docs/content/docs/adapters/telnyx.mdx @@ -0,0 +1,157 @@ +--- +title: Telnyx +description: Configure the Telnyx adapter for SMS and MMS messaging. +type: integration +prerequisites: + - /docs/getting-started +--- + +## Installation + +```sh title="Terminal" +pnpm add @chat-adapter/telnyx +``` + +## Usage + +The adapter auto-detects `TELNYX_API_KEY`, `TELNYX_FROM_NUMBER`, and `TELNYX_PUBLIC_KEY` from environment variables: + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createTelnyxAdapter } from "@chat-adapter/telnyx"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + telnyx: createTelnyxAdapter(), + }, +}); + +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +Every inbound SMS is treated as a mention, so `onNewMention` handlers fire for all incoming messages. + +## Webhook route + +```typescript title="app/api/webhooks/telnyx/route.ts" lineNumbers +import { bot } from "@/lib/bot"; + +export async function POST(request: Request): Promise { + return bot.webhooks.telnyx(request); +} +``` + +Configure this URL as your webhook in the [Telnyx Mission Control Portal](https://portal.telnyx.com/): + +1. Go to **Messaging** → **Programmable Messaging** +2. Create or edit a messaging profile +3. Set the **Webhook URL** to `https://your-domain.com/api/webhooks/telnyx` + +## Telnyx account setup + +### 1. Get your API key + +1. Go to [portal.telnyx.com](https://portal.telnyx.com/) +2. Navigate to **Auth** → **API Keys** +3. Create or copy your API key as `TELNYX_API_KEY` + +### 2. Get a phone number + +1. Go to **Numbers** → **Search & Buy** +2. Purchase a number with SMS/MMS capability +3. Copy the number (in E.164 format, e.g. `+15551234567`) as `TELNYX_FROM_NUMBER` + +### 3. Set up webhook verification (recommended) + +1. Go to **Auth** → **Public Key** +2. Copy your public key as `TELNYX_PUBLIC_KEY` + +This enables Ed25519 signature verification on incoming webhooks, including replay attack prevention with a 5-minute timestamp window. + +### 4. Create a messaging profile + +1. Go to **Messaging** → **Programmable Messaging** +2. Create a new messaging profile +3. Assign your phone number to it +4. Set the webhook URL to your route + +## Configuration + +All options are auto-detected from environment variables when not provided. + +| Option | Required | Description | +|--------|----------|-------------| +| `apiKey` | No* | Telnyx API key. Auto-detected from `TELNYX_API_KEY` | +| `phoneNumber` | No* | Phone number to send from (E.164 format). Auto-detected from `TELNYX_FROM_NUMBER` | +| `publicKey` | No | Ed25519 public key for webhook signature verification. Auto-detected from `TELNYX_PUBLIC_KEY` | +| `messagingProfileId` | No | Messaging profile ID for advanced routing | +| `userName` | No | Bot username. Auto-detected from `BOT_USERNAME` | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +*`apiKey` and `phoneNumber` are required — either via config or env vars. + +## Environment variables + +```bash title=".env.local" +TELNYX_API_KEY=KEY... +TELNYX_FROM_NUMBER=+15559876543 +TELNYX_PUBLIC_KEY=... # Optional, for webhook verification +``` + +## MMS support + +When posting a message with attachments that have public URLs, the adapter automatically sends an MMS instead of SMS: + +```typescript title="lib/bot.ts" +await thread.post({ + raw: "Check out this image", + attachments: [ + { + type: "image", + mimeType: "image/jpeg", + url: "https://example.com/photo.jpg", + }, + ], +}); +``` + + +Telnyx MMS requires publicly-accessible URLs. Binary file uploads are not supported for MMS — only URL-based attachments. + + +## Features + +| Feature | Supported | +|---------|-----------| +| Mentions | Yes (all inbound SMS) | +| Reactions (add/remove) | No | +| Cards | No | +| Modals | No | +| Streaming | No | +| DMs | Yes (all SMS is DM) | +| Ephemeral messages | No | +| File uploads | ⚠️ MMS with public URLs only | +| Typing indicator | No | +| Message history | No | + +## Thread ID format + +Telnyx thread IDs follow the pattern `telnyx:{telnyxNumber}:{recipientNumber}`: + +``` +telnyx:+15559876543:+15551234567 +``` + +Both numbers use E.164 format. + +## Notes + +- All inbound SMS messages set `isMention: true` since every SMS is a direct message to the bot. +- SMS messages are truncated to 1,600 characters. +- Telnyx does not provide a message history API, so `fetchMessages` returns an empty array. +- Edit, delete, and reaction operations are not supported by SMS and throw `NotImplementedError`. +- The adapter includes rate limit handling — if Telnyx returns HTTP 429, an `AdapterRateLimitError` is thrown with the `retry-after` value when available. +- Webhook signature verification uses Ed25519 and includes a 5-minute staleness check to prevent replay attacks.