@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.
- package/README.md +180 -0
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-ojFEXEyN.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +262 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +499 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,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 };
|