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