@vercel/board 0.30.0 → 0.31.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 +8 -17
- package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
- package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
- package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
- package/dist/access.d.mts +4 -4
- package/dist/access.mjs +5 -4
- package/dist/{actors-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
- package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
- package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
- package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
- package/dist/backend-a2/blob-vercel.d.mts +1 -1
- package/dist/backend-a2/contract.d.mts +37 -97
- package/dist/backend-a2/contract.mjs +1 -1
- package/dist/backend-a2/store-memory.d.mts +1 -1
- package/dist/backend-a2/store-memory.mjs +2 -2
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +1 -1
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +1 -1
- package/dist/backend-a2/store-redis.d.mts +1 -1
- package/dist/backend-a2/store-redis.mjs +1 -1
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +2 -2
- package/dist/backend-a2.d.mts +131 -240
- package/dist/backend-a2.mjs +2 -2
- package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
- package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
- package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
- 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/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
- package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
- package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
- package/dist/backend-vercel.d.mts +1 -1
- package/dist/backend-vercel.mjs +1 -1
- package/dist/backend-vercel.node.d.mts +1 -1
- package/dist/backend-vercel.node.mjs +1 -1
- package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
- package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
- package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
- package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
- package/dist/cli-commands.d.mts +1 -1
- package/dist/cli-commands.mjs +1 -1
- package/dist/cli-embedded.d.mts +2 -2
- package/dist/cli-embedded.mjs +1 -1
- package/dist/cli.mjs +4 -4
- package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
- package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
- package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
- package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
- package/dist/conformance.d.mts +8 -2
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
- package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
- package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
- package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
- package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
- package/dist/exports-FBOQWT7O.d.mts +5 -0
- package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
- package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
- package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
- package/dist/index.d.mts +7 -7
- package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +7 -7
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +62 -61
- package/dist/inspect/engine.js +3 -3
- package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
- package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
- package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
- package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
- package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
- package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
- package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
- package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
- package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
- package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
- package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
- package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
- package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
- package/dist/sync-chat-sdk.mjs +44 -0
- package/dist/sync-github.d.mts +5 -18
- package/dist/sync-github.mjs +4 -70
- package/dist/sync-linear.d.mts +3 -3
- package/dist/sync-linear.mjs +2 -3
- package/dist/sync-slack.d.mts +7 -7
- package/dist/sync-slack.mjs +33 -54
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +2 -4
- package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
- package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
- package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
- package/docs/01-board/01-mounting.md +2 -2
- package/docs/01-board/03-identity.md +5 -6
- package/docs/01-board/04-messages.md +3 -2
- package/docs/01-board/05-reading.md +2 -1
- package/docs/01-board/06-watchers.md +8 -6
- package/docs/01-board/08-middleware.md +1 -1
- package/docs/01-board/09-access.md +145 -14
- package/docs/02-sync/01-overview.md +19 -26
- package/docs/02-sync/02-functions.md +8 -9
- package/docs/02-sync/03-slack.md +5 -5
- package/docs/02-sync/04-github.md +2 -2
- package/docs/02-sync/06-providers.md +91 -62
- package/docs/03-guides/01-coding-agents.md +1 -1
- package/docs/03-guides/03-ci.md +7 -4
- package/docs/04-reference/01-cli.md +86 -41
- package/docs/04-reference/02-board.md +56 -23
- package/docs/04-reference/03-http.md +59 -32
- package/docs/04-reference/04-log.md +8 -11
- package/docs/04-reference/05-conformance.md +35 -6
- package/docs/index.md +2 -2
- package/package.json +6 -9
- package/sql/postgres/board.sql +1 -1
- package/sql/postgres/schema.sql +1 -1
- package/dist/events-qf5XG5VQ.mjs +0 -75
- package/dist/exports-CrGA5_yN.d.mts +0 -5
- package/dist/sync-kapso.mjs +0 -89
- package/dist/sync.chat.d.mts +0 -2
- package/dist/sync.chat.mjs +0 -2
- package/dist/users-yryHKFMo.mjs +0 -42
- package/docs/02-sync/05-whatsapp.md +0 -152
|
@@ -28,6 +28,30 @@ with them. It writes to one channel, `conformance` (`--channel`, or
|
|
|
28
28
|
`{ channel }`), with every post kept on the board, labeled with the run's own
|
|
29
29
|
`conformance=<id>`, and deletes what it posted when it's done.
|
|
30
30
|
|
|
31
|
+
To check that a token does only what its `can` says, it calls the board with
|
|
32
|
+
one it mints, through `withToken(secret)`: the same board, with that secret as
|
|
33
|
+
the credential, as `BOARD_TOKEN` sends it. `vb conformance` passes it; from
|
|
34
|
+
code, pass your own, or that check is skipped:
|
|
35
|
+
|
|
36
|
+
```ts scripts/conformance.ts
|
|
37
|
+
import { board } from '@vercel/board'
|
|
38
|
+
import { http } from '@vercel/board/backend-http'
|
|
39
|
+
import { conformance } from '@vercel/board/conformance'
|
|
40
|
+
|
|
41
|
+
const url = 'https://board.example.com/api/board'
|
|
42
|
+
const withToken = (secret: string) =>
|
|
43
|
+
board({
|
|
44
|
+
backend: http(url, {
|
|
45
|
+
fetch: (input, init) => {
|
|
46
|
+
const headers = new Headers(init?.headers)
|
|
47
|
+
headers.set('authorization', `Bearer ${secret}`)
|
|
48
|
+
return fetch(input, { ...init, headers })
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
const report = await conformance(board({ backend: http(url) }), { withToken })
|
|
53
|
+
```
|
|
54
|
+
|
|
31
55
|
## What it checks
|
|
32
56
|
|
|
33
57
|
The whole protocol, on every board:
|
|
@@ -55,6 +79,9 @@ The whole protocol, on every board:
|
|
|
55
79
|
until the next one.
|
|
56
80
|
- **A delete leaves a tombstone.**
|
|
57
81
|
- **Channels and actors** list the channel it posted in, and you.
|
|
82
|
+
- **Creating a channel** a post made answers `created: false`, and a path
|
|
83
|
+
that isn't one is 400. It creates none of its own, since a board keeps every
|
|
84
|
+
channel.
|
|
58
85
|
- **Errors:** an ID that names nothing is 404 `message_not_found`, and a
|
|
59
86
|
request that doesn't validate is 400 `invalid_request`.
|
|
60
87
|
|
|
@@ -63,15 +90,17 @@ Then the rest, a group each in the report:
|
|
|
63
90
|
| Group | Check |
|
|
64
91
|
| --- | --- |
|
|
65
92
|
| `schedules` | schedules a reply a day out, lists it, and cancels it; and keeps a schedule's `noSync`, so its sends stay off every platform |
|
|
93
|
+
| `grants` | grants a project that doesn't exist, lists it, takes the same issuer and claims again as one grant, revokes it twice, and changes a grant's `can` by creating it again. Grants a GitHub repository's workflows and another team's project, and keeps two environments of one project as two grants. Skipped when the token can't change the board's grants |
|
|
94
|
+
| `tokens` | mints a token for an hour that reads the channel, checks its `can` and its expiry, lists it without its secret, and revokes it twice. Then, with another one's secret: `/me` names its minter and only what it may read, it reads the channel and can't post there, and once it's revoked, it's refused with a 401 |
|
|
66
95
|
| `attachments` | uploads a file, posts it, and downloads it |
|
|
67
96
|
| `watchers` | registers a tail, pulls the match for a post, and acks it. Skipped when the token already has watchers or a sync registered there, since registering replaces them |
|
|
68
|
-
| `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, lists where the thread is synced with it, answers 404 for a link no sync there knows, and deletes it. Pushes two events on one platform thread to the sync's inbox, and the first again, then leases them one at a time and in order, each once the one before is acked. Pulls the sync's `@sync` match for a post. Each of these is skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered. Then, registering nothing: reads a post's and a reply's `noSync` back
|
|
97
|
+
| `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, lists where the thread is synced with it, answers 404 for a link no sync there knows, and deletes it. Pushes two events on one platform thread to the sync's inbox, and the first again, then leases them one at a time and in order, each once the one before is acked. Pulls the sync's `@sync` match for a post. Each of these is skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered. Then, registering nothing: reads a post's and a reply's `noSync` back |
|
|
69
98
|
|
|
70
99
|
Every board answers all of them: see
|
|
71
100
|
[the whole protocol](./03-http.md#the-whole-protocol). A board may keep no
|
|
72
|
-
schedules or no
|
|
73
|
-
|
|
74
|
-
board said. A board on `a2()` without a `scheduler` answers 501
|
|
101
|
+
schedules, grants, or files, or issue no tokens, and say so: one that answers
|
|
102
|
+
one of those groups' checks with a 501 has it skipped, `not kept here`, with
|
|
103
|
+
what the board said. A board that's meant to keep them and doesn't yet answers otherwise, and fails: Vercel's boards fail both groups until Vercel's API keeps grants and tokens. A board on `a2()` without a `scheduler` answers 501
|
|
75
104
|
`no_scheduler`, and without a `blob` 501 `no_files`: see
|
|
76
105
|
[Mounting](../01-board/01-mounting.md#without-a-scheduler). `vb serve` keeps
|
|
77
106
|
both, so it passes every check.
|
|
@@ -89,7 +118,7 @@ attachments
|
|
|
89
118
|
skipped takes a file, posts it, and gives it back
|
|
90
119
|
not kept here: this board keeps no files: pass `blob` to `a2()`
|
|
91
120
|
…
|
|
92
|
-
|
|
121
|
+
26 passed, 3 skipped
|
|
93
122
|
```
|
|
94
123
|
|
|
95
124
|
## The report
|
|
@@ -97,7 +126,7 @@ attachments
|
|
|
97
126
|
```ts
|
|
98
127
|
type ConformanceReport = {
|
|
99
128
|
url: string
|
|
100
|
-
checks: { group: 'core' | 'schedules' | 'attachments' | 'watchers' | 'sync'; name: string; status: 'passed' | 'failed' | 'skipped'; detail?: string }[]
|
|
129
|
+
checks: { group: 'core' | 'schedules' | 'grants' | 'tokens' | 'attachments' | 'watchers' | 'sync'; name: string; status: 'passed' | 'failed' | 'skipped'; detail?: string }[]
|
|
101
130
|
ok: boolean // nothing failed
|
|
102
131
|
}
|
|
103
132
|
```
|
package/docs/index.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Introduction
|
|
3
|
-
description: 'A message board you mount in one route: one durable log, verified authors, a live tail, and two-way sync with Slack, GitHub, and
|
|
3
|
+
description: 'A message board you mount in one route: one durable log, verified authors, a live tail, and two-way sync with Slack, GitHub, Linear, and any platform with a Chat SDK adapter.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```bash
|
|
@@ -72,7 +72,7 @@ export const { scheduler } = backend
|
|
|
72
72
|
|
|
73
73
|
[Vercel Connect](./02-sync/01-overview.md#set-up-webhooks) holds the Slack
|
|
74
74
|
and GitHub credentials, so the board keeps no platform secrets. board builds
|
|
75
|
-
the adapters from the connector names
|
|
75
|
+
the adapters from the connector names. A Slack reply,
|
|
76
76
|
a PR review comment, and `vb reply` land in the same thread, and each one
|
|
77
77
|
reaches the others. A PR's root carries its state as labels
|
|
78
78
|
(`state=open`, `review=approved`), so an agent can wait on GitHub without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "A message board you mount in one route: one durable log, verified authors, live tail, and two-way sync.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -97,9 +97,9 @@
|
|
|
97
97
|
"types": "./dist/sync.d.mts",
|
|
98
98
|
"default": "./dist/sync.mjs"
|
|
99
99
|
},
|
|
100
|
-
"./sync
|
|
101
|
-
"types": "./dist/sync
|
|
102
|
-
"default": "./dist/sync
|
|
100
|
+
"./sync-chat-sdk": {
|
|
101
|
+
"types": "./dist/sync-chat-sdk.d.mts",
|
|
102
|
+
"default": "./dist/sync-chat-sdk.mjs"
|
|
103
103
|
},
|
|
104
104
|
"./sync-slack": {
|
|
105
105
|
"types": "./dist/sync-slack.d.mts",
|
|
@@ -109,10 +109,6 @@
|
|
|
109
109
|
"types": "./dist/sync-github.d.mts",
|
|
110
110
|
"default": "./dist/sync-github.mjs"
|
|
111
111
|
},
|
|
112
|
-
"./sync-kapso": {
|
|
113
|
-
"types": "./dist/sync-kapso.d.mts",
|
|
114
|
-
"default": "./dist/sync-kapso.mjs"
|
|
115
|
-
},
|
|
116
112
|
"./sync-linear": {
|
|
117
113
|
"types": "./dist/sync-linear.d.mts",
|
|
118
114
|
"default": "./dist/sync-linear.mjs"
|
|
@@ -138,12 +134,13 @@
|
|
|
138
134
|
},
|
|
139
135
|
"devDependencies": {
|
|
140
136
|
"@base-ui/react": "^1.8.0",
|
|
137
|
+
"@chat-adapter/discord": "4.41.1",
|
|
141
138
|
"@chat-adapter/github": "^4.41.1",
|
|
142
139
|
"@chat-adapter/linear": "^4.41.1",
|
|
143
140
|
"@chat-adapter/slack": "^4.41.1",
|
|
141
|
+
"@chat-adapter/telegram": "4.41.1",
|
|
144
142
|
"@chat-adapter/tests": "^4.41.1",
|
|
145
143
|
"@electric-sql/pglite": "0.3.15",
|
|
146
|
-
"@kapso/chat-adapter": "^0.1.1",
|
|
147
144
|
"@types/node": "^26.6.3",
|
|
148
145
|
"@types/pg": "8.15.5",
|
|
149
146
|
"@types/react": "^19.3.0",
|
package/sql/postgres/board.sql
CHANGED
|
@@ -74,7 +74,7 @@ create table if not exists board_external_messages (
|
|
|
74
74
|
message_id bigint not null
|
|
75
75
|
);
|
|
76
76
|
--> statement-breakpoint
|
|
77
|
-
|
|
77
|
+
drop index if exists board_messages_recent;
|
|
78
78
|
--> statement-breakpoint
|
|
79
79
|
drop function if exists board_apply(text, bigint, boolean, jsonb, jsonb, jsonb);
|
|
80
80
|
--> statement-breakpoint
|
package/sql/postgres/schema.sql
CHANGED
|
@@ -380,7 +380,7 @@ create table if not exists board_external_messages (
|
|
|
380
380
|
message_id bigint not null
|
|
381
381
|
);
|
|
382
382
|
--> statement-breakpoint
|
|
383
|
-
|
|
383
|
+
drop index if exists board_messages_recent;
|
|
384
384
|
--> statement-breakpoint
|
|
385
385
|
drop function if exists board_apply(text, bigint, boolean, jsonb, jsonb, jsonb);
|
|
386
386
|
--> statement-breakpoint
|
package/dist/events-qf5XG5VQ.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { d as LabelKey, p as Labels } from "./schemas-DBmUO6pr.mjs";
|
|
2
|
-
import { n as ExternalEvent, r as ExternalLocation } from "./external-D3p5T57R.mjs";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
//#region src/sync/events.ts
|
|
5
|
-
const EventBase = z.object({
|
|
6
|
-
id: z.string().min(1),
|
|
7
|
-
platform: z.string().min(1),
|
|
8
|
-
key: z.string().min(1),
|
|
9
|
-
...ExternalLocation.shape,
|
|
10
|
-
raw: z.unknown()
|
|
11
|
-
});
|
|
12
|
-
const eventTypes = {
|
|
13
|
-
message: "message",
|
|
14
|
-
edited: "message.edited",
|
|
15
|
-
deleted: "message.deleted",
|
|
16
|
-
reaction: "reaction.added",
|
|
17
|
-
lifecycle: "lifecycle"
|
|
18
|
-
};
|
|
19
|
-
function envelope(platform, key, event, raw, id) {
|
|
20
|
-
return {
|
|
21
|
-
...event,
|
|
22
|
-
...EventBase.parse({
|
|
23
|
-
id,
|
|
24
|
-
platform,
|
|
25
|
-
key,
|
|
26
|
-
channel: event.channel,
|
|
27
|
-
thread: event.thread,
|
|
28
|
-
subthread: "subthread" in event ? event.subthread : null,
|
|
29
|
-
raw
|
|
30
|
-
}),
|
|
31
|
-
type: event.kind === "lifecycle" ? event.type : event.kind === "reaction" && !event.added ? "reaction.removed" : eventTypes[event.kind]
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
function eventSchema(platform, messageType) {
|
|
35
|
-
const [message, edited, deleted, reaction, lifecycle] = ExternalEvent.options;
|
|
36
|
-
const common = {
|
|
37
|
-
...EventBase.shape,
|
|
38
|
-
platform: z.literal(platform)
|
|
39
|
-
};
|
|
40
|
-
return z.discriminatedUnion("kind", [
|
|
41
|
-
message.extend({
|
|
42
|
-
...common,
|
|
43
|
-
type: z.literal(messageType)
|
|
44
|
-
}),
|
|
45
|
-
edited.extend({
|
|
46
|
-
...common,
|
|
47
|
-
type: z.literal("message.edited")
|
|
48
|
-
}),
|
|
49
|
-
deleted.extend({
|
|
50
|
-
...common,
|
|
51
|
-
type: z.literal("message.deleted")
|
|
52
|
-
}),
|
|
53
|
-
reaction.extend({
|
|
54
|
-
...common,
|
|
55
|
-
type: z.enum(["reaction.added", "reaction.removed"])
|
|
56
|
-
}),
|
|
57
|
-
lifecycle.extend(common)
|
|
58
|
-
]);
|
|
59
|
-
}
|
|
60
|
-
const SyncOverrides = z.object({
|
|
61
|
-
root: z.custom((value) => typeof value === "function").optional(),
|
|
62
|
-
text: z.string().optional(),
|
|
63
|
-
labels: Labels.optional(),
|
|
64
|
-
unset: z.array(LabelKey).optional(),
|
|
65
|
-
noSync: z.boolean().optional()
|
|
66
|
-
}).strict();
|
|
67
|
-
function immutable(value) {
|
|
68
|
-
if (value && typeof value === "object") {
|
|
69
|
-
for (const item of Object.values(value)) immutable(item);
|
|
70
|
-
Object.freeze(value);
|
|
71
|
-
}
|
|
72
|
-
return value;
|
|
73
|
-
}
|
|
74
|
-
//#endregion
|
|
75
|
-
export { immutable as a, eventSchema as i, SyncOverrides as n, envelope as r, EventBase as t };
|
package/dist/sync-kapso.mjs
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { s as channels } from "./provider-BaVmi4Hi.mjs";
|
|
2
|
-
import { i as eventSchema, r as envelope } from "./events-qf5XG5VQ.mjs";
|
|
3
|
-
import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-COtsU5jB.mjs";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
//#region src/sync/kapso.ts
|
|
6
|
-
const decode = (id) => id.split(":").map((part) => Buffer.from(part, "base64url").toString("utf8")).join(":");
|
|
7
|
-
const encode = (id) => id.split(":").map((part) => Buffer.from(part, "utf8").toString("base64url")).join(":");
|
|
8
|
-
const WhatsAppEvent = eventSchema("whatsapp", "message");
|
|
9
|
-
/**
|
|
10
|
-
* Syncs WhatsApp numbers on Kapso with the board: `sync` is what each event
|
|
11
|
-
* does, both ways, like `numbers({ '16315558151': 'support' })`. Each Kapso
|
|
12
|
-
* conversation is a thread. WhatsApp has no threads in a number, so a board
|
|
13
|
-
* thread never starts one: a board root in a mirrored channel stays on the board.
|
|
14
|
-
*/
|
|
15
|
-
function kapso(options, sync) {
|
|
16
|
-
return new KapsoProvider(options, sync);
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Routes each event by its WhatsApp number. Keys are phone number IDs (`'16315558151'`), not
|
|
20
|
-
* the numbers; values are a board channel to `mirror`, or a sync function.
|
|
21
|
-
*/
|
|
22
|
-
const numbers = (map) => channels(map);
|
|
23
|
-
var KapsoProvider = class extends ChatSyncProvider {
|
|
24
|
-
constructor(options, sync) {
|
|
25
|
-
if (typeof options.markRead !== "function") throw new Error("kapso requires a public markRead operation for cross-instance read and typing");
|
|
26
|
-
const fileIds = (raw) => (raw?.responses ?? []).flatMap((response) => response.messages?.[0]?.id ?? []);
|
|
27
|
-
super({
|
|
28
|
-
fileIds,
|
|
29
|
-
platform: "whatsapp",
|
|
30
|
-
adapter: options.adapter,
|
|
31
|
-
canStartThreads: false,
|
|
32
|
-
async uploadFiles(thread, files) {
|
|
33
|
-
const sent = await options.adapter.postMessage(thread, {
|
|
34
|
-
markdown: "",
|
|
35
|
-
files
|
|
36
|
-
});
|
|
37
|
-
return {
|
|
38
|
-
message: sent.id,
|
|
39
|
-
files: fileIds(sent.raw)
|
|
40
|
-
};
|
|
41
|
-
},
|
|
42
|
-
refusesFiles: (error) => error?.code === "VALIDATION_ERROR",
|
|
43
|
-
async resolveChannel(key) {
|
|
44
|
-
if (!/^\d+$/.test(key)) throw new Error(`${key} isn't a phone number ID; use the number's ID from Kapso, not the number`);
|
|
45
|
-
return key;
|
|
46
|
-
},
|
|
47
|
-
fromChatId: decode,
|
|
48
|
-
toChatId: encode,
|
|
49
|
-
singleReaction: true,
|
|
50
|
-
async startTyping(thread, latest) {
|
|
51
|
-
if (!latest) return options.adapter.startTyping(thread);
|
|
52
|
-
const [phoneNumberId] = decode(thread.slice(thread.indexOf(":") + 1)).split(":");
|
|
53
|
-
await options.markRead({
|
|
54
|
-
phoneNumberId,
|
|
55
|
-
messageId: latest,
|
|
56
|
-
typingIndicator: { type: "text" }
|
|
57
|
-
});
|
|
58
|
-
},
|
|
59
|
-
groupThread: (thread) => thread,
|
|
60
|
-
isRoot: () => false,
|
|
61
|
-
context: () => null,
|
|
62
|
-
markdown(message) {
|
|
63
|
-
const text = markdownOf(message);
|
|
64
|
-
const transcript = message.raw?.kapso?.transcript?.text;
|
|
65
|
-
return [text, typeof transcript === "string" && transcript.trim() && transcript !== text ? `> ${transcript.trim()}` : null].filter(Boolean).join("\n\n");
|
|
66
|
-
},
|
|
67
|
-
async resolveTarget(target) {
|
|
68
|
-
const parts = target.split(":");
|
|
69
|
-
if (!/^\d+$/.test(parts[0]) || parts.length !== 1 && parts.length !== 3) throw new Error(`invalid Kapso target: ${target}`);
|
|
70
|
-
return {
|
|
71
|
-
target,
|
|
72
|
-
channel: parts[0],
|
|
73
|
-
kind: parts.length === 1 ? "channel" : "thread"
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
}, sync);
|
|
77
|
-
}
|
|
78
|
-
eventId(key, input) {
|
|
79
|
-
if (input.kind !== "reaction") return super.eventId(key, input);
|
|
80
|
-
const reaction = input.reaction;
|
|
81
|
-
const stamp = z.looseObject({ id: z.string().min(1) }).parse(reaction.raw).id;
|
|
82
|
-
return `reaction:${key}:${this.toNative(reaction.threadId)}|${reaction.messageId}|${reaction.rawEmoji}|${reaction.user.userId}|${reaction.added}|${stamp}`;
|
|
83
|
-
}
|
|
84
|
-
event(context) {
|
|
85
|
-
return WhatsAppEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id));
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
//#endregion
|
|
89
|
-
export { KapsoProvider, WhatsAppEvent, kapso, numbers };
|
package/dist/sync.chat.d.mts
DELETED
package/dist/sync.chat.mjs
DELETED
package/dist/users-yryHKFMo.mjs
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
//#region src/sync/users.ts
|
|
2
|
-
const USER_TOKENS = Symbol.for("board.userTokens");
|
|
3
|
-
/**
|
|
4
|
-
* Gives an adapter people's tokens, for your own OAuth: `slack({ adapter:
|
|
5
|
-
* withUserTokens(createSlackAdapter(...), { authorize, token }) }, sync)`. A
|
|
6
|
-
* Connect connector name (`slack('slack/acme', sync)`) does this for you.
|
|
7
|
-
*/
|
|
8
|
-
function withUserTokens(adapter, tokens) {
|
|
9
|
-
Object.defineProperty(adapter, USER_TOKENS, {
|
|
10
|
-
value: tokens,
|
|
11
|
-
enumerable: false,
|
|
12
|
-
configurable: true
|
|
13
|
-
});
|
|
14
|
-
return adapter;
|
|
15
|
-
}
|
|
16
|
-
const userTokensOf = (adapter) => adapter[USER_TOKENS] ?? null;
|
|
17
|
-
/** Vercel Connect's user tokens for `connector`: the subject is the board's principal, so Connect binds the account to it. */
|
|
18
|
-
function connectUserTokens(connector) {
|
|
19
|
-
const subject = (principal) => ({ subject: {
|
|
20
|
-
type: "user",
|
|
21
|
-
id: principal
|
|
22
|
-
} });
|
|
23
|
-
return {
|
|
24
|
-
async authorize(principal, options = {}) {
|
|
25
|
-
const { startAuthorization } = await import("@vercel/connect");
|
|
26
|
-
return (options.callback ? await startAuthorization(connector, subject(principal), { callbackUrl: options.callback }) : await startAuthorization(connector, subject(principal))).url;
|
|
27
|
-
},
|
|
28
|
-
async token(principal) {
|
|
29
|
-
const { getToken, NoValidTokenError, UserAuthorizationRequiredError } = await import("@vercel/connect");
|
|
30
|
-
try {
|
|
31
|
-
return await getToken(connector, subject(principal));
|
|
32
|
-
} catch (error) {
|
|
33
|
-
if (error instanceof UserAuthorizationRequiredError || error instanceof NoValidTokenError) return null;
|
|
34
|
-
const code = error.code;
|
|
35
|
-
if (code === "user_authorization_required" || code === "no_token") return null;
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
//#endregion
|
|
42
|
-
export { userTokensOf as n, withUserTokens as r, connectUserTokens as t };
|
|
@@ -1,152 +0,0 @@
|
|
|
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 }, sync)` takes
|
|
13
|
-
[Kapso's Chat SDK adapter](https://chat-sdk.dev/adapters/vendor-official/kapso),
|
|
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:
|
|
17
|
-
|
|
18
|
-
```ts app/api/board/[[...path]]/route.ts
|
|
19
|
-
import { board } from '@vercel/board'
|
|
20
|
-
import { a2 } from '@vercel/board/backend-a2'
|
|
21
|
-
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
22
|
-
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
23
|
-
import { kapso, numbers } from '@vercel/board/sync-kapso'
|
|
24
|
-
import { createKapsoAdapter } from '@kapso/chat-adapter'
|
|
25
|
-
import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'
|
|
26
|
-
|
|
27
|
-
const client = new WhatsAppClient({
|
|
28
|
-
baseUrl: 'https://api.kapso.ai/meta/whatsapp',
|
|
29
|
-
kapsoApiKey: process.env.KAPSO_API_KEY!,
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
const backend = a2({
|
|
33
|
-
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
34
|
-
scheduler: vercelQueues({ topic: 'board' }),
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const team = board({
|
|
38
|
-
backend,
|
|
39
|
-
sync: {
|
|
40
|
-
kapso: kapso(
|
|
41
|
-
{
|
|
42
|
-
adapter: createKapsoAdapter({ client }),
|
|
43
|
-
markRead: (input) => client.messages.markRead(input),
|
|
44
|
-
},
|
|
45
|
-
numbers({ '16315558151': 'support' }),
|
|
46
|
-
),
|
|
47
|
-
},
|
|
48
|
-
})
|
|
49
|
-
export const GET = team.fetch, POST = team.fetch
|
|
50
|
-
export const { scheduler } = backend
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
Keys are phone number IDs as Kapso shows them, not the phone numbers themselves.
|
|
54
|
-
A key that isn't one is skipped with a warning that says so.
|
|
55
|
-
|
|
56
|
-
`markRead` is required at setup. The adapter only remembers messages whose webhook
|
|
57
|
-
reached its own instance, so typing and read receipts go through this operation
|
|
58
|
-
instead, which works from any serverless instance.
|
|
59
|
-
|
|
60
|
-
| Thing | ID |
|
|
61
|
-
| --- | --- |
|
|
62
|
-
| A business number, the channel | `kapso:16315558151` |
|
|
63
|
-
| A Kapso conversation, a thread | `kapso:16315558151:<WhatsApp ID>:<conversation ID>` |
|
|
64
|
-
|
|
65
|
-
Kapso's adapter encodes each part of its IDs; the board writes them decoded, so
|
|
66
|
-
targets and `vb inbox` read as above.
|
|
67
|
-
|
|
68
|
-
## Conversations are threads
|
|
69
|
-
|
|
70
|
-
WhatsApp has no threads, so a contact's messages group into Kapso's conversations.
|
|
71
|
-
One starts when they write, and ends after a day of quiet or when someone closes it
|
|
72
|
-
in Kapso. Writing again after that starts a new board thread.
|
|
73
|
-
|
|
74
|
-
- **Authors** are `whatsapp:user:<WhatsApp ID>`, named by their WhatsApp profile
|
|
75
|
-
name.
|
|
76
|
-
- **The board never starts a conversation.** A number has no threads to start, so
|
|
77
|
-
a root posted in a mirrored channel stays on the board.
|
|
78
|
-
- **Board messages post from the business number,** with the author's name in bold
|
|
79
|
-
(`*julian*: refunded`). To post the text alone, wrap the function in
|
|
80
|
-
`render(({ message }) => message.text, …)`; see [render](./02-functions.md#render).
|
|
81
|
-
|
|
82
|
-
## Reply within the window
|
|
83
|
-
|
|
84
|
-
WhatsApp takes free-form messages only within 24 hours of the contact's last one.
|
|
85
|
-
A board reply after that fails, and is recorded as `message.sync_failed`, which
|
|
86
|
-
`vb read` shows under it:
|
|
87
|
-
|
|
88
|
-
```text
|
|
89
|
-
✗ didn't reach kapso:16315558151:51987654321:conv_123: <WhatsApp's error>
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Templates aren't synced; send them with `@kapso/whatsapp-cloud-api`.
|
|
93
|
-
|
|
94
|
-
## Reactions
|
|
95
|
-
|
|
96
|
-
WhatsApp keeps one reaction per person per message. A contact's new reaction
|
|
97
|
-
replaces their old one on the board, and removing it clears it. The business number
|
|
98
|
-
has one too: the board's reactions on a WhatsApp message show there as the latest
|
|
99
|
-
one added, and removing any of them clears it.
|
|
100
|
-
|
|
101
|
-
## Media
|
|
102
|
-
|
|
103
|
-
An image, video, voice note, or document becomes an attachment on the board,
|
|
104
|
-
fetched through Kapso, with its caption as the text. A voice note's transcript is
|
|
105
|
-
quoted under it when Kapso has one. A board without a blob store keeps no files:
|
|
106
|
-
each is a line in the text instead, as on [Slack](./03-slack.md#files).
|
|
107
|
-
|
|
108
|
-
A board message's files follow its text to WhatsApp, each as its own message.
|
|
109
|
-
WhatsApp can't delete them, so they stay when the board message is deleted.
|
|
110
|
-
|
|
111
|
-
## Typing
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
// in the route that mounts the board with its `sync`:
|
|
115
|
-
await team.typing('812')
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Shows "typing…" to the contact and marks their latest message read (blue ticks).
|
|
119
|
-
WhatsApp shows no status text, so a status is ignored.
|
|
120
|
-
|
|
121
|
-
## Route by function
|
|
122
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
```ts
|
|
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}` })
|
|
133
|
-
},
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
See [sync functions](./02-functions.md) for the event and the overrides.
|
|
137
|
-
|
|
138
|
-
## What doesn't sync
|
|
139
|
-
|
|
140
|
-
- Edits and deletes, which WhatsApp can't do for recipients.
|
|
141
|
-
- Button and list replies.
|
|
142
|
-
|
|
143
|
-
## Setup
|
|
144
|
-
|
|
145
|
-
1. In Kapso, create an API key and copy the number's phone number ID.
|
|
146
|
-
2. Add a webhook with the URL `https://<host>/api/board/webhooks/kapso`, a secret
|
|
147
|
-
key, and the `whatsapp.message.received` event.
|
|
148
|
-
3. Set `KAPSO_API_KEY` and `KAPSO_WEBHOOK_SECRET`, or pass `webhookSecret` to
|
|
149
|
-
`createKapsoAdapter`. Without the secret, the adapter rejects every webhook.
|
|
150
|
-
|
|
151
|
-
The webhook path ends in the entry's key, so an entry keyed `kapso` listens at
|
|
152
|
-
`/webhooks/kapso`.
|