chat 4.19.0 → 4.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WAB7KMH4.js → chunk-JW7GYSMH.js} +3 -3
- package/dist/chunk-JW7GYSMH.js.map +1 -0
- package/dist/index.d.ts +219 -5
- package/dist/index.js +350 -49
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.d.ts +1 -1
- package/dist/jsx-runtime.js +1 -1
- package/docs/adapters/whatsapp.mdx +222 -0
- package/docs/adapters.mdx +54 -0
- package/docs/api/channel.mdx +15 -0
- package/docs/api/index.mdx +7 -0
- package/docs/api/message.mdx +48 -0
- package/docs/api/thread.mdx +50 -0
- package/docs/contributing/building.mdx +1 -0
- package/docs/error-handling.mdx +1 -1
- package/docs/getting-started.mdx +2 -0
- package/docs/guides/durable-chat-sessions-nextjs.mdx +331 -0
- package/docs/guides/meta.json +7 -1
- package/docs/guides/scheduled-posts-neon.mdx +447 -0
- package/docs/handling-events.mdx +5 -5
- package/docs/index.mdx +3 -1
- package/docs/streaming.mdx +54 -7
- package/docs/threads-messages-channels.mdx +17 -0
- package/package.json +1 -1
- package/dist/chunk-WAB7KMH4.js.map +0 -1
- package/dist/{jsx-runtime-BYavlUk9.d.ts → jsx-runtime-C2ATKxHQ.d.ts} +95 -95
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Schedule Slack posts with Next.js, Workflow, and Neon
|
|
3
|
+
description: This guide walks through scheduling Slack channel posts with Chat SDK, persisting schedules in Neon, and using Workflow sleep timers for durable delivery.
|
|
4
|
+
type: guide
|
|
5
|
+
prerequisites: []
|
|
6
|
+
related:
|
|
7
|
+
- /docs/guides/slack-nextjs
|
|
8
|
+
- /docs/slash-commands
|
|
9
|
+
- /docs/state/postgres
|
|
10
|
+
- /docs/api/channel
|
|
11
|
+
- /docs/api/chat
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Chat SDK already supports native scheduled messages on Slack with `thread.schedule()` and `channel.schedule()`. That is useful for simple Slack-only cases.
|
|
15
|
+
|
|
16
|
+
This guide uses a different pattern: Workflow owns the timer with `sleep()`, and Neon stores the schedule as your source of truth. That gives you a more durable scheduling system you can extend with cancellation, rescheduling, approvals, and cross-platform delivery later.
|
|
17
|
+
|
|
18
|
+
The example uses Slack slash commands to schedule top-level channel posts.
|
|
19
|
+
|
|
20
|
+
## Prerequisites
|
|
21
|
+
|
|
22
|
+
- Node.js 18+
|
|
23
|
+
- [pnpm](https://pnpm.io) (or npm/yarn)
|
|
24
|
+
- A Next.js App Router project
|
|
25
|
+
- A Slack workspace where you can install apps
|
|
26
|
+
- A Neon Postgres database
|
|
27
|
+
|
|
28
|
+
<Callout type="info">
|
|
29
|
+
If you still need Slack app setup and the webhook route, start with [Slack bot with Next.js and Redis](/docs/guides/slack-nextjs), then come back here and swap the state adapter to PostgreSQL.
|
|
30
|
+
</Callout>
|
|
31
|
+
|
|
32
|
+
## Install the dependencies
|
|
33
|
+
|
|
34
|
+
Install Chat SDK, the Slack adapter, the PostgreSQL state adapter, Workflow, and `pg`:
|
|
35
|
+
|
|
36
|
+
```sh title="Terminal"
|
|
37
|
+
pnpm add chat @chat-adapter/slack @chat-adapter/state-pg workflow pg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Create a Neon database
|
|
41
|
+
|
|
42
|
+
Create a Neon project, copy its **pooled** Postgres connection string, and store it as `POSTGRES_URL`.
|
|
43
|
+
|
|
44
|
+
`createPostgresState()` already supports `POSTGRES_URL` and `DATABASE_URL`, so the same Neon database can back both Chat SDK state and your app's schedule table.
|
|
45
|
+
|
|
46
|
+
## Configure environment variables
|
|
47
|
+
|
|
48
|
+
Create a `.env.local` file:
|
|
49
|
+
|
|
50
|
+
```bash title=".env.local"
|
|
51
|
+
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
|
52
|
+
SLACK_SIGNING_SECRET=your-signing-secret
|
|
53
|
+
POSTGRES_URL=postgresql://user:password@your-neon-host-pooler.neon.tech/neondb?sslmode=require
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Enable Workflow in Next.js
|
|
57
|
+
|
|
58
|
+
Wrap your Next.js config with `withWorkflow()` so `"use workflow"` and `"use step"` directives are compiled:
|
|
59
|
+
|
|
60
|
+
```typescript title="next.config.ts" lineNumbers
|
|
61
|
+
import { withWorkflow } from "workflow/next";
|
|
62
|
+
import type { NextConfig } from "next";
|
|
63
|
+
|
|
64
|
+
const nextConfig: NextConfig = {
|
|
65
|
+
// ...your existing config
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default withWorkflow(nextConfig);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
<Callout type="info">
|
|
72
|
+
If your app uses `proxy.ts`, exclude `.well-known/workflow/` from the matcher so Workflow's internal routes are not intercepted.
|
|
73
|
+
</Callout>
|
|
74
|
+
|
|
75
|
+
## Add slash commands to Slack
|
|
76
|
+
|
|
77
|
+
Add two slash commands to your Slack app and point both at your webhook route:
|
|
78
|
+
|
|
79
|
+
```yaml title="slack-manifest.yml"
|
|
80
|
+
features:
|
|
81
|
+
slash_commands:
|
|
82
|
+
- command: /remind
|
|
83
|
+
url: https://your-domain.com/api/webhooks/slack
|
|
84
|
+
description: Schedule a channel post
|
|
85
|
+
usage_hint: "<ISO-8601 timestamp> <message>"
|
|
86
|
+
should_escape: false
|
|
87
|
+
- command: /remind-cancel
|
|
88
|
+
url: https://your-domain.com/api/webhooks/slack
|
|
89
|
+
description: Cancel a scheduled post
|
|
90
|
+
usage_hint: "<schedule-id>"
|
|
91
|
+
should_escape: false
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If you're starting from scratch rather than the Slack guide, make sure your app also has the `commands` and `chat:write` bot scopes in **OAuth & Permissions**, then reinstall the app after changing scopes.
|
|
95
|
+
|
|
96
|
+
This guide keeps parsing simple by accepting an ISO timestamp like `2026-03-15T09:00:00Z`.
|
|
97
|
+
|
|
98
|
+
## Create the schedule table in Neon
|
|
99
|
+
|
|
100
|
+
Run this SQL in the Neon SQL Editor:
|
|
101
|
+
|
|
102
|
+
```sql title="schema.sql"
|
|
103
|
+
CREATE TABLE scheduled_posts (
|
|
104
|
+
id text PRIMARY KEY,
|
|
105
|
+
channel_id text NOT NULL,
|
|
106
|
+
message text NOT NULL,
|
|
107
|
+
post_at timestamptz NOT NULL,
|
|
108
|
+
status text NOT NULL CHECK (status IN ('pending', 'sent', 'canceled')),
|
|
109
|
+
workflow_run_id text,
|
|
110
|
+
created_by text NOT NULL,
|
|
111
|
+
sent_at timestamptz,
|
|
112
|
+
canceled_at timestamptz,
|
|
113
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
CREATE INDEX scheduled_posts_status_post_at_idx
|
|
117
|
+
ON scheduled_posts (status, post_at);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Create the Postgres client
|
|
121
|
+
|
|
122
|
+
Use a shared `pg` pool for your own schedule records:
|
|
123
|
+
|
|
124
|
+
```typescript title="lib/db.ts" lineNumbers
|
|
125
|
+
import pg from "pg";
|
|
126
|
+
|
|
127
|
+
if (!process.env.POSTGRES_URL) {
|
|
128
|
+
throw new Error("POSTGRES_URL is required");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const pool = new pg.Pool({
|
|
132
|
+
connectionString: process.env.POSTGRES_URL,
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Create the Chat instance
|
|
137
|
+
|
|
138
|
+
Use Neon for Chat SDK state by switching to the PostgreSQL state adapter:
|
|
139
|
+
|
|
140
|
+
```typescript title="lib/bot.ts" lineNumbers
|
|
141
|
+
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
142
|
+
import { createPostgresState } from "@chat-adapter/state-pg";
|
|
143
|
+
import { Chat } from "chat";
|
|
144
|
+
|
|
145
|
+
export const bot = new Chat({
|
|
146
|
+
userName: "reminder-bot",
|
|
147
|
+
adapters: {
|
|
148
|
+
slack: createSlackAdapter(),
|
|
149
|
+
},
|
|
150
|
+
state: createPostgresState(),
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Add schedule helpers
|
|
155
|
+
|
|
156
|
+
Create a small data layer for inserting, loading, updating, and canceling scheduled posts:
|
|
157
|
+
|
|
158
|
+
```typescript title="lib/scheduled-posts.ts" lineNumbers
|
|
159
|
+
import { randomUUID } from "node:crypto";
|
|
160
|
+
import { pool } from "@/lib/db";
|
|
161
|
+
|
|
162
|
+
export interface ScheduledPost {
|
|
163
|
+
id: string;
|
|
164
|
+
channelId: string;
|
|
165
|
+
message: string;
|
|
166
|
+
postAt: string;
|
|
167
|
+
status: "pending" | "sent" | "canceled";
|
|
168
|
+
workflowRunId: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function createScheduledPost(input: {
|
|
172
|
+
channelId: string;
|
|
173
|
+
createdBy: string;
|
|
174
|
+
message: string;
|
|
175
|
+
postAt: Date;
|
|
176
|
+
}) {
|
|
177
|
+
const id = randomUUID();
|
|
178
|
+
|
|
179
|
+
await pool.query(
|
|
180
|
+
`INSERT INTO scheduled_posts (
|
|
181
|
+
id, channel_id, message, post_at, status, created_by
|
|
182
|
+
) VALUES ($1, $2, $3, $4, 'pending', $5)`,
|
|
183
|
+
[id, input.channelId, input.message, input.postAt, input.createdBy]
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return { id, ...input, postAt: input.postAt.toISOString() };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function getScheduledPost(id: string): Promise<ScheduledPost | null> {
|
|
190
|
+
const result = await pool.query(
|
|
191
|
+
`SELECT id, channel_id, message, post_at, status, workflow_run_id
|
|
192
|
+
FROM scheduled_posts
|
|
193
|
+
WHERE id = $1
|
|
194
|
+
LIMIT 1`,
|
|
195
|
+
[id]
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (result.rows.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const row = result.rows[0];
|
|
203
|
+
return {
|
|
204
|
+
id: row.id as string,
|
|
205
|
+
channelId: row.channel_id as string,
|
|
206
|
+
message: row.message as string,
|
|
207
|
+
postAt: (row.post_at as Date).toISOString(),
|
|
208
|
+
status: row.status as ScheduledPost["status"],
|
|
209
|
+
workflowRunId: (row.workflow_run_id as string | null) ?? null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function attachWorkflowRun(id: string, runId: string) {
|
|
214
|
+
await pool.query(
|
|
215
|
+
`UPDATE scheduled_posts
|
|
216
|
+
SET workflow_run_id = $2
|
|
217
|
+
WHERE id = $1`,
|
|
218
|
+
[id, runId]
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function markScheduledPostSent(id: string) {
|
|
223
|
+
await pool.query(
|
|
224
|
+
`UPDATE scheduled_posts
|
|
225
|
+
SET status = 'sent', sent_at = now()
|
|
226
|
+
WHERE id = $1 AND status = 'pending'`,
|
|
227
|
+
[id]
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function cancelScheduledPost(id: string, createdBy: string) {
|
|
232
|
+
const result = await pool.query(
|
|
233
|
+
`UPDATE scheduled_posts
|
|
234
|
+
SET status = 'canceled', canceled_at = now()
|
|
235
|
+
WHERE id = $1 AND created_by = $2 AND status = 'pending'`,
|
|
236
|
+
[id, createdBy]
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return (result.rowCount ?? 0) > 0;
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Create the workflow
|
|
244
|
+
|
|
245
|
+
The workflow only orchestrates. All Postgres access and Slack posting stay in `"use step"` functions:
|
|
246
|
+
|
|
247
|
+
```typescript title="workflows/send-scheduled-post.ts" lineNumbers
|
|
248
|
+
import { sleep } from "workflow";
|
|
249
|
+
import { bot } from "@/lib/bot";
|
|
250
|
+
import {
|
|
251
|
+
getScheduledPost,
|
|
252
|
+
markScheduledPostSent,
|
|
253
|
+
} from "@/lib/scheduled-posts";
|
|
254
|
+
|
|
255
|
+
async function loadScheduledPost(scheduleId: string) {
|
|
256
|
+
"use step";
|
|
257
|
+
return getScheduledPost(scheduleId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function postToChannel(channelId: string, message: string) {
|
|
261
|
+
"use step";
|
|
262
|
+
|
|
263
|
+
await bot.initialize();
|
|
264
|
+
|
|
265
|
+
const channel = bot.channel(channelId);
|
|
266
|
+
await channel.post(message);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function completeScheduledPost(scheduleId: string) {
|
|
270
|
+
"use step";
|
|
271
|
+
await markScheduledPostSent(scheduleId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function sendScheduledPost(scheduleId: string) {
|
|
275
|
+
"use workflow";
|
|
276
|
+
|
|
277
|
+
const schedule = await loadScheduledPost(scheduleId);
|
|
278
|
+
if (!schedule || schedule.status !== "pending") {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await sleep(new Date(schedule.postAt));
|
|
283
|
+
|
|
284
|
+
// Re-check the row after waking up in case it was canceled.
|
|
285
|
+
const fresh = await loadScheduledPost(scheduleId);
|
|
286
|
+
if (!fresh || fresh.status !== "pending") {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await postToChannel(fresh.channelId, fresh.message);
|
|
291
|
+
await completeScheduledPost(scheduleId);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
This is the key Workflow pattern:
|
|
296
|
+
|
|
297
|
+
- schedule creation writes a row in Neon first
|
|
298
|
+
- the workflow sleeps until `postAt`
|
|
299
|
+
- the workflow reloads the row after waking up
|
|
300
|
+
- canceled schedules no-op instead of posting
|
|
301
|
+
|
|
302
|
+
That makes cancellation simple and durable.
|
|
303
|
+
|
|
304
|
+
## Register the slash command handlers
|
|
305
|
+
|
|
306
|
+
Create a side-effect module that parses commands, writes rows to Neon, starts workflows, and handles cancellation:
|
|
307
|
+
|
|
308
|
+
```typescript title="lib/reminder-handlers.ts" lineNumbers
|
|
309
|
+
import { start } from "workflow/api";
|
|
310
|
+
import { bot } from "@/lib/bot";
|
|
311
|
+
import {
|
|
312
|
+
attachWorkflowRun,
|
|
313
|
+
cancelScheduledPost,
|
|
314
|
+
createScheduledPost,
|
|
315
|
+
} from "@/lib/scheduled-posts";
|
|
316
|
+
import { sendScheduledPost } from "@/workflows/send-scheduled-post";
|
|
317
|
+
|
|
318
|
+
function parseReminderInput(input: string) {
|
|
319
|
+
const trimmed = input.trim();
|
|
320
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
321
|
+
|
|
322
|
+
if (firstSpace === -1) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const timestamp = trimmed.slice(0, firstSpace);
|
|
327
|
+
const message = trimmed.slice(firstSpace + 1).trim();
|
|
328
|
+
const postAt = new Date(timestamp);
|
|
329
|
+
|
|
330
|
+
if (!message || Number.isNaN(postAt.getTime()) || postAt <= new Date()) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { message, postAt };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
bot.onSlashCommand("/remind", async (event) => {
|
|
338
|
+
const parsed = parseReminderInput(event.text);
|
|
339
|
+
|
|
340
|
+
if (!parsed) {
|
|
341
|
+
await event.channel.postEphemeral(
|
|
342
|
+
event.user,
|
|
343
|
+
"Usage: `/remind 2026-03-15T09:00:00Z Review launch checklist`",
|
|
344
|
+
{ fallbackToDM: false }
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const scheduled = await createScheduledPost({
|
|
350
|
+
channelId: event.channel.id,
|
|
351
|
+
createdBy: event.user.userId,
|
|
352
|
+
message: parsed.message,
|
|
353
|
+
postAt: parsed.postAt,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const run = await start(sendScheduledPost, [scheduled.id]);
|
|
357
|
+
await attachWorkflowRun(scheduled.id, run.runId);
|
|
358
|
+
|
|
359
|
+
await event.channel.postEphemeral(
|
|
360
|
+
event.user,
|
|
361
|
+
`Scheduled \`${scheduled.id}\` for ${parsed.postAt.toISOString()}.`,
|
|
362
|
+
{ fallbackToDM: false }
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
bot.onSlashCommand("/remind-cancel", async (event) => {
|
|
367
|
+
const scheduleId = event.text.trim();
|
|
368
|
+
|
|
369
|
+
if (!scheduleId) {
|
|
370
|
+
await event.channel.postEphemeral(
|
|
371
|
+
event.user,
|
|
372
|
+
"Usage: `/remind-cancel <schedule-id>`",
|
|
373
|
+
{ fallbackToDM: false }
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const canceled = await cancelScheduledPost(scheduleId, event.user.userId);
|
|
379
|
+
|
|
380
|
+
await event.channel.postEphemeral(
|
|
381
|
+
event.user,
|
|
382
|
+
canceled
|
|
383
|
+
? `Canceled \`${scheduleId}\`.`
|
|
384
|
+
: `No pending schedule found for \`${scheduleId}\`.`,
|
|
385
|
+
{ fallbackToDM: false }
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The cancellation path is intentionally simple. It only updates the database row, and it only allows the creator of the schedule to cancel it. If the workflow wakes up later, it re-checks the row and exits without posting.
|
|
391
|
+
|
|
392
|
+
If you want workspace admins or moderators to cancel other users' schedules, extend the cancellation query with your own permission rules instead of removing the ownership check.
|
|
393
|
+
|
|
394
|
+
## Create the webhook route
|
|
395
|
+
|
|
396
|
+
Import the handler module once so the slash command handlers are registered:
|
|
397
|
+
|
|
398
|
+
```typescript title="app/api/webhooks/[platform]/route.ts" lineNumbers
|
|
399
|
+
import "@/lib/reminder-handlers";
|
|
400
|
+
import { after } from "next/server";
|
|
401
|
+
import { bot } from "@/lib/bot";
|
|
402
|
+
|
|
403
|
+
type Platform = keyof typeof bot.webhooks;
|
|
404
|
+
|
|
405
|
+
export async function POST(
|
|
406
|
+
request: Request,
|
|
407
|
+
context: RouteContext<"/api/webhooks/[platform]">
|
|
408
|
+
) {
|
|
409
|
+
const { platform } = await context.params;
|
|
410
|
+
|
|
411
|
+
const handler = bot.webhooks[platform as Platform];
|
|
412
|
+
if (!handler) {
|
|
413
|
+
return new Response(`Unknown platform: ${platform}`, { status: 404 });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return handler(request, {
|
|
417
|
+
waitUntil: (task) => after(() => task),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Test locally
|
|
423
|
+
|
|
424
|
+
1. Start your development server with `pnpm dev`
|
|
425
|
+
2. Expose it with a tunnel such as `ngrok http 3000`
|
|
426
|
+
3. Update your Slack slash command URLs to the tunnel URL
|
|
427
|
+
4. In Slack, run `/remind 2026-03-15T09:00:00Z Review launch checklist`
|
|
428
|
+
5. Confirm that you receive an ephemeral confirmation with a schedule ID
|
|
429
|
+
6. Wait for the scheduled time and verify the bot posts to the channel
|
|
430
|
+
7. Create another one and cancel it with `/remind-cancel <schedule-id>`
|
|
431
|
+
|
|
432
|
+
## Extending the pattern
|
|
433
|
+
|
|
434
|
+
From here you can add:
|
|
435
|
+
|
|
436
|
+
- rescheduling by canceling the old row and creating a new one
|
|
437
|
+
- recurring posts by having the workflow create the next schedule before it exits
|
|
438
|
+
- approvals by pausing the workflow with a hook before posting
|
|
439
|
+
- thread reminders by storing `thread.toJSON()` instead of `channelId` and restoring it with `bot.reviver()`
|
|
440
|
+
|
|
441
|
+
## Next steps
|
|
442
|
+
|
|
443
|
+
- [Slack bot with Next.js and Redis](/docs/guides/slack-nextjs) — Base Slack app and webhook setup
|
|
444
|
+
- [Slash Commands](/docs/slash-commands) — Command handling patterns
|
|
445
|
+
- [PostgreSQL State Adapter](/docs/state/postgres) — Using Postgres for Chat SDK state
|
|
446
|
+
- [Channel API](/docs/api/channel) — Posting to channels and working with channel IDs
|
|
447
|
+
- [Chat API](/docs/api/chat) — `bot.channel()` and lifecycle management
|
package/docs/handling-events.mdx
CHANGED
|
@@ -94,18 +94,18 @@ Messages sent by the bot itself do not trigger this handler. You don't need to f
|
|
|
94
94
|
### Example: Conversational AI with history
|
|
95
95
|
|
|
96
96
|
```typescript title="lib/bot.ts" lineNumbers
|
|
97
|
+
import { toAiMessages } from "chat";
|
|
98
|
+
|
|
97
99
|
bot.onSubscribedMessage(async (thread, message) => {
|
|
98
100
|
await thread.startTyping();
|
|
99
101
|
|
|
100
102
|
// Build conversation history from thread messages
|
|
101
|
-
const
|
|
103
|
+
const messages = [];
|
|
102
104
|
for await (const msg of thread.allMessages) {
|
|
103
|
-
|
|
104
|
-
role: msg.author.isMe ? "assistant" : "user",
|
|
105
|
-
content: msg.text,
|
|
106
|
-
});
|
|
105
|
+
messages.push(msg);
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
const history = await toAiMessages(messages);
|
|
109
109
|
const response = await generateAIResponse(history);
|
|
110
110
|
await thread.post(response);
|
|
111
111
|
});
|
package/docs/index.mdx
CHANGED
|
@@ -4,7 +4,7 @@ description: A unified SDK for building chat bots across Slack, Microsoft Teams,
|
|
|
4
4
|
type: overview
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, and
|
|
7
|
+
Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp.
|
|
8
8
|
|
|
9
9
|
## Why Chat SDK?
|
|
10
10
|
|
|
@@ -58,6 +58,7 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK
|
|
|
58
58
|
| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Post+Edit | Yes |
|
|
59
59
|
| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No |
|
|
60
60
|
| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No |
|
|
61
|
+
| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | Yes |
|
|
61
62
|
|
|
62
63
|
## AI coding agent support
|
|
63
64
|
|
|
@@ -83,6 +84,7 @@ The SDK is distributed as a set of packages you install based on your needs:
|
|
|
83
84
|
| `@chat-adapter/telegram` | Telegram adapter |
|
|
84
85
|
| `@chat-adapter/github` | GitHub Issues adapter |
|
|
85
86
|
| `@chat-adapter/linear` | Linear Issues adapter |
|
|
87
|
+
| `@chat-adapter/whatsapp` | WhatsApp Business adapter |
|
|
86
88
|
| `@chat-adapter/state-redis` | Redis state adapter (production) |
|
|
87
89
|
| `@chat-adapter/state-ioredis` | ioredis state adapter (alternative) |
|
|
88
90
|
| `@chat-adapter/state-pg` | PostgreSQL state adapter (production) |
|
package/docs/streaming.mdx
CHANGED
|
@@ -183,21 +183,68 @@ await thread.stream(textStream, {
|
|
|
183
183
|
|
|
184
184
|
## Streaming with conversation history
|
|
185
185
|
|
|
186
|
-
Combine message history with streaming for multi-turn AI conversations
|
|
186
|
+
Combine message history with streaming for multi-turn AI conversations.
|
|
187
|
+
Use `toAiMessages()` to convert chat messages into the `{ role, content }` format expected by AI SDKs:
|
|
187
188
|
|
|
188
189
|
```typescript title="lib/bot.ts" lineNumbers
|
|
190
|
+
import { toAiMessages } from "chat";
|
|
191
|
+
|
|
189
192
|
bot.onSubscribedMessage(async (thread, message) => {
|
|
190
193
|
// Fetch recent messages for context
|
|
191
194
|
const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 });
|
|
192
195
|
|
|
193
|
-
const history = result.messages
|
|
194
|
-
.filter((msg) => msg.text.trim())
|
|
195
|
-
.map((msg) => ({
|
|
196
|
-
role: msg.author.isMe ? "assistant" as const : "user" as const,
|
|
197
|
-
content: msg.text,
|
|
198
|
-
}));
|
|
196
|
+
const history = await toAiMessages(result.messages);
|
|
199
197
|
|
|
200
198
|
const response = await agent.stream({ prompt: history });
|
|
201
199
|
await thread.post(response.fullStream);
|
|
202
200
|
});
|
|
203
201
|
```
|
|
202
|
+
|
|
203
|
+
### `toAiMessages(messages, options?)`
|
|
204
|
+
|
|
205
|
+
Converts an array of `Message` objects into AI SDK conversation format:
|
|
206
|
+
|
|
207
|
+
- Maps `author.isMe` to `"assistant"` role, all others to `"user"`
|
|
208
|
+
- Filters out empty messages
|
|
209
|
+
- Sorts chronologically (oldest first)
|
|
210
|
+
- Appends link metadata (URLs, titles, descriptions) when present
|
|
211
|
+
- Labels embedded message links (e.g. shared Slack messages) as `[Embedded message: ...]`
|
|
212
|
+
|
|
213
|
+
| Option | Type | Default | Description |
|
|
214
|
+
|--------|------|---------|-------------|
|
|
215
|
+
| `includeNames` | `boolean` | `false` | Prefix user messages with `[username]: ` for multi-user context |
|
|
216
|
+
| `transformMessage` | `(aiMessage, source) => AiMessage \| Promise<AiMessage \| null> \| null` | — | Transform or filter each message after default processing. Return `null` to skip. |
|
|
217
|
+
| `onUnsupportedAttachment` | `(attachment, message) => void` | `console.warn` | Called when an attachment type is not supported |
|
|
218
|
+
|
|
219
|
+
### Customizing messages with `transformMessage`
|
|
220
|
+
|
|
221
|
+
Use `transformMessage` to modify, enrich, or filter messages after default processing:
|
|
222
|
+
|
|
223
|
+
```typescript title="lib/bot.ts" lineNumbers
|
|
224
|
+
import { toAiMessages } from "chat";
|
|
225
|
+
|
|
226
|
+
const history = await toAiMessages(result.messages, {
|
|
227
|
+
transformMessage: (aiMessage, source) => {
|
|
228
|
+
// Replace bot user IDs with readable names
|
|
229
|
+
if (typeof aiMessage.content === "string") {
|
|
230
|
+
return {
|
|
231
|
+
...aiMessage,
|
|
232
|
+
content: aiMessage.content.replace(/<@U123>/g, "@VercelBot"),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return aiMessage;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Return `null` to skip a message entirely:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
const history = await toAiMessages(result.messages, {
|
|
244
|
+
transformMessage: (aiMessage, source) => {
|
|
245
|
+
// Skip messages from a specific user
|
|
246
|
+
if (source.author.userId === "U_NOISY_BOT") return null;
|
|
247
|
+
return aiMessage;
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
```
|
|
@@ -99,6 +99,23 @@ State is stored in your state adapter with a 30-day TTL. Use `{ replace: true }`
|
|
|
99
99
|
await thread.setState({ aiMode: false }, { replace: true });
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
### Scheduled messages
|
|
103
|
+
|
|
104
|
+
Schedule a message for future delivery. The returned `ScheduledMessage` includes a `cancel()` method to abort before it's sent.
|
|
105
|
+
|
|
106
|
+
```typescript title="lib/bot.ts" lineNumbers
|
|
107
|
+
const scheduled = await thread.schedule("Reminder: standup in 5 minutes!", {
|
|
108
|
+
postAt: new Date("2026-03-09T09:00:00Z"),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Cancel before it's sent
|
|
112
|
+
await scheduled.cancel();
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
<Callout type="info">
|
|
116
|
+
Scheduled messages are currently only supported by the Slack adapter. Other adapters throw `NotImplementedError`. See the [feature matrix](/docs/adapters) for details.
|
|
117
|
+
</Callout>
|
|
118
|
+
|
|
102
119
|
## Messages
|
|
103
120
|
|
|
104
121
|
Incoming messages are normalized across platforms into a consistent format:
|