pi-roundtable-web 0.22.5 → 0.23.1

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 (83) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +444 -104
  3. package/assets/icons/192.png +0 -0
  4. package/assets/icons/512.png +0 -0
  5. package/assets/icons/apple-180.png +0 -0
  6. package/assets/icons/badge-96.png +0 -0
  7. package/assets/icons/mark-maskable.svg +1 -0
  8. package/assets/icons/mark.svg +1 -0
  9. package/assets/icons/maskable-512.png +0 -0
  10. package/dist/chunk-character-creator-660d2t6p.js +1 -0
  11. package/dist/chunk-connectors-d19v8cjc.js +1 -0
  12. package/dist/chunk-conversations-4cmexmqq.js +1 -0
  13. package/dist/chunk-entry-8xpx35ef.js +1 -0
  14. package/dist/chunk-index-19g8jmr8.js +2 -0
  15. package/dist/chunk-index-362dfedj.js +1 -0
  16. package/dist/chunk-index-6g69tqt0.js +47 -0
  17. package/dist/chunk-index-89q2qdkj.css +1 -0
  18. package/dist/chunk-index-9tvf6es7.js +41 -0
  19. package/dist/chunk-index-p8h0e861.js +2 -0
  20. package/dist/chunk-index-xw5hhmfq.js +2 -0
  21. package/dist/chunk-notes-fefwds14.js +1 -0
  22. package/dist/chunk-overview-bxpwpmn6.js +1 -0
  23. package/dist/chunk-skills-pce3zgxy.js +1 -0
  24. package/dist/chunk-table-zmpbrfvz.js +1 -0
  25. package/dist/chunk-table2d-ac2gez71.js +1 -0
  26. package/dist/chunk-table3d-g258jemr.js +4229 -0
  27. package/dist/chunk-transcript-yhtjp3pd.js +1 -0
  28. package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
  29. package/dist/index.html +6 -2
  30. package/dist/sw.js +1 -0
  31. package/package.json +8 -3
  32. package/src/api-types.ts +448 -1
  33. package/src/app/app.ts +351 -0
  34. package/src/app/card-log.ts +119 -0
  35. package/src/app/desk.ts +533 -0
  36. package/src/app/files.ts +262 -0
  37. package/src/app/frames.ts +132 -0
  38. package/src/app/multipart.ts +172 -0
  39. package/src/app/presence.ts +73 -0
  40. package/src/app/protocol.ts +144 -0
  41. package/src/app/room-log.ts +137 -0
  42. package/src/app/rooms.ts +189 -0
  43. package/src/app/routes.ts +434 -0
  44. package/src/app/socket.ts +406 -0
  45. package/src/app/sockets.ts +84 -0
  46. package/src/app/surface-threads.ts +92 -0
  47. package/src/app/surface.ts +384 -0
  48. package/src/app/threads.ts +99 -0
  49. package/src/app/uploads.ts +363 -0
  50. package/src/assets.ts +1 -3
  51. package/src/auth/authenticator.ts +167 -0
  52. package/src/auth/login-page.ts +44 -0
  53. package/src/auth/none.ts +38 -0
  54. package/src/auth/password.ts +250 -0
  55. package/src/auth/session-key.ts +65 -0
  56. package/src/cleanup-api.ts +1 -1
  57. package/src/cloudflare-access.ts +5 -2
  58. package/src/console-api.ts +13 -15
  59. package/src/console-server.ts +77 -49
  60. package/src/features.ts +6 -1
  61. package/src/http.ts +8 -0
  62. package/src/i18n/index.ts +13 -0
  63. package/src/i18n/locale.ts +11 -0
  64. package/src/i18n/zh-tw.ts +696 -0
  65. package/src/index.ts +1 -1
  66. package/src/options.ts +308 -12
  67. package/src/page-colors.ts +9 -0
  68. package/src/page-presentation.ts +17 -12
  69. package/src/push/api.ts +190 -0
  70. package/src/push/channel.ts +50 -0
  71. package/src/push/encrypt.ts +136 -0
  72. package/src/push/endpoint.ts +54 -0
  73. package/src/push/sender.ts +109 -0
  74. package/src/push/subscriptions.ts +140 -0
  75. package/src/push/test-fetch.ts +17 -0
  76. package/src/push/triggers.ts +155 -0
  77. package/src/push/vapid.ts +124 -0
  78. package/src/pwa.ts +130 -0
  79. package/src/request-body.ts +20 -0
  80. package/src/verifier.ts +7 -1
  81. package/src/web-plugin.ts +123 -11
  82. package/dist/chunk-82frzte9.js +0 -36
  83. package/dist/chunk-e8z9q554.css +0 -1
@@ -0,0 +1,406 @@
1
+ import type {
2
+ ActorFacts,
3
+ ChannelKey,
4
+ ConversationPort,
5
+ IdentityService,
6
+ InboundMessage,
7
+ Logger,
8
+ RouteSocket,
9
+ WebSocketAccept,
10
+ WebSocketRoute,
11
+ } from "pi-roundtable";
12
+ import { PluginError } from "pi-roundtable";
13
+ import {
14
+ APP_CLOSE_EXPIRED,
15
+ APP_CLOSE_FORBIDDEN,
16
+ APP_PROTOCOL,
17
+ APP_PROTOCOL_VERSION,
18
+ type ClientFrame,
19
+ MAX_MESSAGE_CHARS,
20
+ MAX_MESSAGE_FILES,
21
+ type RefusalCode,
22
+ type RoomFile,
23
+ type RoomMessage,
24
+ } from "../api-types.ts";
25
+ import type { Authenticator } from "../auth/authenticator.ts";
26
+ import type { Answerer, OwnerDesk } from "./desk.ts";
27
+ import type { Presence } from "./presence.ts";
28
+ import { parseClientFrame } from "./protocol.ts";
29
+ import type { Rooms } from "./rooms.ts";
30
+ import type { AppSocket, Sockets } from "./sockets.ts";
31
+ import type { AppSurface } from "./surface.ts";
32
+ import type { Uploads } from "./uploads.ts";
33
+
34
+ /** The longest delay a timer keeps; a longer one fires at once. */
35
+ const MAX_TIMER_MS = 2 ** 31 - 1;
36
+
37
+ /** The most sockets the route holds open at once. */
38
+ const MAX_SOCKETS = 32;
39
+ /** How often an open socket's person is looked up again, when the options say nothing else. */
40
+ const RECHECK_MS = 30_000;
41
+ /** The largest frame in bytes; a `send` carries text only, so this is far above `MAX_MESSAGE_CHARS`. */
42
+ const MAX_FRAME_BYTES = 192 * 1024;
43
+
44
+ export interface SocketRouteOptions {
45
+ /** The path of the socket: `<mount>/api/socket`. */
46
+ path: string;
47
+ /** The console's own origin, the only one a browser may open the socket from. */
48
+ origin: string;
49
+ authenticator: Pick<Authenticator, "authenticate">;
50
+ sockets: Sockets;
51
+ surface: AppSurface;
52
+ desk: OwnerDesk;
53
+ rooms: Rooms;
54
+ /** The table of who is busy, sent after `ready`; absent in a route built without one. */
55
+ presence?: Pick<Presence, "frame">;
56
+ uploads: Uploads;
57
+ identity: Pick<IdentityService, "tierOf">;
58
+ conversations: Pick<ConversationPort, "stop">;
59
+ logger: Logger;
60
+ /**
61
+ * The longest a socket stays open, whatever its credential says. A password session lives for
62
+ * days and a logout or a new password does not reach an open socket, so the socket is closed
63
+ * with 4401 after this long and its reconnect carries the cookie again. Absent: no cap.
64
+ */
65
+ maxLifeMs?: number;
66
+ /** How often an open socket's person is looked up again; default 30 s. */
67
+ recheckMs?: number;
68
+ /** The clock, in milliseconds; default `Date.now`. */
69
+ now?: () => number;
70
+ }
71
+
72
+ /** The identity string a person's messages are written as: `<provider>:<subject>`. */
73
+ const identityOf = (actor: ActorFacts | undefined, fallback: string) =>
74
+ actor ? `${actor.provider}:${actor.subject}` : fallback;
75
+
76
+ /**
77
+ * The WebSocket of the owner app: the upgrade passes the console's authenticator, so it is the
78
+ * same check as a page's and the API's, and every frame after it is a write by an owner whose
79
+ * tier is read again. A frame is answered with a frame; a refused one says why, and nothing a
80
+ * client names is taken on trust: a room must be one of the app's, an upload must be the
81
+ * sender's own, a card must be open and answerable by them.
82
+ */
83
+ export class SocketRoute {
84
+ readonly #options: SocketRouteOptions;
85
+ readonly #now: () => number;
86
+
87
+ constructor(options: SocketRouteOptions) {
88
+ this.#options = options;
89
+ this.#now = options.now ?? Date.now;
90
+ }
91
+
92
+ route(): WebSocketRoute<AppSocket> {
93
+ return {
94
+ origins: [this.#options.origin],
95
+ maxConnections: MAX_SOCKETS,
96
+ maxMessageBytes: MAX_FRAME_BYTES,
97
+ maxBufferedBytes: 4 * 1024 * 1024,
98
+ rate: { messages: 600, perMs: 60_000 },
99
+ accept: (request) => this.#accept(request),
100
+ open: (socket) => this.#open(socket),
101
+ message: (socket, message) => this.#message(socket, message),
102
+ close: (socket) => this.#options.sockets.remove(socket.data),
103
+ };
104
+ }
105
+
106
+ async #accept(request: Request): Promise<WebSocketAccept<AppSocket>> {
107
+ if (URL.parse(request.url)?.pathname !== this.#options.path)
108
+ return new Response("Not Found", { status: 404 });
109
+ const offered = (request.headers.get("sec-websocket-protocol") ?? "")
110
+ .split(",")
111
+ .map((value) => value.trim());
112
+ if (!offered.includes(APP_PROTOCOL))
113
+ return new Response(`Offer the ${APP_PROTOCOL} subprotocol`, {
114
+ status: 400,
115
+ });
116
+ const admission = await this.#options.authenticator.authenticate(request);
117
+ if ("refusal" in admission) {
118
+ this.#options.logger.info(
119
+ { refusal: admission.refusal },
120
+ "an app socket was refused",
121
+ );
122
+ return new Response(
123
+ admission.status === 401 ? "Unauthorized" : "Forbidden",
124
+ { status: admission.status },
125
+ );
126
+ }
127
+ const { principal } = admission.visitor;
128
+ return {
129
+ data: {
130
+ principal: { id: principal.id, name: principal.displayName },
131
+ actor: admission.actor,
132
+ expiresAt: admission.expiresAt,
133
+ visible: false,
134
+ focus: undefined,
135
+ },
136
+ headers: { "Sec-WebSocket-Protocol": APP_PROTOCOL },
137
+ };
138
+ }
139
+
140
+ #open(socket: RouteSocket<AppSocket>): void {
141
+ const connection = socket.data;
142
+ const { sockets, desk, uploads } = this.#options;
143
+ sockets.add(connection, socket);
144
+ const now = this.#now();
145
+ const lapses = [
146
+ connection.expiresAt?.getTime(),
147
+ this.#options.maxLifeMs === undefined
148
+ ? undefined
149
+ : now + this.#options.maxLifeMs,
150
+ ].filter((at): at is number => at !== undefined);
151
+ if (lapses.length > 0) {
152
+ const wait = Math.min(...lapses) - now;
153
+ if (wait <= 0) {
154
+ socket.close(APP_CLOSE_EXPIRED, "expired");
155
+ return;
156
+ }
157
+ connection.expiry = setTimeout(
158
+ () => socket.close(APP_CLOSE_EXPIRED, "expired"),
159
+ Math.min(wait, MAX_TIMER_MS),
160
+ );
161
+ }
162
+ connection.recheck = setInterval(() => {
163
+ this.#owner(connection).then(
164
+ (who) => {
165
+ if (!who) socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
166
+ },
167
+ (error: unknown) =>
168
+ this.#options.logger.warn(
169
+ { err: error },
170
+ "an app socket's person was not looked up",
171
+ ),
172
+ );
173
+ }, this.#options.recheckMs ?? RECHECK_MS);
174
+ sockets.send(connection, {
175
+ type: "ready",
176
+ protocol: APP_PROTOCOL_VERSION,
177
+ principal: connection.principal.id,
178
+ name: connection.principal.name,
179
+ uploads: {
180
+ perMessage: MAX_MESSAGE_FILES,
181
+ maxBytes: uploads.maxBytes,
182
+ },
183
+ });
184
+ for (const frame of desk.openFrames()) sockets.send(connection, frame);
185
+ const { presence } = this.#options;
186
+ presence?.frame().then(
187
+ (frame) => sockets.send(connection, frame),
188
+ (error: unknown) =>
189
+ this.#options.logger.warn({ err: error }, "presence was not sent"),
190
+ );
191
+ }
192
+
193
+ /** The person behind a socket as an answerer at their tier now; undefined when they are no owner any more. */
194
+ async #owner(connection: AppSocket): Promise<Answerer | undefined> {
195
+ const tier = await this.#options.identity.tierOf(connection.principal.id);
196
+ if (tier !== "owner") return undefined;
197
+ return {
198
+ principalId: connection.principal.id,
199
+ tier,
200
+ speakerId: identityOf(connection.actor, connection.principal.id),
201
+ name: connection.principal.name,
202
+ ...(connection.actor ? { actor: connection.actor } : {}),
203
+ };
204
+ }
205
+
206
+ #refuse(
207
+ connection: AppSocket,
208
+ code: RefusalCode,
209
+ id?: string,
210
+ prompt?: string,
211
+ ): void {
212
+ this.#options.sockets.send(connection, {
213
+ type: "refused",
214
+ code,
215
+ ...(id === undefined ? {} : { id }),
216
+ ...(prompt === undefined ? {} : { prompt }),
217
+ });
218
+ }
219
+
220
+ async #message(
221
+ socket: RouteSocket<AppSocket>,
222
+ raw: string | Uint8Array,
223
+ ): Promise<void> {
224
+ const connection = socket.data;
225
+ const frame = parseClientFrame(raw);
226
+ if (!frame) {
227
+ this.#refuse(connection, "bad_frame");
228
+ return;
229
+ }
230
+ // What a socket shows or watches applies the moment its frame arrives: the owner lookup
231
+ // below awaits, and frames that waited on it could land out of order.
232
+ switch (frame.type) {
233
+ case "hello":
234
+ connection.visible = frame.visible;
235
+ connection.focus = frame.focus;
236
+ break;
237
+ case "visibility":
238
+ connection.visible = frame.visible;
239
+ break;
240
+ case "focus":
241
+ connection.focus = frame.room;
242
+ break;
243
+ default:
244
+ break;
245
+ }
246
+ // Every frame comes from an owner, at their tier now; a socket whose person is no owner
247
+ // any more is closed rather than left to receive what the owners are shown.
248
+ const who = await this.#owner(connection);
249
+ if (!who) {
250
+ this.#refuse(
251
+ connection,
252
+ "forbidden",
253
+ frame.type === "send" ? frame.id : undefined,
254
+ );
255
+ socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
256
+ return;
257
+ }
258
+ if (
259
+ frame.type === "hello" ||
260
+ frame.type === "visibility" ||
261
+ frame.type === "focus"
262
+ )
263
+ return;
264
+ await this.#write(connection, who, frame);
265
+ }
266
+
267
+ async #write(
268
+ connection: AppSocket,
269
+ who: Answerer,
270
+ frame: Exclude<ClientFrame, { type: "hello" | "visibility" | "focus" }>,
271
+ ): Promise<void> {
272
+ const { desk, rooms, conversations } = this.#options;
273
+ let refused: RefusalCode | undefined;
274
+ switch (frame.type) {
275
+ case "send":
276
+ await this.#send(connection, who, frame);
277
+ return;
278
+ case "stop": {
279
+ const room = await rooms.resolve(frame.room);
280
+ if (!room || room.readOnly) refused = "unknown_room";
281
+ else conversations.stop(room.key);
282
+ break;
283
+ }
284
+ case "approval":
285
+ refused = this.#needsActor(who, frame.prompt)
286
+ ? "forbidden"
287
+ : desk.approve(who, frame.prompt, frame.approved);
288
+ break;
289
+ case "answer":
290
+ refused = this.#needsActor(who, frame.prompt)
291
+ ? "forbidden"
292
+ : desk.answer(who, frame.prompt, frame.answer);
293
+ break;
294
+ case "answers":
295
+ refused = this.#needsActor(who, frame.prompt)
296
+ ? "forbidden"
297
+ : desk.answers(who, frame.prompt, frame.answers, frame.note);
298
+ break;
299
+ case "cancel":
300
+ refused = desk.cancel(who, frame.prompt);
301
+ break;
302
+ }
303
+ if (refused)
304
+ this.#refuse(
305
+ connection,
306
+ refused,
307
+ undefined,
308
+ "prompt" in frame ? frame.prompt : undefined,
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Whether this answer cannot be taken: a late card's answer starts a turn written as the
314
+ * answerer, which needs the actor their credential vouched for. Refused up front, the card
315
+ * stays open for someone who has one rather than closing on an answer that goes nowhere.
316
+ */
317
+ #needsActor(who: Answerer, prompt: string): boolean {
318
+ return !who.actor && this.#options.desk.isLate(prompt);
319
+ }
320
+
321
+ async #send(
322
+ connection: AppSocket,
323
+ who: Answerer,
324
+ frame: Extract<ClientFrame, { type: "send" }>,
325
+ ): Promise<void> {
326
+ const { rooms, uploads, surface, sockets, logger } = this.#options;
327
+ const refuse = (code: RefusalCode) =>
328
+ this.#refuse(connection, code, frame.id);
329
+ const room = await rooms.resolve(frame.room);
330
+ if (!room) return refuse("unknown_room");
331
+ if (room.readOnly) return refuse("read_only");
332
+ const text = frame.text.trim();
333
+ if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
334
+ const ids = frame.uploads ?? [];
335
+ if (text === "" && ids.length === 0) return refuse("empty");
336
+ // Without the actor the credential vouched for, the router cannot tell whose message it is.
337
+ if (!who.actor) return refuse("forbidden");
338
+ const files = uploads.claim(ids, who.principalId);
339
+ if (!files) return refuse("unknown_upload");
340
+ if (!surface.started) return refuse("not_accepted");
341
+ try {
342
+ await this.say(room.key, who, text, files, connection, (message) =>
343
+ sockets.send(connection, {
344
+ type: "accepted",
345
+ id: frame.id,
346
+ room: room.key,
347
+ message,
348
+ }),
349
+ );
350
+ } catch (error) {
351
+ logger.warn(
352
+ { room: room.key, err: error },
353
+ "an app message was not taken",
354
+ );
355
+ refuse("not_accepted");
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Takes a message of the owner into a room: stores it, shows it to the other sockets, and
361
+ * hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
362
+ * the stored files once the message is stored. `stored` hears the stored message before the
363
+ * host does, so a reply cannot overtake it.
364
+ */
365
+ async say(
366
+ room: ChannelKey,
367
+ who: Answerer,
368
+ text: string,
369
+ files: readonly RoomFile[],
370
+ except?: AppSocket,
371
+ stored?: (message: RoomMessage) => void,
372
+ ): Promise<void> {
373
+ const { surface, uploads } = this.#options;
374
+ if (!who.actor)
375
+ throw new PluginError(
376
+ "a message needs the actor its sender's credential vouched for",
377
+ );
378
+ const message = await surface.publish(
379
+ {
380
+ room,
381
+ kind: "owner",
382
+ principalId: who.principalId,
383
+ author: who.name,
384
+ text,
385
+ files: [...files],
386
+ },
387
+ except,
388
+ );
389
+ uploads.use(files.map((file) => file.id));
390
+ stored?.(message);
391
+ const inbound: InboundMessage = {
392
+ channel: room,
393
+ messageId: `app-${message.id}`,
394
+ actor: who.actor,
395
+ authorId: who.principalId,
396
+ authorName: who.name,
397
+ authorIsBot: false,
398
+ isDirect: false,
399
+ mentionsBot: false,
400
+ repliesToBot: false,
401
+ text,
402
+ attachments: files.map((file) => uploads.refOf(file)),
403
+ };
404
+ surface.deliver(inbound);
405
+ }
406
+ }
@@ -0,0 +1,84 @@
1
+ import type { ActorFacts, Logger, RouteSocket } from "pi-roundtable";
2
+ import type { ServerFrame } from "../api-types.ts";
3
+
4
+ /** One open WebSocket of an owner: who, what the page shows, and when its credential lapses. */
5
+ export interface AppSocket {
6
+ principal: { id: string; name: string };
7
+ /** The actor the credential vouched for, which the person's messages are written as. */
8
+ actor: ActorFacts | undefined;
9
+ expiresAt: Date | undefined;
10
+ /** The page is in view; a push goes out only when no socket is. */
11
+ visible: boolean;
12
+ /** The room the page shows, whose progress streams to it. */
13
+ focus: string | undefined;
14
+ socket?: RouteSocket<AppSocket>;
15
+ /** Closes the socket when its credential lapses or the cap on its life is reached. */
16
+ expiry?: ReturnType<typeof setTimeout>;
17
+ /** Looks again at whether the person is still an owner. */
18
+ recheck?: ReturnType<typeof setInterval>;
19
+ }
20
+
21
+ /** The open sockets. Every one belongs to an owner, so a frame for the owners goes to them all. */
22
+ export class Sockets {
23
+ readonly #open = new Set<AppSocket>();
24
+ readonly #logger: Logger;
25
+
26
+ constructor(logger: Logger) {
27
+ this.#logger = logger;
28
+ }
29
+
30
+ add(connection: AppSocket, socket: RouteSocket<AppSocket>): void {
31
+ connection.socket = socket;
32
+ this.#open.add(connection);
33
+ }
34
+
35
+ remove(connection: AppSocket): void {
36
+ clearTimeout(connection.expiry);
37
+ connection.expiry = undefined;
38
+ clearInterval(connection.recheck);
39
+ connection.recheck = undefined;
40
+ this.#open.delete(connection);
41
+ }
42
+
43
+ /** Sends a frame on one socket; a dropped send is logged, never thrown. */
44
+ send(connection: AppSocket, frame: ServerFrame): void {
45
+ const result = connection.socket?.send(JSON.stringify(frame));
46
+ if (result === "dropped")
47
+ this.#logger.warn(
48
+ { frame: frame.type },
49
+ "an app frame was dropped; the socket is closed",
50
+ );
51
+ }
52
+
53
+ /** Sends a frame to every open socket, or those `where` accepts, except `except`. */
54
+ broadcast(
55
+ frame: ServerFrame,
56
+ where?: (connection: AppSocket) => boolean,
57
+ except?: AppSocket,
58
+ ): void {
59
+ for (const connection of this.#open)
60
+ if (connection !== except && (!where || where(connection)))
61
+ this.send(connection, frame);
62
+ }
63
+
64
+ /** Whether a page of this person is in view now; of any owner when no one is named. */
65
+ anyVisible(principalId?: string): boolean {
66
+ for (const connection of this.#open)
67
+ if (
68
+ connection.visible &&
69
+ (principalId === undefined || connection.principal.id === principalId)
70
+ )
71
+ return true;
72
+ return false;
73
+ }
74
+
75
+ /** Closes every socket, when the app stops. */
76
+ closeAll(code: number, reason: string): void {
77
+ for (const connection of [...this.#open])
78
+ connection.socket?.close(code, reason);
79
+ }
80
+
81
+ get size(): number {
82
+ return this.#open.size;
83
+ }
84
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ type ChannelKey,
3
+ channelKey,
4
+ type InterimMessage,
5
+ type SurfaceThreads,
6
+ } from "pi-roundtable";
7
+ import type { RoomMessage } from "../api-types.ts";
8
+ import type { NewRoomMessage } from "./room-log.ts";
9
+ import type { WebThreads } from "./threads.ts";
10
+
11
+ /** The name a post shows when its sender names none. */
12
+ const ASSISTANT = "Assistant";
13
+ /** Discord's small-print marker, which the notices of the host carry. */
14
+ const NOTICE = /^-# /;
15
+
16
+ export interface AppSurfaceThreadsOptions {
17
+ /** The key prefix of the rooms, such as `app`. */
18
+ surface: string;
19
+ threads: Pick<WebThreads, "open" | "close">;
20
+ /** Stores a message of a room and shows it to every open socket. */
21
+ publish(row: NewRoomMessage): Promise<RoomMessage>;
22
+ /** Changes a stored message's text and shows the change to every open socket. */
23
+ edit(room: ChannelKey, id: number, text: string): Promise<void>;
24
+ /** Tells the sockets the room list differs. */
25
+ changed(): void;
26
+ /** Whether the room can host a thread: an agent's or group's room that exists, not a thread. */
27
+ canHost(parent: ChannelKey): Promise<boolean>;
28
+ }
29
+
30
+ /** A thread's title as the label of a Markdown link, which cannot hold brackets. */
31
+ const labelOf = (title: string): string =>
32
+ title.replace(/\[/g, "(").replace(/]/g, ")");
33
+
34
+ /**
35
+ * The app's dispatch threads as a surface hosts them: a thread is a read-only room beside the
36
+ * room that started the dispatch, one start line there that links to it, and the dispatch's
37
+ * posts, cards and report inside. Closing ends the thread; the room stays readable.
38
+ */
39
+ export class AppSurfaceThreads implements SurfaceThreads {
40
+ readonly #options: AppSurfaceThreadsOptions;
41
+
42
+ constructor(options: AppSurfaceThreadsOptions) {
43
+ this.#options = options;
44
+ }
45
+
46
+ async open(
47
+ parent: ChannelKey,
48
+ name: string,
49
+ line: (mention?: string) => string,
50
+ ): Promise<{ id: string; mention: string } | undefined> {
51
+ const { surface, threads, publish, edit, changed, canHost } = this.#options;
52
+ if (!(await canHost(parent))) return undefined;
53
+ const start = await publish({
54
+ room: parent,
55
+ kind: "system",
56
+ author: ASSISTANT,
57
+ text: line(),
58
+ });
59
+ const thread = await threads.open(parent, name);
60
+ const mention = `[${labelOf(thread.title)}](${channelKey(surface, thread.id)})`;
61
+ // The start line links the thread once it exists; the thread stands even if the edit fails.
62
+ await edit(parent, start.id, line(mention)).catch(() => undefined);
63
+ changed();
64
+ return { id: thread.id, mention };
65
+ }
66
+
67
+ async post(thread: string, text: string): Promise<void> {
68
+ const notice = NOTICE.test(text);
69
+ await this.#options.publish({
70
+ room: channelKey(this.#options.surface, thread),
71
+ kind: notice ? "system" : "agent",
72
+ author: ASSISTANT,
73
+ text: notice ? text.replace(NOTICE, "") : text,
74
+ });
75
+ }
76
+
77
+ async send(thread: string, text: string): Promise<InterimMessage> {
78
+ const room = channelKey(this.#options.surface, thread);
79
+ const message = await this.#options.publish({
80
+ room,
81
+ kind: "agent",
82
+ author: ASSISTANT,
83
+ text,
84
+ });
85
+ return { edit: (change) => this.#options.edit(room, message.id, change) };
86
+ }
87
+
88
+ async close(thread: string): Promise<void> {
89
+ await this.#options.threads.close(thread);
90
+ this.#options.changed();
91
+ }
92
+ }