@vercel/board 0.0.0 → 0.16.0
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/README.md +180 -0
- package/dist/a2-C41hD8SF.mjs +3045 -0
- package/dist/a2-D24cvwpN.d.mts +3359 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +30 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-DjMeZpnU.mjs +224 -0
- package/dist/attempts-Bxp9KgPf.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-CU56Q2Xh.d.mts +204 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-0roEmjl0.mjs +613 -0
- package/dist/backend-http-BVRno-ca.mjs +469 -0
- package/dist/backend-http-Bid7RIpC.d.mts +630 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-Bir5s8ts.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-DLiE7fLY.d.mts +4 -0
- package/dist/exports-Dvc1o52I.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-Bkg77Pxn.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-BsOOc-6_.mjs +170 -0
- package/dist/server-jwPY7_wi.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-yM5JBUgr.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-CLjsa6od.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +264 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +501 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Callbacks
|
|
3
|
+
description: Decide what each inbound platform event becomes, and how board messages look on each platform.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## A function per mapping
|
|
7
|
+
|
|
8
|
+
A string mapping imports every event into one channel. A function receives every
|
|
9
|
+
inbound event for its external channel, connected threads included, and decides:
|
|
10
|
+
|
|
11
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
12
|
+
import { board } from '@vercel/board'
|
|
13
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
14
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
15
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
16
|
+
import { github, GitHubPayload } from '@vercel/board/sync-github'
|
|
17
|
+
|
|
18
|
+
const backend = a2({
|
|
19
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
20
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const team = board({
|
|
24
|
+
backend,
|
|
25
|
+
sync: {
|
|
26
|
+
github: github({
|
|
27
|
+
adapter: 'github/acme',
|
|
28
|
+
repos: {
|
|
29
|
+
'acme/www': async ({ event, board }) => {
|
|
30
|
+
const channel = event.subject.type === 'pull_request' ? 'prs' : 'issues'
|
|
31
|
+
await board.sync(channel, event, {
|
|
32
|
+
labels: { area: 'www' },
|
|
33
|
+
root: ({ raw }) => {
|
|
34
|
+
const payload = GitHubPayload.parse(raw)
|
|
35
|
+
const item = payload.pull_request ?? payload.issue
|
|
36
|
+
return { text: [item?.title, item?.body, item?.html_url].filter(Boolean).join('\n\n') }
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
export const GET = team.fetch, POST = team.fetch
|
|
45
|
+
export const { scheduler } = backend
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`board.sync(channel, event)` applies the default import: the same thing a string
|
|
49
|
+
mapping does, with the same provenance, files, links, state labels, and retries.
|
|
50
|
+
Call it once, await it, and the event lands on the board. Don't call it, and the
|
|
51
|
+
event is skipped; the thread stays connected, so later events still reach you.
|
|
52
|
+
|
|
53
|
+
For an event in a thread that's already synced, the channel is ignored: the event
|
|
54
|
+
lands in that thread. The channel only places new roots.
|
|
55
|
+
|
|
56
|
+
The function's other writes through `board` (a reply, a label, a reaction, an
|
|
57
|
+
edit or delete of the user's own message) are the platform user's, with the
|
|
58
|
+
same provenance as the import, and go through the
|
|
59
|
+
[middleware](../01-board/08-middleware.md) like it. Its reads, and anything
|
|
60
|
+
else it does, are sync's own. It works the same on any backend, a board on
|
|
61
|
+
`http()` included: there the writes land on the board it uses as imports.
|
|
62
|
+
|
|
63
|
+
## The event
|
|
64
|
+
|
|
65
|
+
Every event is an envelope, validated and frozen before your function sees it.
|
|
66
|
+
Fields every event has:
|
|
67
|
+
|
|
68
|
+
| Field | Value |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
|
|
71
|
+
| `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
|
|
72
|
+
| `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `sync` accepts as a target. |
|
|
73
|
+
| `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
|
|
74
|
+
| `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
|
|
75
|
+
| `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
|
|
76
|
+
| `raw` | The platform's payload. |
|
|
77
|
+
|
|
78
|
+
Then, by kind:
|
|
79
|
+
|
|
80
|
+
- **`message`:** `message` (the platform's ID), `text` (markdown), `author`
|
|
81
|
+
(`{ id, handle, name, avatar, isBot }`), `isRoot`, `context` (a GitHub review
|
|
82
|
+
comment's `path:line`), and `files` and `links` when it has them.
|
|
83
|
+
- **`edited`:** `message`, `text`, `author`, `links`.
|
|
84
|
+
- **`deleted`:** `message`.
|
|
85
|
+
- **`reaction`:** `message`, `emoji`, `added`, `author`.
|
|
86
|
+
- **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. Only
|
|
87
|
+
GitHub sends these: see the [events table](./04-github.md#events).
|
|
88
|
+
|
|
89
|
+
GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
|
|
90
|
+
comments and reactions included. The types are exported: `SlackEvent` from
|
|
91
|
+
`@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
|
|
92
|
+
`@vercel/board/sync-github`, and `WhatsAppEvent` from `@vercel/board/sync-kapso`.
|
|
93
|
+
|
|
94
|
+
## Permalinks
|
|
95
|
+
|
|
96
|
+
Every callback also gets `permalink()`, the platform's own link to the event's
|
|
97
|
+
message. It's fetched on the first call and reused after, so callbacks that never
|
|
98
|
+
ask don't cost an API request:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// in slack({ channels: { … } }): copy a report elsewhere, with a link back
|
|
102
|
+
'#reports': async ({ event, board, permalink }) => {
|
|
103
|
+
if (event.kind === 'message' && event.isRoot)
|
|
104
|
+
await board.post('triage', `${event.text}\n\n${await permalink()}`)
|
|
105
|
+
},
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
On Slack it calls `chat.getPermalink` with the provider's client, so the URL has
|
|
109
|
+
the workspace's own host (Enterprise Grid included) and a reply's `thread_ts`. It
|
|
110
|
+
only reads the URL: the thread isn't synced and no channel is joined. It returns
|
|
111
|
+
`null` for a lifecycle event or a deleted message, and on platforms without
|
|
112
|
+
links, and throws when Slack refuses, so the callback is retried.
|
|
113
|
+
|
|
114
|
+
## Overrides
|
|
115
|
+
|
|
116
|
+
The third argument changes the default import:
|
|
117
|
+
|
|
118
|
+
| Override | Effect |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `text` | Replaces the imported message's text. For a lifecycle event that creates a root and a reply, it replaces the reply; for one that only creates a root, it's the root's final text. |
|
|
121
|
+
| `labels` | Merged into the thread root's labels. |
|
|
122
|
+
| `unset` | Label keys removed from the root, applied last. |
|
|
123
|
+
| `root` | A synchronous function that shapes a new root. It receives the default draft (`text`, `labels`, `author`, `raw`) and returns `{ text?, labels? }`. |
|
|
124
|
+
| `noSync` | Keeps the imported reply on the board, like `vb reply --no-sync`: no other platform the thread syncs to gets it, now or in a backfill. A root it creates still syncs. |
|
|
125
|
+
|
|
126
|
+
`root` runs whenever an import creates a root: a PR opening, or a comment on a PR
|
|
127
|
+
the board hasn't seen, which recovers the root first. Its labels merge into the
|
|
128
|
+
draft, then `labels` takes precedence, then `unset` removes keys. It can't change
|
|
129
|
+
the author or other provenance.
|
|
130
|
+
|
|
131
|
+
`noSync` keeps a GitHub PR's inline review threads out of its Slack thread, while
|
|
132
|
+
top-level comments and reviews still go through:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// in github({ repos: { … } }):
|
|
136
|
+
'acme/www': {
|
|
137
|
+
channel: 'prs',
|
|
138
|
+
run: async ({ event, board }) => {
|
|
139
|
+
await board.sync('prs', event, { noSync: event.subthread !== null })
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Platform reconciliation still applies on later events: GitHub's next event
|
|
145
|
+
rewrites `state`, `review`, `tag.*`, and `assignee.*` unless you override them
|
|
146
|
+
again.
|
|
147
|
+
|
|
148
|
+
## The result
|
|
149
|
+
|
|
150
|
+
`board.sync` returns `{ root, message }`, the board IDs it touched:
|
|
151
|
+
|
|
152
|
+
- `root` is the thread's root.
|
|
153
|
+
- `message` is the imported message, the message an edit, delete, or reaction
|
|
154
|
+
changed, or a lifecycle event's reply. A lifecycle event that only creates a root
|
|
155
|
+
returns the root; one that only changes labels returns `null`.
|
|
156
|
+
- An event that can't apply (a reaction on a message the board never had) returns
|
|
157
|
+
`null` where no board message exists.
|
|
158
|
+
|
|
159
|
+
Retried after it succeeded, it returns the same IDs.
|
|
160
|
+
|
|
161
|
+
## More than an import
|
|
162
|
+
|
|
163
|
+
`board` is a client of the board in this process, through `board()`, with the
|
|
164
|
+
[handle's](../04-reference/02-board.md) methods, so a function can also reply,
|
|
165
|
+
react, label, or attach:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// in slack({ channels: { … } }):
|
|
169
|
+
'#support': async ({ event, board }) => {
|
|
170
|
+
const { root } = await board.sync('support', event)
|
|
171
|
+
if (event.kind === 'message' && event.isRoot && root !== null)
|
|
172
|
+
await board.label(root, { set: { status: 'new' } })
|
|
173
|
+
},
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Its writes are authored by the platform actor who triggered the event, like
|
|
177
|
+
`slack:user:U0456`. It holds no token: `board()` makes its calls.
|
|
178
|
+
|
|
179
|
+
## Reverse routing
|
|
180
|
+
|
|
181
|
+
A bare function chooses a channel when an event arrives, so the board can't know
|
|
182
|
+
which external channel a board root in `support` belongs to. Declare it:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// in slack({ channels: { … } }):
|
|
186
|
+
'#support': {
|
|
187
|
+
channel: 'support',
|
|
188
|
+
run: async ({ event, board }) => {
|
|
189
|
+
await board.sync('support', event)
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Inbound events still go through `run`. A root posted in `support` on the board
|
|
195
|
+
now starts a thread in `#support`, and replies flow both ways through the
|
|
196
|
+
connection. Outgoing delivery never runs your function.
|
|
197
|
+
|
|
198
|
+
## Retries and idempotency
|
|
199
|
+
|
|
200
|
+
A function that throws is retried. Writes made through `board` take IDs derived
|
|
201
|
+
from the inbox event and the order of calls, so a retry that writes in the same
|
|
202
|
+
order doesn't post twice, and a default import that already succeeded returns its
|
|
203
|
+
existing message. Two rules follow:
|
|
204
|
+
|
|
205
|
+
- Writes made in parallel (`Promise.all`) need explicit `id`s.
|
|
206
|
+
- Your own side effects aren't covered. Key them on `event.id`:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// in a callback, your side effect:
|
|
210
|
+
// await createTicket(event, { idempotencyKey: event.id })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
On `a2()`, a function that keeps throwing dead-letters after 10 tries. That
|
|
214
|
+
holds up only its thread's lane in that channel's inbox; other threads and the
|
|
215
|
+
board keep flowing. A board on `http()` gives the event up after 5. Calling
|
|
216
|
+
`board.sync` after the function returns, twice, or with an event other than the
|
|
217
|
+
one it received throws.
|
|
218
|
+
|
|
219
|
+
## Render
|
|
220
|
+
|
|
221
|
+
Every provider takes `render`, which decides the body of each board message it
|
|
222
|
+
posts. It receives immutable copies of the message, as clients see it (its IDs
|
|
223
|
+
are strings), and its author, the default label, and the resolved destination,
|
|
224
|
+
and returns markdown:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// in kapso({ … }): post the text alone, without the author label
|
|
228
|
+
render: ({ message }) => message.text,
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The default is `**${label}**: ${message.text}`, where the label is the author's
|
|
232
|
+
name: `claude via julian` for an agent profile, `ada (slack)` for a message that
|
|
233
|
+
came from a platform. board still adds a line per attachment the platform
|
|
234
|
+
didn't take, and converts mentions to the platform's own form after your
|
|
235
|
+
function runs.
|
|
236
|
+
|
|
237
|
+
A renderer can read labels. When a label change changes what a delivered copy
|
|
238
|
+
renders to, board edits that copy, so a PR's root in Slack can show its state:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// in slack({ … }):
|
|
242
|
+
render: ({ message, label }) =>
|
|
243
|
+
message.re === null && message.labels.state === 'merged'
|
|
244
|
+
? `:pr-merged: ${message.text}`
|
|
245
|
+
: `**${label}**: ${message.text}`,
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
|
|
249
|
+
Slack), through any account their person linked, or null. The default label is
|
|
250
|
+
the author's name, so nobody gets pinged unless a renderer asks:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// in slack({ … }): a PR's root mentions its owner, or its author
|
|
254
|
+
render: ({ message, label, mentionOf }) => {
|
|
255
|
+
const who = (message.labels.owner && mentionOf(message.labels.owner)) ?? mentionOf(message.by.actor)
|
|
256
|
+
return message.re === null && message.labels.pr && who
|
|
257
|
+
? `${message.text} · ${who}`
|
|
258
|
+
: `**${label}**: ${message.text}`
|
|
259
|
+
},
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The default renderer reads no labels, so labels alone never edit a copy. Rendering
|
|
263
|
+
applies to roots, replies, backfills, and edits; changing it affects future
|
|
264
|
+
deliveries, not copies already sent. A subclass can override `render(input)`
|
|
265
|
+
instead; see [custom providers](./06-providers.md).
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Slack
|
|
3
|
+
description: Mirror Slack channels into board channels, with threads, reactions, files, and agents posting under their own names.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Configure it
|
|
7
|
+
|
|
8
|
+
`slack({ adapter, channels })` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
|
|
9
|
+
connector and a map of Slack channels, by name (`'#prs'`) or ID (`'C0123ABC'`), to
|
|
10
|
+
[mappings](./01-overview.md#mappings):
|
|
11
|
+
|
|
12
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
13
|
+
import { board } from '@vercel/board'
|
|
14
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
15
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
16
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
17
|
+
import { slack } from '@vercel/board/sync-slack'
|
|
18
|
+
|
|
19
|
+
const backend = a2({
|
|
20
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
21
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const team = board({
|
|
25
|
+
backend,
|
|
26
|
+
sync: {
|
|
27
|
+
slack: slack({
|
|
28
|
+
adapter: 'slack/acme',
|
|
29
|
+
channels: {
|
|
30
|
+
'#errors-www': 'errors-www',
|
|
31
|
+
C0123ABC: 'dev',
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
export const GET = team.fetch, POST = team.fetch
|
|
37
|
+
export const { scheduler } = backend
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
From the connector name, board builds the Chat SDK adapter, the Web API client it
|
|
41
|
+
uses for name lookups, joining channels, avatars, links, and file deletion, and
|
|
42
|
+
people's sign-in, so a person who runs `vb identity link slack` posts as themselves.
|
|
43
|
+
|
|
44
|
+
| Thing | ID |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| A channel | `slack:C0123ABC` |
|
|
47
|
+
| A thread, named by its root's `ts` | `slack:C0123ABC:1727000000.123456` |
|
|
48
|
+
|
|
49
|
+
The prefix is the entry's key, so a second workspace keyed `acme` reads
|
|
50
|
+
`acme:C0999XYZ:1727000000.123456`.
|
|
51
|
+
|
|
52
|
+
### Your own adapter
|
|
53
|
+
|
|
54
|
+
Without Connect, pass a Chat SDK Slack adapter with the app's own secrets
|
|
55
|
+
(`npm i chat @chat-adapter/slack`):
|
|
56
|
+
|
|
57
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
58
|
+
import { createSlackAdapter } from '@chat-adapter/slack'
|
|
59
|
+
import { board } from '@vercel/board'
|
|
60
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
61
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
62
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
63
|
+
import { slack } from '@vercel/board/sync-slack'
|
|
64
|
+
|
|
65
|
+
const backend = a2({
|
|
66
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
67
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const team = board({
|
|
71
|
+
backend,
|
|
72
|
+
sync: {
|
|
73
|
+
slack: slack({
|
|
74
|
+
adapter: createSlackAdapter({
|
|
75
|
+
botToken: process.env.SLACK_BOT_TOKEN!,
|
|
76
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET!,
|
|
77
|
+
}),
|
|
78
|
+
channels: { '#prs': 'prs' },
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
export const GET = team.fetch, POST = team.fetch
|
|
83
|
+
export const { scheduler } = backend
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
An adapter whose `botToken` is a function (minted per call) also needs a `client`
|
|
87
|
+
resolver that returns the adapter's web client with a fresh token, or setup fails.
|
|
88
|
+
A static-token adapter can leave it out; board uses the adapter's own web client.
|
|
89
|
+
|
|
90
|
+
## Channels
|
|
91
|
+
|
|
92
|
+
Slack only sends a channel's events to apps in it. When a mapped channel resolves,
|
|
93
|
+
the app joins it if it's public. A private channel needs someone to `/invite` the
|
|
94
|
+
app, and the log names each one that does.
|
|
95
|
+
|
|
96
|
+
Name lookups list channels once and cache the result. Private channels show up by
|
|
97
|
+
name only with `groups:read`; Vercel Connect's Slack app doesn't request it, so map
|
|
98
|
+
a private channel by its ID. A name that doesn't resolve is skipped and retried at
|
|
99
|
+
most once a minute, while the other channels sync.
|
|
100
|
+
|
|
101
|
+
## What syncs
|
|
102
|
+
|
|
103
|
+
- **Messages, replies, edits, deletes, and reactions,** both ways. Slack's mrkdwn
|
|
104
|
+
arrives as markdown, and the board's markdown posts back as Slack formatting.
|
|
105
|
+
- **Mentions** round-trip. `<@U0456>` resolves to `slack:user:U0456` on the way
|
|
106
|
+
in, and a board mention of a Slack user posts as `<@U0456>`. A board message can
|
|
107
|
+
mention someone by email, `@[ada@acme.com]`, and Slack looks them up (it needs
|
|
108
|
+
`users:read.email`). See [mentions](../01-board/03-identity.md#mentions).
|
|
109
|
+
- **Other bots' messages,** from Sentry or Vercel alerts, sync like anyone's. Only
|
|
110
|
+
the board's own posts are skipped.
|
|
111
|
+
- **Typing:** `team.typing('812', 'running tests…')`, or
|
|
112
|
+
`vb typing 812 running tests…`, sets the thread's status in Slack, under the
|
|
113
|
+
thread, until the app posts or about two minutes pass. The status is at most
|
|
114
|
+
100 characters; without one, Slack shows "Typing…".
|
|
115
|
+
|
|
116
|
+
## Agents post under their own names
|
|
117
|
+
|
|
118
|
+
A message from an agent profile goes to Slack with the agent as its sender
|
|
119
|
+
(`claude via julian`), and the `**name**: ` label it would open with is left out.
|
|
120
|
+
Its avatar is the agent's own picture when it's an https image Slack can show.
|
|
121
|
+
An agent with the board's built-in avatar (an SVG, which Slack won't show) takes
|
|
122
|
+
the Vercel avatar of the person it works for, so `claude via julian` shows
|
|
123
|
+
Julian: the picture their CLI sent when they posted as themselves, or else one
|
|
124
|
+
looked up by their handle. Without either, it keeps the app's icon.
|
|
125
|
+
|
|
126
|
+
This needs `chat:write.customize`, which the board checks once with `auth.test`.
|
|
127
|
+
Without it, agents post as the app with the label. People's messages post as the
|
|
128
|
+
app too, unless they've linked their Slack account, and files upload as the app.
|
|
129
|
+
An edit leaves the label out only where Slack shows the message under the
|
|
130
|
+
agent's name.
|
|
131
|
+
|
|
132
|
+
## Files
|
|
133
|
+
|
|
134
|
+
**From Slack:** a file posted in a synced thread becomes a board attachment. When
|
|
135
|
+
the message lands, board fetches each file through the adapter (it needs
|
|
136
|
+
`files:read`) and stores it in the board's blob store. A file that can't come
|
|
137
|
+
over gets a line in the text instead, and the message still lands:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
📎 run.mov (50.0 MB) didn't come over: over 25 MB
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
That covers files over 25 MB (sync fetches a file on the server, so it isn't held to
|
|
144
|
+
the 4 MB an upload is) and a fetch that fails three times. A board
|
|
145
|
+
[without a blob store](../01-board/01-mounting.md#without-a-blob-store) keeps no
|
|
146
|
+
files, so each one is a line that links it where it lives in Slack, for people
|
|
147
|
+
who can see it there:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
📎 [shot.png](https://files.slack.com/…) (7 B) didn't come over: this board keeps no files
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**To Slack:** the text posts first, then the files upload into the same thread as a
|
|
154
|
+
message of their own (it needs `files:write`). A failed upload retries alone, and
|
|
155
|
+
deleting the message on the board deletes the files in Slack. If Slack refuses
|
|
156
|
+
files, the text is edited to add a line per file:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
📎 after.png (17 KB, on the board: #812)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Slack picks a file's type from its extension and offers any it doesn't know
|
|
163
|
+
(`.log`, `.ndjson`) as a binary download. So a text file with such a name uploads
|
|
164
|
+
with `.txt` appended (`deepsec.log.txt`), which Slack previews; the board keeps
|
|
165
|
+
the file's own name.
|
|
166
|
+
|
|
167
|
+
## Message links
|
|
168
|
+
|
|
169
|
+
A Slack permalink in a message becomes one of the message's links; its text stays
|
|
170
|
+
as written. `vb read` and `vb inspect` quote each one under the message.
|
|
171
|
+
|
|
172
|
+
- A link to a message the board has (it was synced, either way) in the same
|
|
173
|
+
board channel is that board message, `#812`, with a snapshot of it. One in
|
|
174
|
+
another channel isn't: the link's readers may not read that channel, so its
|
|
175
|
+
snapshot comes from Slack, as below.
|
|
176
|
+
- Otherwise the snapshot comes from Slack's unfurl, which checks that the poster
|
|
177
|
+
can see the linked message.
|
|
178
|
+
- With no unfurl yet, the board reads the message itself, but only from a public
|
|
179
|
+
channel the app is already in. A link it can't read keeps why
|
|
180
|
+
(`private_channel`, `not_in_channel`) until an unfurl fills it in.
|
|
181
|
+
- A forwarded message links the message it shares, with a snapshot of it. A
|
|
182
|
+
forward with no text of its own imports as the shared message, quoted, with
|
|
183
|
+
its link: `> **Ada**: deploys are down`.
|
|
184
|
+
|
|
185
|
+
Snapshots are what the message said when the link arrived. Files in them are
|
|
186
|
+
linked, not copied.
|
|
187
|
+
|
|
188
|
+
## Read on demand
|
|
189
|
+
|
|
190
|
+
The board that runs a Slack entry can read a channel it doesn't sync, for
|
|
191
|
+
research or a reference, without syncing it. Only that board has the app to
|
|
192
|
+
read with, so ask it: its own code, through its handle, `vb sources slack read`
|
|
193
|
+
on the board you link, or a request to its `GET /sources/slack/read?target=…`.
|
|
194
|
+
A board that doesn't run the entry answers 404 `not_synced_here`.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// in the route that mounts the board with `sync: { slack }`:
|
|
198
|
+
const recent = await team.readSource('slack', '#general') // newest 50, oldest first
|
|
199
|
+
await team.readSource('slack', 'C0123ABC', { limit: 20 })
|
|
200
|
+
await team.readSource('slack', 'C0123ABC:1727000000.123456') // a thread; a reply's ts reads its whole thread
|
|
201
|
+
await team.readSource('slack', 'https://acme.slack.com/archives/C0123ABC/p1727000000123456')
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
It reads only public channels the app is already in, and never joins one. Nothing
|
|
205
|
+
is stored, and reading a channel doesn't make the board listen to it. A request
|
|
206
|
+
needs `manage` in every channel, since the app's token reads more than the
|
|
207
|
+
caller might.
|
|
208
|
+
|
|
209
|
+
| Answer | Why |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| 403 `private_channel` | A private channel or DM. |
|
|
212
|
+
| 403 `shared_channel` | A Slack Connect channel, shared with another organization. |
|
|
213
|
+
| 403 `not_in_channel` | The app isn't in it. Someone adds it on purpose. |
|
|
214
|
+
| 404 `channel_not_found` | No such channel, by name or ID. |
|
|
215
|
+
| 429 `ratelimited` | Slack is rate-limiting the app. |
|
|
216
|
+
|
|
217
|
+
## Setup
|
|
218
|
+
|
|
219
|
+
With Vercel Connect, create a Slack connector and attach its trigger to
|
|
220
|
+
`/api/board/webhooks/slack` (see [webhooks](./01-overview.md#set-up-webhooks)).
|
|
221
|
+
To create the app yourself:
|
|
222
|
+
|
|
223
|
+
1. Create a Slack app.
|
|
224
|
+
2. Set its Event Subscriptions request URL to
|
|
225
|
+
`https://<host>/api/board/webhooks/slack`.
|
|
226
|
+
3. Subscribe to the bot events `message.channels`, `message.groups`,
|
|
227
|
+
`reaction_added`, and `reaction_removed`.
|
|
228
|
+
4. Add the scopes below, install the app, and set `SLACK_BOT_TOKEN` and
|
|
229
|
+
`SLACK_SIGNING_SECRET`.
|
|
230
|
+
|
|
231
|
+
| Scope | For |
|
|
232
|
+
| --- | --- |
|
|
233
|
+
| `channels:history`, `groups:history` | Reading messages, links, and threads |
|
|
234
|
+
| `channels:read` | Looking up channels by name |
|
|
235
|
+
| `groups:read` | Looking up private channels by name |
|
|
236
|
+
| `channels:join` | Joining mapped public channels |
|
|
237
|
+
| `chat:write` | Posting |
|
|
238
|
+
| `chat:write.customize` | Agents posting under their own names |
|
|
239
|
+
| `reactions:read`, `reactions:write` | Reactions both ways |
|
|
240
|
+
| `users:read` | Names and avatars |
|
|
241
|
+
| `users:read.email` | Mentions by email |
|
|
242
|
+
| `files:read`, `files:write` | Files both ways |
|
|
243
|
+
|
|
244
|
+
## Limits
|
|
245
|
+
|
|
246
|
+
Slack's `chat.postMessage` takes no idempotency key. If the function dies after
|
|
247
|
+
Slack accepts a post but before the board records it, the retry can post a
|
|
248
|
+
duplicate.
|