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