Files
Vishal Yathish 660a84ee09 cleanup
2026-01-22 15:30:44 -08:00

585 lines
19 KiB
Markdown

# Chat SDK
A unified SDK for building chat bots across Slack, Microsoft Teams, Google Chat, and Discord.
## Features
- Multi-platform support with a single codebase
- Mention-based thread subscriptions
- Reaction handling with type-safe emoji
- Cross-platform emoji helper for consistent rendering
- **AI SDK integration** - Stream LLM responses directly to chat
- **Rich cards with buttons** - TSX or object-based cards
- **Action callbacks** - Handle button clicks across platforms
- **Modals & form inputs** - Collect user input via modal dialogs
- **File uploads** - Send files with messages
- **DM support** - Initiate direct messages programmatically
- Message deduplication for platform quirks
- Serverless-ready with pluggable state backends
## Quick Start
### 1. Create your bot (`lib/bot.ts`)
```typescript
import { Chat, ConsoleLogger, emoji } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTeamsAdapter } from "@chat-adapter/teams";
import { createGoogleChatAdapter } from "@chat-adapter/gchat";
import { createDiscordAdapter } from "@chat-adapter/discord";
import { createRedisState } from "@chat-adapter/state-redis";
const logger = new ConsoleLogger("info");
export const bot = new Chat({
userName: "mybot",
logger,
adapters: {
slack: createSlackAdapter({
botToken: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
logger: logger.child("slack"),
}),
teams: createTeamsAdapter({
appId: process.env.TEAMS_APP_ID!,
appPassword: process.env.TEAMS_APP_PASSWORD!,
logger: logger.child("teams"),
}),
gchat: createGoogleChatAdapter({
credentials: JSON.parse(process.env.GOOGLE_CHAT_CREDENTIALS!),
logger: logger.child("gchat"),
}),
discord: createDiscordAdapter({
botToken: process.env.DISCORD_BOT_TOKEN!,
publicKey: process.env.DISCORD_PUBLIC_KEY!,
applicationId: process.env.DISCORD_APPLICATION_ID!,
logger: logger.child("discord"),
}),
},
state: createRedisState({ url: process.env.REDIS_URL!, logger }),
});
// Handle @mentions - works across all platforms
bot.onNewMention(async (thread) => {
await thread.subscribe();
// Emoji auto-converts to platform format: :wave: on Slack, 👋 on Teams/GChat
await thread.post(`${emoji.wave} Hello! I'm now listening to this thread.`);
});
// Handle follow-up messages in subscribed threads
bot.onSubscribedMessage(async (thread, message) => {
await thread.post(`${emoji.check} You said: ${message.text}`);
});
// Handle emoji reactions (type-safe emoji values)
bot.onReaction([emoji.thumbs_up, emoji.heart, emoji.fire], async (event) => {
if (!event.added) return; // Only respond to added reactions
await event.adapter.addReaction(event.threadId, event.messageId, event.emoji);
});
```
### 2. Create a webhook handler (`app/api/webhooks/[platform]/route.ts`)
```typescript
import { after } from "next/server";
import { bot } from "@/lib/bot";
type Platform = keyof typeof bot.webhooks;
export async function POST(
request: Request,
{ params }: { params: Promise<{ platform: string }> }
) {
const { platform } = await params;
const handler = bot.webhooks[platform as Platform];
if (!handler) {
return new Response(`Unknown platform: ${platform}`, { status: 404 });
}
return handler(request, {
waitUntil: (task) => after(() => task),
});
}
```
This creates endpoints for each platform:
- `POST /api/webhooks/slack`
- `POST /api/webhooks/teams`
- `POST /api/webhooks/gchat`
- `POST /api/webhooks/discord`
The `waitUntil` option ensures message processing completes after the response is sent (required for serverless).
**Note for Discord:** Discord uses HTTP Interactions for slash commands and button clicks, but requires a Gateway WebSocket connection for receiving messages. See [SETUP.md](./SETUP.md) for Discord Gateway configuration.
## Setup
See [SETUP.md](./SETUP.md) for platform configuration instructions including:
- Slack app creation and OAuth scopes
- Microsoft Teams Azure Bot setup
- Google Chat service account and Pub/Sub configuration
- Discord application and Gateway setup
- Environment variables reference
## Emoji Helper
The `emoji` helper provides type-safe, cross-platform emoji that automatically convert to each platform's format. Use it with `thread.post()`:
```
await thread.post(`${emoji.thumbs_up} Great job!`);
// Slack: ":+1: Great job!"
// Teams/GChat: "👍 Great job!"
```
**Available emoji:**
| Name | Emoji | Name | Emoji |
| ----------------- | ----- | ------------------- | ----- |
| `emoji.thumbs_up` | 👍 | `emoji.thumbs_down` | 👎 |
| `emoji.heart` | ❤️ | `emoji.smile` | 😊 |
| `emoji.laugh` | 😂 | `emoji.thinking` | 🤔 |
| `emoji.eyes` | 👀 | `emoji.fire` | 🔥 |
| `emoji.check` | ✅ | `emoji.x` | ❌ |
| `emoji.question` | ❓ | `emoji.party` | 🎉 |
| `emoji.rocket` | 🚀 | `emoji.star` | ⭐ |
| `emoji.wave` | 👋 | `emoji.clap` | 👏 |
| `emoji["100"]` | 💯 | `emoji.warning` | ⚠️ |
For one-off custom emoji, use `emoji.custom("name")`.
### Custom Emoji (Type-Safe)
For workspace-specific emoji with full type safety, use `createEmoji()`:
```typescript
import { createEmoji } from "chat";
// Create emoji helper with custom emoji
const myEmoji = createEmoji({
unicorn: { slack: "unicorn_face", gchat: "🦄" },
company_logo: { slack: "company", gchat: "🏢" },
});
// Type-safe access to custom emoji (with autocomplete)
const message = `${myEmoji.unicorn} Magic! ${myEmoji.company_logo}`;
// Slack: ":unicorn_face: Magic! :company:"
// GChat: "🦄 Magic! 🏢"
```
## Rich Cards with Buttons
Send interactive cards with buttons that work across all platforms. Cards automatically convert to Block Kit (Slack), Adaptive Cards (Teams), and Google Chat Cards.
Configure your `tsconfig.json` to use the chat JSX runtime:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "chat"
}
}
```
Then use JSX syntax:
```tsx
import {
Card,
CardText,
Button,
LinkButton,
Actions,
Section,
Fields,
Field,
Divider,
Image,
Modal,
TextInput,
Select,
SelectOption,
} from "chat";
// Simple card with buttons
await thread.post(
<Card title="Order #1234">
<CardText>Your order has been received!</CardText>
<Section>
<CardText style="bold">Total: $50.00</CardText>
</Section>
<Actions>
<Button id="approve" style="primary">
Approve
</Button>
<Button id="reject" style="danger">
Reject
</Button>
<LinkButton url="https://example.com/order/1234">View Order</LinkButton>
</Actions>
</Card>
);
// Card with fields (key-value pairs)
await thread.post(
<Card title="User Profile">
<Fields>
<Field label="Name" value="John Doe" />
<Field label="Role" value="Developer" />
<Field label="Team" value="Platform" />
</Fields>
<Divider />
<Actions>
<Button id="edit">Edit Profile</Button>
</Actions>
</Card>
);
// Card with image
await thread.post(
<Card title="Product Update">
<Image url="https://example.com/product.png" alt="Product screenshot" />
<CardText>Check out our new feature!</CardText>
</Card>
);
```
**Note:** Use `CardText` (not `Text`) when using JSX to avoid conflicts with React's built-in types.
## Action Callbacks
Handle button clicks from cards:
```typescript
import { Chat, type ActionEvent } from "chat";
// Handle a specific action
bot.onAction("approve", async (event: ActionEvent) => {
await event.thread.post(`Order approved by ${event.user.fullName}!`);
});
// Handle multiple actions
bot.onAction(["approve", "reject"], async (event: ActionEvent) => {
const action = event.actionId === "approve" ? "approved" : "rejected";
await event.thread.post(`Order ${action}!`);
});
// Catch-all action handler
bot.onAction(async (event: ActionEvent) => {
console.log(`Action: ${event.actionId}, Value: ${event.value}`);
});
```
The `ActionEvent` includes `actionId`, `value`, `user`, `thread`, `messageId`, `threadId`, `adapter`, `triggerId`, and `raw` properties.
## Modals & Form Inputs
Open modal dialogs to collect structured user input. Modals support text inputs, dropdowns, and validation. Currently supported on Slack.
### Opening a Modal
Modals are opened in response to button clicks using `event.openModal()`. The SDK automatically tracks the thread and message context, making it available as `relatedThread` and `relatedMessage` in the submit/close handlers:
```tsx
import { Modal, TextInput, Select, SelectOption } from "chat";
// Handle a button click that opens a modal
bot.onAction("feedback", async (event) => {
await event.openModal(
<Modal
callbackId="feedback_form"
title="Send Feedback"
submitLabel="Send"
closeLabel="Cancel"
notifyOnClose
>
<TextInput
id="message"
label="Your Feedback"
placeholder="Tell us what you think..."
multiline
/>
<Select id="category" label="Category" placeholder="Select a category">
<SelectOption label="Bug Report" value="bug" />
<SelectOption label="Feature Request" value="feature" />
<SelectOption label="General Feedback" value="general" />
</Select>
<TextInput
id="email"
label="Email (optional)"
placeholder="your@email.com"
optional
/>
</Modal>
);
});
```
### Modal Components
| Component | Description |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| `Modal` | Container with `callbackId`, `title`, `submitLabel`, `closeLabel`, `notifyOnClose` |
| `TextInput` | Text field with `id`, `label`, `placeholder`, `initialValue`, `multiline`, `optional`, `maxLength` |
| `Select` | Dropdown with `id`, `label`, `placeholder`, `initialOption`, `optional` |
| `SelectOption` | Option for Select with `label` and `value` |
### Handling Modal Submissions
Handle form submissions with `onModalSubmit`. The event includes `relatedThread` and `relatedMessage` which automatically reference the thread/message where the modal was triggered:
```typescript
import type { ModalSubmitEvent } from "chat";
bot.onModalSubmit("feedback_form", async (event: ModalSubmitEvent) => {
const { message, category, email } = event.values;
// Validate input - return errors to show in the modal
if (!message || message.length < 5) {
return {
action: "errors",
errors: { message: "Feedback must be at least 5 characters" },
};
}
// Process the submission
console.log("Received feedback:", { message, category, email });
// Post confirmation to the original thread
if (event.relatedThread) {
await event.relatedThread.post(`Feedback received! Category: ${category}`);
}
// Optionally update the original message that triggered the modal
if (event.relatedMessage) {
await event.relatedMessage.edit("✅ Feedback submitted!");
}
// Return nothing to close the modal
});
```
### Modal Response Types
Return different responses from `onModalSubmit` to control modal behavior:
| Response | Description |
| ------------------------------------------------------------ | --------------------------------------------- |
| `{ action: "close" }` | Close the modal (default if nothing returned) |
| `{ action: "errors", errors: { fieldId: "Error message" } }` | Show validation errors on specific fields |
| `{ action: "update", modal: ModalElement }` | Update the modal content |
| `{ action: "push", modal: ModalElement }` | Push a new modal view onto the stack |
### Handling Modal Close
Optionally handle when users close/cancel a modal (requires `notifyOnClose` on the Modal):
```typescript
import type { ModalCloseEvent } from "chat";
bot.onModalClose("feedback_form", async (event: ModalCloseEvent) => {
console.log(`${event.user.userName} cancelled the feedback form`);
// Post a follow-up to the original thread
if (event.relatedThread) {
await event.relatedThread.post(
"No worries, let us know if you change your mind!"
);
}
});
```
The `ModalSubmitEvent` includes `callbackId`, `viewId`, `values`, `user`, `adapter`, `relatedThread`, `relatedMessage`, and `raw` properties. The `ModalCloseEvent` includes the same properties except `values`.
## AI Integration & Streaming
Stream LLM responses directly to chat platforms. The SDK accepts any `AsyncIterable<string>` (like AI SDK's `textStream`), automatically using native streaming APIs where available (Slack) or falling back to post+edit for other platforms.
```typescript
import { Chat } from "chat";
// Stream AI response on @mention
bot.onNewMention(async (thread, message) => {
const result = await agent.stream({ prompt: message.text });
await thread.post(result.textStream);
});
```
### Platform Behavior
| Platform | Streaming Method |
| ----------- | ----------------------------------- |
| Slack | Native streaming API (`chatStream`) |
| Teams | Post + edit with throttling |
| Google Chat | Post + edit with throttling |
| Discord | Post + edit with throttling |
The fallback method posts an initial message, then edits it as chunks arrive (throttled to avoid rate limits).
The SDK also supports **per-thread state** via `thread.setState()` and `thread.state` for tracking conversation modes, user preferences, or any thread-specific context.
## File Uploads
Send files along with messages:
```typescript
import type { Thread } from "chat";
// Send a file with a message
const reportBuffer = Buffer.from("PDF content");
await thread.post({
markdown: "Here's the report you requested:",
files: [
{
data: reportBuffer,
filename: "report.pdf",
mimeType: "application/pdf",
},
],
});
// Send multiple files
const image1 = Buffer.from("image1");
const image2 = Buffer.from("image2");
await thread.post({
markdown: "Attached are the images:",
files: [
{ data: image1, filename: "screenshot1.png" },
{ data: image2, filename: "screenshot2.png" },
],
});
// Files only (with minimal text)
const buffer = Buffer.from("document content");
await thread.post({
markdown: "",
files: [{ data: buffer, filename: "document.xlsx" }],
});
```
### Reading Attachments
Access attachments from incoming messages:
```typescript
import { Chat } from "chat";
bot.onSubscribedMessage(async (thread, message) => {
for (const attachment of message.attachments ?? []) {
console.log(`File: ${attachment.name}, Type: ${attachment.mimeType}`);
// Download the file data
if (attachment.fetchData) {
const data = await attachment.fetchData();
// Process the file...
console.log(`Downloaded ${data.length} bytes`);
}
}
});
```
The `Attachment` interface includes `type`, `url`, `name`, `mimeType`, `size`, `width`, `height`, and `fetchData` properties.
## Direct Messages
Initiate DM conversations programmatically. The adapter is automatically inferred from the userId format:
```typescript
import { Chat } from "chat";
// Open a DM using Author object (convenient in handlers)
bot.onSubscribedMessage(async (thread, message) => {
if (message.text === "DM me") {
const dmThread = await bot.openDM(message.author);
await dmThread.post("Hello! This is a direct message.");
}
});
// Or use userId string directly - adapter inferred from format:
// - Slack: U... (e.g., "U1234567890")
// - Teams: 29:... (e.g., "29:abc123...")
// - Google Chat: users/... (e.g., "users/123456789")
const dmThread = await bot.openDM("U1234567890");
// Check if a thread is a DM
bot.onSubscribedMessage(async (thread, message) => {
if (thread.isDM) {
await thread.post("This is a private conversation.");
}
});
```
## Ephemeral Messages
Send a message visible only to a specific user:
```typescript
await thread.postEphemeral(user, "Only you can see this!", {
fallbackToDM: true,
});
```
The `fallbackToDM` option is required and controls behavior on platforms without native ephemeral support:
- `fallbackToDM: true` - Send as DM if native ephemeral isn't supported
- `fallbackToDM: false` - Return `null` if native ephemeral isn't supported
### Platform Behavior
| Platform | Native Support | Behavior | Where it appears | Persistence |
| ----------- | -------------- | -------------------- | ------------------------------------------- | ---------------------------------------- |
| Slack | Yes | Ephemeral in channel | In the channel, only visible to target user | Session-only (disappears on page reload) |
| Google Chat | Yes | Private message | In the space, only visible to target user | Persists until deleted |
| Discord | No | DM (if enabled) | In a DM conversation with the bot | Persists in DM |
| Teams | No | DM (if enabled) | In a DM conversation with the bot | Persists in DM |
**Key differences:**
- **Slack**: True ephemeral - message appears in the channel context but disappears when the user refreshes. Other users never see it. **Note:** Requires additional OAuth scopes beyond `chat:write` - add `channels:write` (public), `groups:write` (private), `im:write` (DMs), or `mpim:write` (group DMs) depending on your use case.
- **Google Chat**: Private message viewer - message appears in the space but only the target user can see it. It persists and can be deleted by the bot.
- **Discord/Teams**: No native ephemeral support. With `fallbackToDM: true`, sends a DM instead. With `fallbackToDM: false`, returns `null`.
### Examples
**Always deliver the message (DM fallback):**
```typescript
const result = await thread.postEphemeral(user, "Private notification", {
fallbackToDM: true,
});
if (result?.usedFallback) {
// Was sent as DM on Discord/Teams
console.log(`Sent as DM: ${result.threadId}`);
}
```
**Only send if native ephemeral is supported:**
```typescript
const result = await thread.postEphemeral(user, "Contextual hint", {
fallbackToDM: false,
});
if (!result) {
// Platform doesn't support native ephemeral (Discord/Teams)
// Message was not sent - handle accordingly or skip
}
```
## Development
```bash
pnpm install
pnpm build
pnpm dev # Run example app
pnpm typecheck
pnpm lint
```
## License
MIT