| 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 | */ |
| 9 | import type { ServiceBinding } from "./clients"; |
| 10 | import type { User } from "./identity"; |
| 11 | import type { AskPage } from "./ai"; |
| 12 | import type { Result } from "./result"; |
| 13 | |
| 14 | /** |
| 15 | * Who is speaking: a person (by user id), g1t itself (`system:g1t`, the one |
| 16 | * reserved actor all of g1t's own work shows as), or, on older messages, a |
| 17 | * former workspace agent (by agent id). |
| 18 | */ |
| 19 | export type Principal = { kind: "user" | "agent" | "system"; id: string }; |
| 20 | |
| 21 | /** |
| 22 | * g1t in chat: @g1t, the same reserved `system` actor (id `g1t`) the |
| 23 | * runner and the feed show g1t's own work as. Every member can talk with |
| 24 | * it in a direct message, where it answers with the workspace's own AI |
| 25 | * provider (docs.g1t.sh/guides/ask/). It is never a person: the People |
| 26 | * directory and member pickers never list it. |
| 27 | */ |
| 28 | export const G1T_PRINCIPAL: Principal = { kind: "system", id: "g1t" }; |
| 29 | /** `system:g1t`, as chat keeps members and authors. */ |
| 30 | export const G1T_KEY = "system:g1t"; |
| 31 | |
| 32 | /** Whether a member is g1t itself. */ |
| 33 | export function isG1t(member: { kind: string; id?: string } | null | undefined): boolean { |
| 34 | return member?.kind === "system" && member.id === "g1t"; |
| 35 | } |
| 36 | |
| 37 | export function principalKey(principal: Principal): string { |
| 38 | return `${principal.kind}:${principal.id}`; |
| 39 | } |
| 40 | |
| 41 | export function parsePrincipalKey(key: string): Principal | null { |
| 42 | // g1t is the only `system` member there is. |
| 43 | if (key === G1T_KEY) return { ...G1T_PRINCIPAL }; |
| 44 | const at = key.indexOf(":"); |
| 45 | if (at < 0) return null; |
| 46 | const kind = key.slice(0, at); |
| 47 | const id = key.slice(at + 1); |
| 48 | if ((kind !== "user" && kind !== "agent") || !id) return null; |
| 49 | return { kind, id }; |
| 50 | } |
| 51 | |
| 52 | /** How a member shows: resolved by the chat service when it answers. */ |
| 53 | export type MemberProfile = Principal & { |
| 54 | /** `username` for a person (lowercased: what `@` mentions), `handle` for an agent. */ |
| 55 | name: string; |
| 56 | /** |
| 57 | * A person's username as they wrote it (`Ana`), when that differs from |
| 58 | * `name`; absent for an agent. Shown in their card and in autocomplete. |
| 59 | */ |
| 60 | display_username?: string | null; |
| 61 | /** |
| 62 | * What chat shows them as: a person's display name, else their username |
| 63 | * in its chosen case; an agent's display name. Read it with `memberName`. |
| 64 | */ |
| 65 | display_name: string; |
| 66 | /** Uploaded avatar hash for a person, or null for the letter avatar. */ |
| 67 | avatar: string | null; |
| 68 | /** An agent's one-line role ("Release manager for g1t"). */ |
| 69 | role: string | null; |
| 70 | /** |
| 71 | * An agent's title ("QA Engineer"); null for a person. Chat always sets |
| 72 | * it; optional so profiles a page makes for itself need not. |
| 73 | */ |
| 74 | title?: string | null; |
| 75 | /** What an agent's face is drawn from when it chose none; null for a person. Always set by chat. */ |
| 76 | avatar_seed?: string | null; |
| 77 | /** |
| 78 | * An archived agent (docs.g1t.sh/guides/chat/, "Archived and deleted |
| 79 | * agents"): when, and by whom. Its conversations stay readable; it is |
| 80 | * never woken and never listed to talk to. Null or absent for a live |
| 81 | * agent and for a person. |
| 82 | */ |
| 83 | archived_at?: string | null; |
| 84 | archived_by?: string | null; |
| 85 | /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */ |
| 86 | deleted?: boolean; |
| 87 | /** |
| 88 | * A person who is not a member of the workspace whose chat this is: a |
| 89 | * guest asked into one of its channels, or someone talking in a public |
| 90 | * one. Every surface marks them, so who is in the workspace is never in |
| 91 | * doubt. Absent for members, @g1t and agents. |
| 92 | */ |
| 93 | guest?: boolean; |
| 94 | }; |
| 95 | |
| 96 | /** |
| 97 | * A member's handle as it shows (`@Ana`, without the `@`): a person's |
| 98 | * username in its chosen case, an agent's handle. |
| 99 | */ |
| 100 | export function memberHandle(member: { name: string; display_username?: string | null }): string { |
| 101 | const display = member.display_username; |
| 102 | return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name; |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * How a member's name is shown everywhere in chat (messages, the |
| 107 | * sidebar, direct-message titles, typing, notifications and pushes): their |
| 108 | * display name, else their handle in its chosen case. One rule, so every |
| 109 | * surface agrees. |
| 110 | */ |
| 111 | export function memberName(member: { name: string; display_name?: string | null; display_username?: string | null }): string { |
| 112 | return member.display_name?.trim() || memberHandle(member); |
| 113 | } |
| 114 | |
| 115 | export type ChannelKind = "channel" | "dm"; |
| 116 | |
| 117 | /** |
| 118 | * Who can find, read and join a channel: `private`, only the people in it; |
| 119 | * `workspace`, anyone in the workspace; `public`, anyone signed in to g1t, |
| 120 | * who talk in it as guests. A direct message is always `private`. |
| 121 | */ |
| 122 | export type ChannelVisibility = "private" | "workspace" | "public"; |
| 123 | |
| 124 | export type Channel = { |
| 125 | id: string; |
| 126 | workspace_id: string; |
| 127 | kind: ChannelKind; |
| 128 | /** Lowercase, no `#`. Null for a direct message. */ |
| 129 | name: string | null; |
| 130 | topic: string | null; |
| 131 | /** `visibility === "private"`, kept for older clients. */ |
| 132 | private: boolean; |
| 133 | visibility: ChannelVisibility; |
| 134 | created_by: Principal; |
| 135 | created_at: string; |
| 136 | archived_at: string | null; |
| 137 | last_message_at: string | null; |
| 138 | }; |
| 139 | |
| 140 | export type ChannelMember = { |
| 141 | channel_id: string; |
| 142 | member: MemberProfile; |
| 143 | role: "owner" | "member"; |
| 144 | starred: boolean; |
| 145 | muted: boolean; |
| 146 | last_read_id: string | null; |
| 147 | joined_at: string; |
| 148 | }; |
| 149 | |
| 150 | /** A card g1t or an agent posts: an event, a task, an approval. */ |
| 151 | export type MessageCard = { |
| 152 | /** What it is about, e.g. `pull`, `issue`, `task`, `deploy`, `approval`, `session`, `draft_issue`. */ |
| 153 | kind: string; |
| 154 | title: string; |
| 155 | /** A short line under the title: "3/3 checks · +214 −87". */ |
| 156 | detail: string | null; |
| 157 | /** A status shown on the right: "Needs approval", "Merged". */ |
| 158 | state: string | null; |
| 159 | /** Where clicking the card goes, relative to the site. */ |
| 160 | href: string | null; |
| 161 | /** A preview in Markdown under the title: a draft issue's body, a report's first lines. */ |
| 162 | body?: string | null; |
| 163 | /** Labelled facts shown in two columns: "Repository · acme/web", "Cap · $2.00". */ |
| 164 | fields?: CardField[]; |
| 165 | /** |
| 166 | * What people can do right here. Pressing one goes to the service that |
| 167 | * owns the card (`owner`), which checks the person may, does it, and |
| 168 | * updates the card in place. A card without `owner` has no actions. |
| 169 | */ |
| 170 | actions?: CardAction[]; |
| 171 | /** The service whose card this was and that answered its actions: `agents`, on older cards only. */ |
| 172 | owner?: "agents" | null; |
| 173 | /** What the card is about, for its owner: a session id, a draft id. */ |
| 174 | ref?: string | null; |
| 175 | }; |
| 176 | |
| 177 | export type CardField = { label: string; value: string }; |
| 178 | |
| 179 | /** A button on a card. */ |
| 180 | export type CardAction = { |
| 181 | /** Unique on the card: `stop`, `approve`, `file`. */ |
| 182 | id: string; |
| 183 | label: string; |
| 184 | style?: "primary" | "danger" | "default"; |
| 185 | /** Asked before it runs: "Stop this session and everything under it?". */ |
| 186 | confirm?: string | null; |
| 187 | /** A value it needs first, asked inline: an amount in dollars, or a line of text. */ |
| 188 | input?: { kind: "money" | "text"; label: string; placeholder?: string | null; initial?: string | null } | null; |
| 189 | /** A link instead of an action: opens this place in the site. */ |
| 190 | href?: string | null; |
| 191 | }; |
| 192 | |
| 193 | export type ChatMessage = { |
| 194 | /** Time-sortable (ULID-like), so ordering by id is ordering by time. */ |
| 195 | id: string; |
| 196 | channel_id: string; |
| 197 | author: MemberProfile; |
| 198 | kind: "text" | "card"; |
| 199 | body: string; |
| 200 | card: MessageCard | null; |
| 201 | /** The message this replies under, or null for a top-level message. */ |
| 202 | thread_root: string | null; |
| 203 | reply_count: number; |
| 204 | last_reply_at: string | null; |
| 205 | created_at: string; |
| 206 | edited_at: string | null; |
| 207 | deleted_at: string | null; |
| 208 | /** |
| 209 | * Its reactions, one per emoji in the order each was first used. Chat |
| 210 | * always sets it; optional so messages a page makes for itself need not. |
| 211 | */ |
| 212 | reactions?: ChatReaction[]; |
| 213 | /** |
| 214 | * On a message from @g1t that answers someone: how the answer went, who |
| 215 | * wrote it, and the steps it took. Absent on every other message. |
| 216 | */ |
| 217 | answer?: ChatAnswer | null; |
| 218 | /** |
| 219 | * On a question someone asked @g1t from a page (the top bar's Ask): the |
| 220 | * page they were on, which g1t is told. Absent otherwise. |
| 221 | */ |
| 222 | page?: AskPage | null; |
| 223 | /** |
| 224 | * A "New conversation" mark in a direct message with @g1t: answers after |
| 225 | * it read only what was said after it. Absent otherwise. |
| 226 | */ |
| 227 | reset?: boolean; |
| 228 | }; |
| 229 | |
| 230 | /** |
| 231 | * Where an answer from @g1t is: `thinking` until its first words, |
| 232 | * `streaming` while they come (live as `message.streaming`), then `done`, |
| 233 | * `stopped` (Stop, or a newer question took over) or `error`. |
| 234 | */ |
| 235 | export type ChatAnswerState = "thinking" | "streaming" | "done" | "stopped" | "error"; |
| 236 | |
| 237 | export type ChatAnswer = { |
| 238 | state: ChatAnswerState; |
| 239 | /** The provider's name and the model, once the provider said. */ |
| 240 | provider_label?: string | null; |
| 241 | model?: string | null; |
| 242 | /** Auto chose `model` for this question. */ |
| 243 | auto?: boolean; |
| 244 | /** `max_tokens`, `length`: cut off at its limit. */ |
| 245 | stop_reason?: string | null; |
| 246 | /** What went wrong, for `error`. */ |
| 247 | error?: string | null; |
| 248 | /** |
| 249 | * Steps the answer took on its way (tool calls and the like), as the |
| 250 | * integrations service streamed them: each `ask` event other than meta, |
| 251 | * text, done and error, kept as it came. At most 50. |
| 252 | */ |
| 253 | steps?: Record<string, unknown>[]; |
| 254 | /** Set when Ask is not set up in the workspace: the answer says where to set it up. */ |
| 255 | setup?: "missing" | null; |
| 256 | }; |
| 257 | |
| 258 | /** |
| 259 | * One emoji's reactions on a message. `emoji` is a Unicode emoji or |
| 260 | * `:name:` for one of the workspace's own (`CustomEmoji`). |
| 261 | */ |
| 262 | export type ChatReaction = { |
| 263 | emoji: string; |
| 264 | /** Everyone who reacted with it, people and agents alike. */ |
| 265 | count: number; |
| 266 | /** |
| 267 | * Whether the viewer did. In live `message.*` events, which everyone in |
| 268 | * the room gets, it is always false: keep your own from what you know. |
| 269 | */ |
| 270 | me: boolean; |
| 271 | /** The first ten who reacted with it, for the hover list. */ |
| 272 | by: MemberProfile[]; |
| 273 | }; |
| 274 | |
| 275 | /** Who may add a workspace's emoji: every member (the default), or only its owners. */ |
| 276 | export type EmojiUpload = "members" | "admins"; |
| 277 | |
| 278 | /** |
| 279 | * One of a workspace's own emoji, used as `:name:`. Its image is served |
| 280 | * from the usercontent origin at `/emoji/<file>` (never from the site). |
| 281 | */ |
| 282 | export type CustomEmoji = { |
| 283 | name: string; |
| 284 | /** For an alias, the emoji it is another name for; it shows that one's image. */ |
| 285 | alias_of: string | null; |
| 286 | /** The SHA-256 of the image's bytes. */ |
| 287 | file: string; |
| 288 | content_type: "image/png" | "image/gif" | "image/webp"; |
| 289 | bytes: number; |
| 290 | created_by: MemberProfile; |
| 291 | created_at: string; |
| 292 | }; |
| 293 | |
| 294 | export type EmojiList = { |
| 295 | emoji: CustomEmoji[]; |
| 296 | emoji_upload: EmojiUpload; |
| 297 | /** Whether the viewer may add emoji and aliases. */ |
| 298 | can_upload: boolean; |
| 299 | /** Whether the viewer is an owner: may remove any emoji and change `emoji_upload`. */ |
| 300 | can_manage: boolean; |
| 301 | }; |
| 302 | |
| 303 | /** Who may do something in a workspace's chat: every member, or only its owners. */ |
| 304 | export type ChatAllowed = "members" | "owners"; |
| 305 | |
| 306 | /** |
| 307 | * Who may rename and archive a channel: its owners (whoever made it) and |
| 308 | * the workspace's owners (the default), or the workspace's owners only. |
| 309 | */ |
| 310 | export type ChannelManagers = "channel_owners" | "owners"; |
| 311 | |
| 312 | /** |
| 313 | * A workspace's chat settings, which its owners choose (Workspace, |
| 314 | * Settings, Chat). The chat service enforces each one; the page only |
| 315 | * hides what the viewer may not do. |
| 316 | */ |
| 317 | export type ChatSettings = { |
| 318 | /** Who may create public channels. */ |
| 319 | public_channels: ChatAllowed; |
| 320 | /** Who may create private channels. */ |
| 321 | private_channels: ChatAllowed; |
| 322 | /** Who may rename, archive and unarchive channels. */ |
| 323 | manage_channels: ChannelManagers; |
| 324 | /** Who may add custom emoji (`admins` is the workspace's owners). */ |
| 325 | emoji_upload: EmojiUpload; |
| 326 | /** |
| 327 | * The public channels someone new is put in the first time they open |
| 328 | * Chat, by id. #general unless the owners chose otherwise. |
| 329 | */ |
| 330 | default_channels: string[]; |
| 331 | }; |
| 332 | |
| 333 | /** What the viewer may do in a workspace's chat, from its settings and their role. */ |
| 334 | export type ChatPermissions = { |
| 335 | create_public_channels: boolean; |
| 336 | create_private_channels: boolean; |
| 337 | add_emoji: boolean; |
| 338 | /** Whether they may change the workspace's chat settings: owners only. */ |
| 339 | manage_settings: boolean; |
| 340 | /** Whether they may make a channel public, open to anyone on g1t: owners only. */ |
| 341 | make_public?: boolean; |
| 342 | /** |
| 343 | * Whether they may ask people outside the workspace into its channels as |
| 344 | * guests: owners, and members while the workspace lets members invite |
| 345 | * outside collaborators. |
| 346 | */ |
| 347 | invite_guests?: boolean; |
| 348 | }; |
| 349 | |
| 350 | /** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */ |
| 351 | export type ChatSettingsView = { |
| 352 | settings: ChatSettings; |
| 353 | can: ChatPermissions; |
| 354 | channels: Channel[]; |
| 355 | }; |
| 356 | |
| 357 | /** |
| 358 | * A change to a channel: its name, its topic, whether it is archived, or |
| 359 | * whether a channel that is not private is open to anyone on g1t |
| 360 | * (`workspace` or `public`; workspace owners only). Anything left out stays. |
| 361 | */ |
| 362 | export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean; visibility?: "workspace" | "public" }; |
| 363 | |
| 364 | /** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */ |
| 365 | export type EmojiFile = { data: string }; |
| 366 | |
| 367 | /** The largest custom emoji image, in bytes, and the widest or tallest, in pixels. */ |
| 368 | export const MAX_EMOJI_BYTES = 256 * 1024; |
| 369 | export const MAX_EMOJI_SIDE = 512; |
| 370 | |
| 371 | /** One row of the Chat sidebar. */ |
| 372 | export type ChatSidebarEntry = { |
| 373 | channel: Channel; |
| 374 | /** "g1t-core", or the other members' names for a direct message. */ |
| 375 | title: string; |
| 376 | /** For a direct message: who else is in it (up to four). */ |
| 377 | others: MemberProfile[]; |
| 378 | starred: boolean; |
| 379 | muted: boolean; |
| 380 | unread: number; |
| 381 | mentions: number; |
| 382 | }; |
| 383 | |
| 384 | export type ChatSidebar = { |
| 385 | entries: ChatSidebarEntry[]; |
| 386 | /** Channels the viewer could join and has not: for a guest, the public ones only. */ |
| 387 | browsable: number; |
| 388 | /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */ |
| 389 | can: ChatPermissions; |
| 390 | /** |
| 391 | * The viewer is not a member of this workspace: a guest, who sees only |
| 392 | * the channels they are in and its public ones. Absent for members. |
| 393 | */ |
| 394 | guest?: boolean; |
| 395 | /** The workspace whose chat this is. */ |
| 396 | workspace?: ChatWorkspace; |
| 397 | /** Channels of other workspaces the viewer is a guest in. */ |
| 398 | elsewhere?: GuestChannel[]; |
| 399 | /** Invitations into other workspaces' channels, waiting for the viewer's answer. */ |
| 400 | invitations?: ChannelInvitation[]; |
| 401 | }; |
| 402 | |
| 403 | /** A workspace as chat names it to someone who may not be in it. */ |
| 404 | export type ChatWorkspace = { slug: string; name: string; avatar: string | null }; |
| 405 | |
| 406 | /** A channel of another workspace that the viewer is a guest in, for their own sidebar. */ |
| 407 | export type GuestChannel = { |
| 408 | workspace: ChatWorkspace; |
| 409 | channel: Channel; |
| 410 | unread: number; |
| 411 | mentions: number; |
| 412 | }; |
| 413 | |
| 414 | /** An invitation for someone outside a workspace to join one of its channels as a guest. */ |
| 415 | export type ChannelInvitation = { |
| 416 | id: string; |
| 417 | workspace: ChatWorkspace; |
| 418 | channel: Channel; |
| 419 | /** Who it is for. */ |
| 420 | invitee: MemberProfile; |
| 421 | invited_by: MemberProfile; |
| 422 | created_at: string; |
| 423 | }; |
| 424 | |
| 425 | /** |
| 426 | * `activity`: what was said over a span, in the conversations the viewer |
| 427 | * can read (every public channel, and the private channels and direct |
| 428 | * messages they are in). Messages are text messages and thread replies |
| 429 | * that were not deleted; cards agents post are not messages. Home reads it |
| 430 | * for what people and agents said since you were last there. |
| 431 | */ |
| 432 | export type ChatActivity = { |
| 433 | /** RFC 3339: `[from, until)`. */ |
| 434 | from: string; |
| 435 | until: string; |
| 436 | messages: number; |
| 437 | /** Conversations with at least one message. */ |
| 438 | channels: number; |
| 439 | /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */ |
| 440 | authors: { key: string; messages: number }[]; |
| 441 | }; |
| 442 | |
| 443 | export type MessagePage = { |
| 444 | messages: ChatMessage[]; |
| 445 | /** Pass as `before` to read further back; null at the beginning. */ |
| 446 | older: string | null; |
| 447 | /** |
| 448 | * For a thread's first page: the message the thread is under, however |
| 449 | * many replies it has, so a thread panel always shows it (a session's |
| 450 | * live card, say) at its top. |
| 451 | */ |
| 452 | root?: ChatMessage | null; |
| 453 | /** |
| 454 | * Set when the page was read with `after`: pass it as `after` again for |
| 455 | * the next messages, or null when this page reached the newest. |
| 456 | */ |
| 457 | newer?: string | null; |
| 458 | }; |
| 459 | |
| 460 | /** |
| 461 | * A new channel. `visibility` wins over `private`; left out, a channel is |
| 462 | * `workspace`, or `private` when `private` is set. |
| 463 | */ |
| 464 | export type NewChannel = { name: string; topic?: string | null; private?: boolean; visibility?: ChannelVisibility }; |
| 465 | |
| 466 | export type PostMessage = { |
| 467 | body: string; |
| 468 | thread_root?: string | null; |
| 469 | /** In a direct message with @g1t: the page the person asks from, which g1t is told. Ignored elsewhere. */ |
| 470 | page?: AskPage | null; |
| 471 | }; |
| 472 | |
| 473 | /** |
| 474 | * What the live socket sends. The site opens |
| 475 | * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the |
| 476 | * session and forwards the upgrade to the chat service with the viewer. |
| 477 | */ |
| 478 | export type ChatLiveEvent = |
| 479 | | { type: "message.created"; message: ChatMessage } |
| 480 | | { type: "message.updated"; message: ChatMessage } |
| 481 | | { type: "message.deleted"; channel_id: string; id: string } |
| 482 | /** |
| 483 | * An answer from @g1t as it is written: the whole text so far (not a |
| 484 | * delta, so a late or dropped frame never garbles it) and where it is. |
| 485 | * The stored message catches up with a `message.updated` at the end. |
| 486 | */ |
| 487 | | { type: "message.streaming"; channel_id: string; id: string; body: string; answer: ChatAnswer } |
| 488 | | { type: "typing"; channel_id: string; member: MemberProfile; until: string } |
| 489 | | { type: "read"; channel_id: string; principal: Principal; last_read_id: string } |
| 490 | | { type: "channel.updated"; channel: Channel } |
| 491 | /** |
| 492 | * Everything in the conversation was deleted for good: the person |
| 493 | * cleared their direct message with @g1t. Every message shown goes. |
| 494 | */ |
| 495 | | { type: "channel.cleared"; channel_id: string } |
| 496 | | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile }; |
| 497 | |
| 498 | /** |
| 499 | * Header the site sets on a forwarded live socket: the viewer, as JSON. |
| 500 | * The site forwards the upgrade to the chat service's |
| 501 | * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out, |
| 502 | * at the cost of looking up each of the viewer's workspaces). |
| 503 | */ |
| 504 | export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer"; |
| 505 | |
| 506 | /** |
| 507 | * A channel with its members, and what the viewer may do there. `invited` |
| 508 | * lists guests' open invitations, for those who may ask guests in or take |
| 509 | * people out; empty for anyone else. |
| 510 | */ |
| 511 | export type ChannelDetail = { |
| 512 | channel: Channel; |
| 513 | members: ChannelMember[]; |
| 514 | /** Whether the viewer may rename or archive it. */ |
| 515 | can_manage: boolean; |
| 516 | /** Whether the viewer may make it public, or keep it to the workspace. */ |
| 517 | can_make_public?: boolean; |
| 518 | /** Whether the viewer may ask people outside the workspace in. */ |
| 519 | can_invite_guests?: boolean; |
| 520 | /** Whether the viewer may take people out of it. */ |
| 521 | can_remove_members?: boolean; |
| 522 | /** The viewer is not a member of the workspace: a guest here. */ |
| 523 | guest?: boolean; |
| 524 | /** The workspace whose channel this is. */ |
| 525 | workspace?: ChatWorkspace; |
| 526 | invited?: ChannelInvitation[]; |
| 527 | /** Requests to join waiting for an answer, for those who could let the person in. */ |
| 528 | requests?: ChannelAccessRequest[]; |
| 529 | }; |
| 530 | |
| 531 | /** |
| 532 | * Someone asking to be let into a channel they cannot open. `guest` when |
| 533 | * they are not in the workspace: letting them in makes them a guest. |
| 534 | */ |
| 535 | export type ChannelAccessRequest = { |
| 536 | id: string; |
| 537 | channel_id: string; |
| 538 | requester: MemberProfile; |
| 539 | note: string | null; |
| 540 | created_at: string; |
| 541 | }; |
| 542 | |
| 543 | export type ChatApi = { |
| 544 | sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>; |
| 545 | /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */ |
| 546 | activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>; |
| 547 | channel( |
| 548 | workspace: string, |
| 549 | channelId: string, |
| 550 | viewer: User, |
| 551 | ): Promise<Result<ChannelDetail>>; |
| 552 | /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */ |
| 553 | channelByName( |
| 554 | workspace: string, |
| 555 | name: string, |
| 556 | viewer: User, |
| 557 | ): Promise<Result<ChannelDetail>>; |
| 558 | /** |
| 559 | * Browse channels: every public channel, and the private ones the viewer |
| 560 | * is in. With `archived`, the archived ones instead. |
| 561 | */ |
| 562 | browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>; |
| 563 | /** Makes a channel. Who may make a public or a private one is the workspace's setting. */ |
| 564 | createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>; |
| 565 | /** |
| 566 | * Renames, archives or unarchives a channel (the workspace's |
| 567 | * `manage_channels` setting says who may), or changes its topic (any |
| 568 | * member). #general is never renamed or archived. Everyone looking at it |
| 569 | * gets `channel.updated`. |
| 570 | */ |
| 571 | updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>; |
| 572 | /** The workspace's chat settings, for any member; only owners may change them. */ |
| 573 | chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>; |
| 574 | /** Owners only: changes some of the workspace's chat settings, and answers all of them. */ |
| 575 | setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>; |
| 576 | /** |
| 577 | * The direct message between the viewer and these members, created on |
| 578 | * first use. The same set of members always gets the same channel. |
| 579 | */ |
| 580 | openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>; |
| 581 | join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; |
| 582 | leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; |
| 583 | /** |
| 584 | * Adds a person. A member of the workspace joins at once; anyone else is |
| 585 | * sent an invitation and joins as a guest when they accept (`invited`). |
| 586 | */ |
| 587 | invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<{ invited: boolean }>>; |
| 588 | /** |
| 589 | * Takes someone out of a channel, or takes back their open invitation: |
| 590 | * the workspace's owners, and the channel's own owners where the |
| 591 | * workspace lets them manage channels. Someone outside the workspace |
| 592 | * taken out of a public channel cannot join it again on their own. |
| 593 | */ |
| 594 | removeMember(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>; |
| 595 | /** The viewer's invitations into channels of workspaces they are not in. */ |
| 596 | channelInvitations(viewer: User): Promise<Result<ChannelInvitation[]>>; |
| 597 | /** Accepts or declines one; accepting answers the channel's page. */ |
| 598 | answerChannelInvitation(viewer: User, id: string, accept: boolean): Promise<Result<{ to: string | null }>>; |
| 599 | /** |
| 600 | * Asks to be let into a channel, by the name in its link. Answers the |
| 601 | * same whether or not there is such a channel the viewer may ask about, |
| 602 | * so asking never tells them one exists; a request is kept only when it |
| 603 | * does. Already able to read it: nothing to ask. |
| 604 | */ |
| 605 | requestAccess(workspace: string, name: string, viewer: User, note: string | null): Promise<Result<{ requested: true }>>; |
| 606 | /** Lets the person in, or turns them down: anyone who could have added them. */ |
| 607 | answerAccessRequest(workspace: string, channelId: string, viewer: User, id: string, approve: boolean): Promise<Result<null>>; |
| 608 | /** |
| 609 | * Newest first. Without `thread_root`, the channel's top-level messages; |
| 610 | * with it, that thread's replies, plus the message they reply to as the |
| 611 | * oldest once the page reaches the start of the thread (`older` null). |
| 612 | * With `after` (catching up after a reconnect): the messages after that |
| 613 | * id instead, oldest first, deleted ones included so the client can |
| 614 | * drop them; `newer` says whether there are more. |
| 615 | * Limit 50 by default, 200 at most. |
| 616 | */ |
| 617 | messages( |
| 618 | workspace: string, |
| 619 | channelId: string, |
| 620 | viewer: User, |
| 621 | page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null }, |
| 622 | ): Promise<Result<MessagePage>>; |
| 623 | post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>; |
| 624 | edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>; |
| 625 | remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>; |
| 626 | markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>; |
| 627 | setPreferences( |
| 628 | workspace: string, |
| 629 | channelId: string, |
| 630 | viewer: User, |
| 631 | prefs: { starred?: boolean; muted?: boolean }, |
| 632 | ): Promise<Result<null>>; |
| 633 | /** |
| 634 | * Reacts to a message with `emoji` (one Unicode emoji, or `:name:` for |
| 635 | * one of the workspace's own). Once per member per emoji; at most 50 |
| 636 | * different emoji on a message. Answers the message's reactions now. |
| 637 | */ |
| 638 | react(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>; |
| 639 | unreact(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>; |
| 640 | /** The workspace's own emoji, by name, and who may add them. */ |
| 641 | listEmoji(workspace: string, viewer: User): Promise<Result<EmojiList>>; |
| 642 | /** Adds an emoji: a PNG, GIF or WebP of at most 256 KB and 512×512. */ |
| 643 | addEmoji(workspace: string, viewer: User, name: string, file: EmojiFile): Promise<Result<CustomEmoji>>; |
| 644 | /** Gives an existing emoji (`target`) another name. */ |
| 645 | aliasEmoji(workspace: string, viewer: User, name: string, target: string): Promise<Result<CustomEmoji>>; |
| 646 | /** Removes an emoji, and its aliases with it. Its creator or an owner may. */ |
| 647 | removeEmoji(workspace: string, viewer: User, name: string): Promise<Result<null>>; |
| 648 | /** Owners only: who may add emoji. */ |
| 649 | setEmojiUpload(workspace: string, viewer: User, value: EmojiUpload): Promise<Result<EmojiUpload>>; |
| 650 | /** The viewer's direct message with @g1t, created on first use. */ |
| 651 | g1tDm(workspace: string, viewer: User): Promise<Result<Channel>>; |
| 652 | /** |
| 653 | * Starts a new conversation in the viewer's direct message with @g1t: a |
| 654 | * mark after which answers read only what comes next. Stops an answer |
| 655 | * still coming. |
| 656 | */ |
| 657 | g1tReset(workspace: string, channelId: string, viewer: User): Promise<Result<ChatMessage>>; |
| 658 | /** Has @g1t answer the latest question again, in place of its last answer. */ |
| 659 | g1tRetry(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; |
| 660 | /** Stops @g1t's answer still coming; what came stays. */ |
| 661 | g1tStop(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; |
| 662 | /** |
| 663 | * Clears the viewer's own direct message with @g1t: every message in |
| 664 | * it, g1t's answers and their steps, reactions and the notifications |
| 665 | * from it are deleted for good, and an answer still coming is thrown |
| 666 | * away. `cleared` is how many messages went; 0 when it was empty, so |
| 667 | * clearing again is harmless. Refused for any other conversation. |
| 668 | */ |
| 669 | g1tClear(workspace: string, channelId: string, viewer: User): Promise<Result<{ cleared: number }>>; |
| 670 | }; |
| 671 | |
| 672 | /** |
| 673 | * For g1t's own services only, never the site on a person's behalf: posts |
| 674 | * and edits messages as @g1t in a direct message with it. The chat service |
| 675 | * answers these at `POST /internal/<method>`, which no route from the site |
| 676 | * reaches, and refuses anything that came in from the internet. |
| 677 | */ |
| 678 | export type ChatInternalApi = { |
| 679 | /** Posts `body` as @g1t in a direct message with it, in the workspace with this slug. */ |
| 680 | g1tPost(workspace: string, channelId: string, body: string): Promise<Result<ChatMessage>>; |
| 681 | /** Changes the text of one of @g1t's messages there. */ |
| 682 | g1tEdit(workspace: string, channelId: string, id: string, body: string): Promise<Result<ChatMessage>>; |
| 683 | }; |
| 684 | |
| 685 | export function chatInternalClient(service: ServiceBinding): ChatInternalApi { |
| 686 | const call = async <T>(method: string, args: object): Promise<T> => { |
| 687 | const response = await service.fetch(`https://service/internal/${method}`, { |
| 688 | method: "POST", |
| 689 | headers: { "content-type": "application/json" }, |
| 690 | body: JSON.stringify(args), |
| 691 | }); |
| 692 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 693 | return (await response.json()) as T; |
| 694 | }; |
| 695 | return { |
| 696 | g1tPost: (workspace, channelId, body) => call("g1t_post", { workspace, channel_id: channelId, body }), |
| 697 | g1tEdit: (workspace, channelId, id, body) => call("g1t_edit", { workspace, channel_id: channelId, id, body }), |
| 698 | }; |
| 699 | } |
| 700 | |
| 701 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 702 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 703 | method: "POST", |
| 704 | headers: { "content-type": "application/json" }, |
| 705 | body: JSON.stringify(args), |
| 706 | }); |
| 707 | if (!response.ok) { |
| 708 | throw new Error(`${method} failed with status ${response.status}`); |
| 709 | } |
| 710 | return (await response.json()) as T; |
| 711 | } |
| 712 | |
| 713 | export function chatClient(service: ServiceBinding): ChatApi { |
| 714 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); |
| 715 | return { |
| 716 | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), |
| 717 | activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }), |
| 718 | channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }), |
| 719 | channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }), |
| 720 | browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }), |
| 721 | createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }), |
| 722 | updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }), |
| 723 | chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }), |
| 724 | setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }), |
| 725 | openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }), |
| 726 | join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }), |
| 727 | leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }), |
| 728 | invite: (workspace, channelId, viewer, member) => |
| 729 | call("invite", { workspace, channel_id: channelId, viewer, member }), |
| 730 | removeMember: (workspace, channelId, viewer, member) => |
| 731 | call("remove_member", { workspace, channel_id: channelId, viewer, member }), |
| 732 | channelInvitations: (viewer) => call("channel_invitations", { viewer }), |
| 733 | answerChannelInvitation: (viewer, id, accept) => call("answer_channel_invitation", { viewer, id, accept }), |
| 734 | requestAccess: (workspace, name, viewer, note) => call("request_access", { workspace, name, viewer, note }), |
| 735 | answerAccessRequest: (workspace, channelId, viewer, id, approve) => |
| 736 | call("answer_access_request", { workspace, channel_id: channelId, viewer, id, approve }), |
| 737 | messages: (workspace, channelId, viewer, page) => |
| 738 | call("messages", { |
| 739 | workspace, |
| 740 | channel_id: channelId, |
| 741 | viewer, |
| 742 | before: page?.before ?? null, |
| 743 | after: page?.after ?? null, |
| 744 | limit: page?.limit ?? null, |
| 745 | thread_root: page?.thread_root ?? null, |
| 746 | }), |
| 747 | post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }), |
| 748 | edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }), |
| 749 | remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }), |
| 750 | markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }), |
| 751 | setPreferences: (workspace, channelId, viewer, prefs) => |
| 752 | call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }), |
| 753 | react: (workspace, channelId, viewer, messageId, emoji) => |
| 754 | call("react", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }), |
| 755 | unreact: (workspace, channelId, viewer, messageId, emoji) => |
| 756 | call("unreact", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }), |
| 757 | listEmoji: (workspace, viewer) => call("list_emoji", { workspace, viewer }), |
| 758 | addEmoji: (workspace, viewer, name, file) => call("add_emoji", { workspace, viewer, name, file }), |
| 759 | aliasEmoji: (workspace, viewer, name, target) => call("alias_emoji", { workspace, viewer, name, target }), |
| 760 | removeEmoji: (workspace, viewer, name) => call("remove_emoji", { workspace, viewer, name }), |
| 761 | setEmojiUpload: (workspace, viewer, value) => call("set_emoji_upload", { workspace, viewer, value }), |
| 762 | g1tDm: (workspace, viewer) => call("g1t_dm", { workspace, viewer }), |
| 763 | g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }), |
| 764 | g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }), |
| 765 | g1tStop: (workspace, channelId, viewer) => call("g1t_stop", { workspace, channel_id: channelId, viewer }), |
| 766 | g1tClear: (workspace, channelId, viewer) => call("g1t_clear", { workspace, channel_id: channelId, viewer }), |
| 767 | }; |
| 768 | } |