Skip to content
301 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
41export type ChannelKind = "channel" | "dm";
42
43export type Channel = {
44 id: string;
45 workspace_id: string;
46 kind: ChannelKind;
47 /** Lowercase, no `#`. Null for a direct message. */
48 name: string | null;
49 topic: string | null;
50 private: boolean;
51 created_by: Principal;
52 created_at: string;
53 archived_at: string | null;
54 last_message_at: string | null;
55};
56
57export type ChannelMember = {
58 channel_id: string;
59 member: MemberProfile;
60 role: "owner" | "member";
61 starred: boolean;
62 muted: boolean;
63 last_read_id: string | null;
64 joined_at: string;
65};
66
67/** A card g1t or an agent posts: an event, a task, an approval. */
68export type MessageCard = {
69 /** What it is about, e.g. `pull`, `issue`, `task`, `deploy`, `approval`. */
70 kind: string;
71 title: string;
72 /** A short line under the title: "3/3 checks · +214 −87". */
73 detail: string | null;
74 /** A status shown on the right: "Needs approval", "Merged". */
75 state: string | null;
76 /** Where clicking the card goes, relative to the site. */
77 href: string | null;
78};
79
80export type ChatMessage = {
81 /** Time-sortable (ULID-like), so ordering by id is ordering by time. */
82 id: string;
83 channel_id: string;
84 author: MemberProfile;
85 kind: "text" | "card";
86 body: string;
87 card: MessageCard | null;
88 /** The message this replies under, or null for a top-level message. */
89 thread_root: string | null;
90 reply_count: number;
91 last_reply_at: string | null;
92 created_at: string;
93 edited_at: string | null;
94 deleted_at: string | null;
95};
96
97/** One row of the Chat sidebar. */
98export type ChatSidebarEntry = {
99 channel: Channel;
100 /** "g1t-core", or the other members' names for a direct message. */
101 title: string;
102 /** For a direct message: who else is in it (up to four). */
103 others: MemberProfile[];
104 starred: boolean;
105 muted: boolean;
106 unread: number;
107 mentions: number;
108};
109
110export type ChatSidebar = {
111 entries: ChatSidebarEntry[];
112 /** Public channels in the workspace the viewer has not joined. */
113 browsable: number;
114};
115
116export type MessagePage = {
117 messages: ChatMessage[];
118 /** Pass as `before` to read further back; null at the beginning. */
119 older: string | null;
120 /**
121 * Set when the page was read with `after`: pass it as `after` again for
122 * the next messages, or null when this page reached the newest.
123 */
124 newer?: string | null;
125};
126
127export type NewChannel = { name: string; topic?: string | null; private?: boolean };
128
129export type PostMessage = { body: string; thread_root?: string | null };
130
131/** The most agent-to-agent hops one person's request may start (docs/WORKSPACE.md, "Hop limit"). */
132export const CHAT_MAX_HOPS = 6;
133
134/**
135 * What an agent posts. When it answers a delivery, it passes that
136 * delivery's `hops`, `asked_by` and `asker` back, so another agent it @mentions is
137 * handed the message one hop further along the same person's request, and
138 * the chain stops at `CHAT_MAX_HOPS`. Left out: a new chain (hops 0) asked
139 * by the person who created the agent.
140 */
141export type AgentPostMessage = PostMessage & {
142 card?: MessageCard | null;
143 hops?: number;
144 asked_by?: string | null;
145 /** The delivery's `asker`, handed on to agents this message wakes. Absent: none (they treat the asker as unable to change code). */
146 asker?: AskerAccess | null;
147};
148
149/**
150 * What the live socket sends. The site opens
151 * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
152 * session and forwards the upgrade to the chat service with the viewer.
153 */
154export type ChatLiveEvent =
155 | { type: "message.created"; message: ChatMessage }
156 | { type: "message.updated"; message: ChatMessage }
157 | { type: "message.deleted"; channel_id: string; id: string }
158 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
159 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string };
160
161/**
162 * Header the site sets on a forwarded live socket: the viewer, as JSON.
163 * The site forwards the upgrade to the chat service's
164 * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out,
165 * at the cost of looking up each of the viewer's workspaces).
166 */
167export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
168
169export type ChatApi = {
170 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
171 channel(
172 workspace: string,
173 channelId: string,
174 viewer: User,
175 ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
176 /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
177 channelByName(
178 workspace: string,
179 name: string,
180 viewer: User,
181 ): Promise<Result<{ channel: Channel; members: ChannelMember[] }>>;
182 /** Public channels, for Browse channels. */
183 browse(workspace: string, viewer: User): Promise<Result<Channel[]>>;
184 createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
185 /**
186 * The direct message between the viewer and these members, created on
187 * first use. The same set of members always gets the same channel.
188 */
189 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
190 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
191 leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
192 /** Adds a person or an agent. An invite grants read, never write. */
193 invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
194 /**
195 * Newest first. Without `thread_root`, the channel's top-level messages;
196 * with it, that thread's replies, plus the message they reply to as the
197 * oldest once the page reaches the start of the thread (`older` null).
198 * With `after` (catching up after a reconnect): the messages after that
199 * id instead, oldest first, deleted ones included so the client can
200 * drop them; `newer` says whether there are more.
201 * Limit 50 by default, 200 at most.
202 */
203 messages(
204 workspace: string,
205 channelId: string,
206 viewer: User,
207 page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null },
208 ): Promise<Result<MessagePage>>;
209 post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>;
210 edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>;
211 remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
212 markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
213 setPreferences(
214 workspace: string,
215 channelId: string,
216 viewer: User,
217 prefs: { starred?: boolean; muted?: boolean },
218 ): Promise<Result<null>>;
219 /**
220 * Posts as an agent. Only the agents service calls this, for replies and
221 * cards; the agent must be a member of the channel.
222 */
223 postAsAgent(
224 workspace: string,
225 channelId: string,
226 agentId: string,
227 message: AgentPostMessage,
228 ): Promise<Result<ChatMessage>>;
229 /** Shows "is typing" for an agent while it works on a reply. */
230 agentTyping(workspace: string, channelId: string, agentId: string): Promise<Result<null>>;
231 /**
232 * What an agent reads before it replies, oldest first: with
233 * `thread_root`, that thread (its root, then its replies); without, the
234 * channel's or direct message's latest top-level messages. Only the
235 * agents service calls this; the agent must be a member of the channel,
236 * so it reads only what was said where it was invited. `limit` defaults
237 * to 30, at most 100.
238 */
239 historyForAgent(
240 workspace: string,
241 channelId: string,
242 agentId: string,
243 page?: { thread_root?: string | null; limit?: number },
244 ): Promise<Result<ChatMessage[]>>;
245};
246
247async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
248 const response = await service.fetch(`https://service/rpc/${method}`, {
249 method: "POST",
250 headers: { "content-type": "application/json" },
251 body: JSON.stringify(args),
252 });
253 if (!response.ok) {
254 throw new Error(`${method} failed with status ${response.status}`);
255 }
256 return (await response.json()) as T;
257}
258
259export function chatClient(service: ServiceBinding): ChatApi {
260 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
261 return {
262 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
263 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
264 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
265 browse: (workspace, viewer) => call("browse", { workspace, viewer }),
266 createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
267 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
268 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
269 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
270 invite: (workspace, channelId, viewer, member) =>
271 call("invite", { workspace, channel_id: channelId, viewer, member }),
272 messages: (workspace, channelId, viewer, page) =>
273 call("messages", {
274 workspace,
275 channel_id: channelId,
276 viewer,
277 before: page?.before ?? null,
278 after: page?.after ?? null,
279 limit: page?.limit ?? null,
280 thread_root: page?.thread_root ?? null,
281 }),
282 post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }),
283 edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }),
284 remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }),
285 markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }),
286 setPreferences: (workspace, channelId, viewer, prefs) =>
287 call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
288 postAsAgent: (workspace, channelId, agentId, message) =>
289 call("post_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message }),
290 agentTyping: (workspace, channelId, agentId) =>
291 call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }),
292 historyForAgent: (workspace, channelId, agentId, page) =>
293 call("history_for_agent", {
294 workspace,
295 channel_id: channelId,
296 agent_id: agentId,
297 thread_root: page?.thread_root ?? null,
298 limit: page?.limit ?? null,
299 }),
300 };
301}