Skip to content
1,594 linesCodeBlameRaw
1/**
2 * The chat service: a workspace's channels, direct messages, threads and
3 * messages. Conversations are between people (docs.g1t.sh/guides/chat/);
4 * messages from agents of the past still show, under the name they had.
5 *
6 * Reached through service bindings: `POST /rpc/<method>` with snake_case
7 * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's
8 * socket, which the site forwards after checking the session. Each channel
9 * has a room (src/room.ts) that delivers what happens in it live.
10 *
11 * Workspaces are kept by id, so renaming one changes nothing here; who is
12 * in a workspace comes from the viewer's memberships, as in every service.
13 */
14
15import {
16 CHAT_VIEWER_HEADER,
17 fail,
18 identityClient,
19 idFloor,
20 newId,
21 ok,
22 openD1,
23 parsePrincipalKey,
24 principalKey,
25 type Channel,
26 type ChannelChange,
27 type ChannelDetail,
28 type ChannelMember,
29 type ChatLiveEvent,
30 type ChatMessage,
31 type ChatReaction,
32 type CustomEmoji,
33 type EmojiFile,
34 type EmojiList,
35 type EmojiUpload,
36 type ChatSettings,
37 type ChatSettingsView,
38 type ChatActivity,
39 type ChatSidebar,
40 type ChatSidebarEntry,
41 type Member,
42 type MemberProfile,
43 type MessageCard,
44 type MessagePage,
45 type NewChannel,
46 type PostMessage,
47 type Principal,
48 type Result,
49 type ServiceBinding,
50 type User,
51 type Viewer,
52 type Workspace,
53} from "@g1t/contracts";
54
55import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts";
56import {
57 MAX_REACTIONS_PER_MESSAGE,
58 emojiImage,
59 emojiName,
60 fromBase64,
61 mayRemove,
62 mayUpload,
63 reactionEmoji,
64 roomForReaction,
65 tallyReactions,
66 type ReactionRow,
67 type ReactionTally,
68} from "./emoji.ts";
69import { shownCard } from "./cards.ts";
70import { mentionedHandles, mentionsColumn } from "./mentions.ts";
71import { messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
72import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
73import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
74import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
75import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
76// Live notifications and counts (services/notify).
77import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
78
79export { ChannelRoom } from "./room.ts";
80
81type Env = {
82 DB: D1Database;
83 IDENTITY: ServiceBinding;
84 ROOMS: DurableObjectNamespace<ChannelRoom>;
85 /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */
86 AVATARS: KVNamespace;
87 /** Live notifications and unread counts (services/notify); absent, nobody is told. */
88 NOTIFY?: ServiceBinding;
89};
90
91type EmojiRow = {
92 workspace_id: string;
93 name: string;
94 alias_of: string | null;
95 file: string;
96 content_type: CustomEmoji["content_type"];
97 bytes: number;
98 created_by: string;
99 created_at: string;
100 deleted_at: string | null;
101};
102
103/** The viewer's role in a workspace, or null when they are not in it. */
104function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null {
105 return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null;
106}
107
108async function sha256(bytes: Uint8Array): Promise<string> {
109 const digest = await crypto.subtle.digest("SHA-256", bytes);
110 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
111}
112
113type ChannelRow = {
114 id: string;
115 workspace_id: string;
116 kind: "channel" | "dm";
117 name: string | null;
118 topic: string | null;
119 private: number;
120 dm_key: string | null;
121 created_by: string;
122 created_at: string;
123 archived_at: string | null;
124 last_message_at: string | null;
125};
126
127type MemberRow = {
128 channel_id: string;
129 principal: string;
130 role: "owner" | "member";
131 starred: number;
132 muted: number;
133 last_read_id: string | null;
134 joined_at: string;
135};
136
137type MessageRow = {
138 id: string;
139 channel_id: string;
140 author: string;
141 kind: "text" | "card";
142 body: string;
143 card: string | null;
144 mentions: string;
145 thread_root: string | null;
146 reply_count: number;
147 last_reply_at: string | null;
148 created_at: string;
149 edited_at: string | null;
150 deleted_at: string | null;
151};
152
153/**
154 * An agent deleted for good, as `former_agents` keeps it (migration 0004).
155 * Workspaces no longer have agents; their old messages show under the name
156 * kept here, else as a deleted agent.
157 */
158type FormerAgentRow = { agent_id: string; workspace_id: string; handle: string; display_name: string; avatar_seed: string | null; look: string | null; deleted_at: string };
159
160/** What a method found out about the channel it was asked about. */
161type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
162
163/** The most unread messages one sidebar reads to count; past it, counts are "at least". */
164const MAX_UNREAD_ROWS = 5_000;
165/** The longest channel topic. */
166const MAX_TOPIC = 250;
167/** How many others a direct message's sidebar entry shows. */
168const DM_FACES = 4;
169
170const now = () => new Date().toISOString();
171
172function isMember(viewer: Viewer, workspace: string): boolean {
173 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
174}
175
176function isOwner(viewer: Viewer, workspace: string): boolean {
177 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner");
178}
179
180function userKey(viewer: User): string {
181 return principalKey({ kind: "user", id: viewer.id });
182}
183
184function toChannel(row: ChannelRow): Channel {
185 return {
186 id: row.id,
187 workspace_id: row.workspace_id,
188 kind: row.kind,
189 name: row.kind === "dm" ? null : row.name,
190 topic: row.topic,
191 private: row.kind === "dm" || !!row.private,
192 created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by },
193 created_at: row.created_at,
194 archived_at: row.archived_at,
195 last_message_at: row.last_message_at,
196 };
197}
198
199function bytesOf(text: string): number {
200 return new TextEncoder().encode(text).length;
201}
202
203class Chat {
204 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
205 private readonly people = new Map<string, Promise<Map<string, Member>>>();
206 private readonly usernames = new Map<string, string>();
207 /** Agents deleted for good, by id, from `former_agents`; null for one chat never heard of. */
208 private readonly former = new Map<string, FormerAgentRow | null>();
209 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
210
211 /** `defer` runs work after the answer is sent: the request's waitUntil. */
212 constructor(
213 private readonly env: Env,
214 private readonly defer: (work: Promise<unknown>) => void = () => {},
215 ) {}
216
217 private get db() {
218 return this.env.DB;
219 }
220
221 // ── Who and where ───────────────────────────────────────────────────────
222
223 private workspace(slug: string): Promise<Workspace | null> {
224 const key = slug.toLowerCase();
225 let found = this.workspaces.get(key);
226 if (!found) {
227 found = identityClient(this.env.IDENTITY).getWorkspace(key);
228 this.workspaces.set(key, found);
229 }
230 return found;
231 }
232
233 /** The workspace's people by username, with their names and avatars; asked once per request. */
234 private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> {
235 let found = this.people.get(workspace.id);
236 if (!found) {
237 // Asked as the workspace itself.
238 const actor: User = {
239 id: workspace.id,
240 username: workspace.slug,
241 kind: "workspace",
242 verified: true,
243 workspaces: [{ slug: workspace.slug, role: "member" }],
244 };
245 found = identityClient(this.env.IDENTITY)
246 .listMembers(slug, actor)
247 .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : []))
248 .catch((error) => {
249 console.error("chat could not list members of", slug, error);
250 return new Map<string, Member>();
251 });
252 this.people.set(workspace.id, found);
253 }
254 return found;
255 }
256
257 /**
258 * Agents of the past, by id, as `former_agents` remembers them: the
259 * name and face their messages still show under. Once per request.
260 */
261 private async formerAgents(ids: string[]): Promise<Map<string, FormerAgentRow>> {
262 const wanted = [...new Set(ids)].filter((id) => !this.former.has(id));
263 if (wanted.length) {
264 const rows = await this.db
265 .prepare(`SELECT * FROM former_agents WHERE agent_id IN (${wanted.map(() => "?").join(", ")})`)
266 .bind(...wanted)
267 .all<FormerAgentRow>()
268 .catch((error: unknown) => {
269 console.error("chat could not read former agents", error);
270 return { results: [] as FormerAgentRow[] };
271 });
272 for (const id of wanted) this.former.set(id, rows.results.find((row) => row.agent_id === id) ?? null);
273 }
274 const out = new Map<string, FormerAgentRow>();
275 for (const id of ids) {
276 const row = this.former.get(id);
277 if (row) out.set(id, row);
278 }
279 return out;
280 }
281
282 /**
283 * How each member key shows, for one workspace. An agent of the past
284 * shows under the name it had, else as a deleted agent, marked `deleted`.
285 */
286 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
287 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
288 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
289 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
290 const unnamed = userIds.filter((id) => !this.usernames.has(id));
291 const [named, people, former] = await Promise.all([
292 unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>),
293 userIds.length ? this.members(slug, workspace) : new Map<string, Member>(),
294 agentIds.length ? this.formerAgents(agentIds) : new Map<string, FormerAgentRow>(),
295 ]);
296 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
297 const out = new Map<string, MemberProfile>();
298 for (const p of principals) {
299 if (p.kind === "user") {
300 const username = this.usernames.get(p.id) ?? null;
301 const person = username ? people.get(username) : undefined;
302 out.set(principalKey(p), {
303 ...p,
304 name: username ?? "ghost",
305 display_username: person?.display_username ?? null,
306 // Their display name, else their username as they wrote it
307 // (`memberName` reads this the same way).
308 display_name: person?.name?.trim() || person?.display_username || username || "Former member",
309 avatar: person?.avatar ?? null,
310 role: null,
311 title: null,
312 avatar_seed: null,
313 });
314 } else {
315 const was = former.get(p.id) ?? null;
316 out.set(principalKey(p), {
317 ...p,
318 name: was?.handle ?? p.id,
319 // Never blank: an old agent's messages show under the name it had, else as a deleted agent.
320 display_name: was?.display_name ?? "A deleted agent",
321 avatar: null,
322 role: null,
323 title: null,
324 avatar_seed: was?.avatar_seed ?? null,
325 archived_at: null,
326 archived_by: null,
327 deleted: true,
328 });
329 }
330 }
331 return out;
332 }
333
334 private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> {
335 return (await this.profiles(slug, workspace, [key])).get(key)!;
336 }
337
338 /** Whether `principal` may be added to a conversation in this workspace: people of it only. */
339 private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> {
340 if (principal.kind !== "user") return false;
341 if (!this.usernames.has(principal.id)) {
342 const named = await identityClient(this.env.IDENTITY).usernames([principal.id]);
343 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
344 }
345 const username = this.usernames.get(principal.id);
346 return !!username && (await this.members(slug, workspace)).has(username);
347 }
348
349 /**
350 * The viewer's workspace, checked: they must belong to it, as in every
351 * other service.
352 */
353 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
354 if (!viewer) return fail("unauthenticated", "Sign in to use chat.");
355 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat.");
356 const workspace = await this.workspace(slug);
357 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
358 }
359
360 /**
361 * A channel the viewer may read (`read`: any public one in their
362 * workspace, or one they are in) or write in (`member`: one they are in).
363 * A private channel or direct message they are not in is not found, so
364 * its existence does not leak.
365 */
366 private async place(
367 slug: string,
368 channelId: string,
369 viewer: Viewer,
370 need: "read" | "member",
371 ): Promise<Result<Place>> {
372 const found = await this.viewerWorkspace(slug, viewer);
373 if (!found.ok) return found;
374 const workspace = found.value;
375 const [channel, member] = await Promise.all([
376 this.db
377 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
378 .bind(String(channelId ?? ""), workspace.id)
379 .first<ChannelRow>(),
380 this.db
381 .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?")
382 .bind(String(channelId ?? ""), userKey(viewer!))
383 .first<MemberRow>(),
384 ]);
385 if (!channel) return fail("not_found", "No such channel.");
386 const open = channel.kind === "channel" && !channel.private;
387 if (!member && !open) return fail("not_found", "No such channel.");
388 if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`);
389 return ok({ slug: slug.toLowerCase(), workspace, channel, member });
390 }
391
392 private room(channelId: string) {
393 return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId));
394 }
395
396 /** Tells everyone looking at a channel, after the answer is sent. */
397 private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void {
398 this.defer(
399 this.room(channelId)
400 .broadcast(event, except)
401 .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)),
402 );
403 }
404
405 /**
406 * Messages as they go out, with their reactions. `me` (a member key) is
407 * the viewer an answer is for; null for what everyone in a room gets,
408 * where no reaction is anyone's own.
409 */
410 private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> {
411 const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me);
412 const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by));
413 const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]);
414 return rows.map((row) => {
415 const gone = !!row.deleted_at;
416 return {
417 reactions: gone
418 ? []
419 : (reactions.get(row.id) ?? []).map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })),
420 id: row.id,
421 channel_id: row.channel_id,
422 author: profiles.get(row.author)!,
423 kind: row.kind,
424 body: gone ? "" : row.body,
425 card: gone || !row.card ? null : shownCard(JSON.parse(row.card) as MessageCard),
426 thread_root: row.thread_root,
427 reply_count: row.reply_count,
428 last_reply_at: row.last_reply_at,
429 created_at: row.created_at,
430 edited_at: row.edited_at,
431 deleted_at: row.deleted_at,
432 };
433 });
434 }
435
436 /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */
437 private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> {
438 if (!ids.length) return new Map();
439 const rows = await this.db
440 .prepare(
441 "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))",
442 )
443 .bind(JSON.stringify(ids))
444 .all<ReactionRow>();
445 return tallyReactions(rows.results, me);
446 }
447
448 private async messageRow(channelId: string, id: string): Promise<MessageRow | null> {
449 return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>();
450 }
451
452 /** Sends a message as it now is to everyone looking at its channel. */
453 private rebroadcast(place: Place, id: string): void {
454 this.defer(
455 (async () => {
456 const row = await this.messageRow(place.channel.id, id);
457 if (!row) return;
458 const [message] = await this.toMessages(place.slug, place.workspace, [row]);
459 await this.room(place.channel.id).broadcast({ type: "message.updated", message });
460 })().catch((error) => console.error("chat could not rebroadcast", id, error)),
461 );
462 }
463
464 // ── The sidebar ─────────────────────────────────────────────────────────
465
466 /**
467 * Puts a person in the workspace's #general, once. A new workspace has
468 * no channels, so the first sidebar anyone in it asks for creates
469 * #general; and everyone who asks for the sidebar is put in it the first
470 * time, so a new workspace has somewhere to talk and a new member lands
471 * where everyone is. Someone who leaves it is not put back
472 * (`general_joined`). A private channel someone named `general` is never
473 * joined this way.
474 */
475 private async ensureGeneral(workspace: Workspace, me: string): Promise<void> {
476 const seen = await this.db
477 .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?")
478 .bind(workspace.id, me)
479 .first();
480 if (seen) return;
481 const at = now();
482 await this.db
483 .prepare(
484 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)",
485 )
486 .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at)
487 .run();
488 const general = await this.db
489 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?")
490 .bind(workspace.id, GENERAL)
491 .first<ChannelRow>();
492 const statements = [
493 this.db
494 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
495 .bind(workspace.id, me, at),
496 ];
497 // The workspace's default channels (#general unless its owners chose
498 // others): public and not archived only, whatever was kept.
499 const settings = await this.settings(workspace, general?.id ?? null);
500 const defaults = settings.default_channels.length
501 ? await this.db
502 .prepare(
503 "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(?))",
504 )
505 .bind(workspace.id, JSON.stringify(settings.default_channels))
506 .all<ChannelRow>()
507 : { results: [] as ChannelRow[] };
508 for (const channel of defaults.results) {
509 statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at));
510 }
511 await this.db.batch(statements);
512 }
513
514 /**
515 * Adds a member. Someone joining starts with everything already said
516 * read, so a long channel does not greet them with its whole history as
517 * unread.
518 */
519 private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement {
520 return this.db
521 .prepare(
522 "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)",
523 )
524 .bind(channelId, principal, role, at);
525 }
526
527 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> {
528 const found = await this.viewerWorkspace(a.workspace, a.viewer);
529 if (!found.ok) return found;
530 const workspace = found.value;
531 const slug = a.workspace.toLowerCase();
532 const me = userKey(a.viewer!);
533 await this.ensureGeneral(workspace, me);
534
535 const [joined, unread, dmOthers, browsable] = await Promise.all([
536 this.db
537 .prepare(
538 `SELECT c.*, m.starred, m.muted, m.last_read_id
539 FROM channel_members m JOIN channels c ON c.id = m.channel_id
540 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`,
541 )
542 .bind(me, workspace.id)
543 .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(),
544 this.db
545 .prepare(
546 `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
547 FROM channel_members m
548 JOIN channels c ON c.id = m.channel_id
549 JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
550 WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
551 AND msg.deleted_at IS NULL AND msg.author != ?1
552 LIMIT ${MAX_UNREAD_ROWS}`,
553 )
554 .bind(me, workspace.id)
555 .all<UnreadRow>(),
556 this.db
557 .prepare(
558 `SELECT o.channel_id, o.principal
559 FROM channel_members m
560 JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm'
561 JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal
562 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL
563 ORDER BY o.joined_at, o.principal`,
564 )
565 .bind(me, workspace.id)
566 .all<{ channel_id: string; principal: string }>(),
567 this.db
568 .prepare(
569 `SELECT COUNT(*) AS n FROM channels c
570 WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL
571 AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`,
572 )
573 .bind(workspace.id, me)
574 .first<{ n: number }>(),
575 ]);
576
577 const others = new Map<string, string[]>();
578 for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]);
579 const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]);
580 const self = profiles.get(me) ?? null;
581 const counts = tally(
582 unread.results,
583 me,
584 self?.name ?? a.viewer!.username,
585 new Map(joined.results.map((row) => [row.id, row.last_read_id])),
586 );
587
588 const entries: ChatSidebarEntry[] = joined.results.map((row) => {
589 const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean);
590 const count = counts.get(row.id) ?? { unread: 0, mentions: 0 };
591 return {
592 channel: toChannel(row),
593 title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""),
594 others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [],
595 starred: !!row.starred,
596 muted: !!row.muted,
597 unread: count.unread,
598 mentions: count.mentions,
599 };
600 });
601 const settings = await this.settings(workspace);
602 return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
603 }
604
605 /**
606 * What was said in `[from, until)` where the viewer can read: every
607 * public channel of the workspace, and the private channels and direct
608 * messages they are in (as `place` reads). Text messages and thread
609 * replies not deleted; never a card. Message ids sort by time, so each
610 * conversation's span is one range of `messages_by_channel`, read in one
611 * round trip for them all. Counts only, nothing of what was said.
612 */
613 async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> {
614 const found = await this.viewerWorkspace(a.workspace, a.viewer);
615 if (!found.ok) return found;
616 const workspace = found.value;
617 const span = activitySpan(a.from, a.until);
618 if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until.");
619 const me = userKey(a.viewer!);
620 const rows = await this.db
621 .prepare(
622 `SELECT msg.channel_id, msg.author, COUNT(*) AS n
623 FROM channels c
624 JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4
625 WHERE c.workspace_id = ?1 AND c.archived_at IS NULL
626 AND (
627 (c.kind = 'channel' AND c.private = 0)
628 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)
629 )
630 AND msg.deleted_at IS NULL AND msg.kind = 'text'
631 GROUP BY msg.channel_id, msg.author`,
632 )
633 .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs))
634 .all<ActivityRow>();
635 return ok(chatActivity(rows.results, { from: span.from, until: span.until }));
636 }
637
638 // ── Channels ────────────────────────────────────────────────────────────
639
640 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
641 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
642 if (!found.ok) return found;
643 const { slug, workspace, channel, member } = found.value;
644 const [rows, settings] = await Promise.all([
645 this.db
646 .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
647 .bind(channel.id)
648 .all<MemberRow>(),
649 this.settings(workspace),
650 ]);
651 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
652 return ok({
653 channel: toChannel(channel),
654 can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null),
655 members: rows.results.map((row) => ({
656 channel_id: row.channel_id,
657 member: profiles.get(row.principal)!,
658 role: row.role,
659 starred: !!row.starred,
660 muted: !!row.muted,
661 last_read_id: row.last_read_id,
662 joined_at: row.joined_at,
663 })),
664 });
665 }
666
667 /** A channel by its name, as the site's URLs name them; read like `channel`. */
668 async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
669 const found = await this.viewerWorkspace(a.workspace, a.viewer);
670 if (!found.ok) return found;
671 const named = channelName(a.name ?? "");
672 if (!named.ok) return fail("not_found", "No such channel.");
673 const row = await this.db
674 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?")
675 .bind(found.value.id, named.name)
676 .first<{ id: string }>();
677 if (!row) return fail("not_found", "No such channel.");
678 // A private channel the viewer is not in stays not found there.
679 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
680 }
681
682 /**
683 * Every public channel, and the private ones the viewer is in; a private
684 * one they are not in stays unseen. With `archived`, the archived ones.
685 */
686 async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
687 const found = await this.viewerWorkspace(a.workspace, a.viewer);
688 if (!found.ok) return found;
689 const rows = await this.db
690 .prepare(
691 `SELECT * FROM channels c
692 WHERE c.workspace_id = ?1 AND c.kind = 'channel'
693 AND (c.archived_at IS NULL) = (?3 = 0)
694 AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
695 ORDER BY c.name`,
696 )
697 .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
698 .all<ChannelRow>();
699 return ok(rows.results.map(toChannel));
700 }
701
702 async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> {
703 const found = await this.viewerWorkspace(a.workspace, a.viewer);
704 if (!found.ok) return found;
705 const workspace = found.value;
706 const named = channelName(a.input?.name ?? "");
707 if (!named.ok) return fail("invalid", named.message);
708 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
709 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
710 const isPrivate = !!a.input?.private;
711 if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
712 return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
713 }
714 const taken = await this.db
715 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
716 .bind(workspace.id, named.name)
717 .first();
718 if (taken) return fail("conflict", `#${named.name} already exists.`);
719 const me = userKey(a.viewer!);
720 const row: ChannelRow = {
721 id: newId("chn"),
722 workspace_id: workspace.id,
723 kind: "channel",
724 name: named.name,
725 topic: topic || null,
726 private: isPrivate ? 1 : 0,
727 dm_key: null,
728 created_by: me,
729 created_at: now(),
730 archived_at: null,
731 last_message_at: null,
732 };
733 try {
734 await this.db.batch([
735 this.db
736 .prepare(
737 "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)",
738 )
739 .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at),
740 this.joinStatement(row.id, me, "owner", row.created_at),
741 ]);
742 } catch (error) {
743 if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`);
744 throw error;
745 }
746 return ok(toChannel(row));
747 }
748
749 async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> {
750 const found = await this.viewerWorkspace(a.workspace, a.viewer);
751 if (!found.ok) return found;
752 const workspace = found.value;
753 const slug = a.workspace.toLowerCase();
754 const me = userKey(a.viewer!);
755 const asked = Array.isArray(a.members) ? a.members : [];
756 const principals: Principal[] = [];
757 for (const member of asked) {
758 const p = member && parsePrincipalKey(`${member.kind}:${member.id}`);
759 if (!p || p.kind !== "user") return fail("invalid", "Each member is a person of the workspace, by id.");
760 principals.push(p);
761 }
762 const members = dmMembers(me, principals.map(principalKey));
763 if (members.length > MAX_DM_MEMBERS) {
764 return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people. Make a private channel instead.`);
765 }
766 const key = dmKey(members);
767 const existing = await this.db
768 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
769 .bind(workspace.id, key)
770 .first<ChannelRow>();
771 if (existing) return ok(toChannel(existing));
772
773 for (const member of members) {
774 if (member === me) continue;
775 const p = parsePrincipalKey(member)!;
776 if (!(await this.belongs(slug, workspace, p))) {
777 return fail("not_found", "That person is not in this workspace.");
778 }
779 }
780 const at = now();
781 await this.db
782 .prepare(
783 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)",
784 )
785 .bind(newId("chn"), workspace.id, key, me, at)
786 .run();
787 // Read back by key: if two people opened it at once, both get the one that won.
788 const channel = await this.db
789 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
790 .bind(workspace.id, key)
791 .first<ChannelRow>();
792 if (!channel) return fail("conflict", "The direct message could not be opened. Try again.");
793 await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at)));
794 return ok(toChannel(channel));
795 }
796
797 async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
798 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
799 if (!found.ok) return found;
800 const { channel, member } = found.value;
801 if (member) return ok(null);
802 if (channel.archived_at) return fail("invalid", "This channel is archived.");
803 await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run();
804 return ok(null);
805 }
806
807 async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
808 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
809 if (!found.ok) return found;
810 const { channel, member } = found.value;
811 if (!member) return ok(null);
812 if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead.");
813 const me = userKey(a.viewer!);
814 await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run();
815 // Out of a private channel, they may no longer read it, live either.
816 if (channel.private) {
817 this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error)));
818 }
819 return ok(null);
820 }
821
822 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
823 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
824 if (!found.ok) return found;
825 const { slug, workspace, channel } = found.value;
826 if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it.");
827 if (channel.archived_at) return fail("invalid", "This channel is archived.");
828 const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`);
829 if (!p || p.kind !== "user") return fail("invalid", "Invite a person of the workspace, by id.");
830 if (!(await this.belongs(slug, workspace, p))) return fail("not_found", "That person is not in this workspace.");
831 await this.joinStatement(channel.id, principalKey(p), "member", now()).run();
832 return ok(null);
833 }
834
835 async setPreferences(a: {
836 workspace: string;
837 channel_id: string;
838 viewer: Viewer;
839 prefs: { starred?: boolean; muted?: boolean };
840 }): Promise<Result<null>> {
841 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
842 if (!found.ok) return found;
843 const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null;
844 const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null;
845 await this.db
846 .prepare(
847 "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?",
848 )
849 .bind(starred, muted, found.value.channel.id, userKey(a.viewer!))
850 .run();
851 // Notify: the badge counts the conversation again, or leaves it out, in every tab.
852 if (muted !== null) {
853 this.defer(
854 notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) =>
855 console.error("chat could not notify a mute", error),
856 ),
857 );
858 }
859 return ok(null);
860 }
861
862 // ── What owners decide (src/settings.ts) ────────────────────────────────
863
864 /** The row of a workspace's chat settings, read once per request. */
865 private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
866 let found = this.settingsRows.get(workspace.id);
867 if (!found) {
868 found = this.db
869 .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
870 .bind(workspace.id)
871 .first<SettingsRow>();
872 this.settingsRows.set(workspace.id, found);
873 }
874 return found;
875 }
876
877 /**
878 * A workspace's chat settings. Default channels never chosen are
879 * #general, looked up unless `generalId` is given.
880 */
881 private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
882 const row = await this.settingsRow(workspace);
883 if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
884 const general = await this.db
885 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
886 .bind(workspace.id, GENERAL)
887 .first<{ id: string }>();
888 return settingsOf(row, general?.id ?? null);
889 }
890
891 async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
892 const found = await this.viewerWorkspace(a.workspace, a.viewer);
893 if (!found.ok) return found;
894 const workspace = found.value;
895 const [settings, channels] = await Promise.all([
896 this.settings(workspace),
897 this.db
898 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
899 .bind(workspace.id)
900 .all<ChannelRow>(),
901 ]);
902 return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
903 }
904
905 async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
906 const found = await this.viewerWorkspace(a.workspace, a.viewer);
907 if (!found.ok) return found;
908 const workspace = found.value;
909 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
910 const checked = settingsChange(a.change);
911 if (!checked.ok) return fail("invalid", checked.message);
912 const change = checked.change;
913 if (change.default_channels) {
914 // Only the workspace's public channels that are not archived: a private
915 // one would put people somewhere they were never invited.
916 const rows = await this.db
917 .prepare(
918 "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(?))",
919 )
920 .bind(workspace.id, JSON.stringify(change.default_channels))
921 .all<{ id: string }>();
922 const open = new Set(rows.results.map((r) => r.id));
923 if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
924 }
925 const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
926 const row = rowFor(next);
927 await this.db
928 .prepare(
929 `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
930 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
931 ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
932 )
933 .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
934 .run();
935 this.settingsRows.delete(workspace.id);
936 return ok(next);
937 }
938
939 /**
940 * Renames, archives or unarchives a channel, or changes its topic. The
941 * workspace's `manage_channels` setting says who may do the first three;
942 * any member may change the topic. #general stays #general, and stays.
943 */
944 async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
945 // Archived or not, a channel is found the same way; a private one only by its members.
946 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
947 if (!found.ok) return found;
948 const { slug, workspace, channel, member } = found.value;
949 if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
950 const change = a.change ?? {};
951 const next: ChannelRow = { ...channel };
952 if (change.name !== undefined || change.archived !== undefined) {
953 const settings = await this.settings(workspace);
954 if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
955 return fail(
956 "forbidden",
957 settings.manage_channels === "owners"
958 ? "Only workspace owners can rename or archive channels here."
959 : "Only this channel's owners and workspace owners can rename or archive it.",
960 );
961 }
962 if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
963 }
964 if (change.name !== undefined) {
965 const named = channelName(String(change.name ?? ""));
966 if (!named.ok) return fail("invalid", named.message);
967 if (named.name !== channel.name) {
968 const taken = await this.db
969 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
970 .bind(workspace.id, named.name, channel.id)
971 .first();
972 if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
973 }
974 next.name = named.name;
975 }
976 if (change.topic !== undefined) {
977 if (!member) return fail("forbidden", `Join #${channel.name} first.`);
978 const topic = typeof change.topic === "string" ? change.topic.trim() : "";
979 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
980 next.topic = topic || null;
981 }
982 if (change.archived !== undefined) {
983 next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
984 } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
985 return fail("invalid", "This channel is archived. Unarchive it first.");
986 }
987 try {
988 await this.db
989 .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
990 .bind(next.name, next.topic, next.archived_at, channel.id)
991 .run();
992 } catch (error) {
993 if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
994 throw error;
995 }
996 const updated = toChannel(next);
997 this.broadcast(channel.id, { type: "channel.updated", channel: updated });
998 return ok(updated);
999 }
1000
1001 // ── Messages ────────────────────────────────────────────────────────────
1002
1003 /**
1004 * Newest first, a page at a time. With `thread_root`, that thread's
1005 * replies, and the message they reply to as the oldest once the page
1006 * reaches the start of the thread; without, the channel's top-level
1007 * messages. A deleted message stays only while replies hang off it.
1008 */
1009 async messages(a: {
1010 workspace: string;
1011 channel_id: string;
1012 viewer: Viewer;
1013 before?: string | null;
1014 after?: string | null;
1015 limit?: number | null;
1016 thread_root?: string | null;
1017 }): Promise<Result<MessagePage>> {
1018 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1019 if (!found.ok) return found;
1020 const { slug, workspace, channel } = found.value;
1021 const size = pageSize(a.limit);
1022 // Ids are lowercase letters, digits and `_`, so `~` sorts after every
1023 // one: the first page reads from the newest as a range of the index.
1024 const before = typeof a.before === "string" && a.before ? a.before : "~";
1025 const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null;
1026 if (typeof a.after === "string" && a.after) {
1027 // Catching up after a reconnect: what came after, oldest first. Deleted
1028 // ones too, so the client drops them; read one past the page to know
1029 // whether there is more.
1030 const newer = root
1031 ? await this.db
1032 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?")
1033 .bind(root, channel.id, a.after, size + 1)
1034 .all<MessageRow>()
1035 : await this.db
1036 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?")
1037 .bind(channel.id, a.after, size + 1)
1038 .all<MessageRow>();
1039 const page = pageOf(newer.results, size);
1040 return ok({ messages: await this.toMessages(slug, workspace, page.rows, userKey(a.viewer!)), older: null, newer: page.older });
1041 }
1042 const rows = root
1043 ? await this.db
1044 .prepare(
1045 `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3
1046 ORDER BY id DESC LIMIT ?4`,
1047 )
1048 .bind(root, channel.id, before, size + 1)
1049 .all<MessageRow>()
1050 : await this.db
1051 .prepare(
1052 `SELECT * FROM messages
1053 WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
1054 AND (deleted_at IS NULL OR reply_count > 0)
1055 ORDER BY id DESC LIMIT ?3`,
1056 )
1057 .bind(channel.id, before, size + 1)
1058 .all<MessageRow>();
1059 const page = pageOf(rows.results, size);
1060 let list = page.rows;
1061 // The message a thread is under: the oldest once the page reaches the
1062 // start, and, on the first page, as `root` however long the thread is,
1063 // so what the thread replies to stays at the top of it.
1064 const firstPage = before === "~";
1065 const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1066 if (rootRow && page.older === null) list = [...list, rootRow];
1067 const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1068 if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1069 const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1070 return ok({ messages, older: page.older, root: shown });
1071 }
1072
1073 /**
1074 * Writes a message and everything that follows from it: the thread's
1075 * reply count, the channel's last activity, the author's own read mark,
1076 * the meter; then, after answering, tells the room and notifies the
1077 * people it is for.
1078 */
1079 private async write(
1080 place: Place,
1081 author: string,
1082 input: { body: string; card: MessageCard | null; thread_root: string | null },
1083 ): Promise<Result<ChatMessage>> {
1084 const { channel, workspace } = place;
1085 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1086 let threadRoot: string | null = null;
1087 if (input.thread_root) {
1088 const root = await this.messageRow(channel.id, input.thread_root);
1089 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1090 // A reply to a reply goes in the same thread.
1091 threadRoot = root.thread_root ?? root.id;
1092 }
1093 const at = now();
1094 const body = input.body;
1095 const handles = mentionedHandles(body);
1096 const row: MessageRow = {
1097 id: newId("msg"),
1098 channel_id: channel.id,
1099 author,
1100 kind: input.card ? "card" : "text",
1101 body,
1102 card: input.card ? JSON.stringify(input.card) : null,
1103 mentions: mentionsColumn(handles),
1104 thread_root: threadRoot,
1105 reply_count: 0,
1106 last_reply_at: null,
1107 created_at: at,
1108 edited_at: null,
1109 deleted_at: null,
1110 };
1111 const statements = [
1112 this.db
1113 .prepare(
1114 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1115 )
1116 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at),
1117 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1118 // What you wrote, you have read.
1119 this.db
1120 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1121 .bind(row.id, channel.id, author),
1122 this.db
1123 .prepare(
1124 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1125 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1126 )
1127 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1128 ];
1129 if (threadRoot) {
1130 statements.push(
1131 this.db
1132 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1133 .bind(at, threadRoot),
1134 );
1135 }
1136 await this.db.batch(statements);
1137
1138 const [message] = await this.toMessages(place.slug, workspace, [row]);
1139 this.broadcast(channel.id, { type: "message.created", message });
1140 if (threadRoot) this.rebroadcast(place, threadRoot);
1141 // Notify: counts for everyone in the conversation, a notification for those it is for.
1142 this.defer(
1143 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles }).catch(
1144 (error) => console.error("chat could not notify about", row.id, error),
1145 ),
1146 );
1147 return ok(message);
1148 }
1149
1150 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1151 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1152 if (!found.ok) return found;
1153 const body = messageBody(a.message?.body);
1154 if (!body.ok) return fail("invalid", body.message);
1155 const me = userKey(a.viewer!);
1156 let place = found.value;
1157 if (!place.member) {
1158 // Saying something in a public channel joins it, as reading does not.
1159 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1160 await this.joinStatement(place.channel.id, me, "member", now()).run();
1161 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1162 }
1163 return this.write(
1164 place,
1165 me,
1166 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
1167 );
1168 }
1169
1170 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1171 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1172 if (!found.ok) return found;
1173 const place = found.value;
1174 const row = await this.messageRow(place.channel.id, a.id);
1175 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1176 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1177 const body = messageBody(a.body, !!row.card);
1178 if (!body.ok) return fail("invalid", body.message);
1179 const at = now();
1180 const mentions = mentionsColumn(mentionedHandles(body.body));
1181 await this.db
1182 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1183 .bind(body.body, mentions, at, row.id)
1184 .run();
1185 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1186 this.broadcast(place.channel.id, { type: "message.updated", message });
1187 return ok(message);
1188 }
1189
1190 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1191 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1192 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1193 if (!found.ok) return found;
1194 const place = found.value;
1195 const row = await this.messageRow(place.channel.id, a.id);
1196 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1197 if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) {
1198 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1199 }
1200 const statements = [
1201 this.db
1202 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1203 .bind(now(), row.id),
1204 ];
1205 if (row.thread_root) {
1206 statements.push(
1207 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1208 );
1209 }
1210 await this.db.batch(statements);
1211 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1212 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1213 return ok(null);
1214 }
1215
1216 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1217 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1218 if (!found.ok) return found;
1219 const { channel, member } = found.value;
1220 // Someone reading a public channel they have not joined keeps no read state.
1221 if (!member) return ok(null);
1222 const id = typeof a.id === "string" ? a.id : "";
1223 if (!id) return fail("invalid", "Say which message was read.");
1224 // Only forward: reading an old thread does not mark newer messages unread.
1225 const changed = await this.db
1226 .prepare(
1227 "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)",
1228 )
1229 .bind(id, channel.id, member.principal)
1230 .run();
1231 if (changed.meta.changes) {
1232 this.broadcast(channel.id, {
1233 type: "read",
1234 channel_id: channel.id,
1235 principal: { kind: "user", id: a.viewer!.id },
1236 last_read_id: id,
1237 });
1238 // Notify: the read drops the counts in every tab of theirs.
1239 this.defer(
1240 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(
1241 (error) => console.error("chat could not notify a read", error),
1242 ),
1243 );
1244 }
1245 return ok(null);
1246 }
1247
1248 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1249
1250 /** One message's reactions now, as `me` sees them. */
1251 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1252 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1253 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1254 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1255 }
1256
1257 /**
1258 * Adds or takes back `who`'s reaction. Each member reacts once with each
1259 * emoji, a message holds at most 50 different ones, and a workspace's own
1260 * emoji must exist to be used (taking one back never needs it to). The
1261 * room hears of each change.
1262 */
1263 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1264 const parsed = reactionEmoji(input);
1265 if (!parsed.ok) return fail("invalid", parsed.message);
1266 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1267 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1268 let changed = 0;
1269 if (remove) {
1270 const done = await this.db
1271 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1272 .bind(row.id, who, parsed.emoji)
1273 .run();
1274 changed = done.meta.changes;
1275 } else {
1276 if (parsed.custom) {
1277 const known = await this.db
1278 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1279 .bind(place.workspace.id, parsed.custom)
1280 .first();
1281 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1282 }
1283 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1284 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1285 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1286 }
1287 const done = await this.db
1288 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1289 .bind(row.id, who, parsed.emoji, now())
1290 .run();
1291 changed = done.meta.changes;
1292 }
1293 if (changed) {
1294 const member = await this.profile(place.slug, place.workspace, who);
1295 this.broadcast(place.channel.id, {
1296 type: remove ? "reaction.removed" : "reaction.added",
1297 channel_id: place.channel.id,
1298 message_id: row.id,
1299 emoji: parsed.emoji,
1300 member,
1301 });
1302 }
1303 return ok(await this.reactionsFor(place, row.id, who));
1304 }
1305
1306 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1307 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1308 if (!found.ok) return found;
1309 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1310 }
1311
1312 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1313 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1314 if (!found.ok) return found;
1315 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1316 }
1317
1318 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1319
1320 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
1321 return settingsOf(await this.settingsRow(workspace)).emoji_upload;
1322 }
1323
1324 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1325 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1326 return rows.map((row) => ({
1327 name: row.name,
1328 alias_of: row.alias_of,
1329 file: row.file,
1330 content_type: row.content_type,
1331 bytes: row.bytes,
1332 created_by: profiles.get(row.created_by)!,
1333 created_at: row.created_at,
1334 }));
1335 }
1336
1337 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1338 return this.db
1339 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1340 .bind(workspace.id, name)
1341 .first<EmojiRow>();
1342 }
1343
1344 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1345 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1346 if (!found.ok) return found;
1347 const workspace = found.value;
1348 const [rows, setting] = await Promise.all([
1349 this.db
1350 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1351 .bind(workspace.id)
1352 .all<EmojiRow>(),
1353 this.emojiUpload(workspace),
1354 ]);
1355 const role = roleOf(a.viewer, a.workspace);
1356 return ok({
1357 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1358 emoji_upload: setting,
1359 can_upload: mayUpload(setting, role),
1360 can_manage: role === "owner",
1361 });
1362 }
1363
1364 /** Who may add one: checked against the workspace's setting. */
1365 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1366 const setting = await this.emojiUpload(workspace);
1367 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1368 return ok(null);
1369 }
1370
1371 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1372 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1373 if (!found.ok) return found;
1374 const workspace = found.value;
1375 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1376 if (!allowed.ok) return allowed;
1377 const named = emojiName(a.name);
1378 if (!named.ok) return fail("invalid", named.message);
1379 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1380 const bytes = fromBase64(a.file?.data);
1381 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1382 const checked = emojiImage(bytes);
1383 if (!checked.ok) return fail("invalid", checked.message);
1384 const file = await sha256(bytes);
1385 // Kept by its hash, so the same image stored twice is one file; its
1386 // type is the one read from its bytes (usercontent serves only that).
1387 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1388 const row: EmojiRow = {
1389 workspace_id: workspace.id,
1390 name: named.name,
1391 alias_of: null,
1392 file,
1393 content_type: checked.image.content_type,
1394 bytes: bytes.length,
1395 created_by: userKey(a.viewer!),
1396 created_at: now(),
1397 deleted_at: null,
1398 };
1399 const added = await this.insertEmoji(row);
1400 if (!added.ok) return added;
1401 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1402 return ok(emoji!);
1403 }
1404
1405 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1406 try {
1407 await this.db
1408 .prepare(
1409 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1410 )
1411 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1412 .run();
1413 return ok(null);
1414 } catch (error) {
1415 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1416 throw error;
1417 }
1418 }
1419
1420 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1421 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1422 if (!found.ok) return found;
1423 const workspace = found.value;
1424 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1425 if (!allowed.ok) return allowed;
1426 const named = emojiName(a.name);
1427 if (!named.ok) return fail("invalid", named.message);
1428 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1429 let target = await this.liveEmoji(workspace, targetName);
1430 // An alias of an alias names the emoji itself, so removing one never strands another.
1431 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1432 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1433 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1434 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1435 const added = await this.insertEmoji(row);
1436 if (!added.ok) return added;
1437 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1438 return ok(emoji!);
1439 }
1440
1441 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1442 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1443 if (!found.ok) return found;
1444 const workspace = found.value;
1445 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1446 const row = await this.liveEmoji(workspace, name);
1447 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1448 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1449 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1450 }
1451 // An emoji goes with its aliases; an alias goes alone.
1452 await this.db
1453 .prepare(
1454 "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))",
1455 )
1456 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1457 .run();
1458 // Its image goes once nothing live shows it, in any workspace.
1459 this.defer(
1460 (async () => {
1461 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1462 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1463 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1464 );
1465 return ok(null);
1466 }
1467
1468 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1469 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1470 if (!found.ok) return found;
1471 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1472 if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners.");
1473 // The same setting as Settings → Chat (`setChatSettings`).
1474 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1475 return set.ok ? ok(set.value.emoji_upload) : set;
1476 }
1477
1478 // ── The live socket ─────────────────────────────────────────────────────
1479
1480 /**
1481 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1482 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1483 * session; trusted only because this Worker is reachable through service
1484 * bindings alone (`workers_dev` is off and it has no routes). Checked
1485 * like any read, then handed to the channel's room.
1486 */
1487 async live(request: Request): Promise<Response> {
1488 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
1489 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1490 }
1491 let viewer: Viewer = null;
1492 try {
1493 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
1494 } catch {
1495 viewer = null;
1496 }
1497 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
1498 const url = new URL(request.url);
1499 const channelId = url.searchParams.get("channel") ?? "";
1500 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1501 if (!slug) {
1502 // Not named: whichever of the viewer's workspaces holds the channel.
1503 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
1504 if (row) {
1505 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
1506 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
1507 }
1508 }
1509 const found = await this.place(slug, channelId, viewer, "read");
1510 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1511 const { workspace, channel } = found.value;
1512 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
1513 const headers = new Headers(request.headers);
1514 headers.delete(CHAT_VIEWER_HEADER);
1515 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1516 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
1517 }
1518}
1519
1520/** One RPC method's answer. */
1521async function answer(service: Chat, method: string, args: any): Promise<Response> {
1522 switch (method) {
1523 case "sidebar":
1524 return Response.json(await service.sidebar(args));
1525 case "activity":
1526 return Response.json(await service.activity(args));
1527 case "channel":
1528 return Response.json(await service.channel(args));
1529 case "channel_by_name":
1530 return Response.json(await service.channelByName(args));
1531 case "browse":
1532 return Response.json(await service.browse(args));
1533 case "create_channel":
1534 return Response.json(await service.createChannel(args));
1535 case "update_channel":
1536 return Response.json(await service.updateChannel(args));
1537 case "chat_settings":
1538 return Response.json(await service.chatSettings(args));
1539 case "set_chat_settings":
1540 return Response.json(await service.setChatSettings(args));
1541 case "open_dm":
1542 return Response.json(await service.openDm(args));
1543 case "join":
1544 return Response.json(await service.join(args));
1545 case "leave":
1546 return Response.json(await service.leave(args));
1547 case "invite":
1548 return Response.json(await service.invite(args));
1549 case "messages":
1550 return Response.json(await service.messages(args));
1551 case "post":
1552 return Response.json(await service.post(args));
1553 case "edit":
1554 return Response.json(await service.edit(args));
1555 case "remove":
1556 return Response.json(await service.remove(args));
1557 case "mark_read":
1558 return Response.json(await service.markRead(args));
1559 case "set_preferences":
1560 return Response.json(await service.setPreferences(args));
1561 case "react":
1562 return Response.json(await service.react(args));
1563 case "unreact":
1564 return Response.json(await service.unreact(args));
1565 case "list_emoji":
1566 return Response.json(await service.listEmoji(args));
1567 case "add_emoji":
1568 return Response.json(await service.addEmoji(args));
1569 case "alias_emoji":
1570 return Response.json(await service.aliasEmoji(args));
1571 case "remove_emoji":
1572 return Response.json(await service.removeEmoji(args));
1573 case "set_emoji_upload":
1574 return Response.json(await service.setEmojiUpload(args));
1575 default:
1576 return new Response("Unknown method\n", { status: 404 });
1577 }
1578}
1579
1580export default {
1581 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1582 const url = new URL(request.url);
1583 if (request.method === "GET" && url.pathname === "/live") {
1584 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
1585 }
1586 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
1587 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1588 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1589 const opened = openD1(env.DB, request);
1590 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1591 const args = (await request.json().catch(() => ({}))) as any;
1592 return opened.finish(await answer(service, match[1], args));
1593 },
1594} satisfies ExportedHandler<Env>;