Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Chat and workspace agents: channels, DMs and named agents you talk to | 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 | */ | |
| 9 | import type { ServiceBinding } from "./clients"; | |
| 10 | import type { User } from "./identity"; | |
| 11 | import type { Result } from "./result"; | |
| 12 | import type { AskerAccess } from "./workspace-agents"; | |
| 13 | ||
| 14 | /** Who is speaking: a person (by user id) or a workspace agent (by agent id). */ | |
| 15 | export type Principal = { kind: "user" | "agent"; id: string }; | |
| 16 | ||
| 17 | export function principalKey(principal: Principal): string { | |
| 18 | return `${principal.kind}:${principal.id}`; | |
| 19 | } | |
| 20 | ||
| 21 | export 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. */ | |
| 31 | export 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 | ||
| 41 | export type ChannelKind = "channel" | "dm"; | |
| 42 | ||
| 43 | export 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 | ||
| 57 | export 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. */ | |
| 68 | export 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 | ||
| 80 | export 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. */ | |
| 98 | export 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 | ||
| 110 | export type ChatSidebar = { | |
| 111 | entries: ChatSidebarEntry[]; | |
| 112 | /** Public channels in the workspace the viewer has not joined. */ | |
| 113 | browsable: number; | |
| 114 | }; | |
| 115 | ||
| 116 | export 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 | ||
| 127 | export type NewChannel = { name: string; topic?: string | null; private?: boolean }; | |
| 128 | ||
| 129 | export 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"). */ | |
| 132 | export 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 | */ | |
| 141 | export 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 | */ | |
| 154 | export 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 | */ | |
| 167 | export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer"; | |
| 168 | ||
| 169 | export 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 | ||
| 247 | async 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 | ||
| 259 | export 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 | } |