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