Skip to content

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

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