Skip to content
2,093 linesCodeBlameRaw
1/**
2 * The chat service: a workspace's channels, direct messages, threads and
3 * messages. People and agents are members alike (docs.g1t.sh/guides/chat/).
4 *
5 * Reached through service bindings: `POST /rpc/<method>` with snake_case
6 * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's
7 * socket, which the site forwards after checking the session. Each channel
8 * has a room (src/room.ts) that delivers what happens in it live.
9 *
10 * Workspaces are kept by id, so renaming one changes nothing here; who is
11 * in a workspace comes from the viewer's memberships, as in every service.
12 */
13
14import {
15 CHAT_MAX_HOPS,
16 askerAccess,
17 CHAT_VIEWER_HEADER,
18 fail,
19 identityClient,
20 newId,
21 ok,
22 openD1,
23 parsePrincipalKey,
24 principalKey,
25 workspaceAgentsClient,
26 type AgentDelivery,
27 type AgentFoundMessage,
28 type AgentHandOff,
29 type AgentPostMessage,
30 type AskerAccess,
31 type Channel,
32 type ChannelChange,
33 type ChannelDetail,
34 type ChannelMember,
35 type ChatAudience,
36 type ChatLiveEvent,
37 type ChatMessage,
38 type ChatReaction,
39 type CustomEmoji,
40 type EmojiFile,
41 type EmojiList,
42 type EmojiUpload,
43 type ChatSettings,
44 type ChatSettingsView,
45 type ChatSidebar,
46 type ChatSidebarEntry,
47 CONVERSATION_PEOPLE_SHOWN,
48 type ConversationForAgent,
49 type HandOffResult,
50 type Member,
51 type MemberProfile,
52 type CardActionResult,
53 type MessageCard,
54 type MessagePage,
55 type NewChannel,
56 type PostMessage,
57 type Principal,
58 type Result,
59 type ServiceBinding,
60 type User,
61 type Viewer,
62 type Workspace,
63 type WorkspaceAgent,
64} from "@g1t/contracts";
65
66import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
67import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts";
68import {
69 MAX_REACTIONS_PER_MESSAGE,
70 emojiImage,
71 emojiName,
72 fromBase64,
73 mayRemove,
74 mayUpload,
75 reactionEmoji,
76 roomForReaction,
77 tallyReactions,
78 type ReactionRow,
79 type ReactionTally,
80} from "./emoji.ts";
81import { cleanCard } from "./cards.ts";
82import { mentionedHandles, mentionsColumn, plainOutside } from "./mentions.ts";
83import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
84import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
85import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
86import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
87import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
88// Live notifications and counts (services/notify).
89import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
90
91export { ChannelRoom } from "./room.ts";
92
93// The hop limit here is the one in the contract.
94const SAME_HOP_LIMIT: typeof CHAT_MAX_HOPS = MAX_HOPS;
95void SAME_HOP_LIMIT;
96
97type Env = {
98 DB: D1Database;
99 IDENTITY: ServiceBinding;
100 AGENTS: ServiceBinding;
101 ROOMS: DurableObjectNamespace<ChannelRoom>;
102 /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */
103 AVATARS: KVNamespace;
104 /** Live notifications and unread counts (services/notify); absent, nobody is told. */
105 NOTIFY?: ServiceBinding;
106};
107
108type EmojiRow = {
109 workspace_id: string;
110 name: string;
111 alias_of: string | null;
112 file: string;
113 content_type: CustomEmoji["content_type"];
114 bytes: number;
115 created_by: string;
116 created_at: string;
117 deleted_at: string | null;
118};
119
120/** The viewer's role in a workspace, or null when they are not in it. */
121function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null {
122 return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null;
123}
124
125async function sha256(bytes: Uint8Array): Promise<string> {
126 const digest = await crypto.subtle.digest("SHA-256", bytes);
127 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
128}
129
130type ChannelRow = {
131 id: string;
132 workspace_id: string;
133 kind: "channel" | "dm";
134 name: string | null;
135 topic: string | null;
136 private: number;
137 dm_key: string | null;
138 created_by: string;
139 created_at: string;
140 archived_at: string | null;
141 last_message_at: string | null;
142};
143
144type MemberRow = {
145 channel_id: string;
146 principal: string;
147 role: "owner" | "member";
148 starred: number;
149 muted: number;
150 last_read_id: string | null;
151 joined_at: string;
152};
153
154type MessageRow = {
155 id: string;
156 channel_id: string;
157 author: string;
158 kind: "text" | "card";
159 body: string;
160 card: string | null;
161 mentions: string;
162 thread_root: string | null;
163 reply_count: number;
164 last_reply_at: string | null;
165 created_at: string;
166 edited_at: string | null;
167 deleted_at: string | null;
168};
169
170/** What a method found out about the channel it was asked about. */
171type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
172
173/** The most unread messages one sidebar reads to count; past it, counts are "at least". */
174const MAX_UNREAD_ROWS = 5_000;
175/** The longest channel topic. */
176const MAX_TOPIC = 250;
177/** How many others a direct message's sidebar entry shows. */
178const DM_FACES = 4;
179
180const now = () => new Date().toISOString();
181
182function isMember(viewer: Viewer, workspace: string): boolean {
183 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
184}
185
186function isOwner(viewer: Viewer, workspace: string): boolean {
187 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner");
188}
189
190function userKey(viewer: User): string {
191 return principalKey({ kind: "user", id: viewer.id });
192}
193
194function toChannel(row: ChannelRow): Channel {
195 return {
196 id: row.id,
197 workspace_id: row.workspace_id,
198 kind: row.kind,
199 name: row.kind === "dm" ? null : row.name,
200 topic: row.topic,
201 private: row.kind === "dm" || !!row.private,
202 created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by },
203 created_at: row.created_at,
204 archived_at: row.archived_at,
205 last_message_at: row.last_message_at,
206 };
207}
208
209/** An asker handed back by the agents service, or null when what was sent is not one. */
210function cleanAsker(asker: unknown): AskerAccess | null {
211 if (!asker || typeof asker !== "object") return null;
212 const a = asker as Record<string, unknown>;
213 if (typeof a.username !== "string" || !["owner", "member", "outside"].includes(String(a.role))) return null;
214 return { username: a.username, role: a.role as AskerAccess["role"], can_write: a.can_write === true };
215}
216
217function bytesOf(text: string): number {
218 return new TextEncoder().encode(text).length;
219}
220
221class Chat {
222 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
223 private readonly people = new Map<string, Promise<Map<string, Member>>>();
224 private readonly usernames = new Map<string, string>();
225 private readonly agents = new Map<string, WorkspaceAgent | null>();
226 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
227
228 /** `defer` runs work after the answer is sent: the request's waitUntil. */
229 constructor(
230 private readonly env: Env,
231 private readonly defer: (work: Promise<unknown>) => void = () => {},
232 ) {}
233
234 private get db() {
235 return this.env.DB;
236 }
237
238 // ── Who and where ───────────────────────────────────────────────────────
239
240 private workspace(slug: string): Promise<Workspace | null> {
241 const key = slug.toLowerCase();
242 let found = this.workspaces.get(key);
243 if (!found) {
244 found = identityClient(this.env.IDENTITY).getWorkspace(key);
245 this.workspaces.set(key, found);
246 }
247 return found;
248 }
249
250 /** The workspace's people by username, with their names and avatars; asked once per request. */
251 private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> {
252 let found = this.people.get(workspace.id);
253 if (!found) {
254 // Asked as the workspace itself, so it works for agents' calls too.
255 const actor: User = {
256 id: workspace.id,
257 username: workspace.slug,
258 kind: "workspace",
259 verified: true,
260 workspaces: [{ slug: workspace.slug, role: "member" }],
261 };
262 found = identityClient(this.env.IDENTITY)
263 .listMembers(slug, actor)
264 .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : []))
265 .catch((error) => {
266 console.error("chat could not list members of", slug, error);
267 return new Map<string, Member>();
268 });
269 this.people.set(workspace.id, found);
270 }
271 return found;
272 }
273
274 /** Agents by id; ones the agents service does not know are null. Asked once per request. */
275 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
276 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
277 if (wanted.length) {
278 let found: WorkspaceAgent[] = [];
279 try {
280 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
281 } catch (error) {
282 console.error("chat could not resolve agents", error);
283 }
284 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
285 }
286 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
287 }
288
289 /** An agent of this workspace that is not archived, or null. */
290 private async liveAgent(workspace: Workspace, id: string): Promise<WorkspaceAgent | null> {
291 const agent = (await this.agentsById([id])).get(id) ?? null;
292 return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null;
293 }
294
295 /** How each member key shows, for one workspace. */
296 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
297 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
298 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
299 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
300 const unnamed = userIds.filter((id) => !this.usernames.has(id));
301 const [named, people, agents] = await Promise.all([
302 unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>),
303 userIds.length ? this.members(slug, workspace) : new Map<string, Member>(),
304 this.agentsById(agentIds),
305 ]);
306 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
307 const out = new Map<string, MemberProfile>();
308 for (const p of principals) {
309 if (p.kind === "user") {
310 const username = this.usernames.get(p.id) ?? null;
311 const person = username ? people.get(username) : undefined;
312 out.set(principalKey(p), {
313 ...p,
314 name: username ?? "ghost",
315 display_username: person?.display_username ?? null,
316 // Their display name, else their username as they wrote it
317 // (`memberName` reads this the same way).
318 display_name: person?.name?.trim() || person?.display_username || username || "Former member",
319 avatar: person?.avatar ?? null,
320 role: null,
321 title: null,
322 avatar_seed: null,
323 });
324 } else {
325 const agent = agents.get(p.id) ?? null;
326 out.set(principalKey(p), {
327 ...p,
328 name: agent?.handle ?? p.id,
329 display_name: agent?.display_name ?? "Former agent",
330 avatar: agent?.avatar ?? null,
331 role: agent?.role ?? null,
332 title: agent?.title || null,
333 avatar_seed: agent?.avatar_seed ?? null,
334 });
335 }
336 }
337 return out;
338 }
339
340 private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> {
341 return (await this.profiles(slug, workspace, [key])).get(key)!;
342 }
343
344 /** Whether `principal` may be added to a conversation in this workspace. */
345 private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> {
346 if (principal.kind === "agent") return !!(await this.liveAgent(workspace, principal.id));
347 if (!this.usernames.has(principal.id)) {
348 const named = await identityClient(this.env.IDENTITY).usernames([principal.id]);
349 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
350 }
351 const username = this.usernames.get(principal.id);
352 return !!username && (await this.members(slug, workspace)).has(username);
353 }
354
355 /**
356 * The viewer's workspace, checked: they must belong to it, as in every
357 * other service.
358 */
359 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
360 if (!viewer) return fail("unauthenticated", "Sign in to use chat.");
361 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat.");
362 const workspace = await this.workspace(slug);
363 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
364 }
365
366 /**
367 * A channel the viewer may read (`read`: any public one in their
368 * workspace, or one they are in) or write in (`member`: one they are in).
369 * A private channel or direct message they are not in is not found, so
370 * its existence does not leak.
371 */
372 private async place(
373 slug: string,
374 channelId: string,
375 viewer: Viewer,
376 need: "read" | "member",
377 ): Promise<Result<Place>> {
378 const found = await this.viewerWorkspace(slug, viewer);
379 if (!found.ok) return found;
380 const workspace = found.value;
381 const [channel, member] = await Promise.all([
382 this.db
383 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
384 .bind(String(channelId ?? ""), workspace.id)
385 .first<ChannelRow>(),
386 this.db
387 .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?")
388 .bind(String(channelId ?? ""), userKey(viewer!))
389 .first<MemberRow>(),
390 ]);
391 if (!channel) return fail("not_found", "No such channel.");
392 const open = channel.kind === "channel" && !channel.private;
393 if (!member && !open) return fail("not_found", "No such channel.");
394 if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`);
395 return ok({ slug: slug.toLowerCase(), workspace, channel, member });
396 }
397
398 private room(channelId: string) {
399 return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId));
400 }
401
402 /** Tells everyone looking at a channel, after the answer is sent. */
403 private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void {
404 this.defer(
405 this.room(channelId)
406 .broadcast(event, except)
407 .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)),
408 );
409 }
410
411 /**
412 * Messages as they go out, with their reactions. `me` (a member key) is
413 * the viewer an answer is for; null for what everyone in a room gets,
414 * where no reaction is anyone's own.
415 */
416 private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> {
417 const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me);
418 const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by));
419 const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]);
420 return rows.map((row) => {
421 const gone = !!row.deleted_at;
422 return {
423 reactions: gone
424 ? []
425 : (reactions.get(row.id) ?? []).map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })),
426 id: row.id,
427 channel_id: row.channel_id,
428 author: profiles.get(row.author)!,
429 kind: row.kind,
430 body: gone ? "" : row.body,
431 card: gone || !row.card ? null : (JSON.parse(row.card) as MessageCard),
432 thread_root: row.thread_root,
433 reply_count: row.reply_count,
434 last_reply_at: row.last_reply_at,
435 created_at: row.created_at,
436 edited_at: row.edited_at,
437 deleted_at: row.deleted_at,
438 };
439 });
440 }
441
442 /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */
443 private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> {
444 if (!ids.length) return new Map();
445 const rows = await this.db
446 .prepare(
447 "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))",
448 )
449 .bind(JSON.stringify(ids))
450 .all<ReactionRow>();
451 return tallyReactions(rows.results, me);
452 }
453
454 private async messageRow(channelId: string, id: string): Promise<MessageRow | null> {
455 return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>();
456 }
457
458 /** Sends a message as it now is to everyone looking at its channel. */
459 private rebroadcast(place: Place, id: string): void {
460 this.defer(
461 (async () => {
462 const row = await this.messageRow(place.channel.id, id);
463 if (!row) return;
464 const [message] = await this.toMessages(place.slug, place.workspace, [row]);
465 await this.room(place.channel.id).broadcast({ type: "message.updated", message });
466 })().catch((error) => console.error("chat could not rebroadcast", id, error)),
467 );
468 }
469
470 // ── The sidebar ─────────────────────────────────────────────────────────
471
472 /**
473 * Puts a person in the workspace's #general, once. A new workspace has
474 * no channels, so the first sidebar anyone in it asks for creates
475 * #general; and everyone who asks for the sidebar is put in it the first
476 * time, so a new workspace has somewhere to talk and a new member lands
477 * where everyone is. Someone who leaves it is not put back
478 * (`general_joined`). A private channel someone named `general` is never
479 * joined this way.
480 */
481 private async ensureGeneral(workspace: Workspace, me: string): Promise<void> {
482 const seen = await this.db
483 .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?")
484 .bind(workspace.id, me)
485 .first();
486 if (seen) return;
487 const at = now();
488 await this.db
489 .prepare(
490 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)",
491 )
492 .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at)
493 .run();
494 const general = await this.db
495 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?")
496 .bind(workspace.id, GENERAL)
497 .first<ChannelRow>();
498 const statements = [
499 this.db
500 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
501 .bind(workspace.id, me, at),
502 ];
503 // The workspace's default channels (#general unless its owners chose
504 // others): public and not archived only, whatever was kept.
505 const settings = await this.settings(workspace, general?.id ?? null);
506 const defaults = settings.default_channels.length
507 ? await this.db
508 .prepare(
509 "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
510 )
511 .bind(workspace.id, JSON.stringify(settings.default_channels))
512 .all<ChannelRow>()
513 : { results: [] as ChannelRow[] };
514 for (const channel of defaults.results) {
515 statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at));
516 }
517 await this.db.batch(statements);
518 }
519
520 /**
521 * Adds a member. Someone joining starts with everything already said
522 * read, so a long channel does not greet them with its whole history as
523 * unread.
524 */
525 private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement {
526 return this.db
527 .prepare(
528 "INSERT OR IGNORE INTO channel_members (channel_id, principal, role, last_read_id, joined_at) VALUES (?1, ?2, ?3, (SELECT MAX(id) FROM messages WHERE channel_id = ?1), ?4)",
529 )
530 .bind(channelId, principal, role, at);
531 }
532
533 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> {
534 const found = await this.viewerWorkspace(a.workspace, a.viewer);
535 if (!found.ok) return found;
536 const workspace = found.value;
537 const slug = a.workspace.toLowerCase();
538 const me = userKey(a.viewer!);
539 await this.ensureGeneral(workspace, me);
540
541 const [joined, unread, dmOthers, browsable] = await Promise.all([
542 this.db
543 .prepare(
544 `SELECT c.*, m.starred, m.muted, m.last_read_id
545 FROM channel_members m JOIN channels c ON c.id = m.channel_id
546 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`,
547 )
548 .bind(me, workspace.id)
549 .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(),
550 this.db
551 .prepare(
552 `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
553 FROM channel_members m
554 JOIN channels c ON c.id = m.channel_id
555 JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
556 WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
557 AND msg.deleted_at IS NULL AND msg.author != ?1
558 LIMIT ${MAX_UNREAD_ROWS}`,
559 )
560 .bind(me, workspace.id)
561 .all<UnreadRow>(),
562 this.db
563 .prepare(
564 `SELECT o.channel_id, o.principal
565 FROM channel_members m
566 JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm'
567 JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal
568 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL
569 ORDER BY o.joined_at, o.principal`,
570 )
571 .bind(me, workspace.id)
572 .all<{ channel_id: string; principal: string }>(),
573 this.db
574 .prepare(
575 `SELECT COUNT(*) AS n FROM channels c
576 WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL
577 AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`,
578 )
579 .bind(workspace.id, me)
580 .first<{ n: number }>(),
581 ]);
582
583 const others = new Map<string, string[]>();
584 for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]);
585 const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]);
586 const self = profiles.get(me) ?? null;
587 const counts = tally(
588 unread.results,
589 me,
590 self?.name ?? a.viewer!.username,
591 new Map(joined.results.map((row) => [row.id, row.last_read_id])),
592 );
593
594 const entries: ChatSidebarEntry[] = joined.results.map((row) => {
595 const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean);
596 const count = counts.get(row.id) ?? { unread: 0, mentions: 0 };
597 return {
598 channel: toChannel(row),
599 title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""),
600 others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [],
601 starred: !!row.starred,
602 muted: !!row.muted,
603 unread: count.unread,
604 mentions: count.mentions,
605 };
606 });
607 const settings = await this.settings(workspace);
608 return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
609 }
610
611 // ── Channels ────────────────────────────────────────────────────────────
612
613 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
614 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
615 if (!found.ok) return found;
616 const { slug, workspace, channel, member } = found.value;
617 const [rows, settings] = await Promise.all([
618 this.db
619 .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
620 .bind(channel.id)
621 .all<MemberRow>(),
622 this.settings(workspace),
623 ]);
624 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
625 return ok({
626 channel: toChannel(channel),
627 can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null),
628 members: rows.results.map((row) => ({
629 channel_id: row.channel_id,
630 member: profiles.get(row.principal)!,
631 role: row.role,
632 starred: !!row.starred,
633 muted: !!row.muted,
634 last_read_id: row.last_read_id,
635 joined_at: row.joined_at,
636 })),
637 });
638 }
639
640 /** A channel by its name, as the site's URLs name them; read like `channel`. */
641 async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
642 const found = await this.viewerWorkspace(a.workspace, a.viewer);
643 if (!found.ok) return found;
644 const named = channelName(a.name ?? "");
645 if (!named.ok) return fail("not_found", "No such channel.");
646 const row = await this.db
647 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?")
648 .bind(found.value.id, named.name)
649 .first<{ id: string }>();
650 if (!row) return fail("not_found", "No such channel.");
651 // A private channel the viewer is not in stays not found there.
652 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
653 }
654
655 /**
656 * Every public channel, and the private ones the viewer is in; a private
657 * one they are not in stays unseen. With `archived`, the archived ones.
658 */
659 async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
660 const found = await this.viewerWorkspace(a.workspace, a.viewer);
661 if (!found.ok) return found;
662 const rows = await this.db
663 .prepare(
664 `SELECT * FROM channels c
665 WHERE c.workspace_id = ?1 AND c.kind = 'channel'
666 AND (c.archived_at IS NULL) = (?3 = 0)
667 AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
668 ORDER BY c.name`,
669 )
670 .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
671 .all<ChannelRow>();
672 return ok(rows.results.map(toChannel));
673 }
674
675 async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> {
676 const found = await this.viewerWorkspace(a.workspace, a.viewer);
677 if (!found.ok) return found;
678 const workspace = found.value;
679 const named = channelName(a.input?.name ?? "");
680 if (!named.ok) return fail("invalid", named.message);
681 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
682 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
683 const isPrivate = !!a.input?.private;
684 if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
685 return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
686 }
687 const taken = await this.db
688 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
689 .bind(workspace.id, named.name)
690 .first();
691 if (taken) return fail("conflict", `#${named.name} already exists.`);
692 const me = userKey(a.viewer!);
693 const row: ChannelRow = {
694 id: newId("chn"),
695 workspace_id: workspace.id,
696 kind: "channel",
697 name: named.name,
698 topic: topic || null,
699 private: isPrivate ? 1 : 0,
700 dm_key: null,
701 created_by: me,
702 created_at: now(),
703 archived_at: null,
704 last_message_at: null,
705 };
706 try {
707 await this.db.batch([
708 this.db
709 .prepare(
710 "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)",
711 )
712 .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at),
713 this.joinStatement(row.id, me, "owner", row.created_at),
714 ]);
715 } catch (error) {
716 if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`);
717 throw error;
718 }
719 return ok(toChannel(row));
720 }
721
722 async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> {
723 const found = await this.viewerWorkspace(a.workspace, a.viewer);
724 if (!found.ok) return found;
725 const workspace = found.value;
726 const slug = a.workspace.toLowerCase();
727 const me = userKey(a.viewer!);
728 const asked = Array.isArray(a.members) ? a.members : [];
729 const principals: Principal[] = [];
730 for (const member of asked) {
731 const p = member && parsePrincipalKey(`${member.kind}:${member.id}`);
732 if (!p) return fail("invalid", "Each member is a person or an agent, by id.");
733 principals.push(p);
734 }
735 const members = dmMembers(me, principals.map(principalKey));
736 if (members.length > MAX_DM_MEMBERS) {
737 return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people and agents. Make a private channel instead.`);
738 }
739 const key = dmKey(members);
740 const existing = await this.db
741 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
742 .bind(workspace.id, key)
743 .first<ChannelRow>();
744 if (existing) return ok(toChannel(existing));
745
746 for (const member of members) {
747 if (member === me) continue;
748 const p = parsePrincipalKey(member)!;
749 if (!(await this.belongs(slug, workspace, p))) {
750 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
751 }
752 // A member's personal agent: only in the DM of the two of them.
753 if (p.kind === "agent") {
754 const agent = await this.liveAgent(workspace, p.id);
755 const refused = agent ? personalAgentRefusal(agent, { kind: "dm", members }) : null;
756 if (refused) return fail("forbidden", refused);
757 }
758 }
759 const at = now();
760 await this.db
761 .prepare(
762 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)",
763 )
764 .bind(newId("chn"), workspace.id, key, me, at)
765 .run();
766 // Read back by key: if two people opened it at once, both get the one that won.
767 const channel = await this.db
768 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
769 .bind(workspace.id, key)
770 .first<ChannelRow>();
771 if (!channel) return fail("conflict", "The direct message could not be opened. Try again.");
772 await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at)));
773 return ok(toChannel(channel));
774 }
775
776 async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
777 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
778 if (!found.ok) return found;
779 const { channel, member } = found.value;
780 if (member) return ok(null);
781 if (channel.archived_at) return fail("invalid", "This channel is archived.");
782 await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run();
783 return ok(null);
784 }
785
786 async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
787 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
788 if (!found.ok) return found;
789 const { channel, member } = found.value;
790 if (!member) return ok(null);
791 if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead.");
792 const me = userKey(a.viewer!);
793 await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run();
794 // Out of a private channel, they may no longer read it, live either.
795 if (channel.private) {
796 this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error)));
797 }
798 return ok(null);
799 }
800
801 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
802 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
803 if (!found.ok) return found;
804 const { slug, workspace, channel } = found.value;
805 if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it.");
806 if (channel.archived_at) return fail("invalid", "This channel is archived.");
807 const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`);
808 if (!p) return fail("invalid", "Invite a person or an agent, by id.");
809 if (!(await this.belongs(slug, workspace, p))) {
810 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
811 }
812 if (p.kind === "agent") {
813 const agent = await this.liveAgent(workspace, p.id);
814 const refused = agent ? personalAgentRefusal(agent, { kind: "channel", members: null }) : null;
815 if (refused) return fail("forbidden", refused);
816 }
817 await this.joinStatement(channel.id, principalKey(p), "member", now()).run();
818 return ok(null);
819 }
820
821 async setPreferences(a: {
822 workspace: string;
823 channel_id: string;
824 viewer: Viewer;
825 prefs: { starred?: boolean; muted?: boolean };
826 }): Promise<Result<null>> {
827 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
828 if (!found.ok) return found;
829 const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null;
830 const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null;
831 await this.db
832 .prepare(
833 "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?",
834 )
835 .bind(starred, muted, found.value.channel.id, userKey(a.viewer!))
836 .run();
837 // Notify: the badge counts the conversation again, or leaves it out, in every tab.
838 if (muted !== null) {
839 this.defer(
840 notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) =>
841 console.error("chat could not notify a mute", error),
842 ),
843 );
844 }
845 return ok(null);
846 }
847
848 // ── What owners decide (src/settings.ts) ────────────────────────────────
849
850 /** The row of a workspace's chat settings, read once per request. */
851 private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
852 let found = this.settingsRows.get(workspace.id);
853 if (!found) {
854 found = this.db
855 .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
856 .bind(workspace.id)
857 .first<SettingsRow>();
858 this.settingsRows.set(workspace.id, found);
859 }
860 return found;
861 }
862
863 /**
864 * A workspace's chat settings. Default channels never chosen are
865 * #general, looked up unless `generalId` is given.
866 */
867 private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
868 const row = await this.settingsRow(workspace);
869 if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
870 const general = await this.db
871 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
872 .bind(workspace.id, GENERAL)
873 .first<{ id: string }>();
874 return settingsOf(row, general?.id ?? null);
875 }
876
877 async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
878 const found = await this.viewerWorkspace(a.workspace, a.viewer);
879 if (!found.ok) return found;
880 const workspace = found.value;
881 const [settings, channels] = await Promise.all([
882 this.settings(workspace),
883 this.db
884 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
885 .bind(workspace.id)
886 .all<ChannelRow>(),
887 ]);
888 return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
889 }
890
891 async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
892 const found = await this.viewerWorkspace(a.workspace, a.viewer);
893 if (!found.ok) return found;
894 const workspace = found.value;
895 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
896 const checked = settingsChange(a.change);
897 if (!checked.ok) return fail("invalid", checked.message);
898 const change = checked.change;
899 if (change.default_channels) {
900 // Only the workspace's public channels that are not archived: a private
901 // one would put people somewhere they were never invited.
902 const rows = await this.db
903 .prepare(
904 "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
905 )
906 .bind(workspace.id, JSON.stringify(change.default_channels))
907 .all<{ id: string }>();
908 const open = new Set(rows.results.map((r) => r.id));
909 if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
910 }
911 const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
912 const row = rowFor(next);
913 await this.db
914 .prepare(
915 `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
916 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
917 ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
918 )
919 .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
920 .run();
921 this.settingsRows.delete(workspace.id);
922 return ok(next);
923 }
924
925 /**
926 * Renames, archives or unarchives a channel, or changes its topic. The
927 * workspace's `manage_channels` setting says who may do the first three;
928 * any member may change the topic. #general stays #general, and stays.
929 */
930 async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
931 // Archived or not, a channel is found the same way; a private one only by its members.
932 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
933 if (!found.ok) return found;
934 const { slug, workspace, channel, member } = found.value;
935 if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
936 const change = a.change ?? {};
937 const next: ChannelRow = { ...channel };
938 if (change.name !== undefined || change.archived !== undefined) {
939 const settings = await this.settings(workspace);
940 if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
941 return fail(
942 "forbidden",
943 settings.manage_channels === "owners"
944 ? "Only workspace owners can rename or archive channels here."
945 : "Only this channel's owners and workspace owners can rename or archive it.",
946 );
947 }
948 if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
949 }
950 if (change.name !== undefined) {
951 const named = channelName(String(change.name ?? ""));
952 if (!named.ok) return fail("invalid", named.message);
953 if (named.name !== channel.name) {
954 const taken = await this.db
955 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
956 .bind(workspace.id, named.name, channel.id)
957 .first();
958 if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
959 }
960 next.name = named.name;
961 }
962 if (change.topic !== undefined) {
963 if (!member) return fail("forbidden", `Join #${channel.name} first.`);
964 const topic = typeof change.topic === "string" ? change.topic.trim() : "";
965 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
966 next.topic = topic || null;
967 }
968 if (change.archived !== undefined) {
969 next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
970 } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
971 return fail("invalid", "This channel is archived. Unarchive it first.");
972 }
973 try {
974 await this.db
975 .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
976 .bind(next.name, next.topic, next.archived_at, channel.id)
977 .run();
978 } catch (error) {
979 if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
980 throw error;
981 }
982 const updated = toChannel(next);
983 this.broadcast(channel.id, { type: "channel.updated", channel: updated });
984 return ok(updated);
985 }
986
987 // ── Messages ────────────────────────────────────────────────────────────
988
989 /**
990 * Newest first, a page at a time. With `thread_root`, that thread's
991 * replies, and the message they reply to as the oldest once the page
992 * reaches the start of the thread; without, the channel's top-level
993 * messages. A deleted message stays only while replies hang off it.
994 */
995 async messages(a: {
996 workspace: string;
997 channel_id: string;
998 viewer: Viewer;
999 before?: string | null;
1000 after?: string | null;
1001 limit?: number | null;
1002 thread_root?: string | null;
1003 }): Promise<Result<MessagePage>> {
1004 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1005 if (!found.ok) return found;
1006 const { slug, workspace, channel } = found.value;
1007 const size = pageSize(a.limit);
1008 // Ids are lowercase letters, digits and `_`, so `~` sorts after every
1009 // one: the first page reads from the newest as a range of the index.
1010 const before = typeof a.before === "string" && a.before ? a.before : "~";
1011 const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null;
1012 if (typeof a.after === "string" && a.after) {
1013 // Catching up after a reconnect: what came after, oldest first. Deleted
1014 // ones too, so the client drops them; read one past the page to know
1015 // whether there is more.
1016 const newer = root
1017 ? await this.db
1018 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?")
1019 .bind(root, channel.id, a.after, size + 1)
1020 .all<MessageRow>()
1021 : await this.db
1022 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?")
1023 .bind(channel.id, a.after, size + 1)
1024 .all<MessageRow>();
1025 const page = pageOf(newer.results, size);
1026 return ok({ messages: await this.toMessages(slug, workspace, page.rows, userKey(a.viewer!)), older: null, newer: page.older });
1027 }
1028 const rows = root
1029 ? await this.db
1030 .prepare(
1031 `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3
1032 ORDER BY id DESC LIMIT ?4`,
1033 )
1034 .bind(root, channel.id, before, size + 1)
1035 .all<MessageRow>()
1036 : await this.db
1037 .prepare(
1038 `SELECT * FROM messages
1039 WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
1040 AND (deleted_at IS NULL OR reply_count > 0)
1041 ORDER BY id DESC LIMIT ?3`,
1042 )
1043 .bind(channel.id, before, size + 1)
1044 .all<MessageRow>();
1045 const page = pageOf(rows.results, size);
1046 let list = page.rows;
1047 // The message a thread is under: the oldest once the page reaches the
1048 // start, and, on the first page, as `root` however long the thread is,
1049 // so a session's card stays at the top of its thread.
1050 const firstPage = before === "~";
1051 const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1052 if (rootRow && page.older === null) list = [...list, rootRow];
1053 const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1054 if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1055 const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1056 return ok({ messages, older: page.older, root: shown });
1057 }
1058
1059 /**
1060 * Writes a message and everything that follows from it: the thread's
1061 * reply count, the channel's last activity, the author's own read mark,
1062 * the meter; then, after answering, tells the room and wakes the agents
1063 * it is for: those a person's message is for (src/delivery.ts), or, for
1064 * an agent's, only `wake` (a hand-off's colleague), never by mention.
1065 */
1066 private async write(
1067 place: Place,
1068 author: string,
1069 input: { body: string; card: MessageCard | null; thread_root: string | null },
1070 chain: Chain<AskerAccess>,
1071 wake: Wake[] = [],
1072 ): Promise<Result<ChatMessage>> {
1073 const { channel, workspace } = place;
1074 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1075 let threadRoot: string | null = null;
1076 if (input.thread_root) {
1077 const root = await this.messageRow(channel.id, input.thread_root);
1078 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1079 // A reply to a reply goes in the same thread.
1080 threadRoot = root.thread_root ?? root.id;
1081 }
1082 const at = now();
1083 // An agent's mention of someone who isn't here reaches nobody, so it reads as a plain name.
1084 const body = author.startsWith("agent:") ? await this.agentText(place, input.body) : input.body;
1085 const handles = mentionedHandles(body);
1086 const row: MessageRow = {
1087 id: newId("msg"),
1088 channel_id: channel.id,
1089 author,
1090 kind: input.card ? "card" : "text",
1091 body,
1092 card: input.card ? JSON.stringify(input.card) : null,
1093 mentions: mentionsColumn(handles),
1094 thread_root: threadRoot,
1095 reply_count: 0,
1096 last_reply_at: null,
1097 created_at: at,
1098 edited_at: null,
1099 deleted_at: null,
1100 };
1101 const statements = [
1102 this.db
1103 .prepare(
1104 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1105 )
1106 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at),
1107 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1108 // What you wrote, you have read.
1109 this.db
1110 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1111 .bind(row.id, channel.id, author),
1112 this.db
1113 .prepare(
1114 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1115 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1116 )
1117 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1118 ];
1119 if (threadRoot) {
1120 statements.push(
1121 this.db
1122 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1123 .bind(at, threadRoot),
1124 );
1125 }
1126 await this.db.batch(statements);
1127
1128 const [message] = await this.toMessages(place.slug, workspace, [row]);
1129 this.broadcast(channel.id, { type: "message.created", message });
1130 if (threadRoot) this.rebroadcast(place, threadRoot);
1131 this.defer(
1132 (wake.length ? this.deliverTo(place, row, chain, wake) : this.wake(place, row, handles, chain)).catch((error) =>
1133 console.error("chat could not hand", row.id, "to agents", error),
1134 ),
1135 );
1136 // Notify: counts for everyone in the conversation, a notification for those it is for.
1137 this.defer(
1138 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles, asked_by: chain.asked_by }).catch(
1139 (error) => console.error("chat could not notify about", row.id, error),
1140 ),
1141 );
1142 return ok(message);
1143 }
1144
1145 /** Hands a new message to the agents it is for (src/delivery.ts). */
1146 private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> {
1147 const { channel, workspace } = place;
1148 // Only a person's message wakes anyone, and in a channel only by mention.
1149 if (!row.author.startsWith("user:") || chain.quiet) return;
1150 if (channel.kind === "channel" && !handles.length) return;
1151 const members = await this.db
1152 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'")
1153 .bind(channel.id)
1154 .all<{ principal: string }>();
1155 const ids = members.results.map((m) => m.principal.slice("agent:".length));
1156 let found = await this.agentsById(ids);
1157 const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at);
1158 if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) {
1159 // Mentioning @g1t brings it in: every workspace has it, nobody invites it.
1160 const builtin = await workspaceAgentsClient(this.env.AGENTS)
1161 .builtin(place.slug, workspace.id)
1162 .catch((error: unknown) => {
1163 console.error("chat could not find @g1t for", place.slug, error);
1164 return null;
1165 });
1166 if (builtin?.ok) {
1167 await this.joinStatement(channel.id, `agent:${builtin.value.id}`, "member", now()).run();
1168 this.agents.set(builtin.value.id, builtin.value);
1169 ids.push(builtin.value.id);
1170 found = await this.agentsById(ids);
1171 }
1172 }
1173 if (!ids.length) return;
1174 const agents = [...found.values()].filter(
1175 (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at,
1176 );
1177 const wakes = deliveries({
1178 author: row.author,
1179 hops: chain.hops,
1180 channelKind: channel.kind,
1181 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
1182 mentioned: handles,
1183 });
1184 await this.deliverTo(place, row, chain, wakes);
1185 }
1186
1187 /** Hands a message to these agents (src/delivery.ts `delivery`). */
1188 private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> {
1189 const { channel, workspace } = place;
1190 const client = workspaceAgentsClient(this.env.AGENTS);
1191 await Promise.all(
1192 wakes.map((wake) =>
1193 client
1194 .deliver(
1195 delivery(
1196 {
1197 workspace: place.slug,
1198 workspace_id: workspace.id,
1199 channel_id: channel.id,
1200 channel_kind: channel.kind,
1201 channel_name: channel.kind === "dm" ? null : channel.name,
1202 },
1203 wake,
1204 row,
1205 chain,
1206 ) satisfies AgentDelivery,
1207 )
1208 .then((result) => {
1209 if (!result.ok) console.error("agents refused delivery to", wake.agent_id, result.error.message);
1210 })
1211 .catch((error) => console.error("chat could not deliver to", wake.agent_id, error)),
1212 ),
1213 );
1214 }
1215
1216 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1217 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1218 if (!found.ok) return found;
1219 const body = messageBody(a.message?.body);
1220 if (!body.ok) return fail("invalid", body.message);
1221 const me = userKey(a.viewer!);
1222 let place = found.value;
1223 if (!place.member) {
1224 // Saying something in a public channel joins it, as reading does not.
1225 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1226 await this.joinStatement(place.channel.id, me, "member", now()).run();
1227 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1228 }
1229 return this.write(
1230 place,
1231 me,
1232 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
1233 // A person's message starts a chain; a workflow job's token starts none.
1234 { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [], quiet: !!a.viewer!.token?.job },
1235 );
1236 }
1237
1238 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1239 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1240 if (!found.ok) return found;
1241 const place = found.value;
1242 const row = await this.messageRow(place.channel.id, a.id);
1243 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1244 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1245 const body = messageBody(a.body, !!row.card);
1246 if (!body.ok) return fail("invalid", body.message);
1247 const at = now();
1248 const mentions = mentionsColumn(mentionedHandles(body.body));
1249 await this.db
1250 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1251 .bind(body.body, mentions, at, row.id)
1252 .run();
1253 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1254 this.broadcast(place.channel.id, { type: "message.updated", message });
1255 return ok(message);
1256 }
1257
1258 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1259 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1260 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1261 if (!found.ok) return found;
1262 const place = found.value;
1263 const row = await this.messageRow(place.channel.id, a.id);
1264 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1265 if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) {
1266 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1267 }
1268 const statements = [
1269 this.db
1270 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1271 .bind(now(), row.id),
1272 ];
1273 if (row.thread_root) {
1274 statements.push(
1275 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1276 );
1277 }
1278 await this.db.batch(statements);
1279 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1280 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1281 return ok(null);
1282 }
1283
1284 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1285 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1286 if (!found.ok) return found;
1287 const { channel, member } = found.value;
1288 // Someone reading a public channel they have not joined keeps no read state.
1289 if (!member) return ok(null);
1290 const id = typeof a.id === "string" ? a.id : "";
1291 if (!id) return fail("invalid", "Say which message was read.");
1292 // Only forward: reading an old thread does not mark newer messages unread.
1293 const changed = await this.db
1294 .prepare(
1295 "UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)",
1296 )
1297 .bind(id, channel.id, member.principal)
1298 .run();
1299 if (changed.meta.changes) {
1300 this.broadcast(channel.id, {
1301 type: "read",
1302 channel_id: channel.id,
1303 principal: { kind: "user", id: a.viewer!.id },
1304 last_read_id: id,
1305 });
1306 // Notify: the read drops the counts in every tab of theirs.
1307 this.defer(
1308 notifyRead(this.db, this.env.NOTIFY, { slug: found.value.slug, channel_id: channel.id, user_id: a.viewer!.id, username: a.viewer!.username, last_read_id: id }).catch(
1309 (error) => console.error("chat could not notify a read", error),
1310 ),
1311 );
1312 }
1313 return ok(null);
1314 }
1315
1316 // ── Agents ──────────────────────────────────────────────────────────────
1317
1318 /** The channel and agent for an agent's call: the agent must be of the workspace and in the channel. */
1319 private async agentPlace(slug: string, channelId: string, agentId: string): Promise<Result<{ place: Place; agent: WorkspaceAgent }>> {
1320 const workspace = await this.workspace(String(slug ?? ""));
1321 if (!workspace) return fail("not_found", "No such workspace.");
1322 const agent = await this.liveAgent(workspace, String(agentId ?? ""));
1323 if (!agent) return fail("not_found", "No such agent in this workspace.");
1324 const key = principalKey({ kind: "agent", id: agent.id });
1325 const [channel, member] = await Promise.all([
1326 this.db
1327 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1328 .bind(String(channelId ?? ""), workspace.id)
1329 .first<ChannelRow>(),
1330 this.db.prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?").bind(String(channelId ?? ""), key).first<MemberRow>(),
1331 ]);
1332 if (!channel) return fail("not_found", "No such channel.");
1333 if (!member) return fail("forbidden", "The agent is not a member of this channel.");
1334 return ok({ place: { slug: slug.toLowerCase(), workspace, channel, member }, agent });
1335 }
1336
1337 async postAsAgent(a: { workspace: string; channel_id: string; agent_id: string; message: AgentPostMessage }): Promise<Result<ChatMessage>> {
1338 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1339 if (!found.ok) return found;
1340 const { place, agent } = found.value;
1341 const card = a.message?.card == null ? null : cleanCard(a.message.card);
1342 if (a.message?.card != null && !card) return fail("invalid", "A card needs a kind and a title.");
1343 const body = messageBody(a.message?.body ?? "", !!card);
1344 if (!body.ok) return fail("invalid", body.message);
1345 const hops = typeof a.message?.hops === "number" && a.message.hops >= 0 ? Math.floor(a.message.hops) : 0;
1346 const askedBy = typeof a.message?.asked_by === "string" && a.message.asked_by ? a.message.asked_by : agent.created_by;
1347 return this.write(
1348 place,
1349 principalKey({ kind: "agent", id: agent.id }),
1350 { body: body.body, card, thread_root: a.message?.thread_root ?? null },
1351 // The asker carries on from the delivery the agent is answering;
1352 // without one, agents it wakes treat the asker as unable to change code.
1353 { hops, asked_by: askedBy, asker: cleanAsker(a.message?.asker), chain: chainFor(a.message?.chain, agent.id) },
1354 );
1355 }
1356
1357 /** Everyone in a conversation, as member keys. */
1358 private async memberKeys(channelId: string): Promise<string[]> {
1359 const rows = await this.db.prepare("SELECT principal FROM channel_members WHERE channel_id = ?").bind(channelId).all<{ principal: string }>();
1360 return rows.results.map((row) => row.principal);
1361 }
1362
1363 /** An agent's text as it is kept: mentions of anyone not in the conversation lose their `@` (src/mentions.ts). */
1364 private async agentText(place: Place, body: string): Promise<string> {
1365 if (!body.includes("@")) return body;
1366 const profiles = await this.profiles(place.slug, place.workspace, await this.memberKeys(place.channel.id));
1367 return plainOutside(body, new Set([...profiles.values()].map((p) => p.name.toLowerCase())));
1368 }
1369
1370 /** What an agent is told about where it is answering, every turn. */
1371 async conversationForAgent(a: { workspace: string; channel_id: string; agent_id: string; asked_by?: string | null }): Promise<Result<ConversationForAgent>> {
1372 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1373 if (!found.ok) return found;
1374 const { place } = found.value;
1375 const rows = await this.db
1376 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
1377 .bind(place.channel.id)
1378 .all<{ principal: string }>();
1379 const keys = rows.results.map((row) => row.principal);
1380 const agents = keys.filter((key) => key.startsWith("agent:"));
1381 const people = keys.filter((key) => key.startsWith("user:"));
1382 // The person who asked first, then the earliest to join, up to the cap.
1383 const asker = typeof a.asked_by === "string" && a.asked_by ? `user:${a.asked_by}` : null;
1384 const shown = [...(asker && people.includes(asker) ? [asker] : []), ...people.filter((key) => key !== asker)].slice(0, CONVERSATION_PEOPLE_SHOWN);
1385 const profiles = await this.profiles(place.slug, place.workspace, [...agents, ...shown]);
1386 return ok({
1387 channel: toChannel(place.channel),
1388 members: [...agents, ...shown].map((key) => profiles.get(key)!).filter(Boolean),
1389 people: people.length,
1390 agents: agents.length,
1391 });
1392 }
1393
1394 /**
1395 * An agent hands work to a colleague agent for the person who asked
1396 * (`handOffAsAgent` in @g1t/contracts). The brief is the agent's message,
1397 * so everyone where it lands sees the work move; it wakes the colleague
1398 * and nobody else.
1399 */
1400 async handOffAsAgent(a: { workspace: string; channel_id: string; agent_id: string; hand_off: AgentHandOff }): Promise<Result<HandOffResult>> {
1401 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1402 if (!found.ok) return found;
1403 const { place, agent } = found.value;
1404 const input = a.hand_off ?? ({} as AgentHandOff);
1405 const brief = messageBody(input.brief);
1406 if (!brief.ok) return fail("invalid", brief.message);
1407 const colleague = await this.liveAgent(place.workspace, String(input.colleague_id ?? ""));
1408 if (!colleague) return fail("not_found", "No such agent in this workspace.");
1409 const personal = personalAgentRefusal(colleague, { kind: "hand_off", members: null });
1410 if (personal) return fail("invalid", personal);
1411 const given = typeof input.hops === "number" && input.hops >= 0 ? Math.floor(input.hops) : 0;
1412 const before = chainFor(input.chain, agent.id).slice(0, -1);
1413 const refused = handOffRefusal({ agent: agent.id, colleague, chain: before, hops: given });
1414 if (refused) return fail("invalid", refused);
1415 // Work is handed on for someone in this conversation, never for a stranger to it.
1416 const askedBy = typeof input.asked_by === "string" ? input.asked_by : "";
1417 const keys = await this.memberKeys(place.channel.id);
1418 const askerKey = principalKey({ kind: "user", id: askedBy });
1419 if (!askedBy || !keys.includes(askerKey)) return fail("forbidden", "Only the person who asked, in this conversation, can have work handed on.");
1420 const me = principalKey({ kind: "agent", id: agent.id });
1421 const them = principalKey({ kind: "agent", id: colleague.id });
1422 const chain: Chain<AskerAccess> = { hops: given + 1, asked_by: askedBy, asker: cleanAsker(input.asker), chain: [...before, agent.id] };
1423 const wake: Wake[] = [{ agent_id: colleague.id, hops: given + 1 }];
1424 const threadRoot = typeof input.thread_root === "string" && input.thread_root ? input.thread_root : null;
1425
1426 if (handOffPlace({ channelKind: place.channel.kind, members: keys.length, colleagueHere: keys.includes(them) }) === "here") {
1427 const posted = await this.write(place, me, { body: brief.body, card: null, thread_root: threadRoot }, chain, wake);
1428 return posted.ok ? ok({ where: "here", channel_id: place.channel.id, message_id: posted.value.id, opened: false }) : posted;
1429 }
1430
1431 // The group DM of the person, the agent and the colleague: the same three always get the same one.
1432 if (!(await this.belongs(place.slug, place.workspace, { kind: "user", id: askedBy }))) {
1433 return fail("forbidden", "Only members of the workspace can have work handed on to its agents.");
1434 }
1435 const members = dmMembers(askerKey, [me, them]);
1436 const key = dmKey(members);
1437 let dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1438 const opened = !dm;
1439 if (!dm) {
1440 const at = now();
1441 await this.db
1442 .prepare("INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)")
1443 .bind(newId("chn"), place.workspace.id, key, askerKey, at)
1444 .run();
1445 dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>();
1446 if (!dm) return fail("conflict", "The group message could not be opened. Try again.");
1447 await this.db.batch(members.map((member) => this.joinStatement(dm!.id, member, "member", at)));
1448 }
1449 const there: Place = { slug: place.slug, workspace: place.workspace, channel: dm, member: { channel_id: dm.id, principal: me } as MemberRow };
1450 const posted = await this.write(there, me, { body: brief.body, card: null, thread_root: null }, chain, wake);
1451 if (!posted.ok) return posted;
1452 // Where the work went, here, where it was asked for. It wakes nobody.
1453 const named = await this.profiles(place.slug, place.workspace, [askerKey]);
1454 const person = named.get(askerKey);
1455 await this.write(
1456 place,
1457 me,
1458 {
1459 body: "",
1460 card: {
1461 kind: "handoff",
1462 title: `${agent.display_name} handed this to ${colleague.display_name}`,
1463 detail: `${colleague.display_name} works on it in a group message with ${person ? person.display_name : "the person who asked"} and ${agent.display_name}.`,
1464 state: null,
1465 href: `/${place.slug}/-/chat/dm/${dm.id}`,
1466 },
1467 thread_root: threadRoot,
1468 },
1469 { ...chain, hops: given },
1470 ).catch((error: unknown) => console.error("chat could not post where a hand-off went", error));
1471 return ok({ where: "group_dm", channel_id: dm.id, message_id: posted.value.id, opened });
1472 }
1473
1474 /**
1475 * A person presses an action on a card. They must be able to read the
1476 * conversation; the card must offer the action; its owner (agents)
1477 * decides whether this person may, does it, and updates the card.
1478 */
1479 async cardAction(a: {
1480 workspace: string;
1481 channel_id: string;
1482 viewer: Viewer;
1483 message_id: string;
1484 action_id: string;
1485 input?: string | null;
1486 }): Promise<Result<CardActionResult>> {
1487 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1488 if (!found.ok) return found;
1489 const place = found.value;
1490 const row = await this.messageRow(place.channel.id, String(a.message_id ?? ""));
1491 if (!row || row.deleted_at || !row.card) return fail("not_found", "No such card.");
1492 const card = JSON.parse(row.card) as MessageCard;
1493 const action = card.actions?.find((x) => x.id === a.action_id);
1494 if (!action || action.href || card.owner !== "agents") return fail("invalid", "That card has no such action.");
1495 const input = typeof a.input === "string" ? a.input.slice(0, 4000) : null;
1496 if (action.input && !input?.trim()) return fail("invalid", `${action.input.label || "A value"} is needed.`);
1497 return workspaceAgentsClient(this.env.AGENTS).cardAction({
1498 workspace: place.slug,
1499 channel_id: place.channel.id,
1500 message_id: row.id,
1501 viewer: a.viewer!,
1502 card: { kind: card.kind, ref: card.ref ?? null },
1503 action_id: action.id,
1504 input,
1505 });
1506 }
1507
1508 /**
1509 * Changes a message the agent posted (a session's live card, say): its
1510 * body, its card, or both. Only the agent's own messages; it wakes
1511 * nobody, and everyone in the conversation sees it change.
1512 */
1513 async updateAsAgent(a: {
1514 workspace: string;
1515 channel_id: string;
1516 agent_id: string;
1517 id: string;
1518 change: { body?: string; card?: MessageCard | null };
1519 }): Promise<Result<ChatMessage>> {
1520 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1521 if (!found.ok) return found;
1522 const { place, agent } = found.value;
1523 const row = await this.messageRow(place.channel.id, String(a.id ?? ""));
1524 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1525 if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages.");
1526 const change = a.change ?? {};
1527 const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
1528 if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
1529 const body = messageBody(change.body === undefined ? row.body : await this.agentText(place, String(change.body ?? "")), !!card);
1530 if (!body.ok) return fail("invalid", body.message);
1531 const cardJson = card ? JSON.stringify(card) : null;
1532 const at = now();
1533 // A card's live state is not an edit a person made: no "edited" mark for it.
1534 const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at;
1535 await this.db
1536 .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?")
1537 .bind(body.body, cardJson, card ? "card" : "text", edited, row.id)
1538 .run();
1539 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]);
1540 this.broadcast(place.channel.id, { type: "message.updated", message });
1541 return ok(message);
1542 }
1543
1544 /**
1545 * What an agent reads before replying, oldest first: a thread (its root,
1546 * then its latest replies), or the channel's latest top-level messages.
1547 * Only where the agent is a member, so it reads only what was said where
1548 * it was invited. Deleted messages are left out, save a thread's root.
1549 */
1550 async historyForAgent(a: {
1551 workspace: string;
1552 channel_id: string;
1553 agent_id: string;
1554 thread_root?: string | null;
1555 limit?: number | null;
1556 }): Promise<Result<ChatMessage[]>> {
1557 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1558 if (!found.ok) return found;
1559 const { place } = found.value;
1560 const size = historySize(a.limit);
1561 if (typeof a.thread_root === "string" && a.thread_root) {
1562 const asked = await this.messageRow(place.channel.id, a.thread_root);
1563 if (!asked) return fail("not_found", "No such thread.");
1564 // Asked from a reply: its whole thread.
1565 const root = asked.thread_root ? await this.messageRow(place.channel.id, asked.thread_root) : asked;
1566 if (!root) return fail("not_found", "No such thread.");
1567 const replies = await this.db
1568 .prepare(
1569 "SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1570 )
1571 .bind(root.id, place.channel.id, Math.max(0, size - 1))
1572 .all<MessageRow>();
1573 return ok(await this.toMessages(place.slug, place.workspace, historyOf(replies.results, root)));
1574 }
1575 const rows = await this.db
1576 .prepare(
1577 "SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id < '~' AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1578 )
1579 .bind(place.channel.id, size)
1580 .all<MessageRow>();
1581 return ok(await this.toMessages(place.slug, place.workspace, historyOf(rows.results)));
1582 }
1583
1584 // ── What an agent may read (src/audience.ts) ───────────────────────────
1585
1586 /** The people in a conversation, by user id. */
1587 private async peopleIn(channelId: string): Promise<string[]> {
1588 const rows = await this.db
1589 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'")
1590 .bind(channelId)
1591 .all<{ principal: string }>();
1592 return rows.results.map((row) => row.principal.slice("user:".length));
1593 }
1594
1595 /** A conversation of this workspace and who reads it, worked out here, never taken from a caller. */
1596 private async audienceOf(slug: string, channelId: string): Promise<Result<{ workspace: Workspace; channel: ChannelRow; audience: ChatAudience }>> {
1597 const workspace = await this.workspace(String(slug ?? "").toLowerCase());
1598 if (!workspace) return fail("not_found", "No such workspace.");
1599 const channel = await this.db
1600 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1601 .bind(String(channelId ?? ""), workspace.id)
1602 .first<ChannelRow>();
1603 if (!channel) return fail("not_found", "No such conversation.");
1604 const people = await this.peopleIn(channel.id);
1605 return ok({ workspace, channel, audience: { kind: audienceKind(channel), member_user_ids: people, member_count: people.length } });
1606 }
1607
1608 async audience(a: { workspace: string; channel_id: string }): Promise<Result<ChatAudience>> {
1609 const found = await this.audienceOf(a.workspace, a.channel_id);
1610 return found.ok ? ok(found.value.audience) : found;
1611 }
1612
1613 /**
1614 * The conversations of the workspace the audience of `channelId` may all
1615 * read: public channels, and the ones every person in it is in (a DM only
1616 * with exactly them). At most 500, most recently active first.
1617 */
1618 private async readableFor(workspace: Workspace, audience: ChatAudience): Promise<Map<string, ChannelRow>> {
1619 const people = audience.member_user_ids;
1620 const rows = isShared({ kind: audience.kind, user_ids: people }) || !people.length
1621 ? await this.db
1622 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 ORDER BY last_message_at DESC LIMIT 500")
1623 .bind(workspace.id)
1624 .all<ChannelRow>()
1625 : await this.db
1626 .prepare(
1627 `SELECT * FROM channels WHERE workspace_id = ?1 AND (
1628 (kind = 'channel' AND private = 0)
1629 OR id IN (SELECT channel_id FROM channel_members WHERE principal IN (${people.map((_, i) => `?${i + 2}`).join(", ")})
1630 GROUP BY channel_id HAVING COUNT(DISTINCT principal) = ${people.length})
1631 ) ORDER BY last_message_at DESC LIMIT 500`,
1632 )
1633 .bind(workspace.id, ...people.map((id) => `user:${id}`))
1634 .all<ChannelRow>();
1635 const out = new Map<string, ChannelRow>();
1636 for (const channel of rows.results) {
1637 // The query finds candidates; the rule decides, a DM's people included.
1638 const target = { kind: channel.kind, private: channel.private, user_ids: channel.kind === "channel" && !channel.private ? [] : await this.peopleIn(channel.id) };
1639 if (readableBy(target, { kind: audience.kind, user_ids: people })) out.set(channel.id, channel);
1640 }
1641 return out;
1642 }
1643
1644 private async found(slug: string, workspace: Workspace, channels: Map<string, ChannelRow>, rows: MessageRow[]): Promise<AgentFoundMessage[]> {
1645 const messages = await this.toMessages(slug, workspace, rows);
1646 return messages.map((message) => {
1647 const channel = channels.get(message.channel_id)!;
1648 return { channel_id: channel.id, channel: channel.kind === "dm" ? null : channel.name, message };
1649 });
1650 }
1651
1652 async searchForAgent(a: { workspace: string; channel_id: string; query: string; limit?: number | null }): Promise<Result<AgentFoundMessage[]>> {
1653 const found = await this.audienceOf(a.workspace, a.channel_id);
1654 if (!found.ok) return found;
1655 const pattern = likePattern(a.query);
1656 if (!pattern) return fail("invalid", "Search for at least two characters.");
1657 const { workspace, audience } = found.value;
1658 const channels = await this.readableFor(workspace, audience);
1659 if (!channels.size) return ok([]);
1660 const ids = [...channels.keys()];
1661 const limit = Math.min(20, Math.max(1, Math.floor(Number(a.limit) || 20)));
1662 const rows = await this.db
1663 .prepare(
1664 `SELECT * FROM messages WHERE channel_id IN (${ids.map(() => "?").join(", ")}) AND deleted_at IS NULL AND body LIKE ? ESCAPE '\\'
1665 ORDER BY id DESC LIMIT ?`,
1666 )
1667 .bind(...ids, pattern, limit)
1668 .all<MessageRow>();
1669 return ok(await this.found(a.workspace.toLowerCase(), workspace, channels, rows.results));
1670 }
1671
1672 async threadForAgent(a: { workspace: string; channel_id: string; target_channel_id: string; id: string }): Promise<Result<AgentFoundMessage[]>> {
1673 const found = await this.audienceOf(a.workspace, a.channel_id);
1674 if (!found.ok) return found;
1675 const { workspace, audience } = found.value;
1676 // The same answer for a conversation that is not there and one the audience may not read.
1677 const hidden = fail("not_found", "Not available in this conversation.");
1678 const target = await this.db
1679 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1680 .bind(String(a.target_channel_id ?? ""), workspace.id)
1681 .first<ChannelRow>();
1682 if (!target) return hidden;
1683 const people = target.kind === "channel" && !target.private ? [] : await this.peopleIn(target.id);
1684 if (!readableBy({ kind: target.kind, private: target.private, user_ids: people }, { kind: audience.kind, user_ids: audience.member_user_ids })) return hidden;
1685 const asked = await this.messageRow(target.id, String(a.id ?? ""));
1686 if (!asked) return hidden;
1687 const root = asked.thread_root ? await this.messageRow(target.id, asked.thread_root) : asked;
1688 if (!root) return hidden;
1689 const replies = await this.db
1690 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 49")
1691 .bind(root.id, target.id)
1692 .all<MessageRow>();
1693 const rows = historyOf(replies.results, root).filter((row) => !row.deleted_at);
1694 return ok(await this.found(a.workspace.toLowerCase(), workspace, new Map([[target.id, target]]), rows));
1695 }
1696
1697 async agentTyping(a: { workspace: string; channel_id: string; agent_id: string }): Promise<Result<null>> {
1698 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1699 if (!found.ok) return found;
1700 const { place, agent } = found.value;
1701 const key = principalKey({ kind: "agent", id: agent.id });
1702 const member = await this.profile(place.slug, place.workspace, key);
1703 this.broadcast(place.channel.id, {
1704 type: "typing",
1705 channel_id: place.channel.id,
1706 member,
1707 until: new Date(Date.now() + AGENT_TYPING_MS).toISOString(),
1708 });
1709 return ok(null);
1710 }
1711
1712 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1713
1714 /** One message's reactions now, as `me` sees them. */
1715 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1716 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1717 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1718 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1719 }
1720
1721 /**
1722 * Adds or takes back `who`'s reaction. Each member reacts once with each
1723 * emoji, a message holds at most 50 different ones, and a workspace's own
1724 * emoji must exist to be used (taking one back never needs it to). The
1725 * room hears of each change.
1726 */
1727 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1728 const parsed = reactionEmoji(input);
1729 if (!parsed.ok) return fail("invalid", parsed.message);
1730 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1731 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1732 let changed = 0;
1733 if (remove) {
1734 const done = await this.db
1735 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1736 .bind(row.id, who, parsed.emoji)
1737 .run();
1738 changed = done.meta.changes;
1739 } else {
1740 if (parsed.custom) {
1741 const known = await this.db
1742 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1743 .bind(place.workspace.id, parsed.custom)
1744 .first();
1745 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1746 }
1747 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1748 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1749 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1750 }
1751 const done = await this.db
1752 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1753 .bind(row.id, who, parsed.emoji, now())
1754 .run();
1755 changed = done.meta.changes;
1756 }
1757 if (changed) {
1758 const member = await this.profile(place.slug, place.workspace, who);
1759 this.broadcast(place.channel.id, {
1760 type: remove ? "reaction.removed" : "reaction.added",
1761 channel_id: place.channel.id,
1762 message_id: row.id,
1763 emoji: parsed.emoji,
1764 member,
1765 });
1766 }
1767 return ok(await this.reactionsFor(place, row.id, who));
1768 }
1769
1770 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1771 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1772 if (!found.ok) return found;
1773 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1774 }
1775
1776 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1777 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1778 if (!found.ok) return found;
1779 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1780 }
1781
1782 /** An agent's reaction counts like anyone's; it must be in the channel. */
1783 async reactAsAgent(a: {
1784 workspace: string;
1785 channel_id: string;
1786 agent_id: string;
1787 message_id: string;
1788 emoji: string;
1789 remove?: boolean;
1790 }): Promise<Result<ChatReaction[]>> {
1791 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1792 if (!found.ok) return found;
1793 const { place, agent } = found.value;
1794 return this.reactTo(place, principalKey({ kind: "agent", id: agent.id }), a.message_id, a.emoji, a.remove === true);
1795 }
1796
1797 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1798
1799 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
1800 return settingsOf(await this.settingsRow(workspace)).emoji_upload;
1801 }
1802
1803 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1804 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1805 return rows.map((row) => ({
1806 name: row.name,
1807 alias_of: row.alias_of,
1808 file: row.file,
1809 content_type: row.content_type,
1810 bytes: row.bytes,
1811 created_by: profiles.get(row.created_by)!,
1812 created_at: row.created_at,
1813 }));
1814 }
1815
1816 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1817 return this.db
1818 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1819 .bind(workspace.id, name)
1820 .first<EmojiRow>();
1821 }
1822
1823 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1824 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1825 if (!found.ok) return found;
1826 const workspace = found.value;
1827 const [rows, setting] = await Promise.all([
1828 this.db
1829 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1830 .bind(workspace.id)
1831 .all<EmojiRow>(),
1832 this.emojiUpload(workspace),
1833 ]);
1834 const role = roleOf(a.viewer, a.workspace);
1835 return ok({
1836 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1837 emoji_upload: setting,
1838 can_upload: mayUpload(setting, role),
1839 can_manage: role === "owner",
1840 });
1841 }
1842
1843 /** Who may add one: checked against the workspace's setting. */
1844 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1845 const setting = await this.emojiUpload(workspace);
1846 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1847 return ok(null);
1848 }
1849
1850 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1851 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1852 if (!found.ok) return found;
1853 const workspace = found.value;
1854 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1855 if (!allowed.ok) return allowed;
1856 const named = emojiName(a.name);
1857 if (!named.ok) return fail("invalid", named.message);
1858 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1859 const bytes = fromBase64(a.file?.data);
1860 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1861 const checked = emojiImage(bytes);
1862 if (!checked.ok) return fail("invalid", checked.message);
1863 const file = await sha256(bytes);
1864 // Kept by its hash, so the same image stored twice is one file; its
1865 // type is the one read from its bytes (usercontent serves only that).
1866 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1867 const row: EmojiRow = {
1868 workspace_id: workspace.id,
1869 name: named.name,
1870 alias_of: null,
1871 file,
1872 content_type: checked.image.content_type,
1873 bytes: bytes.length,
1874 created_by: userKey(a.viewer!),
1875 created_at: now(),
1876 deleted_at: null,
1877 };
1878 const added = await this.insertEmoji(row);
1879 if (!added.ok) return added;
1880 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1881 return ok(emoji!);
1882 }
1883
1884 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1885 try {
1886 await this.db
1887 .prepare(
1888 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1889 )
1890 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1891 .run();
1892 return ok(null);
1893 } catch (error) {
1894 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1895 throw error;
1896 }
1897 }
1898
1899 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1900 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1901 if (!found.ok) return found;
1902 const workspace = found.value;
1903 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1904 if (!allowed.ok) return allowed;
1905 const named = emojiName(a.name);
1906 if (!named.ok) return fail("invalid", named.message);
1907 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1908 let target = await this.liveEmoji(workspace, targetName);
1909 // An alias of an alias names the emoji itself, so removing one never strands another.
1910 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1911 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1912 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1913 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1914 const added = await this.insertEmoji(row);
1915 if (!added.ok) return added;
1916 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1917 return ok(emoji!);
1918 }
1919
1920 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1921 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1922 if (!found.ok) return found;
1923 const workspace = found.value;
1924 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1925 const row = await this.liveEmoji(workspace, name);
1926 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1927 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1928 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1929 }
1930 // An emoji goes with its aliases; an alias goes alone.
1931 await this.db
1932 .prepare(
1933 "UPDATE custom_emoji SET deleted_at = ?1 WHERE workspace_id = ?2 AND deleted_at IS NULL AND (name = ?3 OR (?4 = 0 AND alias_of = ?3))",
1934 )
1935 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1936 .run();
1937 // Its image goes once nothing live shows it, in any workspace.
1938 this.defer(
1939 (async () => {
1940 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1941 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1942 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1943 );
1944 return ok(null);
1945 }
1946
1947 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1948 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1949 if (!found.ok) return found;
1950 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1951 if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners.");
1952 // The same setting as Settings → Chat (`setChatSettings`).
1953 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1954 return set.ok ? ok(set.value.emoji_upload) : set;
1955 }
1956
1957 // ── The live socket ─────────────────────────────────────────────────────
1958
1959 /**
1960 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1961 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1962 * session; trusted only because this Worker is reachable through service
1963 * bindings alone (`workers_dev` is off and it has no routes). Checked
1964 * like any read, then handed to the channel's room.
1965 */
1966 async live(request: Request): Promise<Response> {
1967 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
1968 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1969 }
1970 let viewer: Viewer = null;
1971 try {
1972 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
1973 } catch {
1974 viewer = null;
1975 }
1976 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
1977 const url = new URL(request.url);
1978 const channelId = url.searchParams.get("channel") ?? "";
1979 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1980 if (!slug) {
1981 // Not named: whichever of the viewer's workspaces holds the channel.
1982 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
1983 if (row) {
1984 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
1985 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
1986 }
1987 }
1988 const found = await this.place(slug, channelId, viewer, "read");
1989 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1990 const { workspace, channel } = found.value;
1991 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
1992 const headers = new Headers(request.headers);
1993 headers.delete(CHAT_VIEWER_HEADER);
1994 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1995 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
1996 }
1997}
1998
1999/** One RPC method's answer. */
2000async function answer(service: Chat, method: string, args: any): Promise<Response> {
2001 switch (method) {
2002 case "sidebar":
2003 return Response.json(await service.sidebar(args));
2004 case "channel":
2005 return Response.json(await service.channel(args));
2006 case "channel_by_name":
2007 return Response.json(await service.channelByName(args));
2008 case "browse":
2009 return Response.json(await service.browse(args));
2010 case "create_channel":
2011 return Response.json(await service.createChannel(args));
2012 case "update_channel":
2013 return Response.json(await service.updateChannel(args));
2014 case "chat_settings":
2015 return Response.json(await service.chatSettings(args));
2016 case "set_chat_settings":
2017 return Response.json(await service.setChatSettings(args));
2018 case "open_dm":
2019 return Response.json(await service.openDm(args));
2020 case "join":
2021 return Response.json(await service.join(args));
2022 case "leave":
2023 return Response.json(await service.leave(args));
2024 case "invite":
2025 return Response.json(await service.invite(args));
2026 case "messages":
2027 return Response.json(await service.messages(args));
2028 case "post":
2029 return Response.json(await service.post(args));
2030 case "edit":
2031 return Response.json(await service.edit(args));
2032 case "remove":
2033 return Response.json(await service.remove(args));
2034 case "mark_read":
2035 return Response.json(await service.markRead(args));
2036 case "set_preferences":
2037 return Response.json(await service.setPreferences(args));
2038 case "post_as_agent":
2039 return Response.json(await service.postAsAgent(args));
2040 case "card_action":
2041 return Response.json(await service.cardAction(args));
2042 case "update_as_agent":
2043 return Response.json(await service.updateAsAgent(args));
2044 case "agent_typing":
2045 return Response.json(await service.agentTyping(args));
2046 case "history_for_agent":
2047 return Response.json(await service.historyForAgent(args));
2048 case "audience":
2049 return Response.json(await service.audience(args));
2050 case "conversation_for_agent":
2051 return Response.json(await service.conversationForAgent(args));
2052 case "hand_off_as_agent":
2053 return Response.json(await service.handOffAsAgent(args));
2054 case "search_for_agent":
2055 return Response.json(await service.searchForAgent(args));
2056 case "thread_for_agent":
2057 return Response.json(await service.threadForAgent(args));
2058 case "react":
2059 return Response.json(await service.react(args));
2060 case "unreact":
2061 return Response.json(await service.unreact(args));
2062 case "react_as_agent":
2063 return Response.json(await service.reactAsAgent(args));
2064 case "list_emoji":
2065 return Response.json(await service.listEmoji(args));
2066 case "add_emoji":
2067 return Response.json(await service.addEmoji(args));
2068 case "alias_emoji":
2069 return Response.json(await service.aliasEmoji(args));
2070 case "remove_emoji":
2071 return Response.json(await service.removeEmoji(args));
2072 case "set_emoji_upload":
2073 return Response.json(await service.setEmojiUpload(args));
2074 default:
2075 return new Response("Unknown method\n", { status: 404 });
2076 }
2077}
2078
2079export default {
2080 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2081 const url = new URL(request.url);
2082 if (request.method === "GET" && url.pathname === "/live") {
2083 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
2084 }
2085 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2086 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2087 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2088 const opened = openD1(env.DB, request);
2089 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
2090 const args = (await request.json().catch(() => ({}))) as any;
2091 return opened.finish(await answer(service, match[1], args));
2092 },
2093} satisfies ExportedHandler<Env>;