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