@vercel/board 0.16.0 → 0.18.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 +27 -27
- package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
- package/dist/access.d.mts +1 -1
- package/dist/access.mjs +3 -3
- package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
- package/dist/attempts-CLG840Oj.mjs +20 -0
- package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
- package/dist/backend-B-SJ7p6p.d.mts +680 -0
- package/dist/backend-a2/contract.d.mts +873 -2
- package/dist/backend-a2/contract.mjs +2 -2
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
- package/dist/backend-a2/scheduler-qstash.mjs +521 -1
- package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
- package/dist/backend-a2/scheduler-vercel.mjs +203 -1
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +2850 -2
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +490 -2
- package/dist/backend-a2/store-redis.d.mts +10 -2
- package/dist/backend-a2/store-redis.mjs +51 -2
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +2706 -3
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
- package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
- package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
- package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
- package/dist/cli.mjs +30 -10
- package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
- package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
- package/dist/events-CewD4kw7.mjs +75 -0
- package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
- package/dist/exports-ZsgjzEnM.d.mts +4 -0
- package/dist/external-BV_cUncs.mjs +151 -0
- package/dist/folds-CRYY14kc.d.mts +534 -0
- package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
- package/dist/ids-C6AYo1bu.mjs +31 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +4 -4
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +31 -31
- package/dist/inspect/engine.js +5 -5
- package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
- package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
- package/dist/mentions-CB6ShY4R.mjs +72 -0
- package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
- package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
- package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
- package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
- package/dist/provider-B3DpYSDq.mjs +220 -0
- package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
- package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
- package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
- package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
- package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
- package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
- package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
- package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
- package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
- package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
- package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
- package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
- package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
- package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
- package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
- package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
- package/dist/store-memory-Bas9Uq5C.mjs +872 -0
- package/dist/store-memory-ZLiZbH3R.mjs +2 -0
- package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
- package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
- package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
- package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
- package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
- package/dist/sync-github.d.mts +24 -17
- package/dist/sync-github.mjs +89 -22
- package/dist/sync-kapso.d.mts +15 -13
- package/dist/sync-kapso.mjs +17 -14
- package/dist/sync-linear.d.mts +219 -0
- package/dist/sync-linear.mjs +311 -0
- package/dist/sync-slack.d.mts +12 -17
- package/dist/sync-slack.mjs +54 -42
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +5 -4
- package/dist/targets-C-wdR5wP.mjs +33 -0
- package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
- package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
- package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
- package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
- package/docs/01-board/01-mounting.md +1 -1
- package/docs/02-sync/01-overview.md +72 -48
- package/docs/02-sync/02-functions.md +398 -0
- package/docs/02-sync/03-slack.md +30 -25
- package/docs/02-sync/04-github.md +62 -26
- package/docs/02-sync/05-whatsapp.md +23 -19
- package/docs/02-sync/06-providers.md +60 -27
- package/docs/02-sync/07-linear.md +129 -0
- package/docs/03-guides/04-triage.md +1 -1
- package/docs/04-reference/01-cli.md +26 -6
- package/docs/04-reference/02-board.md +18 -5
- package/docs/04-reference/03-http.md +5 -2
- package/docs/04-reference/05-conformance.md +1 -1
- package/docs/index.md +5 -10
- package/package.json +31 -6
- package/dist/a2-D24cvwpN.d.mts +0 -3359
- package/dist/access-CJU2-vaa.mjs +0 -226
- package/dist/attempts-Bxp9KgPf.mjs +0 -919
- package/dist/exports-DLiE7fLY.d.mts +0 -4
- package/dist/provider-CuewcDNZ.mjs +0 -205
- package/dist/redis-tables-B35vv4CZ.mjs +0 -842
- package/dist/reducers-B_vUGQRp.d.mts +0 -1172
- package/dist/watchers-hash-CLjsa6od.mjs +0 -225
- package/docs/02-sync/02-callbacks.md +0 -265
package/docs/02-sync/03-slack.md
CHANGED
|
@@ -5,16 +5,21 @@ description: Mirror Slack channels into board channels, with threads, reactions,
|
|
|
5
5
|
|
|
6
6
|
## Configure it
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
```package-install
|
|
9
|
+
npm i chat @chat-adapter/slack @vercel/connect
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`slack(app, sync)` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
|
|
13
|
+
connector and a [sync function](./02-functions.md). `channels` makes one from a map
|
|
14
|
+
of Slack channels, by name (`'#prs'`) or ID (`'C0123ABC'`), to board channels
|
|
15
|
+
(two-way mirrors) or functions of your own:
|
|
11
16
|
|
|
12
17
|
```ts app/api/board/[[...path]]/route.ts
|
|
13
18
|
import { board } from '@vercel/board'
|
|
14
19
|
import { a2 } from '@vercel/board/backend-a2'
|
|
15
20
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
16
21
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
17
|
-
import { slack } from '@vercel/board/sync-slack'
|
|
22
|
+
import { channels, slack } from '@vercel/board/sync-slack'
|
|
18
23
|
|
|
19
24
|
const backend = a2({
|
|
20
25
|
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
@@ -24,13 +29,10 @@ const backend = a2({
|
|
|
24
29
|
const team = board({
|
|
25
30
|
backend,
|
|
26
31
|
sync: {
|
|
27
|
-
slack: slack({
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
C0123ABC: 'dev',
|
|
32
|
-
},
|
|
33
|
-
}),
|
|
32
|
+
slack: slack('slack/acme', channels({
|
|
33
|
+
'#errors-www': 'errors-www',
|
|
34
|
+
C0123ABC: 'dev',
|
|
35
|
+
})),
|
|
34
36
|
},
|
|
35
37
|
})
|
|
36
38
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -51,8 +53,8 @@ The prefix is the entry's key, so a second workspace keyed `acme` reads
|
|
|
51
53
|
|
|
52
54
|
### Your own adapter
|
|
53
55
|
|
|
54
|
-
Without Connect, pass a Chat SDK Slack adapter
|
|
55
|
-
(`npm i chat @chat-adapter/slack`):
|
|
56
|
+
Without Connect, pass the options, with a Chat SDK Slack adapter that has the
|
|
57
|
+
app's own secrets (`npm i chat @chat-adapter/slack`):
|
|
56
58
|
|
|
57
59
|
```ts app/api/board/[[...path]]/route.ts
|
|
58
60
|
import { createSlackAdapter } from '@chat-adapter/slack'
|
|
@@ -60,7 +62,7 @@ import { board } from '@vercel/board'
|
|
|
60
62
|
import { a2 } from '@vercel/board/backend-a2'
|
|
61
63
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
62
64
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
63
|
-
import { slack } from '@vercel/board/sync-slack'
|
|
65
|
+
import { channels, slack } from '@vercel/board/sync-slack'
|
|
64
66
|
|
|
65
67
|
const backend = a2({
|
|
66
68
|
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
@@ -70,13 +72,15 @@ const backend = a2({
|
|
|
70
72
|
const team = board({
|
|
71
73
|
backend,
|
|
72
74
|
sync: {
|
|
73
|
-
slack: slack(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
slack: slack(
|
|
76
|
+
{
|
|
77
|
+
adapter: createSlackAdapter({
|
|
78
|
+
botToken: process.env.SLACK_BOT_TOKEN!,
|
|
79
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET!,
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
channels({ '#prs': 'prs' }),
|
|
83
|
+
),
|
|
80
84
|
},
|
|
81
85
|
})
|
|
82
86
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -89,9 +93,11 @@ A static-token adapter can leave it out; board uses the adapter's own web client
|
|
|
89
93
|
|
|
90
94
|
## Channels
|
|
91
95
|
|
|
92
|
-
Slack only sends a channel's events to apps in it
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
Slack only sends a channel's events to apps in it, and the app never joins one
|
|
97
|
+
on its own: `/invite` it to each channel you map. Until then that channel's
|
|
98
|
+
events don't arrive, and a post there fails with Slack's `not_in_channel`. The
|
|
99
|
+
log names each channel mapped by name that the app isn't in; one mapped by ID
|
|
100
|
+
isn't checked, since that would ask Slack about it.
|
|
95
101
|
|
|
96
102
|
Name lookups list channels once and cache the result. Private channels show up by
|
|
97
103
|
name only with `groups:read`; Vercel Connect's Slack app doesn't request it, so map
|
|
@@ -233,7 +239,6 @@ To create the app yourself:
|
|
|
233
239
|
| `channels:history`, `groups:history` | Reading messages, links, and threads |
|
|
234
240
|
| `channels:read` | Looking up channels by name |
|
|
235
241
|
| `groups:read` | Looking up private channels by name |
|
|
236
|
-
| `channels:join` | Joining mapped public channels |
|
|
237
242
|
| `chat:write` | Posting |
|
|
238
243
|
| `chat:write.customize` | Agents posting under their own names |
|
|
239
244
|
| `reactions:read`, `reactions:write` | Reactions both ways |
|
|
@@ -5,16 +5,21 @@ description: Turn each PR and issue into a board thread, with its comments, revi
|
|
|
5
5
|
|
|
6
6
|
## Configure it
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
```package-install
|
|
9
|
+
npm i chat @chat-adapter/github @vercel/connect
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`github(app, sync)` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
|
|
13
|
+
connector and a [sync function](./02-functions.md). `repos` makes one from a map
|
|
14
|
+
of repos to board channels. board builds the Chat SDK adapter and people's
|
|
15
|
+
sign-in from the connector name:
|
|
11
16
|
|
|
12
17
|
```ts app/api/board/[[...path]]/route.ts
|
|
13
18
|
import { board } from '@vercel/board'
|
|
14
19
|
import { a2 } from '@vercel/board/backend-a2'
|
|
15
20
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
16
21
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
17
|
-
import { github } from '@vercel/board/sync-github'
|
|
22
|
+
import { github, repos } from '@vercel/board/sync-github'
|
|
18
23
|
|
|
19
24
|
const backend = a2({
|
|
20
25
|
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
@@ -24,24 +29,43 @@ const backend = a2({
|
|
|
24
29
|
const team = board({
|
|
25
30
|
backend,
|
|
26
31
|
sync: {
|
|
27
|
-
github: github({
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'acme/api': { pulls: 'prs', issues: 'issues' },
|
|
33
|
-
},
|
|
34
|
-
}),
|
|
32
|
+
github: github('github/acme', repos({
|
|
33
|
+
'acme/www': 'dev', // PRs and issues, both in dev
|
|
34
|
+
'acme/docs': { pulls: 'docs-prs' }, // PRs only; issues aren't synced
|
|
35
|
+
'acme/api': { pulls: 'prs', issues: 'issues' },
|
|
36
|
+
})),
|
|
35
37
|
},
|
|
36
38
|
})
|
|
37
39
|
export const GET = team.fetch, POST = team.fetch
|
|
38
40
|
export const { scheduler } = backend
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
A value is a board channel or a [function](./02-
|
|
43
|
+
A value is a board channel or a [function](./02-functions.md) for the repo's PRs
|
|
42
44
|
and issues alike, or `{ pulls, issues }` to send each somewhere else. Leave one out
|
|
43
45
|
to skip it.
|
|
44
46
|
|
|
47
|
+
## What goes back to GitHub
|
|
48
|
+
|
|
49
|
+
A board channel brings PRs and issues in, and sends nothing back: GitHub is often
|
|
50
|
+
public, and what's said in Slack or on the board may not be. To send some of it,
|
|
51
|
+
deliver it from the function. This sends what's written on the board, by people
|
|
52
|
+
and their agents, and keeps what was said in Slack out:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// in board({ sync: { … } }):
|
|
56
|
+
github: github('github/acme', repos({
|
|
57
|
+
'acme/www': async (sync) => {
|
|
58
|
+
if (sync.direction === 'in') return void (await sync.import('dev'))
|
|
59
|
+
if (sync.message.from === null) await sync.deliver()
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`mirror('dev')` as a repo's value sends everything back, Slack included. A
|
|
65
|
+
thread someone syncs by hand to a PR or issue in a repo the map doesn't name
|
|
66
|
+
comes in the same way, and gets nothing back either: on Slack, a thread synced
|
|
67
|
+
by hand outside the map syncs both ways.
|
|
68
|
+
|
|
45
69
|
| Thing | ID |
|
|
46
70
|
| --- | --- |
|
|
47
71
|
| A repo's PRs, the channel | `github:acme/www:pulls` |
|
|
@@ -50,13 +74,22 @@ to skip it.
|
|
|
50
74
|
| An issue | `github:acme/www:issue:45` |
|
|
51
75
|
| An inline review thread, grouped under its PR | `github:acme/www:123:rc:<commentId>` |
|
|
52
76
|
|
|
77
|
+
A review thread isn't a sync target: `vb sync 812 github:acme/www:123:rc:345`
|
|
78
|
+
says to sync its PR, `github:acme/www:123`. A board thread synced with the PR
|
|
79
|
+
gets its review comments, and its replies to them go back into their review
|
|
80
|
+
threads. A link to a review comment (`…/pull/123#discussion_r346`) finds it on
|
|
81
|
+
the board, whether it came from GitHub or the board posted it, for comments
|
|
82
|
+
synced on this release or later: earlier ones were recorded without the PR's
|
|
83
|
+
thread, so their links answer 404.
|
|
84
|
+
|
|
53
85
|
## A PR's whole life in one thread
|
|
54
86
|
|
|
55
87
|
1. **A PR opens.** The board creates a root in the mapped channel, `PR #123: <title>`
|
|
56
88
|
with its link, labeled `pr=acme/www#123` and `state=open` (or `state=draft`).
|
|
57
89
|
If the channel also mirrors a Slack channel, the root starts a Slack thread
|
|
58
90
|
there too.
|
|
59
|
-
2. **Comments
|
|
91
|
+
2. **Comments** reach the board and its Slack thread. Board and Slack replies
|
|
92
|
+
reach GitHub only as [the function sends them](#what-goes-back-to-github).
|
|
60
93
|
3. **Inline review comments** arrive as replies in the same thread, with their
|
|
61
94
|
file and line (`src/auth.ts:42`). A board reply to one goes back into that
|
|
62
95
|
review thread on GitHub, and into the PR's Slack thread as written.
|
|
@@ -77,6 +110,7 @@ issue. Resolving review conversations isn't synced.
|
|
|
77
110
|
| Event | What the mirror does |
|
|
78
111
|
| --- | --- |
|
|
79
112
|
| `pr.opened` | A root labeled `pr=owner/repo#123` and `state=open` or `state=draft` |
|
|
113
|
+
| `pr.edited`, `issue.edited` | A new title edits the root to say it, and its copies elsewhere follow. A new description changes nothing |
|
|
80
114
|
| `pr.ready`, `pr.draft` | Sets `state` to `open` or `draft` |
|
|
81
115
|
| `pr.reopened` | A reply ("reopened by julian") and `state=open`, or `state=draft` for a draft |
|
|
82
116
|
| `pr.closed` | A reply ("merged by julian" or "closed by julian") and `state=merged` or `state=closed` |
|
|
@@ -134,8 +168,8 @@ is the PR's whole conversation. Label changes are ordinary events: a PR leaving
|
|
|
134
168
|
A comment on a PR or issue that opened before you mapped the repo still lands.
|
|
135
169
|
The board fetches the PR or issue through the adapter's `octokit`, creates its root
|
|
136
170
|
with its author, state, labels, and assignees, then posts the comment as a reply. A
|
|
137
|
-
failed fetch retries the event. A [function](./02-
|
|
138
|
-
`
|
|
171
|
+
failed fetch retries the event. A [function](./02-functions.md) that doesn't call
|
|
172
|
+
`import` skips the fetch too.
|
|
139
173
|
|
|
140
174
|
## Vercel's deployment comment
|
|
141
175
|
|
|
@@ -172,7 +206,7 @@ import { board } from '@vercel/board'
|
|
|
172
206
|
import { a2 } from '@vercel/board/backend-a2'
|
|
173
207
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
174
208
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
175
|
-
import { github } from '@vercel/board/sync-github'
|
|
209
|
+
import { github, repos } from '@vercel/board/sync-github'
|
|
176
210
|
|
|
177
211
|
const backend = a2({
|
|
178
212
|
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
@@ -182,15 +216,17 @@ const backend = a2({
|
|
|
182
216
|
const team = board({
|
|
183
217
|
backend,
|
|
184
218
|
sync: {
|
|
185
|
-
github: github(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
219
|
+
github: github(
|
|
220
|
+
{
|
|
221
|
+
adapter: createGitHubAdapter({
|
|
222
|
+
appId: process.env.GITHUB_APP_ID!,
|
|
223
|
+
privateKey: process.env.GITHUB_PRIVATE_KEY!,
|
|
224
|
+
installationId: Number(process.env.GITHUB_INSTALLATION_ID),
|
|
225
|
+
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
repos({ 'acme/www': 'dev' }),
|
|
229
|
+
),
|
|
194
230
|
},
|
|
195
231
|
})
|
|
196
232
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -9,17 +9,18 @@ description: Sync WhatsApp conversations on a business number with board threads
|
|
|
9
9
|
npm i chat @kapso/chat-adapter @kapso/whatsapp-cloud-api
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
`kapso({ adapter, markRead,
|
|
12
|
+
`kapso({ adapter, markRead }, sync)` takes
|
|
13
13
|
[Kapso's Chat SDK adapter](https://chat-sdk.dev/adapters/vendor-official/kapso),
|
|
14
|
-
a read operation, and a
|
|
15
|
-
|
|
14
|
+
a read operation, and a [sync function](./02-functions.md). `numbers` makes one
|
|
15
|
+
from a map of WhatsApp phone number IDs to board channels (two-way mirrors) or
|
|
16
|
+
functions of your own:
|
|
16
17
|
|
|
17
18
|
```ts app/api/board/[[...path]]/route.ts
|
|
18
19
|
import { board } from '@vercel/board'
|
|
19
20
|
import { a2 } from '@vercel/board/backend-a2'
|
|
20
21
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
21
22
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
22
|
-
import { kapso } from '@vercel/board/sync-kapso'
|
|
23
|
+
import { kapso, numbers } from '@vercel/board/sync-kapso'
|
|
23
24
|
import { createKapsoAdapter } from '@kapso/chat-adapter'
|
|
24
25
|
import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'
|
|
25
26
|
|
|
@@ -36,11 +37,13 @@ const backend = a2({
|
|
|
36
37
|
const team = board({
|
|
37
38
|
backend,
|
|
38
39
|
sync: {
|
|
39
|
-
kapso: kapso(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
kapso: kapso(
|
|
41
|
+
{
|
|
42
|
+
adapter: createKapsoAdapter({ client }),
|
|
43
|
+
markRead: (input) => client.messages.markRead(input),
|
|
44
|
+
},
|
|
45
|
+
numbers({ '16315558151': 'support' }),
|
|
46
|
+
),
|
|
44
47
|
},
|
|
45
48
|
})
|
|
46
49
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -73,8 +76,8 @@ in Kapso. Writing again after that starts a new board thread.
|
|
|
73
76
|
- **The board never starts a conversation.** A number has no threads to start, so
|
|
74
77
|
a root posted in a mirrored channel stays on the board.
|
|
75
78
|
- **Board messages post from the business number,** with the author's name in bold
|
|
76
|
-
(`*julian*: refunded`). To post the text alone,
|
|
77
|
-
`render
|
|
79
|
+
(`*julian*: refunded`). To post the text alone, wrap the function in
|
|
80
|
+
`render(({ message }) => message.text, …)`; see [render](./02-functions.md#render).
|
|
78
81
|
|
|
79
82
|
## Reply within the window
|
|
80
83
|
|
|
@@ -117,19 +120,20 @@ WhatsApp shows no status text, so a status is ignored.
|
|
|
117
120
|
|
|
118
121
|
## Route by function
|
|
119
122
|
|
|
120
|
-
A function
|
|
121
|
-
channel connects its conversation,
|
|
122
|
-
contact:
|
|
123
|
+
A function for a number receives every event for it, both ways. Importing a
|
|
124
|
+
message into a channel connects its conversation, and delivering sends board
|
|
125
|
+
replies in that thread to the contact:
|
|
123
126
|
|
|
124
127
|
```ts
|
|
125
|
-
// in kapso({ numbers
|
|
126
|
-
'16315558151': async (
|
|
127
|
-
if (
|
|
128
|
-
|
|
128
|
+
// in kapso({ … }, numbers({ … })):
|
|
129
|
+
'16315558151': async (sync) => {
|
|
130
|
+
if (sync.direction === 'out') return sync.deliver()
|
|
131
|
+
if (sync.event.kind !== 'message') return
|
|
132
|
+
await sync.import('leads', { text: `${sync.event.author.name}: ${sync.event.text}` })
|
|
129
133
|
},
|
|
130
134
|
```
|
|
131
135
|
|
|
132
|
-
See [
|
|
136
|
+
See [sync functions](./02-functions.md) for the event and the overrides.
|
|
133
137
|
|
|
134
138
|
## What doesn't sync
|
|
135
139
|
|
|
@@ -14,7 +14,7 @@ Either way, a provider translates between the platform and the board, and the
|
|
|
14
14
|
board does the rest: it validates and stores every incoming event before
|
|
15
15
|
answering, orders imports per external thread and deliveries per board thread,
|
|
16
16
|
retries, skips echoes, records copy and file receipts, and runs the default
|
|
17
|
-
imports and your [
|
|
17
|
+
imports and your [sync functions](./02-functions.md). Durable state lives on the
|
|
18
18
|
board's backend; a provider keeps only SDK caches and credentials.
|
|
19
19
|
|
|
20
20
|
## A native provider
|
|
@@ -25,11 +25,11 @@ import {
|
|
|
25
25
|
SyncProvider,
|
|
26
26
|
envelope,
|
|
27
27
|
eventSchema,
|
|
28
|
-
type EventMapping,
|
|
29
28
|
type ProviderPost,
|
|
30
29
|
type Receive,
|
|
31
30
|
type ResolvedTarget,
|
|
32
31
|
type SyncContext,
|
|
32
|
+
type SyncFn,
|
|
33
33
|
} from '@vercel/board/sync'
|
|
34
34
|
|
|
35
35
|
export const DeskEvent = eventSchema('desk', 'message')
|
|
@@ -47,10 +47,9 @@ type Delivery = {
|
|
|
47
47
|
export class DeskProvider extends SyncProvider<DeskEvent> {
|
|
48
48
|
private readonly secret: string
|
|
49
49
|
|
|
50
|
-
constructor(options: { secret: string
|
|
51
|
-
super({ platform: 'desk', canStartThreads: true })
|
|
50
|
+
constructor(options: { secret: string }, sync: SyncFn<DeskEvent>) {
|
|
51
|
+
super({ platform: 'desk', canStartThreads: true }, sync)
|
|
52
52
|
this.secret = options.secret
|
|
53
|
-
for (const [inbox, mapping] of Object.entries(options.inboxes)) this.map(inbox, mapping)
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
override async receive(key: string, request: Request, emit: Receive): Promise<Response> {
|
|
@@ -91,24 +90,27 @@ export class DeskProvider extends SyncProvider<DeskEvent> {
|
|
|
91
90
|
return { thread: kind === 'channel' ? `${target}:${sent.ticket}` : target, message: sent.message }
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
override event(context:
|
|
93
|
+
override event(context: SyncContext): DeskEvent {
|
|
95
94
|
return DeskEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
|
|
96
95
|
}
|
|
96
|
+
|
|
97
|
+
// The platform channel a thread is in, so `channels` can route what goes out to it.
|
|
98
|
+
override inboxChannel(key: string, thread: string): string {
|
|
99
|
+
return thread.split(':').slice(0, 2).join(':')
|
|
100
|
+
}
|
|
97
101
|
}
|
|
98
102
|
```
|
|
99
103
|
|
|
100
|
-
Then use it like any built-in entry
|
|
104
|
+
Then use it like any built-in entry, with any [sync function](./02-functions.md).
|
|
105
|
+
Its webhook is `/webhooks/desk`:
|
|
101
106
|
|
|
102
107
|
```ts
|
|
103
|
-
// in board():
|
|
108
|
+
// in board(), with channels from '@vercel/board/sync':
|
|
104
109
|
sync: {
|
|
105
|
-
desk: new DeskProvider({ secret: process.env.DESK_SECRET
|
|
110
|
+
desk: new DeskProvider({ secret: process.env.DESK_SECRET! }, channels({ support: 'support' })),
|
|
106
111
|
},
|
|
107
112
|
```
|
|
108
113
|
|
|
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
114
|
## The contract
|
|
113
115
|
|
|
114
116
|
**Required methods:**
|
|
@@ -116,16 +118,17 @@ string, function, and `{ channel, run }` values as the built-in ones.
|
|
|
116
118
|
| Method | Does |
|
|
117
119
|
| --- | --- |
|
|
118
120
|
| `receive(key, request, emit)` | Authenticates the request, then emits normalized `{ event, raw, dedupe }` records, and answers what the platform expects. |
|
|
119
|
-
| `resolveChannel(
|
|
121
|
+
| `resolveChannel(name)` | Turns a channel's name (`'#prs'`, `'support'`) into the platform's channel ID: what `sync.resolve` and `channels` use. Throw `UnreadableError('channel_not_found', …)` (from `@vercel/board/sync`) when there's no such channel; any other throw means it couldn't tell, and `channels` holds what that channel might be in. |
|
|
120
122
|
| `resolveTarget(target)` | Turns a sync target into `{ target, channel, kind }`, where `kind` is `channel` or `thread`. |
|
|
121
123
|
| `post(target, kind, body)` | Posts `body` (`{ markdown, files?, as? }`) and returns `{ thread, message, files? }`. |
|
|
122
|
-
| `event(context)` | Builds the typed envelope
|
|
124
|
+
| `event(context)` | Builds the typed envelope sync functions read as `sync.event`, usually `envelope(…)` parsed by an `eventSchema`. |
|
|
123
125
|
|
|
124
126
|
**IDs.** Incoming locations (`channel`, `thread`, `subthread`) carry the entry's key
|
|
125
127
|
as a prefix: `desk:support:t_812`. A `subthread` extends its `thread`'s ID
|
|
126
128
|
(`desk:support:t_812:r_3` in `desk:support:t_812`), so `groupThread` returns a
|
|
127
129
|
prefix of the ID it's given: the board refuses an import whose subthread isn't
|
|
128
|
-
in its thread.
|
|
130
|
+
in its thread. A subthread's messages are found by their thread too, so a
|
|
131
|
+
platform message ID is unique within its thread, as GitHub's comment IDs are. Targets handed to `post`, `resolveTarget`, and the
|
|
129
132
|
optional methods are native, without it: `support:t_812`. The board records each
|
|
130
133
|
target's `kind` from `resolveTarget`, so it never parses your ID syntax, and a
|
|
131
134
|
channel target on a provider whose `canStartThreads` is false is refused.
|
|
@@ -136,8 +139,9 @@ identical action (a reaction added, removed, and added again) must produce a new
|
|
|
136
139
|
one. Use the platform's delivery or event ID.
|
|
137
140
|
|
|
138
141
|
**Intake.** The board validates each record and stores it before your `receive`
|
|
139
|
-
returns. Events
|
|
140
|
-
|
|
142
|
+
returns. Events outside the channels the sync function's helpers route, and
|
|
143
|
+
outside threads the board syncs, are dropped; see [What the webhook keeps](./02-functions.md#what-the-webhook-keeps).
|
|
144
|
+
If storing fails, the webhook answers 500 so the platform retries.
|
|
141
145
|
|
|
142
146
|
**Options** passed to `super()`:
|
|
143
147
|
|
|
@@ -147,7 +151,6 @@ dropped. If storing fails, the webhook answers 500 so the platform retries.
|
|
|
147
151
|
| `canStartThreads` | `false` | A board root can start a thread in a mirrored channel. |
|
|
148
152
|
| `singleReaction` | `false` | One reaction per person per message, as on WhatsApp. |
|
|
149
153
|
| `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
154
|
| `users` | `null` | People's own tokens, `{ authorize, token }` (`UserTokens` from `@vercel/board/sync`), to post as them and link accounts by signing in. |
|
|
152
155
|
|
|
153
156
|
## Optional methods
|
|
@@ -162,14 +165,44 @@ Each has a default that does nothing, or says the platform can't:
|
|
|
162
165
|
| `fetchFile(file)` | Files in |
|
|
163
166
|
| `fetchRoot(thread)` | Recovering a root when a reply arrives in a thread the board hasn't seen |
|
|
164
167
|
| `fetchLink(link)` | Reading a linked message the platform didn't describe |
|
|
165
|
-
| `permalink(event)` | A
|
|
168
|
+
| `permalink(event)` | A sync function's `permalink()`: the platform's own link to the event's message |
|
|
169
|
+
| `inboxChannel(key, thread)` | The platform channel a thread is in (`desk:support` for `desk:support:t_812`), without calling the platform: `sync.channel` going out, which `channels` routes by |
|
|
166
170
|
| `whoAmI(token)`, `postAs(token, target, kind, body)`, `editAs(…)`, `deleteAs(…)` | Posting as a person, with `users` |
|
|
167
171
|
| `read(target, { limit })` | A source read on the board that runs the entry: `readSource(key, target)`, `GET /sources/:key/read` |
|
|
168
172
|
| `mention(actor)` | A board mention in the platform's native form |
|
|
169
173
|
| `findByEmail(email)` | `@[ada@acme.com]` mentions |
|
|
174
|
+
| `locate(url)`, `url(thread, message)` | Links both ways: `vb read <link>` and `vb sync <id>`. See [Links](#links) |
|
|
175
|
+
|
|
176
|
+
`render(input)` builds the outgoing body when `deliver` gets no renderer;
|
|
177
|
+
override it in a subclass.
|
|
170
178
|
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
## Links
|
|
180
|
+
|
|
181
|
+
Two methods let the board go between its messages and the platform's links to
|
|
182
|
+
them. Both use the platform's own thread and message IDs, without the sync key:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// in your provider, beside post():
|
|
186
|
+
override locate(url: string) {
|
|
187
|
+
// https://tickets.example.com/t/42#c7 → thread t/42, message c7
|
|
188
|
+
const found = /^https:\/\/tickets\.example\.com\/(t\/\d+)(?:#(c\d+))?$/.exec(url)
|
|
189
|
+
return found ? { thread: found[1]!, message: found[2] ?? null } : null
|
|
190
|
+
}
|
|
191
|
+
override async url(thread: string, message: string) {
|
|
192
|
+
return `https://tickets.example.com/${thread}#${message}`
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- **`locate(url)`** reads a link as the platform's thread and message, or
|
|
197
|
+
`message: null` for a link to a thread itself, and null for a link that
|
|
198
|
+
isn't the platform's. It asks the platform nothing: the board looks the IDs
|
|
199
|
+
up in what it synced. `vb read <link>` and `board.linked(url)` use it.
|
|
200
|
+
- **`url(thread, message)`** is the platform's link to one of its messages, or
|
|
201
|
+
null. `vb sync <id>` and `board.sync(id)` show it for each copy.
|
|
202
|
+
|
|
203
|
+
A provider without them still syncs: its links just don't resolve, and its
|
|
204
|
+
copies show their IDs. Slack reads permalinks and asks `chat.getPermalink` for
|
|
205
|
+
one; GitHub reads and makes a PR's, an issue's, and a comment's URLs.
|
|
173
206
|
|
|
174
207
|
## Through Chat SDK
|
|
175
208
|
|
|
@@ -181,14 +214,14 @@ and reactions into records, and fetches files with the adapter's
|
|
|
181
214
|
```ts lib/forum.ts
|
|
182
215
|
import { z } from 'zod'
|
|
183
216
|
import type { Adapter } from 'chat'
|
|
184
|
-
import { envelope, eventSchema, type
|
|
217
|
+
import { envelope, eventSchema, type ResolvedTarget, type SyncContext, type SyncFn } from '@vercel/board/sync'
|
|
185
218
|
import { ChatSyncProvider, type ChatInput } from '@vercel/board/sync/chat'
|
|
186
219
|
|
|
187
220
|
export const ForumEvent = eventSchema('forum', 'message')
|
|
188
221
|
export type ForumEvent = z.infer<typeof ForumEvent>
|
|
189
222
|
|
|
190
223
|
export class ForumProvider extends ChatSyncProvider<ForumEvent> {
|
|
191
|
-
constructor(options: { adapter: Adapter
|
|
224
|
+
constructor(options: { adapter: Adapter }, sync: SyncFn<ForumEvent>) {
|
|
192
225
|
super({
|
|
193
226
|
platform: 'forum',
|
|
194
227
|
adapter: options.adapter,
|
|
@@ -198,8 +231,7 @@ export class ForumProvider extends ChatSyncProvider<ForumEvent> {
|
|
|
198
231
|
const at = target.indexOf(':')
|
|
199
232
|
return { target, channel: at === -1 ? target : target.slice(0, at), kind: at === -1 ? 'channel' : 'thread' }
|
|
200
233
|
},
|
|
201
|
-
})
|
|
202
|
-
for (const [channel, mapping] of Object.entries(options.channels)) this.map(channel, mapping)
|
|
234
|
+
}, sync)
|
|
203
235
|
}
|
|
204
236
|
|
|
205
237
|
// A reaction has no stable identity in general: use the platform's ID for this occurrence.
|
|
@@ -208,7 +240,7 @@ export class ForumProvider extends ChatSyncProvider<ForumEvent> {
|
|
|
208
240
|
return `reaction:${key}:${z.object({ id: z.string() }).parse(input.reaction.raw).id}`
|
|
209
241
|
}
|
|
210
242
|
|
|
211
|
-
override event(context:
|
|
243
|
+
override event(context: SyncContext): ForumEvent {
|
|
212
244
|
return ForumEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
|
|
213
245
|
}
|
|
214
246
|
}
|
|
@@ -224,5 +256,6 @@ review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
|
|
|
224
256
|
`markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
|
|
225
257
|
`read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
|
|
226
258
|
(webhook events the adapter doesn't emit). The built-in providers in
|
|
227
|
-
`@vercel/board/sync-slack`, `@vercel/board/sync-github`,
|
|
259
|
+
`@vercel/board/sync-slack`, `@vercel/board/sync-github`, `@vercel/board/sync-kapso`, and
|
|
260
|
+
`@vercel/board/sync-linear` are
|
|
228
261
|
working examples of each.
|