Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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