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