@vercel/board 0.0.0 → 0.15.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.
Files changed (126) hide show
  1. package/README.md +180 -0
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +168 -1
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,1449 @@
1
+ import { a as typeOf, i as fileSize, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
+ import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
3
+ import { o as derivedId } from "./watch-spec-W0c7ypHJ.mjs";
4
+ import { t as canonicalEmoji } from "./emoji-CTLpvH-A.mjs";
5
+ import { r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
6
+ import { s as replaceMentions } from "./actors-rdN4dDTU.mjs";
7
+ import { i as DroppedError } from "./targets-ojFEXEyN.mjs";
8
+ import { c as isThreadTarget, r as externalMessageKey } from "./reducers-mUPFuFJV.mjs";
9
+ import { _ as waitUntil, o as channelOf, t as afterRetry } from "./attempts-BQUp9MKO.mjs";
10
+ import { i as UnreadableError, l as immutable, n as ResolvedTarget, o as SyncOverrides, t as ReceivedEvent } from "./provider-CuewcDNZ.mjs";
11
+ import { t as displayText } from "./bots-CGkO76qH.mjs";
12
+ //#region src/sync/engine.ts
13
+ /** `files`: a message with files may have no text. */
14
+ const clampText = (raw, files = false) => {
15
+ const text = toStorable(raw);
16
+ const trimmed = text.trim() === "" ? files ? "" : "(no text)" : text;
17
+ return trimmed.length > 1e4 ? `${trimmed.slice(0, MESSAGE_TEXT_MAX - 1).toWellFormed()}…` : trimmed;
18
+ };
19
+ /** A platform file the board couldn't bring over gets a line in the text instead. */
20
+ const fileNote = (file, why, name = file.name ?? "a file") => `📎 ${name}${file.size !== void 0 ? ` (${fileSize(file.size)})` : ""} didn't come over: ${why}`;
21
+ /** What a board that keeps no files answers for every file route. */
22
+ const keepsNoFiles = (error) => error instanceof HttpError && error.code === "no_files";
23
+ /**
24
+ * A platform file on a board that keeps no files: a link to where it lives on the platform, which
25
+ * its readers there can open, or its name when the platform gave no URL.
26
+ */
27
+ const fileLink = (file) => {
28
+ const name = file.name ?? "a file";
29
+ if (!file.url || !/^https?:\/\//.test(file.url)) return fileNote(file, "this board keeps no files");
30
+ const label = name.replace(/[[\]\\]/g, (char) => `\\${char}`);
31
+ const url = file.url.replace(/[ ()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
32
+ return fileNote(file, "this board keeps no files", `[${label}](${url})`);
33
+ };
34
+ /** A platform message's text with its file notes after it. */
35
+ const withNotes = (text, notes) => notes.length ? `${text.trim() ? `${text}\n\n` : ""}${notes.join("\n")}` : text;
36
+ /** The file notes in a message's text, so a platform edit (which only knows the text it shows) keeps them. */
37
+ const notesIn = (text) => text.split("\n").filter((line) => /^📎 .* didn't come over: /u.test(line));
38
+ const linkKey = (link) => JSON.stringify([
39
+ link.url,
40
+ link.message,
41
+ link.snapshot,
42
+ link.unavailable
43
+ ]);
44
+ /** Whether two link lists say the same, whatever order their keys were written in. */
45
+ const sameLinks = (a, b) => a.length === b.length && a.every((link, i) => linkKey(link) === linkKey(b[i]));
46
+ /**
47
+ * A one-time code for linking an account: a platform message that is only a live
48
+ * code is claimed, not imported.
49
+ */
50
+ const CODE = /^\s*`?(vb-[a-z0-9]{4}-[a-z0-9]{4})`?\s*$/i;
51
+ /**
52
+ * How long a board message written by someone who posts as themselves may still be
53
+ * on its way out: their copy comes back through the webhook looking like any of
54
+ * their messages, and until its receipt lands, it's held back rather than imported twice.
55
+ */
56
+ const ECHO_WINDOW_MS = 6e4;
57
+ const ECHO_ATTEMPTS = 6;
58
+ /** Tries per file before it's noted instead: a retry repeats the whole message, so keep it short. */
59
+ const FILE_ATTEMPTS = 3;
60
+ /** How long a webhook waits for the board's tables before it lets an event into the inbox anyway: platforms resend after about 3 seconds. */
61
+ const GATE_WAIT_MS = 2e3;
62
+ /**
63
+ * A2 dead-letters an event after 10 failures, and a dead letter stops its lane,
64
+ * which is the thread's: nothing after it would sync. So a delivery retries a
65
+ * few times, then records a `failed` receipt and lets the thread move on.
66
+ */
67
+ const DELIVERY_ATTEMPTS = 5;
68
+ /** How long one pull of the inbox runs before it leaves the rest to the next. */
69
+ const PULL_MS = 24e4;
70
+ /** A signal that never aborts, for the engine's own calls. */
71
+ const NEVER = new AbortController().signal;
72
+ const jsonSafe = (value) => {
73
+ try {
74
+ return JSON.parse(JSON.stringify(value ?? null));
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ /** Plain JSON with every string, keys too, as the board keeps it: platforms send what Postgres can't store. */
80
+ const storableJson = (value) => {
81
+ if (typeof value === "string") return toStorable(value);
82
+ if (Array.isArray(value)) return value.map(storableJson);
83
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [toStorable(key), storableJson(item)]));
84
+ return value;
85
+ };
86
+ /** A platform error's code (`not_in_channel`) when it has one, else its message. */
87
+ const describe = (error) => {
88
+ const code = error?.data?.error;
89
+ const text = typeof code === "string" ? code : error instanceof Error ? error.message : String(error);
90
+ return toStorable(text.slice(0, 500)) || "unknown error";
91
+ };
92
+ /** A board error that says what wasn't there. */
93
+ const missing = (error) => error instanceof HttpError && error.status === 404;
94
+ /**
95
+ * Two-way sync between a board and its platforms, run by board(): webhooks come in through its
96
+ * adapters and land in the backend's inbox, and what the board's subscription delivers goes out
97
+ * to them. It reads and writes only through the backend's protocol methods, as `caller`.
98
+ */
99
+ function createSyncEngine(entries) {
100
+ let bound = null;
101
+ const host = () => {
102
+ if (!bound) throw new Error("sync used before board() bound it");
103
+ return bound;
104
+ };
105
+ /** Sync's own caller: on a2(), `board:sync`; on http(), this deployment itself. */
106
+ const me = {
107
+ self: "sync",
108
+ origin: { via: { kind: "engine" } }
109
+ };
110
+ /** One protocol method, as sync. */
111
+ function call(method, input, signal = NEVER, caller = me) {
112
+ const backend = host().backend;
113
+ return backend[method].call(backend, caller, input, signal);
114
+ }
115
+ const platformOf = (key) => {
116
+ const platform = entries[key];
117
+ if (!platform) throw badRequest(`no sync entry named ${key}`);
118
+ return platform;
119
+ };
120
+ const keyOf = (target) => target.slice(0, target.indexOf(":"));
121
+ /** A target of this sync's own: another sync on the board (its own, or one elsewhere) delivers the rest. */
122
+ const ours = (target) => entries[keyOf(target)] !== void 0;
123
+ const native = (external) => external.slice(external.indexOf(":") + 1);
124
+ /** A thread's sync, or null when the board has no such thread. */
125
+ async function syncOf(thread) {
126
+ try {
127
+ return await call("syncThread", { thread });
128
+ } catch (error) {
129
+ if (missing(error)) return null;
130
+ throw error;
131
+ }
132
+ }
133
+ /** A board thread: its root and every reply, deleted ones included. */
134
+ async function threadOf(root) {
135
+ try {
136
+ const { message, replies } = await call("message", { id: root });
137
+ return [message, ...replies];
138
+ } catch (error) {
139
+ if (missing(error)) return [];
140
+ throw error;
141
+ }
142
+ }
143
+ /** One board message as it is now. */
144
+ async function messageOf(id) {
145
+ return (await threadOf(id)).find((message) => message.id === id) ?? null;
146
+ }
147
+ async function copiesOf(ids) {
148
+ if (ids.length === 0) return {};
149
+ return (await call("syncCopies", { message: [...new Set(ids)] })).copies;
150
+ }
151
+ const find = (query, signal) => call("syncFind", query, signal);
152
+ /** The actors sync renders with, as the board has them now. */
153
+ async function actorsNow() {
154
+ const { actors } = await call("actors", {});
155
+ const state = {
156
+ byId: {},
157
+ links: {}
158
+ };
159
+ for (const { person, ...actor } of actors) {
160
+ state.byId[actor.id] = {
161
+ id: actor.id,
162
+ kind: actor.kind,
163
+ handle: actor.handle,
164
+ name: actor.name,
165
+ avatar: actor.avatar
166
+ };
167
+ if (person) state.links[actor.id] = person.id;
168
+ }
169
+ return state;
170
+ }
171
+ const linkedTo = (actors, person) => Object.entries(actors.links).filter(([, to]) => to === person).map(([actor]) => actor);
172
+ /** How long a key that didn't resolve (say, a Slack channel that doesn't exist yet) waits before another try. */
173
+ const RETRY_MS = 6e4;
174
+ const resolved = /* @__PURE__ */ new Map();
175
+ /**
176
+ * External channel → board channel or inbound function, for one entry. Each key
177
+ * resolves on its own: one that can't is logged and skipped, and the rest sync.
178
+ */
179
+ const mappings = (key) => {
180
+ const cached = resolved.get(key);
181
+ if (cached && (cached.complete || Date.now() - cached.at < RETRY_MS)) return cached.map;
182
+ const platform = platformOf(key);
183
+ const pairs = Object.entries(platform.mappings);
184
+ const entry = {
185
+ complete: true,
186
+ at: Date.now(),
187
+ map: Promise.resolve(/* @__PURE__ */ new Map())
188
+ };
189
+ entry.map = Promise.allSettled(pairs.map(([raw]) => platform.resolveChannel(raw))).then((results) => {
190
+ const map = /* @__PURE__ */ new Map();
191
+ results.forEach((result, i) => {
192
+ const [raw, target] = pairs[i];
193
+ if (result.status === "fulfilled") map.set(`${key}:${result.value}`, target);
194
+ else {
195
+ entry.complete = false;
196
+ console.warn(`[board] sync ${key}: skipping ${raw}: ${result.reason.message}`);
197
+ }
198
+ });
199
+ return map;
200
+ });
201
+ resolved.set(key, entry);
202
+ return entry.map;
203
+ };
204
+ /** A synced platform's user with this email, for `@[ada@vercel.com]`. */
205
+ async function findByEmail(email) {
206
+ for (const platform of Object.values(entries)) {
207
+ const author = await platform.findByEmail(email).catch(() => null);
208
+ if (author) return {
209
+ id: author.id,
210
+ kind: "external",
211
+ handle: author.handle,
212
+ name: author.name,
213
+ avatar: author.avatar
214
+ };
215
+ }
216
+ return null;
217
+ }
218
+ /** External channels a new root in this board channel starts threads in. */
219
+ async function configTargets(channel) {
220
+ const targets = [];
221
+ for (const [key, platform] of Object.entries(entries)) {
222
+ if (!platform.canStartThreads) continue;
223
+ for (const [external, target] of await mappings(key)) if (target.channel === channel) targets.push({
224
+ target: external,
225
+ kind: "channel"
226
+ });
227
+ }
228
+ return targets;
229
+ }
230
+ /** Validates a requested target and resolves names (`slack:#prs`) to IDs. */
231
+ async function normalizeTarget(target) {
232
+ const at = target.indexOf(":");
233
+ if (at <= 0) throw badRequest(`sync targets look like <key>:<channel or thread>, got: ${target}`);
234
+ const key = target.slice(0, at);
235
+ const platform = platformOf(key);
236
+ try {
237
+ const resolved = ResolvedTarget.parse(await platform.resolveTarget(target.slice(at + 1)));
238
+ if (resolved.kind === "channel" && !platform.canStartThreads) throw new Error(`${platform.platform} cannot start threads; use an existing thread`);
239
+ return {
240
+ target: `${key}:${resolved.target}`,
241
+ kind: resolved.kind
242
+ };
243
+ } catch (error) {
244
+ throw badRequest(`can't sync to ${target}: ${error.message}`);
245
+ }
246
+ }
247
+ async function intake(key, received) {
248
+ const { event, raw, dedupe } = ReceivedEvent.parse(storableJson({
249
+ ...received,
250
+ raw: jsonSafe(received.raw)
251
+ }));
252
+ for (const location of [
253
+ event.channel,
254
+ event.thread,
255
+ "subthread" in event ? event.subthread : null
256
+ ]) if (location !== null && !location.startsWith(`${key}:`)) throw badRequest(`provider locations must belong to sync entry ${key}`);
257
+ if (!(await mappings(key)).has(event.channel) && !await syncsThread(event.thread)) return;
258
+ await call("inboxPush", {
259
+ key,
260
+ events: [{
261
+ id: dedupe,
262
+ event,
263
+ raw
264
+ }]
265
+ });
266
+ }
267
+ /**
268
+ * Whether the board syncs a platform thread outside its mapped channels. A receipt or a
269
+ * request that just synced it may not be in its tables yet, so a miss waits for them;
270
+ * if they're still catching up, the inbox decides, since it waits as long as it takes.
271
+ */
272
+ async function syncsThread(thread) {
273
+ try {
274
+ return (await find({ thread }, AbortSignal.timeout(GATE_WAIT_MS))).thread !== null;
275
+ } catch (error) {
276
+ const name = error?.name;
277
+ if (name === "TimeoutError" || name === "AbortError") return true;
278
+ throw error;
279
+ }
280
+ }
281
+ async function handleWebhook(key, request) {
282
+ const platform = entries[key];
283
+ if (!platform) throw notFound(`no sync entry named ${key}`);
284
+ let response;
285
+ try {
286
+ response = await platform.receive(key, request, (received) => intake(key, received));
287
+ } catch (error) {
288
+ console.error("[board] intake failed", error);
289
+ return new Response("intake failed", { status: 500 });
290
+ }
291
+ const pulling = pull([key]).catch((error) => console.error(`[board] couldn't pull ${key}'s inbox:`, describe(error)));
292
+ waitUntil(pulling);
293
+ return response;
294
+ }
295
+ /**
296
+ * Pulls what this board's sync queued in the backend's inbox, runs it, and acks it, until
297
+ * nothing's left or this run's time is up. A platform thread's events come one at a time.
298
+ */
299
+ async function pull(keys = Object.keys(entries)) {
300
+ let ran = 0;
301
+ const until = Date.now() + PULL_MS;
302
+ for (const key of keys) while (Date.now() < until) {
303
+ const { items } = await call("inboxNext", {
304
+ key,
305
+ limit: 10
306
+ });
307
+ if (items.length === 0) break;
308
+ await Promise.all(items.map((item) => runItem(key, item)));
309
+ ran += items.length;
310
+ }
311
+ return ran;
312
+ }
313
+ async function runItem(key, item) {
314
+ let error;
315
+ try {
316
+ await inbound(key, {
317
+ event: item.event,
318
+ raw: item.raw
319
+ }, {
320
+ id: item.id,
321
+ attempt: item.attempt + 1
322
+ });
323
+ } catch (failure) {
324
+ console.error(`[board] sync ${key} couldn't import ${item.id}:`, describe(failure));
325
+ error = describe(failure);
326
+ }
327
+ await call("inboxAck", {
328
+ key,
329
+ id: item.id,
330
+ lease: item.lease,
331
+ ...error !== void 0 ? { error } : {}
332
+ }).catch((failure) => console.error(`[board] couldn't ack an inbox event:`, describe(failure)));
333
+ }
334
+ /**
335
+ * What a platform message's links point to, for a message in board channel `channel`. A linked
336
+ * message the board has (it was synced) in that same channel is that board message; otherwise
337
+ * it's what the platform sent about it (Slack's unfurl, which the platform checked against who
338
+ * posted the link), what an earlier version of this message had, or what the platform says when
339
+ * asked. A board copy in another channel is never one: sync reads every channel, and the link's
340
+ * readers may not read that one. A link nobody can read says why.
341
+ */
342
+ async function resolveLinks(platform, links, channel, previous = []) {
343
+ if (links.length === 0) return [];
344
+ const resolved = [];
345
+ for (const link of links) {
346
+ if (resolved.some((done) => done.url === link.url)) continue;
347
+ const id = (await find({
348
+ thread: link.thread,
349
+ message: link.message
350
+ })).message;
351
+ const copy = id === null ? null : await messageOf(id);
352
+ if (copy && !copy.deleted && copy.channel === channel) {
353
+ resolved.push({
354
+ url: link.url,
355
+ message: copy.id,
356
+ unavailable: null,
357
+ snapshot: {
358
+ author: copy.author?.name ?? copy.by.actor,
359
+ text: copy.text,
360
+ createdAt: copy.createdAt,
361
+ files: copy.attachments.map((file) => ({
362
+ name: file.name,
363
+ url: null
364
+ }))
365
+ }
366
+ });
367
+ continue;
368
+ }
369
+ if (link.snapshot) {
370
+ resolved.push({
371
+ url: link.url,
372
+ message: null,
373
+ snapshot: link.snapshot,
374
+ unavailable: null
375
+ });
376
+ continue;
377
+ }
378
+ const kept = previous.find((earlier) => earlier.url === link.url && earlier.snapshot && earlier.message === null);
379
+ if (kept) {
380
+ resolved.push(kept);
381
+ continue;
382
+ }
383
+ const fetched = await platform.fetchLink(link).catch((error) => ({
384
+ snapshot: null,
385
+ unavailable: describe(error)
386
+ }));
387
+ resolved.push({
388
+ url: link.url,
389
+ message: null,
390
+ snapshot: fetched.snapshot,
391
+ unavailable: fetched.snapshot ? null : (fetched.unavailable ?? "unknown").slice(0, 300)
392
+ });
393
+ }
394
+ return storableJson(resolved);
395
+ }
396
+ /**
397
+ * A platform message's files, onto the board: each is fetched through its
398
+ * adapter (Chat SDK's `rehydrateAttachment`, so any platform that has it works)
399
+ * and uploaded. A file that can't come over (too big, the platform refused)
400
+ * becomes a line in the text, and on a board that keeps no files, a line that
401
+ * links it where it lives; a failed fetch retries the message a few times first.
402
+ */
403
+ async function importFiles(platform, files, attempt) {
404
+ const attachments = [];
405
+ const notes = [];
406
+ let noFiles = false;
407
+ for (const file of files.slice(0, 10)) {
408
+ if (noFiles) {
409
+ notes.push(fileLink(file));
410
+ continue;
411
+ }
412
+ if (file.size !== void 0 && file.size > 26214400) {
413
+ notes.push(fileNote(file, `over ${IMPORT_BYTES_MAX / 1024 / 1024} MB`));
414
+ continue;
415
+ }
416
+ let data;
417
+ try {
418
+ const fetched = await platform.fetchFile(file);
419
+ if (fetched === null) {
420
+ notes.push(fileNote(file, `${platform.platform} can't hand it over`));
421
+ continue;
422
+ }
423
+ data = fetched;
424
+ } catch (error) {
425
+ if (attempt < FILE_ATTEMPTS) throw error;
426
+ console.warn(`[board] couldn't fetch ${file.name ?? "a file"} from ${platform.platform}:`, describe(error));
427
+ notes.push(fileNote(file, `${platform.platform} wouldn't hand it over`));
428
+ continue;
429
+ }
430
+ if (data.byteLength > 26214400) {
431
+ notes.push(fileNote(file, `over ${IMPORT_BYTES_MAX / 1024 / 1024} MB`));
432
+ continue;
433
+ }
434
+ if (data.byteLength === 0) {
435
+ notes.push(fileNote(file, "it was empty"));
436
+ continue;
437
+ }
438
+ const name = file.name ?? `${file.type}`;
439
+ try {
440
+ attachments.push((await call("upload", {
441
+ data,
442
+ name,
443
+ type: file.mimeType || typeOf(name)
444
+ })).attachment);
445
+ } catch (error) {
446
+ if (error instanceof HttpError && (error.code === "too_large" || error.code === "no_direct_uploads")) {
447
+ notes.push(fileNote(file, `over ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`));
448
+ continue;
449
+ }
450
+ if (!keepsNoFiles(error)) throw error;
451
+ noFiles = true;
452
+ notes.push(fileLink(file));
453
+ }
454
+ }
455
+ for (const file of files.slice(10)) notes.push(fileNote(file, `a message carries at most 10`));
456
+ return {
457
+ attachments,
458
+ notes
459
+ };
460
+ }
461
+ /** One platform event from the inbox, onto the board. `id` is its ID there, which its writes derive theirs from. */
462
+ async function inbound(key, item, meta) {
463
+ const { event, raw } = item;
464
+ const platform = platformOf(key);
465
+ const mapping = (await mappings(key)).get(event.channel);
466
+ const linked = (await find({ thread: event.thread })).thread ?? void 0;
467
+ if (event.kind === "message") {
468
+ const copied = (await find({
469
+ thread: event.subthread ?? event.thread,
470
+ message: event.message
471
+ })).message;
472
+ if (copied !== null) {
473
+ const origin = (await messageOf(copied))?.origin;
474
+ if (!origin || origin.message !== event.message || (origin.subthread ?? origin.thread) !== (event.subthread ?? event.thread)) return;
475
+ } else if (platform.users && meta.attempt < ECHO_ATTEMPTS && await deliveringAsPerson(linked, event)) throw new Error(`holding ${event.message} until ${event.author.id}'s board message there has its receipt`);
476
+ const code = event.author.isBot ? void 0 : CODE.exec(event.text)?.[1];
477
+ if (code && await claimCode(key, event, code)) return;
478
+ }
479
+ const author = "author" in event ? event.author : {
480
+ id: `${platform.platform}:system`,
481
+ handle: platform.platform,
482
+ name: platform.platform,
483
+ avatar: null,
484
+ isBot: true
485
+ };
486
+ const board = host().client({
487
+ author,
488
+ key,
489
+ ...originOf(key, event)
490
+ }, meta.id);
491
+ let applied;
492
+ let active = true;
493
+ const context = {
494
+ id: meta.id,
495
+ key,
496
+ event,
497
+ raw,
498
+ board,
499
+ apply(channel, overrides = {}) {
500
+ if (!active) throw badRequest("the sync callback has finished");
501
+ if (applied) throw badRequest("an event can only be imported once per callback");
502
+ applied = applyIncoming(key, item, meta, ChannelPath.parse(channel), SyncOverrides.parse(overrides)).catch(async (error) => {
503
+ if (!(error instanceof DroppedError)) throw error;
504
+ const thread = (await find({ thread: event.thread })).thread;
505
+ return {
506
+ root: thread === null ? null : (await syncOf(thread))?.root ?? null,
507
+ message: null
508
+ };
509
+ });
510
+ return applied;
511
+ },
512
+ client(publicEvent) {
513
+ async function sync(first, second, overrides) {
514
+ if (!active) throw badRequest("the sync callback has finished");
515
+ if (typeof second === "string") return board.sync(first, second);
516
+ if (typeof first !== "string" || second !== publicEvent) throw badRequest("sync only accepts the current verified event");
517
+ return platform.sync(context, first, overrides);
518
+ }
519
+ return {
520
+ ...board,
521
+ sync
522
+ };
523
+ }
524
+ };
525
+ try {
526
+ if (mapping?.run) {
527
+ await mapping.run(context);
528
+ return;
529
+ }
530
+ const channel = mapping?.channel ?? (linked ? (await syncOf(linked))?.channel : void 0);
531
+ if (channel) await platform.sync(context, channel);
532
+ } finally {
533
+ active = false;
534
+ }
535
+ }
536
+ /**
537
+ * Where a platform event's writes come from, for the middleware, and how it answers
538
+ * there: a reply in the event's own platform thread.
539
+ */
540
+ function originOf(key, event) {
541
+ const platform = platformOf(key);
542
+ const thread = ("subthread" in event ? event.subthread : null) ?? event.thread;
543
+ return {
544
+ via: {
545
+ kind: "sync",
546
+ key,
547
+ platform: platform.platform,
548
+ channel: event.channel,
549
+ thread
550
+ },
551
+ respond: async (text) => {
552
+ await platform.post(native(thread), "thread", { markdown: text });
553
+ }
554
+ };
555
+ }
556
+ /**
557
+ * Whether a board message by the person behind `event`'s author (an account they
558
+ * signed in with) may be on its way there as them, without a receipt yet: then
559
+ * `event` could be that very copy. In a thread the board knows (`thread`), that's
560
+ * any of the person's messages still going to it; anywhere else, a new thread of
561
+ * theirs still going to `event`'s channel, whose platform thread the board learns
562
+ * only from the receipt.
563
+ */
564
+ async function deliveringAsPerson(thread, event) {
565
+ const person = (await find({ account: event.author.id })).person;
566
+ if (!person || person.method !== "consent") return false;
567
+ const now = Date.now();
568
+ const pending = (message, target, copies) => message.by.principal === person.id && message.by.profile === null && message.from === null && !message.deleted && !message.noSync && now - Date.parse(message.createdAt) < ECHO_WINDOW_MS && !copies[target] && !message.undelivered[target];
569
+ if (thread !== void 0) {
570
+ const synced = await syncOf(thread);
571
+ if (!synced) return false;
572
+ const targets = synced.targets.filter((target) => synced.external[target] === event.thread);
573
+ const messages = await threadOf(synced.root);
574
+ const copies = await copiesOf(messages.map((message) => message.id));
575
+ return messages.some((message) => targets.some((target) => pending(message, target, copies[message.id] ?? {})));
576
+ }
577
+ const { roots } = await call("syncRoots", {
578
+ by: person.id,
579
+ since: now - ECHO_WINDOW_MS
580
+ });
581
+ const copies = await copiesOf(roots.map((root) => root.id));
582
+ for (const root of roots) {
583
+ const synced = await syncOf(root.thread);
584
+ if (synced?.targets.includes(event.channel) && !synced.external[event.channel] && pending(root, event.channel, copies[root.id] ?? {})) return true;
585
+ }
586
+ return false;
587
+ }
588
+ /** A live code, posted from `event`'s account through the entry it was issued for: link them. */
589
+ async function claimCode(key, event, code) {
590
+ try {
591
+ await call("syncAccount", {
592
+ sync: key,
593
+ account: event.author,
594
+ code,
595
+ message: externalMessageKey(event.subthread ?? event.thread, event.message)
596
+ });
597
+ } catch (error) {
598
+ if (missing(error)) return false;
599
+ throw error;
600
+ }
601
+ await platformOf(key).react(native(event.subthread ?? event.thread), event.message, "check", true).catch((error) => console.warn(`[board] couldn't confirm a linking code on ${key}:`, describe(error)));
602
+ return true;
603
+ }
604
+ /** Whether entry `key` signs people in: its sign-in links the account, and lets the board post as them. */
605
+ const signsIn = (key) => platformOf(key).users !== null;
606
+ /**
607
+ * A sign-in on entry `key` for `subject`, the person a code was issued to: the platform's URL,
608
+ * which sends them to `callback` once they've approved.
609
+ */
610
+ async function signIn(key, subject, callback) {
611
+ const platform = platformOf(key);
612
+ if (!platform.users) throw badRequest(`${key} can't sign people in: link it with a code`);
613
+ return platform.users.authorize(subject, callback ? { callback } : {});
614
+ }
615
+ /** The account `subject` signed in with on entry `key`, or null while they haven't. */
616
+ async function signedIn(key, subject) {
617
+ const platform = platformOf(key);
618
+ if (!platform.users) throw badRequest(`${key} can't sign people in: link it with a code`);
619
+ const token = await platform.users.token(subject);
620
+ if (!token) return null;
621
+ const account = await platform.whoAmI(token);
622
+ if (!account.id.startsWith(`${platform.platform}:user:`)) throw badRequest(`${key} named an account that isn't a ${platform.platform} user`);
623
+ return account;
624
+ }
625
+ /** Links the account `subject` signed in with to them, by the code they carried through: null while they haven't. */
626
+ async function redeemSignIn(key, subject, code) {
627
+ const account = await signedIn(key, subject);
628
+ if (!account) return null;
629
+ return (await call("syncAccount", {
630
+ sync: key,
631
+ account,
632
+ code,
633
+ method: "consent"
634
+ })).link;
635
+ }
636
+ async function applyIncoming(key, item, meta, channel, overrides) {
637
+ const { event, raw } = item;
638
+ const source = originOf(key, event);
639
+ const platform = platformOf(key);
640
+ const threadKey = (await find({ thread: event.thread })).thread ?? void 0;
641
+ const synced = threadKey ? await syncOf(threadKey) : null;
642
+ const rootId = synced?.root;
643
+ const writeId = (n) => derivedId("board-inbound", meta.id, String(n));
644
+ const from = {
645
+ key,
646
+ channel: source.via.channel,
647
+ thread: source.via.thread
648
+ };
649
+ const screened = async (answer) => {
650
+ if (!answer.dropped) return answer.message;
651
+ for (const text of answer.dropped) await source.respond(text);
652
+ throw new DroppedError(answer.dropped);
653
+ };
654
+ const post = async ({ attachments = [], ...input }) => {
655
+ const where = input.channel ?? synced?.channel ?? channel;
656
+ const caller = input.re === void 0 ? {
657
+ ...me,
658
+ origin: {
659
+ ...me.origin,
660
+ config: await configTargets(where)
661
+ }
662
+ } : me;
663
+ return {
664
+ id: await screened(await call("syncImport", {
665
+ op: "post",
666
+ from: key,
667
+ source: from,
668
+ ...input,
669
+ origin: input.origin ?? null,
670
+ ...attachments.length ? { attachments: attachments.map((file) => file.id) } : {}
671
+ }, NEVER, caller)),
672
+ re: input.re ?? null
673
+ };
674
+ };
675
+ const result = (message) => ({
676
+ root: message.re ?? message.id,
677
+ message: message.id
678
+ });
679
+ const labels = (defaults = {}) => {
680
+ const merged = {
681
+ ...defaults,
682
+ ...overrides.labels
683
+ };
684
+ for (const key of overrides.unset ?? []) delete merged[key];
685
+ return merged;
686
+ };
687
+ const rootDraft = (draft) => {
688
+ if (!overrides.root) return draft;
689
+ const patch = overrides.root(immutable(structuredClone(draft)));
690
+ if (patch && typeof patch === "object" && "then" in patch) throw badRequest("root overrides must be synchronous");
691
+ const checked = SyncOverrides.pick({
692
+ text: true,
693
+ labels: true
694
+ }).parse(patch);
695
+ return {
696
+ ...draft,
697
+ text: checked.text ?? draft.text,
698
+ labels: {
699
+ ...draft.labels,
700
+ ...checked.labels
701
+ }
702
+ };
703
+ };
704
+ /** Labels for the thread's root, as `author`. */
705
+ const setLabels = (author, set, unset) => call("syncImport", {
706
+ op: "labels",
707
+ author,
708
+ from: key,
709
+ origin: { thread: event.thread },
710
+ set,
711
+ unset
712
+ });
713
+ const setExtraLabels = async (author, id) => {
714
+ if (!overrides.labels && !overrides.unset) return;
715
+ const root = await messageOf(id);
716
+ const set = Object.fromEntries(Object.entries(overrides.labels ?? {}).filter(([key, value]) => root?.labels[key] !== value));
717
+ const unset = (overrides.unset ?? []).filter((key) => root?.labels[key] !== void 0);
718
+ if (Object.keys(set).length || unset.length) await setLabels(author, set, unset);
719
+ };
720
+ switch (event.kind) {
721
+ case "message": {
722
+ const existing = (await find({
723
+ thread: event.subthread ?? event.thread,
724
+ message: event.message
725
+ })).message;
726
+ if (existing !== null) return {
727
+ root: rootId ?? existing,
728
+ message: existing
729
+ };
730
+ const { attachments, notes } = event.files?.length ? await importFiles(platform, event.files, meta.attempt) : {
731
+ attachments: [],
732
+ notes: []
733
+ };
734
+ const links = await resolveLinks(platform, event.links ?? [], synced?.channel ?? channel);
735
+ const base = {
736
+ ...links.length ? { links } : {},
737
+ author: event.author,
738
+ text: clampText(withNotes(overrides.text ?? event.text, notes), attachments.length > 0),
739
+ ...attachments.length ? { attachments } : {},
740
+ origin: {
741
+ channel: event.channel,
742
+ thread: event.thread,
743
+ subthread: event.subthread,
744
+ message: event.message,
745
+ context: event.context
746
+ },
747
+ id: await writeId(0)
748
+ };
749
+ const reply = {
750
+ ...base,
751
+ ...overrides.noSync ? { noSync: true } : {}
752
+ };
753
+ if (rootId !== void 0) {
754
+ await setExtraLabels(event.author, rootId);
755
+ return result(await post({
756
+ ...reply,
757
+ re: rootId
758
+ }));
759
+ }
760
+ const fetched = await platform.fetchRoot(native(event.thread));
761
+ if (fetched === null) {
762
+ const draft = rootDraft({
763
+ text: event.text,
764
+ labels: {},
765
+ author: event.author,
766
+ raw
767
+ });
768
+ return result(await post({
769
+ ...base,
770
+ channel,
771
+ text: clampText(withNotes(overrides.text ?? draft.text, notes), attachments.length > 0),
772
+ labels: labels(draft.labels)
773
+ }));
774
+ }
775
+ const draft = rootDraft({
776
+ ...fetched,
777
+ raw: fetched.raw ?? null
778
+ });
779
+ const root = await post({
780
+ author: fetched.author,
781
+ channel,
782
+ text: clampText(draft.text),
783
+ labels: labels(draft.labels),
784
+ origin: {
785
+ channel: event.channel,
786
+ thread: event.thread,
787
+ subthread: null,
788
+ message: `root:${meta.id}`,
789
+ context: null
790
+ },
791
+ id: await writeId(1)
792
+ });
793
+ return result(await post({
794
+ ...reply,
795
+ re: root.id
796
+ }));
797
+ }
798
+ case "edited":
799
+ case "deleted":
800
+ case "reaction": {
801
+ const id = (await find({
802
+ thread: event.subthread ?? event.thread,
803
+ message: event.message
804
+ })).message;
805
+ if (id === null) return {
806
+ root: rootId ?? null,
807
+ message: null
808
+ };
809
+ const author = "author" in event ? event.author : {
810
+ id: `${platform.platform}:system`,
811
+ handle: platform.platform,
812
+ name: platform.platform,
813
+ avatar: null,
814
+ isBot: true
815
+ };
816
+ const origin = {
817
+ thread: event.subthread ?? event.thread,
818
+ message: event.message
819
+ };
820
+ if (rootId !== void 0) await setExtraLabels(author, rootId);
821
+ if (event.kind === "deleted") await call("syncImport", {
822
+ op: "delete",
823
+ author,
824
+ from: key,
825
+ origin
826
+ });
827
+ else if (event.kind === "edited") {
828
+ const stored = await messageOf(id);
829
+ const text = clampText(withNotes(overrides.text ?? event.text, stored ? notesIn(stored.text) : []), (stored?.attachments.length ?? 0) > 0);
830
+ if (stored && !event.linksOnly && stored.text !== text) await screened(await call("syncImport", {
831
+ op: "edit",
832
+ author,
833
+ from: key,
834
+ origin,
835
+ text,
836
+ source: from
837
+ }));
838
+ const links = stored ? await resolveLinks(platform, event.links ?? [], stored.channel, stored.links) : [];
839
+ if (stored && !sameLinks(links, stored.links)) await call("syncImport", {
840
+ op: "links",
841
+ author,
842
+ from: key,
843
+ origin,
844
+ links
845
+ });
846
+ } else {
847
+ if (platform.singleReaction) {
848
+ const emoji = canonicalEmoji(event.emoji);
849
+ for (const [held, actors] of Object.entries((await messageOf(id))?.reactions ?? {})) if (actors.includes(author.id) && !(event.added && held === emoji)) await call("syncImport", {
850
+ op: "react",
851
+ author,
852
+ from: key,
853
+ origin,
854
+ emoji: held,
855
+ remove: true
856
+ });
857
+ }
858
+ if (!platform.singleReaction || event.added) await call("syncImport", {
859
+ op: "react",
860
+ author,
861
+ from: key,
862
+ origin,
863
+ emoji: event.emoji,
864
+ remove: !event.added
865
+ });
866
+ }
867
+ return {
868
+ root: rootId ?? null,
869
+ message: id
870
+ };
871
+ }
872
+ case "lifecycle": {
873
+ if (rootId !== void 0) {
874
+ const root = await messageOf(rootId);
875
+ const createdHere = root?.origin?.message === `lifecycle:${meta.id}`;
876
+ const desired = labels((createdHere ? rootDraft({
877
+ text: event.url ? `${event.title}\n${event.url}` : event.title,
878
+ labels: event.labels,
879
+ author: event.author,
880
+ raw
881
+ }) : null)?.labels ?? event.labels);
882
+ const owned = (key) => event.owns?.some((prefix) => key.startsWith(prefix)) ?? false;
883
+ const set = Object.fromEntries(Object.entries(desired).filter(([key, value]) => root?.labels[key] !== value));
884
+ const unset = Object.keys(root?.labels ?? {}).filter((key) => !(key in desired) && (event.unset.includes(key) || overrides.unset?.includes(key) || owned(key)));
885
+ if (root && !root.deleted && (Object.keys(set).length || unset.length)) await setLabels(event.author, set, unset);
886
+ if (event.reply) return result(await post({
887
+ author: event.author,
888
+ re: rootId,
889
+ text: clampText(overrides.text ?? event.reply),
890
+ ...overrides.noSync ? { noSync: true } : {},
891
+ id: await writeId(1)
892
+ }));
893
+ return {
894
+ root: rootId,
895
+ message: createdHere ? rootId : null
896
+ };
897
+ }
898
+ if (!event.createRoot) return {
899
+ root: null,
900
+ message: null
901
+ };
902
+ const draft = rootDraft({
903
+ text: event.url ? `${event.title}\n${event.url}` : event.title,
904
+ labels: event.labels,
905
+ author: event.author,
906
+ raw
907
+ });
908
+ const root = await post({
909
+ author: event.author,
910
+ channel,
911
+ text: clampText(event.reply ? draft.text : overrides.text ?? draft.text),
912
+ labels: labels(draft.labels),
913
+ origin: {
914
+ channel: event.channel,
915
+ thread: event.thread,
916
+ subthread: null,
917
+ message: `lifecycle:${meta.id}`,
918
+ context: null
919
+ },
920
+ id: await writeId(0)
921
+ });
922
+ if (event.reply) return result(await post({
923
+ author: event.author,
924
+ re: root.id,
925
+ text: clampText(overrides.text ?? event.reply),
926
+ ...overrides.noSync ? { noSync: true } : {},
927
+ id: await writeId(1)
928
+ }));
929
+ return result(root);
930
+ }
931
+ }
932
+ }
933
+ /**
934
+ * A message's text for a platform: each mention it resolved reads natively where the
935
+ * actor is the platform's own, and as their plain name anywhere else. A handle it
936
+ * didn't resolve goes out as written.
937
+ */
938
+ function textFor(platform, message, actors, text = message.text) {
939
+ const mentioned = message.mentions.map((id) => actors.byId[id]).filter((actor) => actor !== void 0);
940
+ return replaceMentions(text, (token, written) => {
941
+ const actor = token.kind === "actor" ? mentioned.find((candidate) => candidate.id === token.actor) : mentioned.find((candidate) => candidate.handle.toLowerCase() === token.handle);
942
+ if (actor) return nativeMention(platform, actor.id, actors) ?? actor.name;
943
+ return null;
944
+ });
945
+ }
946
+ /**
947
+ * How `platform` mentions an actor: its own account, its person's, or any account
948
+ * its person linked there. An agent is just itself.
949
+ */
950
+ function nativeMention(platform, actor, actors) {
951
+ const native = (id) => {
952
+ const account = actors.byId[id];
953
+ return account ? platform.mention(account) : null;
954
+ };
955
+ const person = actors.links[actor] ?? actor;
956
+ const direct = native(actor) ?? (person === actor ? null : native(person));
957
+ if (direct) return direct;
958
+ for (const id of linkedTo(actors, person)) {
959
+ const linked = native(id);
960
+ if (linked) return linked;
961
+ }
962
+ return null;
963
+ }
964
+ /**
965
+ * A person's own token on `platform`, to post `message` as them: only for what they
966
+ * wrote on the board themselves, not their agents, and null when they haven't
967
+ * signed in. A token that can't be had is the bot's turn, never a failed delivery.
968
+ * A new post (`linked`) goes as them only once they've linked the account they signed
969
+ * in with, so its echo is recognized as theirs and `vb identity unlink` stops it. Their
970
+ * copies already out there stay theirs to change.
971
+ */
972
+ async function personToken(platform, message, linked) {
973
+ if (!platform.users || message.from !== null || message.by.profile !== null || !message.by.principal.startsWith("vercel:user:")) return null;
974
+ if (linked) {
975
+ const { accounts } = await find({ person: message.by.principal });
976
+ if (!accounts.some((account) => account.method === "consent" && account.id.startsWith(`${platform.platform}:user:`))) return null;
977
+ }
978
+ try {
979
+ return await platform.users.token(message.by.principal);
980
+ } catch (error) {
981
+ console.warn(`[board] no ${platform.platform} token for ${message.by.principal}; posting #${message.id} as the app:`, describe(error));
982
+ return null;
983
+ }
984
+ }
985
+ /** What a message says as its author: no name in front, since it's theirs. */
986
+ function renderAsPerson(target, message, actors) {
987
+ const platform = platformOf(keyOf(target));
988
+ const markdown = textFor(platform, message, actors, displayText(message.text, message.by.actor));
989
+ return { markdown: message.attachments.length > 0 && !platform.uploadsFiles ? `${markdown}\n${attachmentNotes(message)}` : markdown };
990
+ }
991
+ /** Posts as the message's author when they've signed in; null to post as the app. */
992
+ async function postAsPerson(target, place, kind, message, actors) {
993
+ const platform = platformOf(keyOf(target));
994
+ if (!message.text.trim()) return null;
995
+ const token = await personToken(platform, message, true);
996
+ if (!token) return null;
997
+ try {
998
+ return await platform.postAs(token, native(place), kind, renderAsPerson(target, message, actors));
999
+ } catch (error) {
1000
+ console.warn(`[board] couldn't post #${message.id} to ${target} as its author; posting as the app:`, describe(error));
1001
+ return null;
1002
+ }
1003
+ }
1004
+ /** A board message as markdown for a platform, which converts it to its own format. */
1005
+ function render(key, target, message, actors) {
1006
+ const actor = actors.byId[message.by.actor];
1007
+ let name = actor?.name ?? message.by.actor;
1008
+ if (message.by.profile && actor?.handle.includes("/")) name = `${actor.name} via ${actor.handle.split("/")[0]}`;
1009
+ if (message.from) name = `${name} (${entries[message.from]?.platform ?? message.by.actor.slice(0, message.by.actor.indexOf(":"))})`;
1010
+ const platform = platformOf(key);
1011
+ const body = platform.render(immutable({
1012
+ message: {
1013
+ ...structuredClone(message),
1014
+ text: displayText(message.text, message.by.actor)
1015
+ },
1016
+ author: actor ? { ...actor } : null,
1017
+ mentionOf: (id) => nativeMention(platform, id, actors),
1018
+ label: name,
1019
+ target
1020
+ }));
1021
+ if (typeof body !== "string") throw badRequest("a provider renderer must return markdown");
1022
+ const markdown = textFor(platform, message, actors, body);
1023
+ return message.by.profile && !message.from ? {
1024
+ markdown,
1025
+ as: {
1026
+ name,
1027
+ avatar: senderAvatar(message, actor, actors)
1028
+ }
1029
+ } : { markdown };
1030
+ }
1031
+ /** An agent's own picture, or for the board's built-in one, its person's Vercel avatar: `claude via julian` shows Julian. */
1032
+ function senderAvatar(message, actor, actors) {
1033
+ const builtIn = (avatar) => !avatar || /\/avatars\/[a-z0-9-]+$/.test(avatar);
1034
+ const own = actor?.avatar ?? null;
1035
+ if (!builtIn(own)) return own;
1036
+ const person = actors.byId[message.by.principal]?.avatar;
1037
+ if (!builtIn(person)) return person;
1038
+ const username = actor?.handle.split("/")[0] ?? "";
1039
+ if (!message.by.principal.startsWith("vercel:user:") || !username || /^user-/.test(username)) return own;
1040
+ return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}&s=64&format=png`;
1041
+ }
1042
+ /** What a platform shows for files it didn't get: one line each, so a reader knows they're on the board. */
1043
+ const attachmentNotes = (message) => message.attachments.map((a) => `\n📎 ${a.name} (${fileSize(a.size)}, on the board: #${message.id})`).join("");
1044
+ /**
1045
+ * `render`, with a line per file where the files won't follow: on a platform
1046
+ * that takes no uploads, or one that refused them (`noted`).
1047
+ */
1048
+ const renderFor = (target, message, actors, noted = false) => {
1049
+ const key = keyOf(target);
1050
+ const { markdown, as } = render(key, target, message, actors);
1051
+ return {
1052
+ markdown: message.attachments.length > 0 && (noted || !platformOf(key).uploadsFiles) ? `${markdown}\n${attachmentNotes(message)}` : markdown,
1053
+ ...as ? { as } : {}
1054
+ };
1055
+ };
1056
+ async function postReply(message, target, externalThread, actors) {
1057
+ const replyCopy = message.replyTo === null ? void 0 : (await copiesOf([message.replyTo]))[message.replyTo]?.[target];
1058
+ const place = replyCopy !== void 0 && replyCopy.thread !== externalThread ? replyCopy.thread : externalThread;
1059
+ const platform = platformOf(keyOf(target));
1060
+ let body = renderFor(target, message, actors);
1061
+ let files;
1062
+ let sentId;
1063
+ if (!body.markdown.trim() && message.attachments.length && platform.uploadsFiles) try {
1064
+ const sent = await uploadMessageFiles(message, target, place);
1065
+ sentId = sent.message;
1066
+ files = sent.files;
1067
+ } catch (error) {
1068
+ if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
1069
+ body = renderFor(target, message, actors, true);
1070
+ sentId = (await platform.post(native(place), "thread", body)).message;
1071
+ files = null;
1072
+ }
1073
+ else {
1074
+ const asPerson = await postAsPerson(target, place, "thread", message, actors);
1075
+ if (asPerson) {
1076
+ const external = {
1077
+ thread: place,
1078
+ message: asPerson.message,
1079
+ asUser: true
1080
+ };
1081
+ await recordDelivery(message, target, external);
1082
+ return external;
1083
+ }
1084
+ if (!body.markdown.trim()) throw badRequest("a renderer must return text unless the message has media");
1085
+ sentId = (await platform.post(native(place), "thread", body)).message;
1086
+ }
1087
+ const external = {
1088
+ thread: place,
1089
+ message: sentId,
1090
+ ...files !== void 0 ? { files } : {}
1091
+ };
1092
+ await recordDelivery(message, target, external);
1093
+ return external;
1094
+ }
1095
+ async function recordDelivery(message, target, copy) {
1096
+ await call("syncReceipt", {
1097
+ kind: "message",
1098
+ message: message.id,
1099
+ target,
1100
+ external: {
1101
+ thread: copy.thread,
1102
+ message: copy.message,
1103
+ ...copy.asUser ? { asUser: true } : {}
1104
+ },
1105
+ ...copy.files !== void 0 ? { files: copy.files } : {}
1106
+ });
1107
+ }
1108
+ /** A message's files, as the board has them. */
1109
+ async function messageFiles(message) {
1110
+ return Promise.all(message.attachments.map(async (a) => {
1111
+ const { data } = await call("attachment", { id: a.id });
1112
+ return {
1113
+ data: data instanceof Uint8Array ? data : new Uint8Array(await new Response(data).arrayBuffer()),
1114
+ filename: a.name,
1115
+ mimeType: a.type
1116
+ };
1117
+ }));
1118
+ }
1119
+ async function uploadMessageFiles(message, target, thread) {
1120
+ return platformOf(keyOf(target)).uploadFiles(native(thread), await messageFiles(message));
1121
+ }
1122
+ /**
1123
+ * A message's files, into the thread its text went to, once. The text's
1124
+ * receipt comes first, so a retry sends only what's missing. If the platform
1125
+ * refuses files, the text gets a line for each; any other failure retries.
1126
+ */
1127
+ async function deliverFiles(message, target, copy, actors) {
1128
+ const platform = platformOf(keyOf(target));
1129
+ if (!platform.uploadsFiles || message.attachments.length === 0 || copy.files !== void 0) return;
1130
+ let ids;
1131
+ try {
1132
+ ids = (await uploadMessageFiles(message, target, copy.thread)).files;
1133
+ } catch (error) {
1134
+ if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw Object.assign(/* @__PURE__ */ new Error(`files: ${describe(error)}`), {
1135
+ cause: error,
1136
+ data: { error: `files: ${describe(error)}` }
1137
+ });
1138
+ console.warn(`[board] ${target} won't take #${message.id}'s files (${describe(error)}); noting them in its text`);
1139
+ ids = null;
1140
+ await platform.edit(native(copy.thread), copy.message, renderFor(target, message, actors, true)).catch((edit) => console.warn(`[board] couldn't note #${message.id}'s files on ${target}:`, describe(edit)));
1141
+ }
1142
+ await call("syncReceipt", {
1143
+ kind: "files",
1144
+ message: message.id,
1145
+ target,
1146
+ files: ids
1147
+ });
1148
+ }
1149
+ async function settle(attempt, failures) {
1150
+ if (failures.length === 0) return;
1151
+ if (attempt < DELIVERY_ATTEMPTS || afterRetry(attempt)) throw failures[0].error;
1152
+ for (const { id, target, error } of failures) {
1153
+ console.error(`[board] gave up delivering #${id} to ${target}:`, describe(error));
1154
+ await call("syncReceipt", {
1155
+ kind: "failed",
1156
+ message: id,
1157
+ target,
1158
+ error: describe(error)
1159
+ });
1160
+ }
1161
+ }
1162
+ /**
1163
+ * A new root: the targets its channel syncs with. A root that came through this board landed
1164
+ * with them; one that came another way (straight to the board this one uses) gets them now.
1165
+ */
1166
+ async function configure(message) {
1167
+ if (message.re !== null || message.noSync) return;
1168
+ const originChannel = message.origin ? message.origin.channel ?? channelOf(message.origin.thread) : null;
1169
+ for (const target of await configTargets(message.channel)) if (target.target !== originChannel) await call("syncReceipt", {
1170
+ kind: "thread",
1171
+ message: message.id,
1172
+ target: target.target,
1173
+ targetKind: target.kind,
1174
+ cause: "config"
1175
+ });
1176
+ }
1177
+ async function deliverThread(event, attempt) {
1178
+ const { thread, target } = event.payload;
1179
+ if (!ours(target)) return;
1180
+ if ((event.payload.kind ?? (isThreadTarget(target) ? "thread" : "channel")) === "thread") return;
1181
+ const synced = await syncOf(thread);
1182
+ const [root, ...replies] = synced ? await threadOf(synced.root) : [];
1183
+ if (!synced || !root || root.id !== synced.root || root.deleted) return;
1184
+ const actors = await actorsNow();
1185
+ const copies = await copiesOf([root.id, ...replies.map((reply) => reply.id)]);
1186
+ let externalThread = synced.external[target];
1187
+ if (!externalThread) try {
1188
+ const platform = platformOf(keyOf(target));
1189
+ const body = renderFor(target, root, actors);
1190
+ const mediaOnly = !body.markdown.trim() && root.attachments.length > 0 && platform.uploadsFiles;
1191
+ if (!body.markdown.trim() && !mediaOnly) throw badRequest("a renderer must return text unless the message has media");
1192
+ let uploaded;
1193
+ let postedAsPerson = false;
1194
+ const post = async () => {
1195
+ if (!mediaOnly) {
1196
+ const asPerson = await postAsPerson(target, target, "channel", root, actors);
1197
+ if (asPerson) {
1198
+ postedAsPerson = true;
1199
+ return asPerson;
1200
+ }
1201
+ return platform.post(native(target), "channel", body);
1202
+ }
1203
+ try {
1204
+ const sent = await platform.post(native(target), "channel", {
1205
+ ...body,
1206
+ files: await messageFiles(root)
1207
+ });
1208
+ uploaded = sent.files ?? [];
1209
+ return sent;
1210
+ } catch (error) {
1211
+ if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
1212
+ uploaded = null;
1213
+ return platform.post(native(target), "channel", renderFor(target, root, actors, true));
1214
+ }
1215
+ };
1216
+ const sent = await post();
1217
+ externalThread = `${keyOf(target)}:${sent.thread}`;
1218
+ const external = {
1219
+ thread: externalThread,
1220
+ message: sent.message,
1221
+ ...uploaded !== void 0 ? { files: uploaded } : {},
1222
+ ...postedAsPerson ? { asUser: true } : {}
1223
+ };
1224
+ await recordDelivery(root, target, external);
1225
+ await deliverFiles(root, target, external, actors);
1226
+ } catch (error) {
1227
+ return settle(attempt, [{
1228
+ id: root.id,
1229
+ target,
1230
+ error
1231
+ }]);
1232
+ }
1233
+ else {
1234
+ const copy = copies[root.id]?.[target];
1235
+ if (copy) try {
1236
+ await deliverFiles(root, target, copy, actors);
1237
+ } catch (error) {
1238
+ return settle(attempt, [{
1239
+ id: root.id,
1240
+ target,
1241
+ error
1242
+ }]);
1243
+ }
1244
+ }
1245
+ const failures = [];
1246
+ for (const reply of replies) {
1247
+ if (reply.deleted || reply.noSync || cameFrom(reply, target)) continue;
1248
+ try {
1249
+ await deliverFiles(reply, target, copies[reply.id]?.[target] ?? await postReply(reply, target, externalThread, actors), actors);
1250
+ } catch (error) {
1251
+ failures.push({
1252
+ id: reply.id,
1253
+ target,
1254
+ error
1255
+ });
1256
+ }
1257
+ }
1258
+ await settle(attempt, failures);
1259
+ }
1260
+ /** Never deliver a message back to the platform it came from. */
1261
+ const cameFrom = (message, target) => message.origin?.thread === target || message.from !== null && keyOf(target) === message.from;
1262
+ async function deliverPost(event, attempt) {
1263
+ if (!event.message) return;
1264
+ if (event.message.re === null) return configure(event.message);
1265
+ if (event.payload.noSync) return;
1266
+ const synced = await syncOf(event.payload.thread);
1267
+ if (!synced || synced.targets.length === 0) return;
1268
+ const message = await messageOf(event.message.id);
1269
+ if (!message || message.deleted) return;
1270
+ const actors = await actorsNow();
1271
+ const failures = [];
1272
+ for (const target of synced.targets) {
1273
+ const externalThread = synced.external[target];
1274
+ if (!ours(target) || !externalThread) continue;
1275
+ if (cameFrom(message, target)) continue;
1276
+ try {
1277
+ await deliverFiles(message, target, (await copiesOf([message.id]))[message.id]?.[target] ?? await postReply(message, target, externalThread, actors), actors);
1278
+ } catch (error) {
1279
+ failures.push({
1280
+ id: message.id,
1281
+ target,
1282
+ error
1283
+ });
1284
+ }
1285
+ }
1286
+ await settle(attempt, failures);
1287
+ }
1288
+ /** Edits, deletes, and reactions are best-effort: a platform that can't do one skips it. */
1289
+ async function deliverChange(event) {
1290
+ const copies = (await copiesOf([event.payload.id]))[event.payload.id] ?? {};
1291
+ if (Object.keys(copies).length === 0) return;
1292
+ const message = event.type === "message.edited" ? await messageOf(event.payload.id) : event.message;
1293
+ if (event.type === "message.edited" && (!message || message.deleted)) return;
1294
+ const actors = await actorsNow();
1295
+ for (const [target, copy] of Object.entries(copies)) {
1296
+ if (!ours(target) || event.payload.from !== null && keyOf(target) === event.payload.from) continue;
1297
+ try {
1298
+ const platform = platformOf(keyOf(target));
1299
+ const thread = native(copy.thread);
1300
+ const theirs = copy.asUser && message && (event.type === "message.edited" || event.type === "message.deleted");
1301
+ const token = theirs ? await personToken(platform, message, false) : null;
1302
+ if (theirs && !token) {
1303
+ console.warn(`[board] skipped ${event.type} on ${target}: #${message.id} was posted as its author, and their token is gone`);
1304
+ continue;
1305
+ }
1306
+ if (event.type === "message.edited" && message) if (token) await platform.editAs(token, thread, copy.message, renderAsPerson(target, message, actors));
1307
+ else await platform.edit(thread, copy.message, renderFor(target, message, actors, copy.files === null));
1308
+ else if (event.type === "message.deleted") {
1309
+ if (token) await platform.deleteAs(token, thread, copy.message);
1310
+ else await platform.delete(thread, copy.message);
1311
+ if (copy.files?.length) await platform.deleteFiles(copy.files);
1312
+ } else if (event.type === "reaction.added") await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), true);
1313
+ else if (event.type === "reaction.removed") await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), false);
1314
+ } catch (error) {
1315
+ console.warn(`[board] skipped ${event.type} on ${target}:`, error.message);
1316
+ }
1317
+ }
1318
+ }
1319
+ /**
1320
+ * A renderer can read labels (a PR root showing its state), so a label change edits
1321
+ * each copy whose rendering it changes. The default renderer doesn't, so this never edits.
1322
+ */
1323
+ async function deliverLabels(event) {
1324
+ const { id } = event.payload;
1325
+ const copies = (await copiesOf([id]))[id] ?? {};
1326
+ const after = event.message;
1327
+ if (!after || Object.keys(copies).length === 0) return;
1328
+ const current = await messageOf(id);
1329
+ if (!current || current.deleted) return;
1330
+ const actors = await actorsNow();
1331
+ const message = {
1332
+ ...current,
1333
+ labels: after.labels
1334
+ };
1335
+ const previous = {
1336
+ ...current,
1337
+ labels: event.before?.labels ?? {}
1338
+ };
1339
+ for (const [target, copy] of Object.entries(copies)) {
1340
+ if (!ours(target)) continue;
1341
+ try {
1342
+ const body = renderFor(target, message, actors, copy.files === null);
1343
+ if (body.markdown === renderFor(target, previous, actors, copy.files === null).markdown) continue;
1344
+ await platformOf(keyOf(target)).edit(native(copy.thread), copy.message, body);
1345
+ } catch (error) {
1346
+ console.warn(`[board] skipped labels.changed on ${target}:`, describe(error));
1347
+ }
1348
+ }
1349
+ }
1350
+ /**
1351
+ * Delivers one board event to the platforms: what the board's subscription for sync hands
1352
+ * it, on its thread's lane. A throw is retried; `attempt` counts tries from 1.
1353
+ */
1354
+ async function deliver(event, { attempt }) {
1355
+ switch (event.type) {
1356
+ case "thread.synced": return deliverThread(event, attempt);
1357
+ case "message.posted": return deliverPost(event, attempt);
1358
+ case "message.edited":
1359
+ case "message.deleted":
1360
+ case "reaction.added":
1361
+ case "reaction.removed": return deliverChange(event);
1362
+ case "labels.changed": return deliverLabels(event);
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Shows typing in every platform thread a board thread is synced to. It's
1367
+ * ephemeral and best-effort: nothing goes in the log, and a platform that
1368
+ * fails is skipped. Answers the external threads that took it. `status`
1369
+ * replaces "Typing…" where the platform shows text (Slack).
1370
+ */
1371
+ async function typing(messages, status) {
1372
+ const synced = messages[0] ? await syncOf(messages[0].thread) : null;
1373
+ if (!synced) return [];
1374
+ const externals = [...new Set(Object.values(synced.external))].filter(ours);
1375
+ return (await Promise.all(externals.map(async (external) => {
1376
+ const key = keyOf(external);
1377
+ try {
1378
+ const platform = platformOf(key);
1379
+ let latest = null;
1380
+ for (let i = messages.length - 1; i >= 0 && latest === null; i--) {
1381
+ const origin = messages[i];
1382
+ if (!origin.deleted && origin.origin?.thread === external) latest = origin.origin.message;
1383
+ }
1384
+ await platform.startTyping(native(external), latest, status);
1385
+ return external;
1386
+ } catch (error) {
1387
+ console.warn(`[board] skipped typing on ${external}:`, describe(error));
1388
+ return null;
1389
+ }
1390
+ }))).filter((external) => external !== null);
1391
+ }
1392
+ /** A channel the inbox listing names, as its platform ID: `#general` to `slack:C0123`. */
1393
+ async function inboxChannel(key, channel) {
1394
+ try {
1395
+ return `${key}:${await platformOf(key).resolveChannel(channel)}`;
1396
+ } catch (error) {
1397
+ throw badRequest(`can't resolve inbox channel: ${error.message}`);
1398
+ }
1399
+ }
1400
+ /** The status for what a platform wouldn't read: the caller's mistake, missing, refused, or the platform failing. */
1401
+ const unreadableStatus = (code) => {
1402
+ if (code === "unsupported" || code === "invalid_target") return 400;
1403
+ if (code === "channel_not_found") return 404;
1404
+ if (code === "ratelimited") return 429;
1405
+ if ([
1406
+ "no_client",
1407
+ "list_failed",
1408
+ "internal_error",
1409
+ "fatal_error",
1410
+ "service_unavailable",
1411
+ "request_timeout"
1412
+ ].includes(code)) return 502;
1413
+ return 403;
1414
+ };
1415
+ /** A platform channel or thread, read for the caller: nothing is synced, stored, or joined. */
1416
+ async function readSource(key, target, limit) {
1417
+ const platform = platformOf(key);
1418
+ try {
1419
+ return await platform.read(target, { limit });
1420
+ } catch (error) {
1421
+ if (error instanceof UnreadableError) throw new HttpError(unreadableStatus(error.code), error.code, error.message);
1422
+ throw badRequest(`can't read ${target} from ${key}: ${describe(error)}`, "platform_error");
1423
+ }
1424
+ }
1425
+ return {
1426
+ entries,
1427
+ bind(next) {
1428
+ bound = next;
1429
+ },
1430
+ /** Sync's own caller, for its lookups. */
1431
+ caller: me,
1432
+ deliver,
1433
+ receive: inbound,
1434
+ pull,
1435
+ configTargets,
1436
+ normalizeTarget,
1437
+ findByEmail,
1438
+ handleWebhook,
1439
+ inboxChannel,
1440
+ signsIn,
1441
+ signIn,
1442
+ signedIn,
1443
+ redeemSignIn,
1444
+ typing,
1445
+ readSource
1446
+ };
1447
+ }
1448
+ //#endregion
1449
+ export { createSyncEngine };