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