@frockbot/cloudflare 0.0.0 → 0.7.291

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 (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,736 @@
1
+ // One Group Chat's own Durable Object: its thread, the person's read cursor,
2
+ // each member's Turn here, and the clients watching it.
3
+ //
4
+ // It is addressed as `<userId>:<groupId>` and pins that pair the first time
5
+ // the User object creates it, so a request can only ever reach the one group
6
+ // it names. Who is in the group is the User object's to say; this object reads
7
+ // it before it acts. Member Turns run in the members' own Bot objects: this
8
+ // one admits them, reads them back when a member says something changed, and
9
+ // reads them again from its alarm while any is open.
10
+
11
+ import { DurableObject } from "cloudflare:workers";
12
+ import {
13
+ GroupChatNotFoundError,
14
+ answerGroupRpcV1,
15
+ unwrapGroupRpcV1,
16
+ decodeGroupPostCommandV1,
17
+ decodeGroupRetryCommandV1,
18
+ decodeGroupStopCommandV1,
19
+ groupChatObjectNameV1,
20
+ groupDisplayNameV1,
21
+ type GroupActorV1,
22
+ type GroupChatContextV1,
23
+ type GroupEventV1,
24
+ type GroupPostCommandV1,
25
+ type GroupRetryCommandV1,
26
+ type GroupStopCommandV1,
27
+ } from "@frockbot/app/groups/shared";
28
+ import {
29
+ GroupChatLogV1,
30
+ type GroupEffectV1,
31
+ type GroupKvV1,
32
+ type GroupTurnStateV1,
33
+ } from "@frockbot/app/groups/log";
34
+ import { sha256HexTextV1 } from "@frockbot/core/crypto";
35
+ import type { GroupReplyJudgeV1 } from "@frockbot/core/contracts";
36
+ import { createHostedGroupReplyJudgeV1 } from "@frockbot/app/supervision";
37
+ import {
38
+ decodeRpcEnvelopeV1,
39
+ rpcBoolean,
40
+ rpcBotId,
41
+ rpcDecoded,
42
+ rpcIdentifier,
43
+ rpcInteger,
44
+ rpcJsonRecord,
45
+ rpcPattern,
46
+ rpcString,
47
+ } from "./durable-rpc.js";
48
+
49
+ export const GROUP_CHANNEL_INTERNAL_PATH = "/internal/group-channel";
50
+
51
+ /** How often an open member Turn is read back when no nudge has arrived. */
52
+ const OPEN_TURN_POLL_MS = 30_000;
53
+ /** How long an owed admission waits before it is asked for again. */
54
+ const ADMISSION_RETRY_MS = 15_000;
55
+
56
+ export interface GroupChatEnv {
57
+ BOT_STATES: DurableObjectNamespace;
58
+ USER_CONFIGURATIONS: DurableObjectNamespace;
59
+ GROUP_CHATS: DurableObjectNamespace;
60
+ /** Jev, who judges who answers each message. Absent, nobody extra is asked. */
61
+ JEV_API_KEY?: string;
62
+ }
63
+
64
+ /** How long one judgment may take before the group goes on without it. */
65
+ const JUDGEMENT_TIMEOUT_MS = 45_000;
66
+
67
+ interface BotGroupRpc {
68
+ admitGroupTurn(input: unknown): Promise<unknown>;
69
+ readGroupTurn(input: unknown): Promise<unknown>;
70
+ signalGroupMessage(input: unknown): Promise<unknown>;
71
+ stopRun(input: unknown): Promise<unknown>;
72
+ }
73
+
74
+ interface UserGroupRpc {
75
+ readGroupChatContext(input: unknown): Promise<unknown>;
76
+ deliverGroupPush(input: unknown): Promise<unknown>;
77
+ }
78
+
79
+ const groupIdDecoder = rpcPattern(/^g-[0-9a-f]{20}$/, 22);
80
+
81
+ function errorNamed(error: unknown, name: string): boolean {
82
+ return (
83
+ typeof error === "object" &&
84
+ error !== null &&
85
+ "name" in error &&
86
+ error.name === name
87
+ );
88
+ }
89
+
90
+ export class GroupChat extends DurableObject<GroupChatEnv> {
91
+ private judgeInstance?: GroupReplyJudgeV1;
92
+
93
+ /** Jev when this deployment has it; the unavailable judge otherwise. */
94
+ private judge(): GroupReplyJudgeV1 {
95
+ this.judgeInstance ??= createHostedGroupReplyJudgeV1({
96
+ JEV_API_KEY: this.env.JEV_API_KEY,
97
+ });
98
+ return this.judgeInstance;
99
+ }
100
+
101
+ private log(kv: GroupKvV1 = this.ctx.storage as unknown as GroupKvV1) {
102
+ return new GroupChatLogV1(kv);
103
+ }
104
+
105
+ private transaction<T>(closure: (log: GroupChatLogV1) => Promise<T>) {
106
+ return this.ctx.storage.transaction((transaction) =>
107
+ closure(this.log(transaction as unknown as GroupKvV1)),
108
+ );
109
+ }
110
+
111
+ /** The caller named this object: its namespace name is the User and group. */
112
+ private assertAddressed(userId: string, groupId: string): void {
113
+ const id = this.env.GROUP_CHATS.idFromName(
114
+ groupChatObjectNameV1(userId, groupId),
115
+ );
116
+ if (!id.equals(this.ctx.id)) {
117
+ throw new Error("this Group Chat object is a different group");
118
+ }
119
+ }
120
+
121
+ /** Addressed, pinned, and pinned to the same pair. */
122
+ private async assertIdentity(userId: string, groupId: string): Promise<void> {
123
+ this.assertAddressed(userId, groupId);
124
+ const identity = await this.log().identity();
125
+ if (!identity) throw new GroupChatNotFoundError(groupId);
126
+ if (identity.userId !== userId || identity.groupId !== groupId) {
127
+ throw new Error("this Group Chat object is a different group");
128
+ }
129
+ }
130
+
131
+ private botStub(userId: string, botId: string): BotGroupRpc {
132
+ return this.env.BOT_STATES.get(
133
+ this.env.BOT_STATES.idFromName(`${userId}:${botId}`),
134
+ ) as unknown as BotGroupRpc;
135
+ }
136
+
137
+ /** The group and its members' names, as the User object has them now. */
138
+ private async context(
139
+ userId: string,
140
+ groupId: string,
141
+ ): Promise<GroupChatContextV1> {
142
+ const user = this.env.USER_CONFIGURATIONS.get(
143
+ this.env.USER_CONFIGURATIONS.idFromName(userId),
144
+ ) as unknown as UserGroupRpc;
145
+ return unwrapGroupRpcV1<GroupChatContextV1>(
146
+ await user.readGroupChatContext({ schemaVersion: 1, userId, groupId }),
147
+ );
148
+ }
149
+
150
+ // ---- from the User object ---------------------------------------------
151
+
152
+ /** A change the User object committed: the line it adds to the thread. */
153
+ async recordEvent(input: unknown) {
154
+ const request = decodeRpcEnvelopeV1(
155
+ input,
156
+ {
157
+ userId: rpcIdentifier,
158
+ groupId: groupIdDecoder,
159
+ commandId: rpcString(256),
160
+ actor: rpcJsonRecord,
161
+ event: rpcJsonRecord,
162
+ context: rpcJsonRecord,
163
+ },
164
+ { initialize: rpcBoolean },
165
+ );
166
+ const userId = request.userId as string;
167
+ const groupId = request.groupId as string;
168
+ this.assertAddressed(userId, groupId);
169
+ if (request.initialize === true) {
170
+ await this.log().initialize({ schemaVersion: 1, userId, groupId });
171
+ }
172
+ await this.assertIdentity(userId, groupId);
173
+ const outcome = await this.transaction((log) =>
174
+ log.recordEvent({
175
+ commandId: request.commandId as string,
176
+ actor: request.actor as unknown as GroupActorV1,
177
+ event: request.event as unknown as GroupEventV1,
178
+ context: request.context as unknown as GroupChatContextV1,
179
+ }),
180
+ );
181
+ await this.carryOut(userId, outcome.effects);
182
+ return { schemaVersion: 1, seq: outcome.value.seq } as const;
183
+ }
184
+
185
+ /** The User deleted the group: its member Turns stop and its thread goes. */
186
+ async destroy(input: unknown) {
187
+ const request = decodeRpcEnvelopeV1(input, {
188
+ userId: rpcIdentifier,
189
+ groupId: groupIdDecoder,
190
+ });
191
+ const userId = request.userId as string;
192
+ const groupId = request.groupId as string;
193
+ this.assertAddressed(userId, groupId);
194
+ if (!(await this.log().identity())) {
195
+ return { schemaVersion: 1 } as const;
196
+ }
197
+ await this.assertIdentity(userId, groupId);
198
+ for (const turn of await this.log().openTurns()) {
199
+ await this.stopMember(
200
+ userId,
201
+ turn.botId,
202
+ turn.runId,
203
+ `delete-${groupId}`,
204
+ );
205
+ }
206
+ for (const socket of this.ctx.getWebSockets()) {
207
+ try {
208
+ socket.close(1000, "group deleted");
209
+ } catch {
210
+ // Already closing.
211
+ }
212
+ }
213
+ await this.ctx.storage.deleteAlarm();
214
+ await this.ctx.storage.deleteAll();
215
+ return { schemaVersion: 1 } as const;
216
+ }
217
+
218
+ // ---- from the client, through the gateway -----------------------------
219
+
220
+ async view(input: unknown) {
221
+ return answerGroupRpcV1(async () => {
222
+ const request = decodeRpcEnvelopeV1(input, {
223
+ userId: rpcIdentifier,
224
+ groupId: groupIdDecoder,
225
+ });
226
+ const userId = request.userId as string;
227
+ const groupId = request.groupId as string;
228
+ await this.assertIdentity(userId, groupId);
229
+ return this.log().view(await this.context(userId, groupId));
230
+ });
231
+ }
232
+
233
+ async page(input: unknown) {
234
+ return answerGroupRpcV1(async () => {
235
+ const request = decodeRpcEnvelopeV1(
236
+ input,
237
+ {
238
+ userId: rpcIdentifier,
239
+ groupId: groupIdDecoder,
240
+ limit: rpcInteger({ minimum: 1, maximum: 100 }),
241
+ },
242
+ {
243
+ before: rpcInteger({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
244
+ after: rpcInteger({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
245
+ },
246
+ );
247
+ await this.assertIdentity(
248
+ request.userId as string,
249
+ request.groupId as string,
250
+ );
251
+ return this.log().page({
252
+ limit: request.limit as number,
253
+ ...(request.before !== undefined
254
+ ? { before: request.before as number }
255
+ : {}),
256
+ ...(request.after !== undefined
257
+ ? { after: request.after as number }
258
+ : {}),
259
+ });
260
+ });
261
+ }
262
+
263
+ /** The person said something in the group. */
264
+ async postFromUser(input: unknown) {
265
+ return answerGroupRpcV1(async () => {
266
+ const request = decodeRpcEnvelopeV1(input, {
267
+ userId: rpcIdentifier,
268
+ groupId: groupIdDecoder,
269
+ command: rpcDecoded(decodeGroupPostCommandV1),
270
+ });
271
+ const userId = request.userId as string;
272
+ const groupId = request.groupId as string;
273
+ await this.assertIdentity(userId, groupId);
274
+ const command = request.command as unknown as GroupPostCommandV1;
275
+ const context = await this.context(userId, groupId);
276
+ const outcome = await this.transaction((log) =>
277
+ log.post({
278
+ // The app matches its unconfirmed send to this id, so the message
279
+ // is drawn once whether the post or the channel brings it back.
280
+ messageId: `u-${command.commandId}`,
281
+ author: { kind: "user" },
282
+ text: command.text,
283
+ context,
284
+ }),
285
+ );
286
+ await this.carryOut(userId, outcome.effects);
287
+ return { schemaVersion: 1, message: outcome.value } as const;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * A member posting into the group from outside it — its own chat, or a
293
+ * Routine. The message id is the Bot's, derived from the call that posted,
294
+ * so a replayed call posts once.
295
+ */
296
+ async postFromBot(input: unknown) {
297
+ return answerGroupRpcV1(async () => {
298
+ const request = decodeRpcEnvelopeV1(input, {
299
+ userId: rpcIdentifier,
300
+ groupId: groupIdDecoder,
301
+ botId: rpcBotId,
302
+ messageId: rpcPattern(/^o-[0-9a-f]{40}$/, 42),
303
+ text: rpcDecoded(
304
+ (value) =>
305
+ decodeGroupPostCommandV1({
306
+ schemaVersion: 1,
307
+ commandId: "outside",
308
+ text: value,
309
+ }).text,
310
+ ),
311
+ });
312
+ const userId = request.userId as string;
313
+ const groupId = request.groupId as string;
314
+ await this.assertIdentity(userId, groupId);
315
+ const context = await this.context(userId, groupId);
316
+ const outcome = await this.transaction((log) =>
317
+ log.post({
318
+ messageId: request.messageId as string,
319
+ author: { kind: "bot", botId: request.botId as string },
320
+ text: request.text as string,
321
+ context,
322
+ }),
323
+ );
324
+ await this.carryOut(userId, outcome.effects);
325
+ return { schemaVersion: 1, message: outcome.value } as const;
326
+ });
327
+ }
328
+
329
+ async markRead(input: unknown) {
330
+ return answerGroupRpcV1(async () => {
331
+ const request = decodeRpcEnvelopeV1(input, {
332
+ userId: rpcIdentifier,
333
+ groupId: groupIdDecoder,
334
+ upTo: rpcInteger({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
335
+ });
336
+ await this.assertIdentity(
337
+ request.userId as string,
338
+ request.groupId as string,
339
+ );
340
+ const readThrough = await this.transaction((log) =>
341
+ log.markRead(request.upTo as number),
342
+ );
343
+ this.broadcast();
344
+ return { schemaVersion: 1, readThrough } as const;
345
+ });
346
+ }
347
+
348
+ /** `/stop`: the group's member Turns, or one member's. */
349
+ async stop(input: unknown) {
350
+ return answerGroupRpcV1(async () => {
351
+ const request = decodeRpcEnvelopeV1(input, {
352
+ userId: rpcIdentifier,
353
+ groupId: groupIdDecoder,
354
+ command: rpcDecoded(decodeGroupStopCommandV1),
355
+ });
356
+ const userId = request.userId as string;
357
+ const groupId = request.groupId as string;
358
+ await this.assertIdentity(userId, groupId);
359
+ const command = request.command as unknown as GroupStopCommandV1;
360
+ const outcome = await this.transaction((log) =>
361
+ log.stop({
362
+ commandId: command.commandId,
363
+ ...(command.botId ? { botId: command.botId } : {}),
364
+ }),
365
+ );
366
+ await this.carryOut(userId, outcome.effects);
367
+ return { schemaVersion: 1, stopped: outcome.value.stopped } as const;
368
+ });
369
+ }
370
+
371
+ async retry(input: unknown) {
372
+ return answerGroupRpcV1(async () => {
373
+ const request = decodeRpcEnvelopeV1(input, {
374
+ userId: rpcIdentifier,
375
+ groupId: groupIdDecoder,
376
+ command: rpcDecoded(decodeGroupRetryCommandV1),
377
+ });
378
+ const userId = request.userId as string;
379
+ const groupId = request.groupId as string;
380
+ await this.assertIdentity(userId, groupId);
381
+ const command = request.command as unknown as GroupRetryCommandV1;
382
+ const context = await this.context(userId, groupId);
383
+ const outcome = await this.transaction((log) =>
384
+ log.retry({ ...command, context }),
385
+ );
386
+ await this.carryOut(userId, outcome.effects);
387
+ return { schemaVersion: 1 } as const;
388
+ });
389
+ }
390
+
391
+ // ---- from a member's Bot object ---------------------------------------
392
+
393
+ /** A member Turn changed; read it back. */
394
+ async turnChanged(input: unknown) {
395
+ const request = decodeRpcEnvelopeV1(input, {
396
+ userId: rpcIdentifier,
397
+ groupId: groupIdDecoder,
398
+ botId: rpcBotId,
399
+ runId: rpcString(128),
400
+ });
401
+ const userId = request.userId as string;
402
+ const groupId = request.groupId as string;
403
+ await this.assertIdentity(userId, groupId);
404
+ await this.readBack(
405
+ userId,
406
+ groupId,
407
+ request.botId as string,
408
+ request.runId as string,
409
+ );
410
+ return { schemaVersion: 1 } as const;
411
+ }
412
+
413
+ private async readBack(
414
+ userId: string,
415
+ groupId: string,
416
+ botId: string,
417
+ runId: string,
418
+ context?: GroupChatContextV1,
419
+ ): Promise<void> {
420
+ const read = (await this.botStub(userId, botId).readGroupTurn({
421
+ schemaVersion: 1,
422
+ userId,
423
+ botId,
424
+ runId,
425
+ })) as { found: boolean; state?: GroupTurnStateV1 };
426
+ const state: GroupTurnStateV1 =
427
+ read.found && read.state
428
+ ? structuredClone(read.state)
429
+ : // The member no longer has the run: count it as ended.
430
+ { status: "failed", started: true, sends: [], yielded: false };
431
+ const resolved = context ?? (await this.context(userId, groupId));
432
+ const outcome = await this.transaction((log) =>
433
+ log.applyTurnState({ botId, runId, state, context: resolved }),
434
+ );
435
+ await this.carryOut(userId, outcome.effects);
436
+ }
437
+
438
+ // ---- effects and the alarm ---------------------------------------------
439
+
440
+ /** What a committed change owes the outside world, after the commit. */
441
+ private async carryOut(
442
+ userId: string,
443
+ effects: readonly GroupEffectV1[],
444
+ ): Promise<void> {
445
+ let broadcast = false;
446
+ const identity = await this.log().identity();
447
+ const judged: number[] = [];
448
+ let pushed = false;
449
+ for (const effect of effects) {
450
+ switch (effect.kind) {
451
+ case "broadcast":
452
+ broadcast = true;
453
+ break;
454
+ case "judge":
455
+ judged.push(effect.seq);
456
+ break;
457
+ case "push":
458
+ pushed = true;
459
+ break;
460
+ case "signal":
461
+ if (!identity) break;
462
+ await this.botStub(userId, effect.botId)
463
+ .signalGroupMessage({
464
+ schemaVersion: 1,
465
+ userId,
466
+ botId: effect.botId,
467
+ groupId: identity.groupId,
468
+ seq: effect.seq,
469
+ })
470
+ .catch(() => undefined);
471
+ break;
472
+ case "stop":
473
+ await this.stopMember(
474
+ userId,
475
+ effect.botId,
476
+ effect.runId,
477
+ effect.commandId,
478
+ );
479
+ break;
480
+ case "admit":
481
+ try {
482
+ await this.botStub(userId, effect.botId).admitGroupTurn({
483
+ schemaVersion: 1,
484
+ userId,
485
+ botId: effect.botId,
486
+ command: effect.admission,
487
+ });
488
+ await this.transaction((log) =>
489
+ log.admitted(effect.botId, effect.admission.runId),
490
+ );
491
+ broadcast = true;
492
+ } catch {
493
+ // Written down already; the alarm asks again, until the member
494
+ // has refused often enough that the group shows it did not
495
+ // answer and offers Retry.
496
+ const gaveUp = await this.transaction((log) =>
497
+ log.admissionFailed(effect.botId, effect.admission.runId),
498
+ );
499
+ if (gaveUp) broadcast = true;
500
+ }
501
+ break;
502
+ }
503
+ }
504
+ if (broadcast) this.broadcast();
505
+ if (identity && pushed) {
506
+ await this.deliverPushes(userId, identity.groupId);
507
+ }
508
+ if (identity) {
509
+ for (const seq of judged) {
510
+ await this.judgeMessage(userId, identity.groupId, seq);
511
+ }
512
+ }
513
+ await this.armAlarm();
514
+ }
515
+
516
+ /**
517
+ * Notifies the person of every member message that calls them. A device
518
+ * reading this group defers the alert, and a failure keeps it: the alarm
519
+ * tries again until it is delivered.
520
+ */
521
+ private async deliverPushes(userId: string, groupId: string): Promise<void> {
522
+ const log = this.log();
523
+ const pending = await log.pendingPushes();
524
+ if (pending.length === 0) return;
525
+ const context = await this.context(userId, groupId).catch(() => undefined);
526
+ if (!context) return;
527
+ const user = this.env.USER_CONFIGURATIONS.get(
528
+ this.env.USER_CONFIGURATIONS.idFromName(userId),
529
+ ) as unknown as UserGroupRpc;
530
+ const title = groupDisplayNameV1(context.group, context.members);
531
+ for (const seq of pending) {
532
+ const message = await log.message(seq);
533
+ // Read per entry: the person may read the group during a delivery.
534
+ const { readThrough } = await log.channelState();
535
+ if (
536
+ seq <= readThrough ||
537
+ message?.body.kind !== "text" ||
538
+ message.author.kind !== "bot"
539
+ ) {
540
+ await this.transaction((next) => next.pushDelivered(seq));
541
+ continue;
542
+ }
543
+ const authorId = message.author.botId;
544
+ const author =
545
+ context.members.find((member) => member.botId === authorId)?.name ??
546
+ authorId;
547
+ try {
548
+ await user.deliverGroupPush({
549
+ schemaVersion: 1,
550
+ userId,
551
+ groupId,
552
+ botId: authorId,
553
+ seq,
554
+ title: title.slice(0, 200),
555
+ body: `${author}: ${message.body.text}`.slice(0, 240),
556
+ });
557
+ } catch {
558
+ // Still owed; the alarm tries again.
559
+ return;
560
+ }
561
+ await this.transaction((next) => next.pushDelivered(seq));
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Asks Jev who answers one message, and applies the answer once. A
567
+ * judgment that fails is still written down as owed, and the alarm asks
568
+ * again.
569
+ */
570
+ private async judgeMessage(
571
+ userId: string,
572
+ groupId: string,
573
+ seq: number,
574
+ known?: GroupChatContextV1,
575
+ ): Promise<void> {
576
+ const context = known ?? (await this.context(userId, groupId));
577
+ const evidence = await this.log().judgementEvidence(seq, context);
578
+ const decision = evidence
579
+ ? await this.judge()
580
+ .decide(evidence, AbortSignal.timeout(JUDGEMENT_TIMEOUT_MS))
581
+ .catch(() => undefined)
582
+ : { reply: [] };
583
+ if (!decision) return;
584
+ const outcome = await this.transaction((log) =>
585
+ log.applyJudgement({ seq, decision, context }),
586
+ );
587
+ await this.carryOut(userId, outcome.effects);
588
+ }
589
+
590
+ private async stopMember(
591
+ userId: string,
592
+ botId: string,
593
+ runId: string,
594
+ commandId: string,
595
+ ): Promise<void> {
596
+ const digest = await sha256HexTextV1(`${commandId}\n${botId}\n${runId}`);
597
+ await this.botStub(userId, botId)
598
+ .stopRun({
599
+ schemaVersion: 1,
600
+ userId,
601
+ botId,
602
+ command: {
603
+ schemaVersion: 1,
604
+ action: "stop",
605
+ commandId: `gs-${digest.slice(0, 32)}`,
606
+ runId,
607
+ },
608
+ })
609
+ .catch(() => undefined);
610
+ }
611
+
612
+ private async armAlarm(): Promise<void> {
613
+ const log = this.log();
614
+ const open = (await log.openTurns()).length > 0;
615
+ const owed =
616
+ (await log.owedAdmissions()).length > 0 ||
617
+ (await log.pendingJudgements()).length > 0 ||
618
+ (await log.pendingPushes()).length > 0;
619
+ if (!open && !owed) return;
620
+ const due = Date.now() + (owed ? ADMISSION_RETRY_MS : OPEN_TURN_POLL_MS);
621
+ const current = await this.ctx.storage.getAlarm();
622
+ if (current === null || current > due) {
623
+ await this.ctx.storage.setAlarm(due);
624
+ }
625
+ }
626
+
627
+ async alarm(): Promise<void> {
628
+ const identity = await this.log().identity();
629
+ if (!identity) return;
630
+ let context: GroupChatContextV1;
631
+ try {
632
+ context = await this.context(identity.userId, identity.groupId);
633
+ } catch (error) {
634
+ if (errorNamed(error, "GroupChatNotFoundError")) {
635
+ // The User deleted the group and the delete did not reach this
636
+ // object; finish it here.
637
+ await this.ctx.storage.deleteAll();
638
+ return;
639
+ }
640
+ await this.ctx.storage.setAlarm(Date.now() + ADMISSION_RETRY_MS);
641
+ return;
642
+ }
643
+ for (const turn of await this.log().openTurns()) {
644
+ await this.readBack(
645
+ identity.userId,
646
+ identity.groupId,
647
+ turn.botId,
648
+ turn.runId,
649
+ context,
650
+ ).catch(() => undefined);
651
+ }
652
+ await this.deliverPushes(identity.userId, identity.groupId).catch(
653
+ () => undefined,
654
+ );
655
+ for (const seq of await this.log().pendingJudgements()) {
656
+ await this.judgeMessage(
657
+ identity.userId,
658
+ identity.groupId,
659
+ seq,
660
+ context,
661
+ ).catch(() => undefined);
662
+ }
663
+ const due = await this.transaction((log) => log.admissionsDue(context));
664
+ await this.carryOut(identity.userId, due);
665
+ }
666
+
667
+ // ---- the client channel -------------------------------------------------
668
+
669
+ /**
670
+ * A small state frame on every change: where the thread's head is, what the
671
+ * person has read, and who is working. A client reads the messages it has
672
+ * not seen through the ordinary page route.
673
+ */
674
+ private async stateFrame(): Promise<string> {
675
+ const log = this.log();
676
+ const state = await log.channelState();
677
+ return JSON.stringify({ schemaVersion: 1, type: "group/state", ...state });
678
+ }
679
+
680
+ private broadcast(): void {
681
+ const sockets = this.ctx.getWebSockets();
682
+ if (sockets.length === 0) return;
683
+ this.ctx.waitUntil(
684
+ (async () => {
685
+ const frame = await this.stateFrame();
686
+ for (const socket of sockets) {
687
+ try {
688
+ socket.send(frame);
689
+ } catch {
690
+ // Closed; the runtime drops it.
691
+ }
692
+ }
693
+ })(),
694
+ );
695
+ }
696
+
697
+ async fetch(request: Request): Promise<Response> {
698
+ const url = new URL(request.url);
699
+ if (url.pathname !== GROUP_CHANNEL_INTERNAL_PATH) {
700
+ return new Response("not found", { status: 404 });
701
+ }
702
+ const userId = request.headers.get("x-frockbot-user-id") ?? "";
703
+ const groupId = request.headers.get("x-frockbot-group-id") ?? "";
704
+ try {
705
+ await this.assertIdentity(userId, groupId);
706
+ } catch (error) {
707
+ if (errorNamed(error, "GroupChatNotFoundError")) {
708
+ return Response.json(
709
+ { error: "Group Chat not found" },
710
+ { status: 404 },
711
+ );
712
+ }
713
+ throw error;
714
+ }
715
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
716
+ return new Response("expected a WebSocket", { status: 426 });
717
+ }
718
+ const pair = new WebSocketPair();
719
+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
720
+ this.ctx.acceptWebSocket(server, ["group"]);
721
+ server.send(await this.stateFrame());
722
+ return new Response(null, { status: 101, webSocket: client });
723
+ }
724
+
725
+ webSocketMessage(): void {
726
+ // The channel carries state to the client; nothing is read from it.
727
+ }
728
+
729
+ webSocketClose(socket: WebSocket, code: number): void {
730
+ try {
731
+ socket.close(code === 1005 ? 1000 : code, "closing");
732
+ } catch {
733
+ // Already closed.
734
+ }
735
+ }
736
+ }