UNPKG

chat

Version:

Unified chat abstraction for Slack, Teams, Google Chat, and Discord

714 lines (592 loc) 24.2 kB
--- title: Building a community adapter description: Learn how to build, package, and publish your own Chat SDK adapter for any messaging platform. type: guide prerequisites: - /docs/getting-started - /docs/adapters related: - /docs/contributing/testing - /docs/cards - /docs/actions --- ## What adapters are Adapters are the bridge between Chat SDK and a messaging platform. Each adapter handles webhook verification, message parsing, and API calls for one platform so your handler code stays platform-agnostic. Chat SDK ships with Vercel-maintained adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. Community developers can build adapters for any other platform using the same `Adapter` interface. ### Adapter tiers | Tier | Description | Examples | |------|-------------|---------| | Official | Published under `@chat-adapter/*` by Vercel | Slack, Teams, Discord | | Vendor official | Built and maintained by the platform company itself | Resend building a Resend adapter | | Community | Built by third-party developers | Any open-source adapter | <Callout type="warn"> The `@chat-adapter/` npm scope is reserved for official adapters. Publish your adapter under your own scope or as an unscoped package. </Callout> #### Qualifications for vendor official tier - Commitment for continued maintenance of the adapter. - GitHub hosting in official vendor-owned org. - Documentation of the adapter in primary vendor docs. - Announcement of the adapter in blog post or changelog and social media. ## Project setup This guide uses a hypothetical **Matrix** adapter as a running example. Replace "matrix" with your platform name throughout. ### package.json ```json title="package.json" lineNumbers { "name": "chat-adapter-matrix", "version": "0.1.0", "description": "Matrix adapter for Chat SDK", "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" }, "peerDependencies": { "chat": "^4.0.0" }, "dependencies": { "@chat-adapter/shared": "^4.0.0" }, "devDependencies": { "@types/node": "^22.0.0", "chat": "^4.0.0", "tsup": "^8.3.0", "typescript": "^5.7.0", "vitest": "^4.0.0" }, "publishConfig": { "access": "public" }, "keywords": ["chat-sdk", "chat-adapter", "matrix"], "license": "MIT" } ``` Key points: - ESM-only (`"type": "module"`) - `chat` is a **peer dependency** — your adapter runs inside the consumer's Chat instance - `@chat-adapter/shared` provides error classes and utility functions ### tsup.config.ts ```typescript title="tsup.config.ts" lineNumbers import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], dts: true, clean: true, sourcemap: true, }); ``` ### tsconfig.json ```json title="tsconfig.json" lineNumbers { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "declaration": true, "outDir": "./dist", "rootDir": "./src", "strict": true, "strictNullChecks": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } ``` ### vitest.config.ts ```typescript title="vitest.config.ts" lineNumbers 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"], }, }, }); ``` ## Define your types Start by defining the platform-specific types your adapter needs. ```typescript title="src/types.ts" lineNumbers /** Decoded thread ID components for Matrix */ export interface MatrixThreadId { /** Matrix room ID (e.g., "!abc123:matrix.org") */ roomId: string; /** Matrix event ID for the thread root (e.g., "$event123") */ eventId?: string; } /** Configuration for the Matrix adapter */ export interface MatrixAdapterConfig { /** Matrix homeserver URL */ homeserverUrl: string; /** Access token for the bot account */ accessToken: string; /** Optional bot display name override */ userName?: string; } ``` Every adapter needs: 1. A **thread ID interface** — the decoded components of your `{adapter}:{segment1}:{segment2}` thread ID 2. A **config interface** — credentials and options needed to connect to the platform ## Implement the Adapter interface Create your adapter class implementing the `Adapter` interface from `chat`. The following sections walk through each group of methods you need to implement. Start with the class skeleton and constructor: ```typescript title="src/adapter.ts" lineNumbers import { extractCard, extractFiles, toBuffer, ValidationError, } from "@chat-adapter/shared"; import type { Adapter, AdapterPostableMessage, ChatInstance, EmojiValue, FetchOptions, FetchResult, FormattedContent, Logger, RawMessage, ThreadInfo, WebhookOptions, } from "chat"; import { ConsoleLogger, Message } from "chat"; import { MatrixFormatConverter } from "./format-converter"; import type { MatrixAdapterConfig, MatrixThreadId } from "./types"; export class MatrixAdapter implements Adapter<MatrixThreadId, unknown> { readonly name = "matrix"; readonly userName: string; readonly botUserId?: string; private chat: ChatInstance | null = null; private logger: Logger; private config: MatrixAdapterConfig; private converter = new MatrixFormatConverter(); constructor(config: MatrixAdapterConfig & { logger?: Logger }) { this.config = config; this.userName = config.userName ?? "matrix-bot"; this.logger = config.logger ?? new ConsoleLogger(); } // Methods shown in sections below... } ``` The `Adapter` interface takes two generics: `TThreadId` (your decoded thread ID shape) and `TRawMessage` (the platform's raw message type). ### Initialization The SDK calls `initialize` once when the `Chat` instance is created. Use it to store the `ChatInstance` reference, set up your logger, validate credentials, and fetch bot info. ```typescript title="src/adapter.ts" async initialize(chat: ChatInstance): Promise<void> { this.chat = chat; this.logger = chat.getLogger("matrix"); // Validate credentials, fetch bot user info, etc. // Example: const me = await this.apiCall("/account/whoami"); // this.botUserId = me.user_id; } ``` ### Disconnect The optional `disconnect()` method is called during `chat.shutdown()` to clean up resources. Use it to close persistent connections, tear down subscriptions, or release any platform-specific resources. ```typescript title="src/adapter.ts" async disconnect(): Promise<void> { // Close WebSocket connections, clean up subscriptions, etc. // Example: await this.matrixClient.stop(); } ``` Adapters that don't hold persistent connections can skip this method entirely. ### Thread ID encode/decode Thread IDs typically follow the pattern `{adapter}:{segment1}:{segment2}`, though some adapters use more or fewer segments. The `encodeThreadId` and `decodeThreadId` methods must roundtrip consistently. Use `base64url` encoding for segments that contain special characters. ```typescript title="src/adapter.ts" lineNumbers encodeThreadId(data: MatrixThreadId): string { const roomSegment = Buffer.from(data.roomId).toString("base64url"); if (data.eventId) { const eventSegment = Buffer.from(data.eventId).toString("base64url"); return `matrix:${roomSegment}:${eventSegment}`; } return `matrix:${roomSegment}`; } decodeThreadId(threadId: string): MatrixThreadId { const parts = threadId.split(":"); if (parts.length < 2 || parts[0] !== "matrix") { throw new ValidationError(`Invalid Matrix thread ID: ${threadId}`); } const roomId = Buffer.from(parts[1], "base64url").toString(); const eventId = parts[2] ? Buffer.from(parts[2], "base64url").toString() : undefined; return { roomId, eventId }; } ``` ### Webhook handling `handleWebhook` is the entry point for all incoming platform events. Always: 1. Verify the request signature first (return 401 if invalid) 2. Parse the platform payload 3. Call `this.chat.processMessage()` with positional args — it handles `waitUntil` internally 4. Return a fast 200 response immediately ```typescript title="src/adapter.ts" lineNumbers async handleWebhook( request: Request, options?: WebhookOptions ): Promise<Response> { // 1. Verify request signature const signature = request.headers.get("x-matrix-signature"); if (!signature) { return new Response("Missing signature", { status: 401 }); } const body = await request.text(); const isValid = this.verifySignature(body, signature); if (!isValid) { return new Response("Invalid signature", { status: 401 }); } // 2. Parse the webhook payload const payload = JSON.parse(body); // 3. Process the message asynchronously if (this.chat && payload.type === "m.room.message") { const threadId = this.encodeThreadId({ roomId: payload.room_id, eventId: payload.thread_root_id, }); // Use a factory function for lazy async parsing const isMention = this.checkMention(payload); const factory = async (): Promise<Message<unknown>> => { const msg = this.parseMessage(payload); if (isMention) { msg.isMention = true; } return msg; }; // processMessage handles waitUntil registration internally this.chat.processMessage(this, threadId, factory, options); } // 4. Return a fast 200 to acknowledge receipt return new Response("OK", { status: 200 }); } ``` ### Message parsing Convert the raw platform message into a normalized `Message` instance. The `author` fields use `userId` and `userName`, and `isBot` accepts `boolean | "unknown"`. Include a `metadata` object with `dateSent` and `edited` instead of a top-level `createdAt`. ```typescript title="src/adapter.ts" lineNumbers parseMessage(raw: unknown): Message<unknown> { const payload = raw as Record<string, unknown>; return new Message({ id: payload.event_id as string, threadId: this.encodeThreadId({ roomId: payload.room_id as string, eventId: payload.thread_root_id as string | undefined, }), text: payload.body as string, formatted: this.converter.toAst(payload.body as string), raw, author: { userId: payload.sender as string, userName: payload.sender as string, fullName: payload.sender_display_name as string ?? "", isBot: (payload.sender as string).startsWith("@bot"), isMe: false, }, metadata: { dateSent: new Date(payload.origin_server_ts as number), edited: false, }, attachments: [], }); } ``` ### Sending messages Use `extractCard()` and `extractFiles()` from `@chat-adapter/shared` to check for rich content. Use `extractPostableAttachments()` if your adapter maps normalized `Attachment` objects to platform-native media uploads. Use your format converter's `renderPostable()` to convert the message to platform format. ```typescript title="src/adapter.ts" lineNumbers async postMessage( threadId: string, message: AdapterPostableMessage ): Promise<RawMessage<unknown>> { const { roomId, eventId } = this.decodeThreadId(threadId); const card = extractCard(message); const files = extractFiles(message); // Upload files if present for (const file of files) { const buffer = await toBuffer(file.data); // Upload to Matrix media repo... } // Render text content const text = card ? this.converter.renderPostable({ card: message.card }) : this.converter.renderPostable(message); const response = await this.sendMatrixMessage(roomId, text, eventId); return { raw: response, id: response.event_id }; } async editMessage( threadId: string, messageId: string, message: AdapterPostableMessage ): Promise<RawMessage<unknown>> { const { roomId } = this.decodeThreadId(threadId); const text = this.converter.renderPostable(message); const response = await this.editMatrixMessage(roomId, messageId, text); return { raw: response, id: response.event_id }; } async deleteMessage(threadId: string, messageId: string): Promise<void> { const { roomId } = this.decodeThreadId(threadId); await this.redactMatrixEvent(roomId, messageId); } ``` ### Buttons and callback URLs When the host app passes a `Card` with `<Button callbackUrl={...}>`, the SDK rewrites each such button **before** your adapter sees the postable: the `callbackUrl` is stored in the state adapter under a short token, and the button's `value` field is replaced with `__cb:<16-hex-chars>` (21 characters total). Your adapter does not need to know this happens — just round-trip `button.value` through your platform's button payload. What this means in practice: 1. **Send side**: when rendering a `ButtonElement` to your platform's button payload, encode both `button.id` (the action ID) and `button.value` (which may be `undefined`, a user-supplied value, or a callback token). Pick a delimiter that cannot appear in either, and validate the encoded string fits the platform's limit. 2. **Receive side**: when the user clicks a button, decode the platform payload back into `actionId` and `value`, and pass them to `chat.processAction({ actionId, value, ... })`. The SDK will detect the `__cb:` prefix, look up the stored callback URL, POST to it, and pass the original value (if any) to user `onAction` handlers. Discord's adapter is a good reference — it joins the action ID and value with `\n` and validates against Discord's 100-character `custom_id` limit: ```typescript title="src/cards.ts (discord)" lineNumbers const DISCORD_CUSTOM_ID_DELIMITER = "\n"; const DISCORD_CUSTOM_ID_MAX_LENGTH = 100; export function encodeDiscordCustomId( actionId: string, value?: string ): string { if (value == null || value === "") { validateLength(actionId); return actionId; } const encoded = `${actionId}${DISCORD_CUSTOM_ID_DELIMITER}${value}`; validateLength(encoded); return encoded; } export function decodeDiscordCustomId(customId: string): { actionId: string; value: string | undefined; } { const idx = customId.indexOf(DISCORD_CUSTOM_ID_DELIMITER); if (idx === -1) { return { actionId: customId, value: undefined }; } // Use the FIRST delimiter only — values may legitimately contain "\n". return { actionId: customId.slice(0, idx), value: customId.slice(idx + 1), }; } ``` <Callout type="info"> Platform button-data limits are the main constraint to plan for. Discord's `custom_id` is 100 chars; Telegram's `callback_data` is 64 bytes. The SDK's callback token is fixed at 21 chars (`__cb:` + 16 hex), so the worst-case payload your encoding must fit is `actionId + delimiter + 21`. If the encoded string exceeds the platform limit, throw a `ValidationError` from `@chat-adapter/shared` so the host app fails fast at post time rather than silently truncating. </Callout> Modals with `callbackUrl` are handled entirely inside the SDK via stored modal context — your adapter does not need any special handling. Just call `chat.processModalSubmit(event, contextId, { waitUntil })` from your webhook handler and the SDK will POST to the modal's `callbackUrl` (using `waitUntil` if you provide it, so the response is not blocked). ### Reactions Handle both `EmojiValue` objects and plain strings. `EmojiValue` has a `name` property and `toString()` method — there is no `unicode` field. ```typescript title="src/adapter.ts" lineNumbers async addReaction( threadId: string, messageId: string, emoji: EmojiValue | string ): Promise<void> { const { roomId } = this.decodeThreadId(threadId); const emojiStr = typeof emoji === "string" ? emoji : emoji.name; await this.sendReaction(roomId, messageId, emojiStr); } async removeReaction( threadId: string, messageId: string, emoji: EmojiValue | string ): Promise<void> { const { roomId } = this.decodeThreadId(threadId); const emojiStr = typeof emoji === "string" ? emoji : emoji.name; await this.removeMatrixReaction(roomId, messageId, emojiStr); } ``` ### Fetching and typing `fetchMessages` should return messages in chronological order (oldest first). The `nextCursor` enables pagination. ```typescript title="src/adapter.ts" lineNumbers async fetchMessages( threadId: string, options?: FetchOptions ): Promise<FetchResult<unknown>> { const { roomId } = this.decodeThreadId(threadId); // Fetch from platform API with pagination return { messages: [], nextCursor: undefined }; } async fetchThread(threadId: string): Promise<ThreadInfo> { const { roomId } = this.decodeThreadId(threadId); return { id: threadId, title: undefined, createdAt: new Date(), }; } async startTyping(threadId: string): Promise<void> { const { roomId } = this.decodeThreadId(threadId); // Send typing notification via platform API } ``` ### Formatting Delegate to your format converter (covered in the next section). ```typescript title="src/adapter.ts" renderFormatted(content: FormattedContent): string { return this.converter.fromAst(content.ast); } ``` ## Build a format converter Each adapter needs a format converter that translates between the platform's text format and mdast (Markdown AST), the canonical format used by Chat SDK. ```typescript title="src/format-converter.ts" lineNumbers import { BaseFormatConverter, type Root, parseMarkdown, stringifyMarkdown, text, strong, emphasis, inlineCode, codeBlock, link, paragraph, root, } from "chat"; import type { AdapterPostableMessage } from "chat"; export class MatrixFormatConverter extends BaseFormatConverter { /** * Convert platform text to mdast AST. * If your platform uses standard markdown, just use parseMarkdown(). */ toAst(platformText: string): Root { // Matrix supports standard markdown, so we can parse directly return parseMarkdown(platformText); } /** * Convert mdast AST to platform text format. * Walk the AST and produce platform-specific markup. */ fromAst(ast: Root): string { // Matrix supports standard markdown, so we can stringify directly return stringifyMarkdown(ast); } /** * Override renderPostable only if your platform needs custom rendering * (e.g., converting @mentions to platform-specific syntax). * The base class already handles text/formatted/card fallback logic. */ renderPostable(message: AdapterPostableMessage): string { // Example: convert @mention syntax to Matrix pill format const rendered = super.renderPostable(message); return rendered.replace( /@(\w+)/g, (_, name) => `<a href="https://matrix.to/#/@${name}:matrix.org">@${name}</a>` ); } } ``` For platforms with non-standard formatting, implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax. ## Optional methods These methods are not required but extend your adapter's capabilities: | Method | Purpose | |--------|---------| | `disconnect()` | Clean up connections and resources during shutdown | | `openDM(userId)` | Open a direct message conversation | | `isDM(threadId)` | Check if a thread is a DM | | `stream(threadId, textStream)` | Stream AI responses in real-time | | `openModal(triggerId, modal)` | Open a modal/dialog form | | `postEphemeral(threadId, userId, message)` | Post a message visible to one user | | `postChannelMessage(channelId, message)` | Post a top-level message (not in a thread) | | `onThreadSubscribe(threadId)` | Hook for platform-specific subscription setup | | `fetchChannelInfo(channelId)` | Fetch channel metadata | | `listThreads(channelId)` | List threads in a channel | | `fetchMessage(threadId, messageId)` | Fetch a single message by ID | | `fetchChannelMessages(channelId)` | Fetch top-level channel messages | | `channelIdFromThreadId(threadId)` | Extract channel ID from a thread ID | | `scheduleMessage(threadId, message, options)` | Schedule a message for future delivery; return a `ScheduledMessage` with `cancel()` | Implement only the methods your platform supports. The SDK gracefully handles missing optional methods. ## Factory function Export a factory function that creates your adapter with environment variable fallbacks: ```typescript title="src/factory.ts" lineNumbers import { ConsoleLogger } from "chat"; import type { Logger } from "chat"; import { ValidationError } from "@chat-adapter/shared"; import { MatrixAdapter } from "./adapter"; import type { MatrixAdapterConfig } from "./types"; export function createMatrixAdapter( config?: Partial<MatrixAdapterConfig> & { logger?: Logger } ): MatrixAdapter { const homeserverUrl = config?.homeserverUrl ?? process.env.MATRIX_HOMESERVER_URL; const accessToken = config?.accessToken ?? process.env.MATRIX_ACCESS_TOKEN; if (!homeserverUrl) { throw new ValidationError( "Matrix homeserver URL is required. Pass it in config or set MATRIX_HOMESERVER_URL." ); } if (!accessToken) { throw new ValidationError( "Matrix access token is required. Pass it in config or set MATRIX_ACCESS_TOKEN." ); } return new MatrixAdapter({ homeserverUrl, accessToken, userName: config?.userName, logger: config?.logger, }); } ``` Then export both the class and factory from your entry point: ```typescript title="src/index.ts" lineNumbers export { MatrixAdapter } from "./adapter"; export { MatrixFormatConverter } from "./format-converter"; export { createMatrixAdapter } from "./factory"; export type { MatrixAdapterConfig, MatrixThreadId } from "./types"; ``` ## Shared utilities The `@chat-adapter/shared` package provides utilities you should use instead of reimplementing: ### Error classes ```typescript import { AdapterError, // Base error class AdapterRateLimitError, // Platform rate limit hit AuthenticationError, // Invalid credentials ResourceNotFoundError, // Thread/message not found PermissionError, // Insufficient permissions ValidationError, // Invalid input NetworkError, // HTTP/connection failure } from "@chat-adapter/shared"; ``` Throw these errors from your adapter methods. The SDK catches and logs them with appropriate context. ### Message utilities ```typescript import { extractCard, // Extract CardElement from AdapterPostableMessage extractFiles, // Extract FileUpload[] from AdapterPostableMessage toBuffer, // Convert FileDataInput to Buffer (async) toBufferSync, // Convert FileDataInput to Buffer (sync) cardToFallbackText, // Convert card to plain text } from "@chat-adapter/shared"; ``` ### Token encryption If your adapter persists OAuth tokens to a `StateAdapter`, encrypt them at rest with the shared AES-256-GCM helpers instead of rolling your own: ```typescript import { encryptToken, // Encrypt a string into an EncryptedTokenData envelope decryptToken, // Decrypt an envelope back to the original string decodeKey, // Decode a hex-64 or base64-44 32-byte key (throws on wrong length) isEncryptedTokenData, // Type guard for tolerating legacy plaintext records type EncryptedTokenData, } from "@chat-adapter/shared"; ``` Accept the key as an optional `encryptionKey` config field (auto-detected from a `*_ENCRYPTION_KEY` env var), encrypt on `setInstallation()`, decrypt on `getInstallation()`, and use `isEncryptedTokenData` to keep accepting plaintext records so operators can roll the key in without flushing existing installs. See `@chat-adapter/slack` and `@chat-adapter/linear` for reference implementations.