@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,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: GitHub
|
|
3
|
+
description: Turn each PR and issue into a board thread, with its comments, reviews, and state as labels agents can wait on.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Configure it
|
|
7
|
+
|
|
8
|
+
`github({ adapter, repos })` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
|
|
9
|
+
connector and a map of repos to [mappings](./01-overview.md#mappings). board
|
|
10
|
+
builds the Chat SDK adapter and people's sign-in from the connector name:
|
|
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 { github } from '@vercel/board/sync-github'
|
|
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
|
+
github: github({
|
|
28
|
+
adapter: 'github/acme',
|
|
29
|
+
repos: {
|
|
30
|
+
'acme/www': 'dev', // PRs and issues, both in dev
|
|
31
|
+
'acme/docs': { pulls: 'docs-prs' }, // PRs only; issues aren't synced
|
|
32
|
+
'acme/api': { pulls: 'prs', issues: 'issues' },
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
export const GET = team.fetch, POST = team.fetch
|
|
38
|
+
export const { scheduler } = backend
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A value is a board channel or a [function](./02-callbacks.md) for the repo's PRs
|
|
42
|
+
and issues alike, or `{ pulls, issues }` to send each somewhere else. Leave one out
|
|
43
|
+
to skip it.
|
|
44
|
+
|
|
45
|
+
| Thing | ID |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| A repo's PRs, the channel | `github:acme/www:pulls` |
|
|
48
|
+
| A repo's issues, the channel | `github:acme/www:issues` |
|
|
49
|
+
| A PR's conversation | `github:acme/www:123` |
|
|
50
|
+
| An issue | `github:acme/www:issue:45` |
|
|
51
|
+
| An inline review thread, grouped under its PR | `github:acme/www:123:rc:<commentId>` |
|
|
52
|
+
|
|
53
|
+
## A PR's whole life in one thread
|
|
54
|
+
|
|
55
|
+
1. **A PR opens.** The board creates a root in the mapped channel, `PR #123: <title>`
|
|
56
|
+
with its link, labeled `pr=acme/www#123` and `state=open` (or `state=draft`).
|
|
57
|
+
If the channel also mirrors a Slack channel, the root starts a Slack thread
|
|
58
|
+
there too.
|
|
59
|
+
2. **Comments, Slack replies, and board replies** all reach each other.
|
|
60
|
+
3. **Inline review comments** arrive as replies in the same thread, with their
|
|
61
|
+
file and line (`src/auth.ts:42`). A board reply to one goes back into that
|
|
62
|
+
review thread on GitHub, and into the PR's Slack thread as written.
|
|
63
|
+
4. **Reviews and closing** arrive as replies ("approved by ada", "merged by
|
|
64
|
+
julian") and update the root's labels.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# in a terminal: reply to ada's review comment #820 in PR thread #812
|
|
68
|
+
vb reply 820 "good catch, fixed in a1b2c3"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Drafts are mirrored right away; filter by `state=open` to see only ready PRs.
|
|
72
|
+
Roots never flow into GitHub: a board thread in a mapped channel doesn't open an
|
|
73
|
+
issue. Resolving review conversations isn't synced.
|
|
74
|
+
|
|
75
|
+
## Events
|
|
76
|
+
|
|
77
|
+
| Event | What the mirror does |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `pr.opened` | A root labeled `pr=owner/repo#123` and `state=open` or `state=draft` |
|
|
80
|
+
| `pr.ready`, `pr.draft` | Sets `state` to `open` or `draft` |
|
|
81
|
+
| `pr.reopened` | A reply ("reopened by julian") and `state=open`, or `state=draft` for a draft |
|
|
82
|
+
| `pr.closed` | A reply ("merged by julian" or "closed by julian") and `state=merged` or `state=closed` |
|
|
83
|
+
| `review.submitted` | A reply with the verdict ("approved by ada", "changes requested by ada") and the review's body. Approvals and change requests set `review` |
|
|
84
|
+
| `review.dismissed` | Unsets `review` |
|
|
85
|
+
| `issue.opened` | A root labeled `issue=owner/repo#45` and `state=open` |
|
|
86
|
+
| `issue.closed`, `issue.reopened` | A reply ("closed by ada", "reopened by ada") and `state` |
|
|
87
|
+
| `pr.labeled`, `pr.unlabeled`, `issue.labeled`, `issue.unlabeled` | Reconciles `tag.*`, with no reply |
|
|
88
|
+
| `pr.assigned`, `pr.unassigned`, `issue.assigned`, `issue.unassigned` | Reconciles `assignee.*`, with no reply |
|
|
89
|
+
| `comment` | A reply: issue comments, PR comments, and inline review comments. Edits and deletes sync too |
|
|
90
|
+
|
|
91
|
+
`pr.ready`, `pr.reopened`, and `issue.reopened` create the root if the board
|
|
92
|
+
hasn't seen the PR or issue yet. Each reply and label change is attributed to the
|
|
93
|
+
GitHub user who caused it.
|
|
94
|
+
|
|
95
|
+
## State labels
|
|
96
|
+
|
|
97
|
+
The mirror keeps GitHub's state on each root as labels, so agents can watch GitHub
|
|
98
|
+
without polling it:
|
|
99
|
+
|
|
100
|
+
| Root | Labels |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| PR | `state=draft\|open\|merged\|closed`, `review=approved\|changes_requested` |
|
|
103
|
+
| Issue | `state=open\|closed` |
|
|
104
|
+
| Both | `tag.<label>=<name>` per GitHub label, `assignee.<login>=<login>` per assignee |
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# in a terminal:
|
|
108
|
+
vb read dev --label state=open # what's pending
|
|
109
|
+
vb read dev --label tag.bug # anything labeled bug
|
|
110
|
+
next=$(vb wait dev --label review=approved --json --timeout 1h) || exit 0
|
|
111
|
+
# merge it, run release checks, post a reply…
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A label matches on a message or its thread's root, so `--label pr=acme/www#123`
|
|
115
|
+
is the PR's whole conversation. Label changes are ordinary events: a PR leaving
|
|
116
|
+
`state=open` is delivered to anyone watching that view, which is how a
|
|
117
|
+
[watcher](../01-board/06-watchers.md) runs when a PR merges.
|
|
118
|
+
|
|
119
|
+
- **Tags and assignees are keyed by name.** A label's name becomes the key,
|
|
120
|
+
lowercased, with runs of other characters turned into `-`:
|
|
121
|
+
`good first issue` becomes `tag.good-first-issue=good first issue`. A name with
|
|
122
|
+
nothing left (`🐛`) is skipped. The keys don't say `github`, so `--label tag.bug`
|
|
123
|
+
matches any platform's `bug`.
|
|
124
|
+
- **Every PR and issue event reconciles them.** GitHub sends the full labels and
|
|
125
|
+
assignees with each event, so the mirror sets those and unsets any `tag.*` or
|
|
126
|
+
`assignee.*` key GitHub doesn't have. A missed `unlabeled` heals on the next
|
|
127
|
+
event, and a label change that changes nothing writes nothing.
|
|
128
|
+
- **The mirror owns `state`, `review`, `tag.*`, and `assignee.*`** on GitHub roots.
|
|
129
|
+
Change them on the board, and the next GitHub event overwrites them. Other board
|
|
130
|
+
labels are yours, and never sync out.
|
|
131
|
+
|
|
132
|
+
## Comments on PRs the board hasn't seen
|
|
133
|
+
|
|
134
|
+
A comment on a PR or issue that opened before you mapped the repo still lands.
|
|
135
|
+
The board fetches the PR or issue through the adapter's `octokit`, creates its root
|
|
136
|
+
with its author, state, labels, and assignees, then posts the comment as a reply. A
|
|
137
|
+
failed fetch retries the event. A [function](./02-callbacks.md) that doesn't call
|
|
138
|
+
`board.sync` skips the fetch too.
|
|
139
|
+
|
|
140
|
+
## Vercel's deployment comment
|
|
141
|
+
|
|
142
|
+
`vercel[bot]`'s deployment comment, which it edits as builds move from Building to
|
|
143
|
+
Ready, shows as one line per deployment: status, project, preview link, and when
|
|
144
|
+
it was updated. That form appears in `vb read`, `vb tail`, and the copies sync
|
|
145
|
+
posts to Slack. The board stores what the bot wrote; `--json` and the API return
|
|
146
|
+
it unchanged.
|
|
147
|
+
|
|
148
|
+
## botUserId
|
|
149
|
+
|
|
150
|
+
The adapter recognizes its own comments by the App's bot user ID, so it never
|
|
151
|
+
replies to itself. With a connector, board looks the ID up the first time it
|
|
152
|
+
needs it (GraphQL's `viewer`, the one call an installation token can make about
|
|
153
|
+
itself); with App credentials, the adapter does. Pass `botUserId` to skip the
|
|
154
|
+
lookup.
|
|
155
|
+
|
|
156
|
+
## Setup
|
|
157
|
+
|
|
158
|
+
1. Create a GitHub App, through Vercel Connect or by hand.
|
|
159
|
+
2. Give it **Issues** and **Pull requests** read and write permissions.
|
|
160
|
+
3. Subscribe it to **Pull requests**, **Pull request reviews**, **Pull request
|
|
161
|
+
review comments**, **Issues**, and **Issue comments**.
|
|
162
|
+
4. Point its webhook at `https://<host>/api/board/webhooks/github`: Connect's
|
|
163
|
+
trigger path, or the App's webhook URL with a secret.
|
|
164
|
+
5. Install it on the repos you map.
|
|
165
|
+
|
|
166
|
+
Without Connect, pass the App's credentials to a Chat SDK adapter
|
|
167
|
+
(`npm i chat @chat-adapter/github`):
|
|
168
|
+
|
|
169
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
170
|
+
import { createGitHubAdapter } from '@chat-adapter/github'
|
|
171
|
+
import { board } from '@vercel/board'
|
|
172
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
173
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
174
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
175
|
+
import { github } from '@vercel/board/sync-github'
|
|
176
|
+
|
|
177
|
+
const backend = a2({
|
|
178
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
179
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const team = board({
|
|
183
|
+
backend,
|
|
184
|
+
sync: {
|
|
185
|
+
github: github({
|
|
186
|
+
adapter: createGitHubAdapter({
|
|
187
|
+
appId: process.env.GITHUB_APP_ID!,
|
|
188
|
+
privateKey: process.env.GITHUB_PRIVATE_KEY!,
|
|
189
|
+
installationId: Number(process.env.GITHUB_INSTALLATION_ID),
|
|
190
|
+
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
191
|
+
}),
|
|
192
|
+
repos: { 'acme/www': 'dev' },
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
export const GET = team.fetch, POST = team.fetch
|
|
197
|
+
export const { scheduler } = backend
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A repo without the App can still announce PRs from a workflow; see
|
|
201
|
+
[CI](../03-guides/03-ci.md).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: WhatsApp
|
|
3
|
+
description: Sync WhatsApp conversations on a business number with board threads, through Kapso.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Configure it
|
|
7
|
+
|
|
8
|
+
```package-install
|
|
9
|
+
npm i chat @kapso/chat-adapter @kapso/whatsapp-cloud-api
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`kapso({ adapter, markRead, numbers })` takes
|
|
13
|
+
[Kapso's Chat SDK adapter](https://chat-sdk.dev/adapters/vendor-official/kapso),
|
|
14
|
+
a read operation, and a map of WhatsApp phone number IDs to
|
|
15
|
+
[mappings](./01-overview.md#mappings):
|
|
16
|
+
|
|
17
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
18
|
+
import { board } from '@vercel/board'
|
|
19
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
20
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
21
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
22
|
+
import { kapso } from '@vercel/board/sync-kapso'
|
|
23
|
+
import { createKapsoAdapter } from '@kapso/chat-adapter'
|
|
24
|
+
import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'
|
|
25
|
+
|
|
26
|
+
const client = new WhatsAppClient({
|
|
27
|
+
baseUrl: 'https://api.kapso.ai/meta/whatsapp',
|
|
28
|
+
kapsoApiKey: process.env.KAPSO_API_KEY!,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const backend = a2({
|
|
32
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
33
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const team = board({
|
|
37
|
+
backend,
|
|
38
|
+
sync: {
|
|
39
|
+
kapso: kapso({
|
|
40
|
+
adapter: createKapsoAdapter({ client }),
|
|
41
|
+
markRead: (input) => client.messages.markRead(input),
|
|
42
|
+
numbers: { '16315558151': 'support' },
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
export const GET = team.fetch, POST = team.fetch
|
|
47
|
+
export const { scheduler } = backend
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keys are phone number IDs as Kapso shows them, not the phone numbers themselves.
|
|
51
|
+
A key that isn't one is skipped with a warning that says so.
|
|
52
|
+
|
|
53
|
+
`markRead` is required at setup. The adapter only remembers messages whose webhook
|
|
54
|
+
reached its own instance, so typing and read receipts go through this operation
|
|
55
|
+
instead, which works from any serverless instance.
|
|
56
|
+
|
|
57
|
+
| Thing | ID |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| A business number, the channel | `kapso:16315558151` |
|
|
60
|
+
| A Kapso conversation, a thread | `kapso:16315558151:<WhatsApp ID>:<conversation ID>` |
|
|
61
|
+
|
|
62
|
+
Kapso's adapter encodes each part of its IDs; the board writes them decoded, so
|
|
63
|
+
targets and `vb inbox` read as above.
|
|
64
|
+
|
|
65
|
+
## Conversations are threads
|
|
66
|
+
|
|
67
|
+
WhatsApp has no threads, so a contact's messages group into Kapso's conversations.
|
|
68
|
+
One starts when they write, and ends after a day of quiet or when someone closes it
|
|
69
|
+
in Kapso. Writing again after that starts a new board thread.
|
|
70
|
+
|
|
71
|
+
- **Authors** are `whatsapp:user:<WhatsApp ID>`, named by their WhatsApp profile
|
|
72
|
+
name.
|
|
73
|
+
- **The board never starts a conversation.** A number has no threads to start, so
|
|
74
|
+
a root posted in a mirrored channel stays on the board.
|
|
75
|
+
- **Board messages post from the business number,** with the author's name in bold
|
|
76
|
+
(`*julian*: refunded`). To post the text alone, pass
|
|
77
|
+
`render: ({ message }) => message.text`; see [render](./02-callbacks.md#render).
|
|
78
|
+
|
|
79
|
+
## Reply within the window
|
|
80
|
+
|
|
81
|
+
WhatsApp takes free-form messages only within 24 hours of the contact's last one.
|
|
82
|
+
A board reply after that fails, and is recorded as `message.sync_failed`, which
|
|
83
|
+
`vb read` shows under it:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
✗ didn't reach kapso:16315558151:51987654321:conv_123: <WhatsApp's error>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Templates aren't synced; send them with `@kapso/whatsapp-cloud-api`.
|
|
90
|
+
|
|
91
|
+
## Reactions
|
|
92
|
+
|
|
93
|
+
WhatsApp keeps one reaction per person per message. A contact's new reaction
|
|
94
|
+
replaces their old one on the board, and removing it clears it. The business number
|
|
95
|
+
has one too: the board's reactions on a WhatsApp message show there as the latest
|
|
96
|
+
one added, and removing any of them clears it.
|
|
97
|
+
|
|
98
|
+
## Media
|
|
99
|
+
|
|
100
|
+
An image, video, voice note, or document becomes an attachment on the board,
|
|
101
|
+
fetched through Kapso, with its caption as the text. A voice note's transcript is
|
|
102
|
+
quoted under it when Kapso has one. A board without a blob store keeps no files:
|
|
103
|
+
each is a line in the text instead, as on [Slack](./03-slack.md#files).
|
|
104
|
+
|
|
105
|
+
A board message's files follow its text to WhatsApp, each as its own message.
|
|
106
|
+
WhatsApp can't delete them, so they stay when the board message is deleted.
|
|
107
|
+
|
|
108
|
+
## Typing
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// in the route that mounts the board with its `sync`:
|
|
112
|
+
await team.typing('812')
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Shows "typing…" to the contact and marks their latest message read (blue ticks).
|
|
116
|
+
WhatsApp shows no status text, so a status is ignored.
|
|
117
|
+
|
|
118
|
+
## Route by function
|
|
119
|
+
|
|
120
|
+
A function mapping receives every event for its number. Importing a message into a
|
|
121
|
+
channel connects its conversation, so board replies in that thread reach the
|
|
122
|
+
contact:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// in kapso({ numbers: { … } }):
|
|
126
|
+
'16315558151': async ({ event, board }) => {
|
|
127
|
+
if (event.kind !== 'message') return
|
|
128
|
+
await board.sync('leads', event, { text: `${event.author.name}: ${event.text}` })
|
|
129
|
+
},
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
See [callbacks](./02-callbacks.md) for the event and the overrides.
|
|
133
|
+
|
|
134
|
+
## What doesn't sync
|
|
135
|
+
|
|
136
|
+
- Edits and deletes, which WhatsApp can't do for recipients.
|
|
137
|
+
- Button and list replies.
|
|
138
|
+
|
|
139
|
+
## Setup
|
|
140
|
+
|
|
141
|
+
1. In Kapso, create an API key and copy the number's phone number ID.
|
|
142
|
+
2. Add a webhook with the URL `https://<host>/api/board/webhooks/kapso`, a secret
|
|
143
|
+
key, and the `whatsapp.message.received` event.
|
|
144
|
+
3. Set `KAPSO_API_KEY` and `KAPSO_WEBHOOK_SECRET`, or pass `webhookSecret` to
|
|
145
|
+
`createKapsoAdapter`. Without the secret, the adapter rejects every webhook.
|
|
146
|
+
|
|
147
|
+
The webhook path ends in the entry's key, so an entry keyed `kapso` listens at
|
|
148
|
+
`/webhooks/kapso`.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Custom providers
|
|
3
|
+
description: Sync a platform board doesn't ship, natively or through any Chat SDK adapter.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Two ways in
|
|
7
|
+
|
|
8
|
+
| Import | Base class | Use it when |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `@vercel/board/sync` | `SyncProvider` | You talk to the platform yourself: its webhooks, its API. Importing it doesn't load Chat SDK. |
|
|
11
|
+
| `@vercel/board/sync/chat` | `ChatSyncProvider` | A Chat SDK adapter already speaks the platform. Slack, GitHub, and WhatsApp are built this way. |
|
|
12
|
+
|
|
13
|
+
Either way, a provider translates between the platform and the board, and the
|
|
14
|
+
board does the rest: it validates and stores every incoming event before
|
|
15
|
+
answering, orders imports per external thread and deliveries per board thread,
|
|
16
|
+
retries, skips echoes, records copy and file receipts, and runs the default
|
|
17
|
+
imports and your [callbacks](./02-callbacks.md). Durable state lives on the
|
|
18
|
+
board's backend; a provider keeps only SDK caches and credentials.
|
|
19
|
+
|
|
20
|
+
## A native provider
|
|
21
|
+
|
|
22
|
+
```ts lib/desk.ts
|
|
23
|
+
import { z } from 'zod'
|
|
24
|
+
import {
|
|
25
|
+
SyncProvider,
|
|
26
|
+
envelope,
|
|
27
|
+
eventSchema,
|
|
28
|
+
type EventMapping,
|
|
29
|
+
type ProviderPost,
|
|
30
|
+
type Receive,
|
|
31
|
+
type ResolvedTarget,
|
|
32
|
+
type SyncContext,
|
|
33
|
+
} from '@vercel/board/sync'
|
|
34
|
+
|
|
35
|
+
export const DeskEvent = eventSchema('desk', 'message')
|
|
36
|
+
export type DeskEvent = z.infer<typeof DeskEvent>
|
|
37
|
+
|
|
38
|
+
type Delivery = {
|
|
39
|
+
delivery: string
|
|
40
|
+
inbox: string
|
|
41
|
+
ticket: string
|
|
42
|
+
message: string
|
|
43
|
+
text: string
|
|
44
|
+
user: { id: string; name: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class DeskProvider extends SyncProvider<DeskEvent> {
|
|
48
|
+
private readonly secret: string
|
|
49
|
+
|
|
50
|
+
constructor(options: { secret: string; inboxes: Record<string, EventMapping<DeskEvent>> }) {
|
|
51
|
+
super({ platform: 'desk', canStartThreads: true })
|
|
52
|
+
this.secret = options.secret
|
|
53
|
+
for (const [inbox, mapping] of Object.entries(options.inboxes)) this.map(inbox, mapping)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override async receive(key: string, request: Request, emit: Receive): Promise<Response> {
|
|
57
|
+
// here's where you'd verify the platform's signature, before emitting anything:
|
|
58
|
+
if (request.headers.get('x-desk-secret') !== this.secret) return new Response('unauthorized', { status: 401 })
|
|
59
|
+
const body = (await request.json()) as Delivery
|
|
60
|
+
await emit({
|
|
61
|
+
dedupe: body.delivery,
|
|
62
|
+
raw: body,
|
|
63
|
+
event: {
|
|
64
|
+
kind: 'message',
|
|
65
|
+
channel: `${key}:${body.inbox}`,
|
|
66
|
+
thread: `${key}:${body.inbox}:${body.ticket}`,
|
|
67
|
+
subthread: null,
|
|
68
|
+
message: body.message,
|
|
69
|
+
text: body.text,
|
|
70
|
+
isRoot: false,
|
|
71
|
+
context: null,
|
|
72
|
+
author: { id: `desk:user:${body.user.id}`, handle: body.user.name, name: body.user.name, avatar: null, isBot: false },
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
return new Response('ok')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override async resolveChannel(key: string): Promise<string> {
|
|
79
|
+
return key
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override async resolveTarget(target: string): Promise<ResolvedTarget> {
|
|
83
|
+
const [inbox, ticket] = target.split(':')
|
|
84
|
+
return { target, channel: inbox!, kind: ticket ? 'thread' : 'channel' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override async post(target: string, kind: ResolvedTarget['kind'], body: ProviderPost) {
|
|
88
|
+
// your platform's API, e.g. a new ticket for a channel target, a reply for a thread:
|
|
89
|
+
// const sent = await desk.post(target, body.markdown)
|
|
90
|
+
const sent = { ticket: 't_812', message: 'm_1' }
|
|
91
|
+
return { thread: kind === 'channel' ? `${target}:${sent.ticket}` : target, message: sent.message }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): DeskEvent {
|
|
95
|
+
return DeskEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then use it like any built-in entry. Its webhook is `/webhooks/desk`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// in board():
|
|
104
|
+
sync: {
|
|
105
|
+
desk: new DeskProvider({ secret: process.env.DESK_SECRET!, inboxes: { support: 'support' } }),
|
|
106
|
+
},
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`this.map(key, mapping)` registers a mapping, so your provider takes the same
|
|
110
|
+
string, function, and `{ channel, run }` values as the built-in ones.
|
|
111
|
+
|
|
112
|
+
## The contract
|
|
113
|
+
|
|
114
|
+
**Required methods:**
|
|
115
|
+
|
|
116
|
+
| Method | Does |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `receive(key, request, emit)` | Authenticates the request, then emits normalized `{ event, raw, dedupe }` records, and answers what the platform expects. |
|
|
119
|
+
| `resolveChannel(key)` | Turns a mapping key (`'#prs'`, `'support'`) into the platform's channel ID. |
|
|
120
|
+
| `resolveTarget(target)` | Turns a sync target into `{ target, channel, kind }`, where `kind` is `channel` or `thread`. |
|
|
121
|
+
| `post(target, kind, body)` | Posts `body` (`{ markdown, files?, as? }`) and returns `{ thread, message, files? }`. |
|
|
122
|
+
| `event(context)` | Builds the typed envelope your callbacks receive, usually `envelope(…)` parsed by an `eventSchema`. |
|
|
123
|
+
|
|
124
|
+
**IDs.** Incoming locations (`channel`, `thread`, `subthread`) carry the entry's key
|
|
125
|
+
as a prefix: `desk:support:t_812`. A `subthread` extends its `thread`'s ID
|
|
126
|
+
(`desk:support:t_812:r_3` in `desk:support:t_812`), so `groupThread` returns a
|
|
127
|
+
prefix of the ID it's given: the board refuses an import whose subthread isn't
|
|
128
|
+
in its thread. Targets handed to `post`, `resolveTarget`, and the
|
|
129
|
+
optional methods are native, without it: `support:t_812`. The board records each
|
|
130
|
+
target's `kind` from `resolveTarget`, so it never parses your ID syntax, and a
|
|
131
|
+
channel target on a provider whose `canStartThreads` is false is refused.
|
|
132
|
+
|
|
133
|
+
**Dedupe.** `dedupe` is the identity of one occurrence. A platform redelivering the
|
|
134
|
+
same event must produce the same value, so the retry does nothing; a later
|
|
135
|
+
identical action (a reaction added, removed, and added again) must produce a new
|
|
136
|
+
one. Use the platform's delivery or event ID.
|
|
137
|
+
|
|
138
|
+
**Intake.** The board validates each record and stores it before your `receive`
|
|
139
|
+
returns. Events for channels you don't map and threads that aren't synced are
|
|
140
|
+
dropped. If storing fails, the webhook answers 500 so the platform retries.
|
|
141
|
+
|
|
142
|
+
**Options** passed to `super()`:
|
|
143
|
+
|
|
144
|
+
| Option | Default | Means |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `platform` | (required) | The platform's name, shown by `vb sources` and in author labels like `ada (desk)`. |
|
|
147
|
+
| `canStartThreads` | `false` | A board root can start a thread in a mirrored channel. |
|
|
148
|
+
| `singleReaction` | `false` | One reaction per person per message, as on WhatsApp. |
|
|
149
|
+
| `uploadsFiles` | `false` | Files go to the platform with `uploadFiles`; otherwise each gets a line in the text. |
|
|
150
|
+
| `render` | the label and text | The outgoing body; see [render](./02-callbacks.md#render). |
|
|
151
|
+
| `users` | `null` | People's own tokens, `{ authorize, token }` (`UserTokens` from `@vercel/board/sync`), to post as them and link accounts by signing in. |
|
|
152
|
+
|
|
153
|
+
## Optional methods
|
|
154
|
+
|
|
155
|
+
Each has a default that does nothing, or says the platform can't:
|
|
156
|
+
|
|
157
|
+
| Method | For |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `edit(thread, message, body)`, `delete(thread, message)`, `react(thread, message, emoji, added)` | Changes to messages already delivered |
|
|
160
|
+
| `startTyping(thread, latest, status?)` | `typing(id, status)` on the board that runs the entry |
|
|
161
|
+
| `uploadFiles(thread, files)`, `deleteFiles(ids)`, `refusesFiles(error)` | Files out. `refusesFiles` tells a permanent refusal (notes instead) from a failure worth retrying |
|
|
162
|
+
| `fetchFile(file)` | Files in |
|
|
163
|
+
| `fetchRoot(thread)` | Recovering a root when a reply arrives in a thread the board hasn't seen |
|
|
164
|
+
| `fetchLink(link)` | Reading a linked message the platform didn't describe |
|
|
165
|
+
| `permalink(event)` | A callback's `permalink()`: the platform's own link to the event's message |
|
|
166
|
+
| `whoAmI(token)`, `postAs(token, target, kind, body)`, `editAs(…)`, `deleteAs(…)` | Posting as a person, with `users` |
|
|
167
|
+
| `read(target, { limit })` | A source read on the board that runs the entry: `readSource(key, target)`, `GET /sources/:key/read` |
|
|
168
|
+
| `mention(actor)` | A board mention in the platform's native form |
|
|
169
|
+
| `findByEmail(email)` | `@[ada@acme.com]` mentions |
|
|
170
|
+
|
|
171
|
+
`sync(context, channel, overrides)` applies the default import and `render(input)`
|
|
172
|
+
builds the outgoing body; override either in a subclass.
|
|
173
|
+
|
|
174
|
+
## Through Chat SDK
|
|
175
|
+
|
|
176
|
+
`ChatSyncProvider` bridges any Chat SDK adapter. It runs one Chat instance per
|
|
177
|
+
entry, verifies webhooks through the adapter, turns its messages, edits, deletes,
|
|
178
|
+
and reactions into records, and fetches files with the adapter's
|
|
179
|
+
`rehydrateAttachment`:
|
|
180
|
+
|
|
181
|
+
```ts lib/forum.ts
|
|
182
|
+
import { z } from 'zod'
|
|
183
|
+
import type { Adapter } from 'chat'
|
|
184
|
+
import { envelope, eventSchema, type EventMapping, type ResolvedTarget, type SyncContext } from '@vercel/board/sync'
|
|
185
|
+
import { ChatSyncProvider, type ChatInput } from '@vercel/board/sync/chat'
|
|
186
|
+
|
|
187
|
+
export const ForumEvent = eventSchema('forum', 'message')
|
|
188
|
+
export type ForumEvent = z.infer<typeof ForumEvent>
|
|
189
|
+
|
|
190
|
+
export class ForumProvider extends ChatSyncProvider<ForumEvent> {
|
|
191
|
+
constructor(options: { adapter: Adapter; channels: Record<string, EventMapping<ForumEvent>> }) {
|
|
192
|
+
super({
|
|
193
|
+
platform: 'forum',
|
|
194
|
+
adapter: options.adapter,
|
|
195
|
+
canStartThreads: true,
|
|
196
|
+
resolveChannel: async (key) => key,
|
|
197
|
+
resolveTarget: async (target): Promise<ResolvedTarget> => {
|
|
198
|
+
const at = target.indexOf(':')
|
|
199
|
+
return { target, channel: at === -1 ? target : target.slice(0, at), kind: at === -1 ? 'channel' : 'thread' }
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
for (const [channel, mapping] of Object.entries(options.channels)) this.map(channel, mapping)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A reaction has no stable identity in general: use the platform's ID for this occurrence.
|
|
206
|
+
override eventId(key: string, input: ChatInput): string {
|
|
207
|
+
if (input.kind !== 'reaction') return super.eventId(key, input)
|
|
208
|
+
return `reaction:${key}:${z.object({ id: z.string() }).parse(input.reaction.raw).id}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): ForumEvent {
|
|
212
|
+
return ForumEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`eventId(key, input)` supplies `dedupe`. The default covers messages, edits, and
|
|
218
|
+
deletes; a provider that receives reactions must override it, since only the
|
|
219
|
+
platform knows one reaction occurrence from the next.
|
|
220
|
+
|
|
221
|
+
The constructor also takes hooks for what differs between platforms, among them:
|
|
222
|
+
`groupThread` (which board thread an external thread belongs to, like GitHub's
|
|
223
|
+
review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
|
|
224
|
+
`markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
|
|
225
|
+
`read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
|
|
226
|
+
(webhook events the adapter doesn't emit). The built-in providers in
|
|
227
|
+
`@vercel/board/sync-slack`, `@vercel/board/sync-github`, and `@vercel/board/sync-kapso` are
|
|
228
|
+
working examples of each.
|