Skip to content
503 linesCodeBlameRaw
1/**
2 * Chat: channels, direct messages, threads and messages, kept by the chat
3 * service (`services/chat`). Members are people; messages from the agents
4 * workspaces once had stay readable under the names they had.
5 *
6 * Wire shapes are snake_case end to end, so the site, the public API and
7 * the live socket all carry the same objects.
8 */
9import type { ServiceBinding } from "./clients";
10import type { User } from "./identity";
11import type { Result } from "./result";
12
13/** Who is speaking: a person (by user id), or, on older messages, a former workspace agent (by agent id). */
14export type Principal = { kind: "user" | "agent"; id: string };
15
16export function principalKey(principal: Principal): string {
17 return `${principal.kind}:${principal.id}`;
18}
19
20export function parsePrincipalKey(key: string): Principal | null {
21 const at = key.indexOf(":");
22 if (at < 0) return null;
23 const kind = key.slice(0, at);
24 const id = key.slice(at + 1);
25 if ((kind !== "user" && kind !== "agent") || !id) return null;
26 return { kind, id };
27}
28
29/** How a member shows: resolved by the chat service when it answers. */
30export type MemberProfile = Principal & {
31 /** `username` for a person (lowercased: what `@` mentions), `handle` for an agent. */
32 name: string;
33 /**
34 * A person's username as they wrote it (`Ana`), when that differs from
35 * `name`; absent for an agent. Shown in their card and in autocomplete.
36 */
37 display_username?: string | null;
38 /**
39 * What chat shows them as: a person's display name, else their username
40 * in its chosen case; an agent's display name. Read it with `memberName`.
41 */
42 display_name: string;
43 /** Uploaded avatar hash for a person, or null for the letter avatar. */
44 avatar: string | null;
45 /** An agent's one-line role ("Release manager for g1t"). */
46 role: string | null;
47 /**
48 * An agent's title ("QA Engineer"); null for a person. Chat always sets
49 * it; optional so profiles a page makes for itself need not.
50 */
51 title?: string | null;
52 /** What an agent's face is drawn from when it chose none; null for a person. Always set by chat. */
53 avatar_seed?: string | null;
54 /**
55 * An archived agent (docs.g1t.sh/guides/chat/, "Archived and deleted
56 * agents"): when, and by whom. Its conversations stay readable; it is
57 * never woken and never listed to talk to. Null or absent for a live
58 * agent and for a person.
59 */
60 archived_at?: string | null;
61 archived_by?: string | null;
62 /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */
63 deleted?: boolean;
64};
65
66/**
67 * A member's handle as it shows (`@Ana`, without the `@`): a person's
68 * username in its chosen case, an agent's handle.
69 */
70export function memberHandle(member: { name: string; display_username?: string | null }): string {
71 const display = member.display_username;
72 return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name;
73}
74
75/**
76 * How a member's name is shown everywhere in chat (messages, the
77 * sidebar, direct-message titles, typing, notifications and pushes): their
78 * display name, else their handle in its chosen case. One rule, so every
79 * surface agrees.
80 */
81export function memberName(member: { name: string; display_name?: string | null; display_username?: string | null }): string {
82 return member.display_name?.trim() || memberHandle(member);
83}
84
85export type ChannelKind = "channel" | "dm";
86
87export type Channel = {
88 id: string;
89 workspace_id: string;
90 kind: ChannelKind;
91 /** Lowercase, no `#`. Null for a direct message. */
92 name: string | null;
93 topic: string | null;
94 private: boolean;
95 created_by: Principal;
96 created_at: string;
97 archived_at: string | null;
98 last_message_at: string | null;
99};
100
101export type ChannelMember = {
102 channel_id: string;
103 member: MemberProfile;
104 role: "owner" | "member";
105 starred: boolean;
106 muted: boolean;
107 last_read_id: string | null;
108 joined_at: string;
109};
110
111/** A card g1t or an agent posts: an event, a task, an approval. */
112export type MessageCard = {
113 /** What it is about, e.g. `pull`, `issue`, `task`, `deploy`, `approval`, `session`, `draft_issue`. */
114 kind: string;
115 title: string;
116 /** A short line under the title: "3/3 checks · +214 −87". */
117 detail: string | null;
118 /** A status shown on the right: "Needs approval", "Merged". */
119 state: string | null;
120 /** Where clicking the card goes, relative to the site. */
121 href: string | null;
122 /** A preview in Markdown under the title: a draft issue's body, a report's first lines. */
123 body?: string | null;
124 /** Labelled facts shown in two columns: "Repository · acme/web", "Cap · $2.00". */
125 fields?: CardField[];
126 /**
127 * What people can do right here. Pressing one goes to the service that
128 * owns the card (`owner`), which checks the person may, does it, and
129 * updates the card in place. A card without `owner` has no actions.
130 */
131 actions?: CardAction[];
132 /** The service whose card this was and that answered its actions: `agents`, on older cards only. */
133 owner?: "agents" | null;
134 /** What the card is about, for its owner: a session id, a draft id. */
135 ref?: string | null;
136};
137
138export type CardField = { label: string; value: string };
139
140/** A button on a card. */
141export type CardAction = {
142 /** Unique on the card: `stop`, `approve`, `file`. */
143 id: string;
144 label: string;
145 style?: "primary" | "danger" | "default";
146 /** Asked before it runs: "Stop this session and everything under it?". */
147 confirm?: string | null;
148 /** A value it needs first, asked inline: an amount in dollars, or a line of text. */
149 input?: { kind: "money" | "text"; label: string; placeholder?: string | null; initial?: string | null } | null;
150 /** A link instead of an action: opens this place in the site. */
151 href?: string | null;
152};
153
154export type ChatMessage = {
155 /** Time-sortable (ULID-like), so ordering by id is ordering by time. */
156 id: string;
157 channel_id: string;
158 author: MemberProfile;
159 kind: "text" | "card";
160 body: string;
161 card: MessageCard | null;
162 /** The message this replies under, or null for a top-level message. */
163 thread_root: string | null;
164 reply_count: number;
165 last_reply_at: string | null;
166 created_at: string;
167 edited_at: string | null;
168 deleted_at: string | null;
169 /**
170 * Its reactions, one per emoji in the order each was first used. Chat
171 * always sets it; optional so messages a page makes for itself need not.
172 */
173 reactions?: ChatReaction[];
174};
175
176/**
177 * One emoji's reactions on a message. `emoji` is a Unicode emoji or
178 * `:name:` for one of the workspace's own (`CustomEmoji`).
179 */
180export type ChatReaction = {
181 emoji: string;
182 /** Everyone who reacted with it, people and agents alike. */
183 count: number;
184 /**
185 * Whether the viewer did. In live `message.*` events, which everyone in
186 * the room gets, it is always false: keep your own from what you know.
187 */
188 me: boolean;
189 /** The first ten who reacted with it, for the hover list. */
190 by: MemberProfile[];
191};
192
193/** Who may add a workspace's emoji: every member (the default), or only its owners. */
194export type EmojiUpload = "members" | "admins";
195
196/**
197 * One of a workspace's own emoji, used as `:name:`. Its image is served
198 * from the usercontent origin at `/emoji/<file>` (never from the site).
199 */
200export type CustomEmoji = {
201 name: string;
202 /** For an alias, the emoji it is another name for; it shows that one's image. */
203 alias_of: string | null;
204 /** The SHA-256 of the image's bytes. */
205 file: string;
206 content_type: "image/png" | "image/gif" | "image/webp";
207 bytes: number;
208 created_by: MemberProfile;
209 created_at: string;
210};
211
212export type EmojiList = {
213 emoji: CustomEmoji[];
214 emoji_upload: EmojiUpload;
215 /** Whether the viewer may add emoji and aliases. */
216 can_upload: boolean;
217 /** Whether the viewer is an owner: may remove any emoji and change `emoji_upload`. */
218 can_manage: boolean;
219};
220
221/** Who may do something in a workspace's chat: every member, or only its owners. */
222export type ChatAllowed = "members" | "owners";
223
224/**
225 * Who may rename and archive a channel: its owners (whoever made it) and
226 * the workspace's owners (the default), or the workspace's owners only.
227 */
228export type ChannelManagers = "channel_owners" | "owners";
229
230/**
231 * A workspace's chat settings, which its owners choose (Workspace,
232 * Settings, Chat). The chat service enforces each one; the page only
233 * hides what the viewer may not do.
234 */
235export type ChatSettings = {
236 /** Who may create public channels. */
237 public_channels: ChatAllowed;
238 /** Who may create private channels. */
239 private_channels: ChatAllowed;
240 /** Who may rename, archive and unarchive channels. */
241 manage_channels: ChannelManagers;
242 /** Who may add custom emoji (`admins` is the workspace's owners). */
243 emoji_upload: EmojiUpload;
244 /**
245 * The public channels someone new is put in the first time they open
246 * Chat, by id. #general unless the owners chose otherwise.
247 */
248 default_channels: string[];
249};
250
251/** What the viewer may do in a workspace's chat, from its settings and their role. */
252export type ChatPermissions = {
253 create_public_channels: boolean;
254 create_private_channels: boolean;
255 add_emoji: boolean;
256 /** Whether they may change the workspace's chat settings: owners only. */
257 manage_settings: boolean;
258};
259
260/** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */
261export type ChatSettingsView = {
262 settings: ChatSettings;
263 can: ChatPermissions;
264 channels: Channel[];
265};
266
267/** A change to a channel: its name, its topic, or whether it is archived. Anything left out stays. */
268export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean };
269
270/** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */
271export type EmojiFile = { data: string };
272
273/** The largest custom emoji image, in bytes, and the widest or tallest, in pixels. */
274export const MAX_EMOJI_BYTES = 256 * 1024;
275export const MAX_EMOJI_SIDE = 512;
276
277/** One row of the Chat sidebar. */
278export type ChatSidebarEntry = {
279 channel: Channel;
280 /** "g1t-core", or the other members' names for a direct message. */
281 title: string;
282 /** For a direct message: who else is in it (up to four). */
283 others: MemberProfile[];
284 starred: boolean;
285 muted: boolean;
286 unread: number;
287 mentions: number;
288};
289
290export type ChatSidebar = {
291 entries: ChatSidebarEntry[];
292 /** Public channels in the workspace the viewer has not joined. */
293 browsable: number;
294 /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */
295 can: ChatPermissions;
296};
297
298/**
299 * `activity`: what was said over a span, in the conversations the viewer
300 * can read (every public channel, and the private channels and direct
301 * messages they are in). Messages are text messages and thread replies
302 * that were not deleted; cards agents post are not messages. Home reads it
303 * for what people and agents said since you were last there.
304 */
305export type ChatActivity = {
306 /** RFC 3339: `[from, until)`. */
307 from: string;
308 until: string;
309 messages: number;
310 /** Conversations with at least one message. */
311 channels: number;
312 /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */
313 authors: { key: string; messages: number }[];
314};
315
316export type MessagePage = {
317 messages: ChatMessage[];
318 /** Pass as `before` to read further back; null at the beginning. */
319 older: string | null;
320 /**
321 * For a thread's first page: the message the thread is under, however
322 * many replies it has, so a thread panel always shows it (a session's
323 * live card, say) at its top.
324 */
325 root?: ChatMessage | null;
326 /**
327 * Set when the page was read with `after`: pass it as `after` again for
328 * the next messages, or null when this page reached the newest.
329 */
330 newer?: string | null;
331};
332
333export type NewChannel = { name: string; topic?: string | null; private?: boolean };
334
335export type PostMessage = { body: string; thread_root?: string | null };
336
337/**
338 * What the live socket sends. The site opens
339 * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
340 * session and forwards the upgrade to the chat service with the viewer.
341 */
342export type ChatLiveEvent =
343 | { type: "message.created"; message: ChatMessage }
344 | { type: "message.updated"; message: ChatMessage }
345 | { type: "message.deleted"; channel_id: string; id: string }
346 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
347 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string }
348 | { type: "channel.updated"; channel: Channel }
349 | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile };
350
351/**
352 * Header the site sets on a forwarded live socket: the viewer, as JSON.
353 * The site forwards the upgrade to the chat service's
354 * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out,
355 * at the cost of looking up each of the viewer's workspaces).
356 */
357export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
358
359/** A channel with its members, and whether the viewer may rename or archive it. */
360export type ChannelDetail = { channel: Channel; members: ChannelMember[]; can_manage: boolean };
361
362export type ChatApi = {
363 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
364 /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */
365 activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>;
366 channel(
367 workspace: string,
368 channelId: string,
369 viewer: User,
370 ): Promise<Result<ChannelDetail>>;
371 /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
372 channelByName(
373 workspace: string,
374 name: string,
375 viewer: User,
376 ): Promise<Result<ChannelDetail>>;
377 /**
378 * Browse channels: every public channel, and the private ones the viewer
379 * is in. With `archived`, the archived ones instead.
380 */
381 browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>;
382 /** Makes a channel. Who may make a public or a private one is the workspace's setting. */
383 createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
384 /**
385 * Renames, archives or unarchives a channel (the workspace's
386 * `manage_channels` setting says who may), or changes its topic (any
387 * member). #general is never renamed or archived. Everyone looking at it
388 * gets `channel.updated`.
389 */
390 updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>;
391 /** The workspace's chat settings, for any member; only owners may change them. */
392 chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>;
393 /** Owners only: changes some of the workspace's chat settings, and answers all of them. */
394 setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>;
395 /**
396 * The direct message between the viewer and these members, created on
397 * first use. The same set of members always gets the same channel.
398 */
399 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
400 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
401 leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
402 /** Adds a person. An invite grants read, never write. */
403 invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
404 /**
405 * Newest first. Without `thread_root`, the channel's top-level messages;
406 * with it, that thread's replies, plus the message they reply to as the
407 * oldest once the page reaches the start of the thread (`older` null).
408 * With `after` (catching up after a reconnect): the messages after that
409 * id instead, oldest first, deleted ones included so the client can
410 * drop them; `newer` says whether there are more.
411 * Limit 50 by default, 200 at most.
412 */
413 messages(
414 workspace: string,
415 channelId: string,
416 viewer: User,
417 page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null },
418 ): Promise<Result<MessagePage>>;
419 post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>;
420 edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>;
421 remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
422 markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
423 setPreferences(
424 workspace: string,
425 channelId: string,
426 viewer: User,
427 prefs: { starred?: boolean; muted?: boolean },
428 ): Promise<Result<null>>;
429 /**
430 * Reacts to a message with `emoji` (one Unicode emoji, or `:name:` for
431 * one of the workspace's own). Once per member per emoji; at most 50
432 * different emoji on a message. Answers the message's reactions now.
433 */
434 react(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
435 unreact(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
436 /** The workspace's own emoji, by name, and who may add them. */
437 listEmoji(workspace: string, viewer: User): Promise<Result<EmojiList>>;
438 /** Adds an emoji: a PNG, GIF or WebP of at most 256 KB and 512×512. */
439 addEmoji(workspace: string, viewer: User, name: string, file: EmojiFile): Promise<Result<CustomEmoji>>;
440 /** Gives an existing emoji (`target`) another name. */
441 aliasEmoji(workspace: string, viewer: User, name: string, target: string): Promise<Result<CustomEmoji>>;
442 /** Removes an emoji, and its aliases with it. Its creator or an owner may. */
443 removeEmoji(workspace: string, viewer: User, name: string): Promise<Result<null>>;
444 /** Owners only: who may add emoji. */
445 setEmojiUpload(workspace: string, viewer: User, value: EmojiUpload): Promise<Result<EmojiUpload>>;
446};
447
448async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
449 const response = await service.fetch(`https://service/rpc/${method}`, {
450 method: "POST",
451 headers: { "content-type": "application/json" },
452 body: JSON.stringify(args),
453 });
454 if (!response.ok) {
455 throw new Error(`${method} failed with status ${response.status}`);
456 }
457 return (await response.json()) as T;
458}
459
460export function chatClient(service: ServiceBinding): ChatApi {
461 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
462 return {
463 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
464 activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }),
465 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
466 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
467 browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }),
468 createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
469 updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }),
470 chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }),
471 setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }),
472 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
473 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
474 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
475 invite: (workspace, channelId, viewer, member) =>
476 call("invite", { workspace, channel_id: channelId, viewer, member }),
477 messages: (workspace, channelId, viewer, page) =>
478 call("messages", {
479 workspace,
480 channel_id: channelId,
481 viewer,
482 before: page?.before ?? null,
483 after: page?.after ?? null,
484 limit: page?.limit ?? null,
485 thread_root: page?.thread_root ?? null,
486 }),
487 post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }),
488 edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }),
489 remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }),
490 markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }),
491 setPreferences: (workspace, channelId, viewer, prefs) =>
492 call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
493 react: (workspace, channelId, viewer, messageId, emoji) =>
494 call("react", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
495 unreact: (workspace, channelId, viewer, messageId, emoji) =>
496 call("unreact", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
497 listEmoji: (workspace, viewer) => call("list_emoji", { workspace, viewer }),
498 addEmoji: (workspace, viewer, name, file) => call("add_emoji", { workspace, viewer, name, file }),
499 aliasEmoji: (workspace, viewer, name, target) => call("alias_emoji", { workspace, viewer, name, target }),
500 removeEmoji: (workspace, viewer, name) => call("remove_emoji", { workspace, viewer, name }),
501 setEmojiUpload: (workspace, viewer, value) => call("set_emoji_upload", { workspace, viewer, value }),
502 };
503}