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.

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