UNPKG

chat

Version:

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

694 lines (577 loc) 21.3 kB
--- title: Chat description: The main entry point for creating a multi-platform chat bot. type: reference --- The `Chat` class coordinates adapters, state, and event handlers. Create one instance and register handlers for different event types. ```typescript import { Chat } from "chat"; ``` ## Constructor ```typescript const bot = new Chat(config); ``` <TypeTable type={{ userName: { description: 'Default bot username across all adapters.', type: 'string', }, adapters: { description: 'Map of adapter name to adapter instance.', type: 'Record<string, Adapter>', }, dedupeTtlMs: { description: 'TTL for message deduplication entries in milliseconds. Increase if webhook cold starts cause platform retries after the default window.', type: 'number', default: '300000', }, state: { description: 'State adapter for subscriptions, locking, and caching.', type: 'StateAdapter', }, logger: { description: 'Logger instance or log level. Defaults to ConsoleLogger("info") if omitted.', type: 'Logger | "debug" | "info" | "warn" | "error" | "silent"', default: 'ConsoleLogger("info")', }, streamingUpdateIntervalMs: { description: 'Throttle interval for fallback streaming (post + edit) in milliseconds.', type: 'number', default: '500', }, }} /> ## Event handlers ### onNewMention Fires when the bot is @-mentioned in a thread it has **not** subscribed to. This is the primary entry point for new conversations. ```typescript bot.onNewMention(async (thread, message) => { await thread.subscribe(); await thread.post("Hello!"); }); ``` <TypeTable type={{ thread: { description: 'The thread where the mention occurred.', type: 'Thread', }, message: { description: 'The message that contains the @-mention.', type: 'Message', }, }} /> ### onDirectMessage Fires for every direct message when registered. Direct message handlers run before `onSubscribedMessage`, `onNewMention`, and pattern handlers. If no direct message handler is registered, unsubscribed DMs fall through to `onNewMention` for backward compatibility. ```typescript bot.onDirectMessage(async (thread, message, channel) => { await thread.post(`Got your DM in ${channel.id}: ${message.text}`); }); ``` <TypeTable type={{ thread: { description: 'The DM thread where the message occurred.', type: 'Thread', }, message: { description: 'The direct message.', type: 'Message', }, channel: { description: 'The DM channel.', type: 'Channel', }, }} /> ### onSubscribedMessage Fires for every new message in a subscribed non-DM thread. Once subscribed, messages (including @-mentions) route here instead of `onNewMention`. DM threads route to `onDirectMessage` first when a direct message handler is registered. ```typescript bot.onSubscribedMessage(async (thread, message) => { if (message.isMention) { // User @-mentioned us in a thread we're already watching } await thread.post(`Got: ${message.text}`); }); ``` ### onNewMessage Fires for messages matching a regex pattern in **unsubscribed** threads. ```typescript bot.onNewMessage(/^!help/i, async (thread, message) => { await thread.post("Available commands: !help, !status"); }); ``` <TypeTable type={{ pattern: { description: 'Regular expression to match against message text.', type: 'RegExp', }, handler: { description: 'Handler called when the pattern matches.', type: '(thread: Thread, message: Message) => Promise<void>', }, }} /> ### onReaction Fires when a user adds or removes an emoji reaction. ```typescript import { emoji } from "chat"; // Filter to specific emoji bot.onReaction([emoji.thumbs_up, emoji.heart], async (event) => { if (event.added) { await event.thread.post(`Thanks for the ${event.emoji}!`); } }); // Handle all reactions bot.onReaction(async (event) => { /* ... */ }); ``` <TypeTable type={{ 'event.emoji': { description: 'Normalized emoji value (supports === comparison).', type: 'EmojiValue', }, 'event.rawEmoji': { description: 'Platform-specific emoji string.', type: 'string', }, 'event.added': { description: 'true if added, false if removed.', type: 'boolean', }, 'event.user': { description: 'The user who reacted.', type: 'Author', }, 'event.thread': { description: 'The thread where the reaction occurred.', type: 'Thread', }, 'event.message': { description: 'The message that was reacted to (if available).', type: 'Message | undefined', }, 'event.messageId': { description: 'The message ID that was reacted to.', type: 'string', }, }} /> ### onAction Fires when a user clicks a button or selects an option in a card. ```typescript // Single action bot.onAction("approve", async (event) => { if (event.thread) { await event.thread.post("Approved!"); } }); // Multiple actions bot.onAction(["approve", "reject"], async (event) => { /* ... */ }); // All actions bot.onAction(async (event) => { /* ... */ }); ``` <TypeTable type={{ 'event.actionId': { description: 'Action ID from the button or select.', type: 'string', }, 'event.value': { description: 'Optional payload value from the button.', type: 'string | undefined', }, 'event.user': { description: 'User who triggered the action.', type: 'Author', }, 'event.thread': { description: 'The thread containing the card, or null for view-based actions.', type: 'Thread | null', }, 'event.triggerId': { description: 'Trigger ID for opening modals (platform-specific, may expire quickly).', type: 'string | undefined', }, 'event.openModal': { description: 'Open a modal form in response to this action.', type: '(modal: ModalElement | CardJSXElement) => Promise<{ viewId: string } | undefined>', }, }} /> ### onModalSubmit Fires when a user submits a modal form. ```typescript bot.onModalSubmit("feedback", async (event) => { const comment = event.values.comment; if (event.relatedThread) { await event.relatedThread.post(`Feedback: ${comment}`); } }); ``` <TypeTable type={{ 'event.callbackId': { description: 'The callback ID specified when the modal was created.', type: 'string', }, 'event.values': { description: 'Form field values keyed by input ID.', type: 'Record<string, string>', }, 'event.user': { description: 'User who submitted the modal.', type: 'Author', }, 'event.relatedThread': { description: 'The thread where the modal was triggered from (if available).', type: 'Thread | undefined', }, 'event.relatedMessage': { description: 'The message containing the action that opened the modal.', type: 'SentMessage | undefined', }, 'event.relatedChannel': { description: 'The channel where the modal was triggered from (available when opened via slash commands).', type: 'Channel | undefined', }, 'event.privateMetadata': { description: 'Arbitrary string passed through the modal lifecycle.', type: 'string | undefined', }, }} /> Returns `ModalResponse | undefined` to control the modal after submission: - `{ action: "close" }` — close the current view (goes back one level in the stack) - `{ action: "clear" }` — close all views and dismiss the modal entirely - `{ action: "errors", errors: { fieldId: "message" } }` — show validation errors - `{ action: "update", modal: ModalElement }` — replace the modal content - `{ action: "push", modal: ModalElement }` — push a new modal view onto the stack ### onOptionsLoad Fires when an `ExternalSelect` requests options dynamically. The handler is keyed on the select's `id` and must return options synchronously enough for Slack's 3-second budget (the adapter caps the loader at ~2.5s and substitutes an empty result on timeout). Slack-only. ```typescript bot.onOptionsLoad("assignee", async (event) => { const people = await peopleService.search(event.query); return people.map((p) => ({ label: p.fullName, value: p.id })); }); ``` Return an array of `OptionsLoadGroup` (`{ label, options }[]`) instead of a flat array to render grouped headers (e.g. "Recent" / "All"). Slack limits: max 100 groups, max 100 options per group. <TypeTable type={{ 'event.actionId': { description: 'The id of the select requesting options (matches the id passed to bot.onOptionsLoad).', type: 'string', }, 'event.query': { description: 'The text the user has typed so far.', type: 'string', }, 'event.user': { description: 'The user requesting options.', type: 'Author', }, 'event.adapter': { description: 'The adapter that received this event.', type: 'Adapter', }, 'event.raw': { description: 'Raw platform-specific payload.', type: 'unknown', }, }} /> ### onSlashCommand Fires when a user invokes a `/command` in the message composer. Currently supported on Slack and Discord. ```typescript // Specific command bot.onSlashCommand("/status", async (event) => { await event.channel.post("All systems operational!"); }); // Multiple commands bot.onSlashCommand(["/help", "/info"], async (event) => { await event.channel.post(`You invoked ${event.command}`); }); // Catch-all bot.onSlashCommand(async (event) => { console.log(`${event.command} ${event.text}`); }); ``` <TypeTable type={{ 'event.command': { description: 'The command name (e.g., "/status").', type: 'string', }, 'event.text': { description: 'Arguments after the command.', type: 'string', }, 'event.user': { description: 'The user who invoked the command.', type: 'Author', }, 'event.channel': { description: 'The channel where the command was invoked.', type: 'Channel', }, 'event.triggerId': { description: 'Trigger ID for opening modals (time-limited).', type: 'string | undefined', }, 'event.openModal': { description: 'Open a modal form in response to this command.', type: '(modal: ModalElement | CardJSXElement) => Promise<{ viewId: string } | undefined>', }, 'event.adapter': { description: 'The platform adapter.', type: 'Adapter', }, 'event.raw': { description: 'Platform-specific raw payload.', type: 'unknown', }, }} /> ### onModalClose Fires when a user closes a modal (requires `notifyOnClose: true` on the modal). ```typescript bot.onModalClose("feedback", async (event) => { /* ... */ }); ``` ### onAssistantThreadStarted Fires when a user opens a new assistant thread (Slack Assistants API). Use this to set suggested prompts, show a status indicator, or send an initial greeting. ```typescript bot.onAssistantThreadStarted(async (event) => { const slack = bot.getAdapter("slack") as SlackAdapter; await slack.setSuggestedPrompts(event.channelId, event.threadTs, [ { title: "Get started", message: "What can you help me with?" }, ]); }); ``` <TypeTable type={{ 'event.threadId': { description: 'Encoded thread ID.', type: 'string', }, 'event.userId': { description: 'The user who opened the thread.', type: 'string', }, 'event.channelId': { description: 'The DM channel ID.', type: 'string', }, 'event.threadTs': { description: 'Thread timestamp.', type: 'string', }, 'event.context': { description: 'Context about where the thread was opened (channel, team, enterprise, entry point).', type: 'AssistantThreadContext', }, 'event.adapter': { description: 'The platform adapter.', type: 'Adapter', }, }} /> ### onAssistantContextChanged Fires when a user navigates to a different channel while the assistant panel is open (Slack Assistants API). Use this to update suggested prompts or context based on the new channel. ```typescript bot.onAssistantContextChanged(async (event) => { const slack = bot.getAdapter("slack") as SlackAdapter; await slack.setAssistantStatus(event.channelId, event.threadTs, "Updating context..."); }); ``` The event shape is identical to `onAssistantThreadStarted`. ### onAppHomeOpened Fires when a user opens the bot's Home tab in Slack. Use this to publish a dynamic Home tab view. ```typescript bot.onAppHomeOpened(async (event) => { const slack = bot.getAdapter("slack") as SlackAdapter; await slack.publishHomeView(event.userId, { type: "home", blocks: [{ type: "section", text: { type: "mrkdwn", text: "Welcome!" } }], }); }); ``` <TypeTable type={{ 'event.userId': { description: 'The user who opened the Home tab.', type: 'string', }, 'event.channelId': { description: 'The channel ID associated with the Home tab.', type: 'string', }, 'event.adapter': { description: 'The platform adapter.', type: 'Adapter', }, }} /> ## Utility methods ### webhooks Type-safe webhook handlers keyed by adapter name. Pass these to your HTTP route handler. ```typescript bot.webhooks.slack(request, { waitUntil }); bot.webhooks.teams(request, { waitUntil }); ``` ### getAdapter Get a typed adapter instance by name. ```typescript const slack = bot.getAdapter("slack"); ``` #### Direct client access Access the platform's typed native API client directly via an SDK-named getter — `.webClient` on Slack, `.linearClient` on Linear, `.octokit` on GitHub: ```typescript // Slack - full WebClient from @slack/web-api const slack = bot.getAdapter("slack").webClient; await slack.pins.add({ channel: "C123ABC", timestamp: "1234567890.123456" }); // Linear - full LinearClient from @linear/sdk const linear = bot.getAdapter("linear").linearClient; const issue = await linear.issue("ENG-123"); const project = await issue.project; // GitHub - full Octokit from @octokit/rest const github = bot.getAdapter("github").octokit; const { data: pulls } = await github.rest.pulls.list({ owner: "vercel", repo: "chat", state: "open", }); ``` The client uses the credentials from your adapter config. For multi-tenant / multi-workspace adapters (Slack, Linear, GitHub), it returns the client bound to the credentials for the current webhook request context. <Callout type="info"> The previous `.client` getter still works on all three adapters as a deprecated alias for `.webClient` / `.linearClient` / `.octokit`. </Callout> <Callout type="warn"> Multi-tenant adapters (GitHub App without a fixed installation ID, Linear with per-org OAuth, Slack in multi-workspace mode) require a webhook handler context to resolve credentials when the native client getter is accessed. Calling it outside a handler throws. For Slack, you can also bind a token explicitly outside a webhook with `adapter.withBotToken(token, () => adapter.webClient.…)` — useful for cron jobs or workflows. The same pattern is required when `botToken` is configured as an async resolver function, since `.webClient` resolves the token synchronously. Single-tenant adapters (PAT, API key, static `botToken` string, or a synchronous `botToken` resolver) work anywhere. </Callout> | Adapter | Getter | Type | |---------|--------|------| | Slack | `.webClient` | `WebClient` from `@slack/web-api` | | Linear | `.linearClient` | `LinearClient` from `@linear/sdk` | | GitHub | `.octokit` | `Octokit` from `@octokit/rest` | ### openDM Open a direct message thread with a user. ```typescript const dm = await bot.openDM("U123456"); await dm.post("Hello via DM!"); // Or with an Author object const dm = await bot.openDM(message.author); ``` ### getUser Look up user information by user ID. Returns a `UserInfo` object with name, email, avatar, and bot status, or `null` if the user was not found. Supported on Slack, Microsoft Teams, Discord, Google Chat, GitHub, Linear, and Telegram. Other adapters will throw `NOT_SUPPORTED`. ```typescript const user = await bot.getUser("U123456"); console.log(user?.email); // "alice@company.com" console.log(user?.fullName); // "Alice Smith" ``` ```typescript // Or with an Author object from a message handler const user = await bot.getUser(message.author); ``` <TypeTable type={{ userId: { description: 'Platform-specific user ID.', type: 'string', }, userName: { description: 'Username/handle.', type: 'string', }, fullName: { description: 'Display name / full name.', type: 'string', }, isBot: { description: 'Whether the user is a bot.', type: 'boolean', }, email: { description: 'Email address (requires scopes on some platforms).', type: 'string | undefined', }, avatarUrl: { description: 'Profile image URL.', type: 'string | undefined', }, }} /> <Callout type="info"> **Per-platform constraints:** - **Slack** — requires both `users:read` and `users:read.email` scopes (the email scope must be granted at OAuth install time). - **Discord** — bot tokens never see email (the `email` OAuth scope only applies in user-context auth). - **Telegram** — bots can only look up users who have previously messaged them. - **Microsoft Teams** — only works for users who previously interacted with the bot (cached from webhook activity). `avatarUrl` is not returned (Graph API requires a separate photo call). - **Google Chat** — same caching constraint as Teams: only users seen in prior webhooks. - **GitHub** — `email` is `null` unless the user made it public, or you authenticated with the `user:email` scope. - **Linear** — full profile (incl. email + avatar) for any active workspace member. Fields that aren't available return `undefined`. Numeric user IDs (Discord/Telegram/GitHub) can be ambiguous when multiple of those adapters are registered — `bot.getUser` throws a `ChatError` with code `AMBIGUOUS_USER_ID` in that case. Pass an `Author` from a message handler (which already carries the adapter), or call the adapter directly (`adapter.getUser(userId)`). </Callout> `bot.getUser` throws a `ChatError` in three cases. Handle them if your bot runs on multiple platforms: | Code | When | |------|------| | `NOT_SUPPORTED` | The resolved adapter doesn't implement `getUser` (e.g. WhatsApp) | | `AMBIGUOUS_USER_ID` | A numeric user ID could belong to more than one registered adapter (Discord/Telegram/GitHub) | | `UNKNOWN_USER_ID_FORMAT` | The `userId` string doesn't match any registered platform's ID format | ```typescript import { ChatError } from "chat"; try { const user = await bot.getUser(userId); if (!user) { // User not found on this platform } } catch (error) { if (error instanceof ChatError) { if (error.code === "NOT_SUPPORTED") { // This adapter doesn't support user lookups } else if (error.code === "AMBIGUOUS_USER_ID") { // Pass message.author or call adapter.getUser(userId) directly } else if (error.code === "UNKNOWN_USER_ID_FORMAT") { // userId doesn't match any known platform format } } } ``` ### thread Get a Thread handle by its thread ID. Useful for posting to threads outside of webhook contexts (e.g. cron jobs, external triggers). ```typescript const thread = bot.thread("slack:C123ABC:1234567890.123456"); await thread.post("Hello from a cron job!"); ``` ### channel Get a Channel by its channel ID. ```typescript const channel = bot.channel("slack:C123ABC"); for await (const msg of channel.messages) { console.log(msg.text); } ``` ### initialize / shutdown Manually manage the lifecycle. Initialization happens automatically on the first webhook, but you can call it explicitly for non-webhook use cases. ```typescript await bot.initialize(); // ... do work ... await bot.shutdown(); ``` During shutdown, the SDK calls the optional `disconnect()` method on each adapter before disconnecting the state adapter. This lets adapters clean up platform connections, close WebSockets, or tear down subscriptions. If any adapter's `disconnect()` fails, the remaining adapters and state adapter still disconnect gracefully. ### reviver Get a `JSON.parse` reviver that deserializes `Thread` and `Message` objects from workflow payloads. ```typescript const data = JSON.parse(payload, bot.reviver()); await data.thread.post("Hello from workflow!"); ``` There is also a standalone `reviver` export that works without a `Chat` instance. This is useful in Vercel Workflow functions where importing the full Chat instance (with its adapter dependencies) is not possible: ```typescript import { reviver } from "chat"; const data = JSON.parse(payload, reviver) as { thread: Thread; message: Message }; ``` The standalone reviver uses lazy adapter resolution - the adapter is looked up from the Chat singleton when first accessed. Call `chat.registerSingleton()` before using thread methods like `post()` (typically inside a `"use step"` function).