Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
798 linesCodeBlameRaw
1/**
2 * Chat: channels, direct messages, threads and messages, kept by the chat
3 * service (`services/chat`). Members are people; messages from the agents
4 * workspaces once had stay readable under the names they had.
5 *
6 * Wire shapes are snake_case end to end, so the site, the public API and
7 * the live socket all carry the same objects.
8 */
9import type { ServiceBinding } from "./clients";
10import type { User } from "./identity";
11import type { AskPage } from "./ai";
12import 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 */
19export 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 */
28export const G1T_PRINCIPAL: Principal = { kind: "system", id: "g1t" };
29/** `system:g1t`, as chat keeps members and authors. */
30export const G1T_KEY = "system:g1t";
31
32/** Whether a member is g1t itself. */
33export function isG1t(member: { kind: string; id?: string } | null | undefined): boolean {
34 return member?.kind === "system" && member.id === "g1t";
35}
36
37export function principalKey(principal: Principal): string {
38 return `${principal.kind}:${principal.id}`;
39}
40
41export 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. */
53export 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 */
100export 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 */
111export function memberName(member: { name: string; display_name?: string | null; display_username?: string | null }): string {
112 return member.display_name?.trim() || memberHandle(member);
113}
114
115export 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 */
122export type ChannelVisibility = "private" | "workspace" | "public";
123
124export 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
140export 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. */
151export 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
177export type CardField = { label: string; value: string };
178
179/** A button on a card. */
180export 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
193export 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 */
235export type ChatAnswerState = "thinking" | "streaming" | "done" | "stopped" | "error";
236
237/**
238 * What a proposal's card (Ask's `proposal` step) has done so far, kept on
239 * the answer as a `proposal_state` step with the same `id`, so the card
240 * shows it wherever the answer is read and a second click carries on.
241 */
242export type AskProposalState = {
243 id: string;
244 /** When a click began starting it (ms); another click waits on it. */
245 starting_at?: number;
246 /** A new repository's: where it is, its default branch, where it deploys or why it can't. */
247 repo_url?: string;
248 default_branch?: string;
249 production_url?: string | null;
250 deploy_error?: string | null;
251 /** The issue it opened or worked on. */
252 issue?: number;
253 /** The session it started on the workspace's Anthropic account. */
254 started?: boolean;
255 session_url?: string | null;
256 provider_session_url?: string | null;
257};
258
259export type ChatAnswer = {
260 state: ChatAnswerState;
261 /** The provider's name and the model, once the provider said. */
262 provider_label?: string | null;
263 model?: string | null;
264 /** Auto chose `model` for this question. */
265 auto?: boolean;
266 /** `max_tokens`, `length`: cut off at its limit. */
267 stop_reason?: string | null;
268 /** What went wrong, for `error`. */
269 error?: string | null;
270 /**
271 * Steps the answer took on its way (tool calls and the like), as the
272 * integrations service streamed them: each `ask` event other than meta,
273 * text, done and error, kept as it came. At most 50.
274 */
275 steps?: Record<string, unknown>[];
276 /** Set when Ask is not set up in the workspace: the answer says where to set it up. */
277 setup?: "missing" | null;
278};
279
280/**
281 * One emoji's reactions on a message. `emoji` is a Unicode emoji or
282 * `:name:` for one of the workspace's own (`CustomEmoji`).
283 */
284export type ChatReaction = {
285 emoji: string;
286 /** Everyone who reacted with it, people and agents alike. */
287 count: number;
288 /**
289 * Whether the viewer did. In live `message.*` events, which everyone in
290 * the room gets, it is always false: keep your own from what you know.
291 */
292 me: boolean;
293 /** The first ten who reacted with it, for the hover list. */
294 by: MemberProfile[];
295};
296
297/** Who may add a workspace's emoji: every member (the default), or only its owners. */
298export type EmojiUpload = "members" | "admins";
299
300/**
301 * One of a workspace's own emoji, used as `:name:`. Its image is served
302 * from the usercontent origin at `/emoji/<file>` (never from the site).
303 */
304export type CustomEmoji = {
305 name: string;
306 /** For an alias, the emoji it is another name for; it shows that one's image. */
307 alias_of: string | null;
308 /** The SHA-256 of the image's bytes. */
309 file: string;
310 content_type: "image/png" | "image/gif" | "image/webp";
311 bytes: number;
312 created_by: MemberProfile;
313 created_at: string;
314};
315
316export type EmojiList = {
317 emoji: CustomEmoji[];
318 emoji_upload: EmojiUpload;
319 /** Whether the viewer may add emoji and aliases. */
320 can_upload: boolean;
321 /** Whether the viewer is an owner: may remove any emoji and change `emoji_upload`. */
322 can_manage: boolean;
323};
324
325/** Who may do something in a workspace's chat: every member, or only its owners. */
326export type ChatAllowed = "members" | "owners";
327
328/**
329 * Who may rename and archive a channel: its owners (whoever made it) and
330 * the workspace's owners (the default), or the workspace's owners only.
331 */
332export type ChannelManagers = "channel_owners" | "owners";
333
334/**
335 * A workspace's chat settings, which its owners choose (Workspace,
336 * Settings, Chat). The chat service enforces each one; the page only
337 * hides what the viewer may not do.
338 */
339export type ChatSettings = {
340 /** Who may create public channels. */
341 public_channels: ChatAllowed;
342 /** Who may create private channels. */
343 private_channels: ChatAllowed;
344 /** Who may rename, archive and unarchive channels. */
345 manage_channels: ChannelManagers;
346 /** Who may add custom emoji (`admins` is the workspace's owners). */
347 emoji_upload: EmojiUpload;
348 /**
349 * The public channels someone new is put in the first time they open
350 * Chat, by id. #general unless the owners chose otherwise.
351 */
352 default_channels: string[];
353};
354
355/** What the viewer may do in a workspace's chat, from its settings and their role. */
356export type ChatPermissions = {
357 create_public_channels: boolean;
358 create_private_channels: boolean;
359 add_emoji: boolean;
360 /** Whether they may change the workspace's chat settings: owners only. */
361 manage_settings: boolean;
362 /** Whether they may make a channel public, open to anyone on g1t: owners only. */
363 make_public?: boolean;
364 /**
365 * Whether they may ask people outside the workspace into its channels as
366 * guests: owners, and members while the workspace lets members invite
367 * outside collaborators.
368 */
369 invite_guests?: boolean;
370};
371
372/** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */
373export type ChatSettingsView = {
374 settings: ChatSettings;
375 can: ChatPermissions;
376 channels: Channel[];
377};
378
379/**
380 * A change to a channel: its name, its topic, whether it is archived, or
381 * whether a channel that is not private is open to anyone on g1t
382 * (`workspace` or `public`; workspace owners only). Anything left out stays.
383 */
384export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean; visibility?: "workspace" | "public" };
385
386/** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */
387export type EmojiFile = { data: string };
388
389/** The largest custom emoji image, in bytes, and the widest or tallest, in pixels. */
390export const MAX_EMOJI_BYTES = 256 * 1024;
391export const MAX_EMOJI_SIDE = 512;
392
393/** One row of the Chat sidebar. */
394export type ChatSidebarEntry = {
395 channel: Channel;
396 /** "g1t-core", or the other members' names for a direct message. */
397 title: string;
398 /** For a direct message: who else is in it (up to four). */
399 others: MemberProfile[];
400 starred: boolean;
401 muted: boolean;
402 unread: number;
403 mentions: number;
404};
405
406export type ChatSidebar = {
407 entries: ChatSidebarEntry[];
408 /** Channels the viewer could join and has not: for a guest, the public ones only. */
409 browsable: number;
410 /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */
411 can: ChatPermissions;
412 /**
413 * The viewer is not a member of this workspace: a guest, who sees only
414 * the channels they are in and its public ones. Absent for members.
415 */
416 guest?: boolean;
417 /** The workspace whose chat this is. */
418 workspace?: ChatWorkspace;
419 /** Channels of other workspaces the viewer is a guest in. */
420 elsewhere?: GuestChannel[];
421 /** Invitations into other workspaces' channels, waiting for the viewer's answer. */
422 invitations?: ChannelInvitation[];
423};
424
425/** A workspace as chat names it to someone who may not be in it. */
426export type ChatWorkspace = { slug: string; name: string; avatar: string | null };
427
428/** A channel of another workspace that the viewer is a guest in, for their own sidebar. */
429export type GuestChannel = {
430 workspace: ChatWorkspace;
431 channel: Channel;
432 unread: number;
433 mentions: number;
434};
435
436/** An invitation for someone outside a workspace to join one of its channels as a guest. */
437export type ChannelInvitation = {
438 id: string;
439 workspace: ChatWorkspace;
440 channel: Channel;
441 /** Who it is for. */
442 invitee: MemberProfile;
443 invited_by: MemberProfile;
444 created_at: string;
445};
446
447/**
448 * `activity`: what was said over a span, in the conversations the viewer
449 * can read (every public channel, and the private channels and direct
450 * messages they are in). Messages are text messages and thread replies
451 * that were not deleted; cards agents post are not messages. Home reads it
452 * for what people and agents said since you were last there.
453 */
454export type ChatActivity = {
455 /** RFC 3339: `[from, until)`. */
456 from: string;
457 until: string;
458 messages: number;
459 /** Conversations with at least one message. */
460 channels: number;
461 /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */
462 authors: { key: string; messages: number }[];
463};
464
465export type MessagePage = {
466 messages: ChatMessage[];
467 /** Pass as `before` to read further back; null at the beginning. */
468 older: string | null;
469 /**
470 * For a thread's first page: the message the thread is under, however
471 * many replies it has, so a thread panel always shows it (a session's
472 * live card, say) at its top.
473 */
474 root?: ChatMessage | null;
475 /**
476 * Set when the page was read with `after`: pass it as `after` again for
477 * the next messages, or null when this page reached the newest.
478 */
479 newer?: string | null;
480};
481
482/**
483 * A new channel. `visibility` wins over `private`; left out, a channel is
484 * `workspace`, or `private` when `private` is set.
485 */
486export type NewChannel = { name: string; topic?: string | null; private?: boolean; visibility?: ChannelVisibility };
487
488export type PostMessage = {
489 body: string;
490 thread_root?: string | null;
491 /** In a direct message with @g1t: the page the person asks from, which g1t is told. Ignored elsewhere. */
492 page?: AskPage | null;
493};
494
495/**
496 * What the live socket sends. The site opens
497 * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
498 * session and forwards the upgrade to the chat service with the viewer.
499 */
500export type ChatLiveEvent =
501 | { type: "message.created"; message: ChatMessage }
502 | { type: "message.updated"; message: ChatMessage }
503 | { type: "message.deleted"; channel_id: string; id: string }
504 /**
505 * An answer from @g1t as it is written: the whole text so far (not a
506 * delta, so a late or dropped frame never garbles it) and where it is.
507 * The stored message catches up with a `message.updated` at the end.
508 */
509 | { type: "message.streaming"; channel_id: string; id: string; body: string; answer: ChatAnswer }
510 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
511 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string }
512 | { type: "channel.updated"; channel: Channel }
513 /**
514 * Everything in the conversation was deleted for good: the person
515 * cleared their direct message with @g1t. Every message shown goes.
516 */
517 | { type: "channel.cleared"; channel_id: string }
518 | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile };
519
520/**
521 * Header the site sets on a forwarded live socket: the viewer, as JSON.
522 * The site forwards the upgrade to the chat service's
523 * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out,
524 * at the cost of looking up each of the viewer's workspaces).
525 */
526export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
527
528/**
529 * A channel with its members, and what the viewer may do there. `invited`
530 * lists guests' open invitations, for those who may ask guests in or take
531 * people out; empty for anyone else.
532 */
533export type ChannelDetail = {
534 channel: Channel;
535 members: ChannelMember[];
536 /** Whether the viewer may rename or archive it. */
537 can_manage: boolean;
538 /** Whether the viewer may make it public, or keep it to the workspace. */
539 can_make_public?: boolean;
540 /** Whether the viewer may ask people outside the workspace in. */
541 can_invite_guests?: boolean;
542 /** Whether the viewer may take people out of it. */
543 can_remove_members?: boolean;
544 /** The viewer is not a member of the workspace: a guest here. */
545 guest?: boolean;
546 /** The workspace whose channel this is. */
547 workspace?: ChatWorkspace;
548 invited?: ChannelInvitation[];
549 /** Requests to join waiting for an answer, for those who could let the person in. */
550 requests?: ChannelAccessRequest[];
551};
552
553/**
554 * Someone asking to be let into a channel they cannot open. `guest` when
555 * they are not in the workspace: letting them in makes them a guest.
556 */
557export type ChannelAccessRequest = {
558 id: string;
559 channel_id: string;
560 requester: MemberProfile;
561 note: string | null;
562 created_at: string;
563};
564
565export type ChatApi = {
566 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
567 /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */
568 activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>;
569 channel(
570 workspace: string,
571 channelId: string,
572 viewer: User,
573 ): Promise<Result<ChannelDetail>>;
574 /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
575 channelByName(
576 workspace: string,
577 name: string,
578 viewer: User,
579 ): Promise<Result<ChannelDetail>>;
580 /**
581 * Browse channels: every public channel, and the private ones the viewer
582 * is in. With `archived`, the archived ones instead.
583 */
584 browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>;
585 /** Makes a channel. Who may make a public or a private one is the workspace's setting. */
586 createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
587 /**
588 * Renames, archives or unarchives a channel (the workspace's
589 * `manage_channels` setting says who may), or changes its topic (any
590 * member). #general is never renamed or archived. Everyone looking at it
591 * gets `channel.updated`.
592 */
593 updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>;
594 /** The workspace's chat settings, for any member; only owners may change them. */
595 chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>;
596 /** Owners only: changes some of the workspace's chat settings, and answers all of them. */
597 setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>;
598 /**
599 * The direct message between the viewer and these members, created on
600 * first use. The same set of members always gets the same channel.
601 */
602 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
603 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
604 leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
605 /**
606 * Adds a person. A member of the workspace joins at once; anyone else is
607 * sent an invitation and joins as a guest when they accept (`invited`).
608 */
609 invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<{ invited: boolean }>>;
610 /**
611 * Takes someone out of a channel, or takes back their open invitation:
612 * the workspace's owners, and the channel's own owners where the
613 * workspace lets them manage channels. Someone outside the workspace
614 * taken out of a public channel cannot join it again on their own.
615 */
616 removeMember(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
617 /** The viewer's invitations into channels of workspaces they are not in. */
618 channelInvitations(viewer: User): Promise<Result<ChannelInvitation[]>>;
619 /** Accepts or declines one; accepting answers the channel's page. */
620 answerChannelInvitation(viewer: User, id: string, accept: boolean): Promise<Result<{ to: string | null }>>;
621 /**
622 * Asks to be let into a channel, by the name in its link. Answers the
623 * same whether or not there is such a channel the viewer may ask about,
624 * so asking never tells them one exists; a request is kept only when it
625 * does. Already able to read it: nothing to ask.
626 */
627 requestAccess(workspace: string, name: string, viewer: User, note: string | null): Promise<Result<{ requested: true }>>;
628 /** Lets the person in, or turns them down: anyone who could have added them. */
629 answerAccessRequest(workspace: string, channelId: string, viewer: User, id: string, approve: boolean): Promise<Result<null>>;
630 /**
631 * Newest first. Without `thread_root`, the channel's top-level messages;
632 * with it, that thread's replies, plus the message they reply to as the
633 * oldest once the page reaches the start of the thread (`older` null).
634 * With `after` (catching up after a reconnect): the messages after that
635 * id instead, oldest first, deleted ones included so the client can
636 * drop them; `newer` says whether there are more.
637 * Limit 50 by default, 200 at most.
638 */
639 messages(
640 workspace: string,
641 channelId: string,
642 viewer: User,
643 page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null },
644 ): Promise<Result<MessagePage>>;
645 post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>;
646 edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>;
647 remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
648 markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
649 setPreferences(
650 workspace: string,
651 channelId: string,
652 viewer: User,
653 prefs: { starred?: boolean; muted?: boolean },
654 ): Promise<Result<null>>;
655 /**
656 * Reacts to a message with `emoji` (one Unicode emoji, or `:name:` for
657 * one of the workspace's own). Once per member per emoji; at most 50
658 * different emoji on a message. Answers the message's reactions now.
659 */
660 react(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
661 unreact(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
662 /** The workspace's own emoji, by name, and who may add them. */
663 listEmoji(workspace: string, viewer: User): Promise<Result<EmojiList>>;
664 /** Adds an emoji: a PNG, GIF or WebP of at most 256 KB and 512×512. */
665 addEmoji(workspace: string, viewer: User, name: string, file: EmojiFile): Promise<Result<CustomEmoji>>;
666 /** Gives an existing emoji (`target`) another name. */
667 aliasEmoji(workspace: string, viewer: User, name: string, target: string): Promise<Result<CustomEmoji>>;
668 /** Removes an emoji, and its aliases with it. Its creator or an owner may. */
669 removeEmoji(workspace: string, viewer: User, name: string): Promise<Result<null>>;
670 /** Owners only: who may add emoji. */
671 setEmojiUpload(workspace: string, viewer: User, value: EmojiUpload): Promise<Result<EmojiUpload>>;
672 /** The viewer's direct message with @g1t, created on first use. */
673 g1tDm(workspace: string, viewer: User): Promise<Result<Channel>>;
674 /**
675 * Starts a new conversation in the viewer's direct message with @g1t: a
676 * mark after which answers read only what comes next. Stops an answer
677 * still coming.
678 */
679 g1tReset(workspace: string, channelId: string, viewer: User): Promise<Result<ChatMessage>>;
680 /** Has @g1t answer the latest question again, in place of its last answer. */
681 g1tRetry(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
682 /** Stops @g1t's answer still coming; what came stays. */
683 g1tStop(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
684 /**
685 * Keeps what a proposal's card did on the @g1t answer that proposed it
686 * (merged into its `proposal_state` step), and says what is kept; with
687 * only `id`, just says. The viewer's own direct message with g1t only.
688 */
689 g1tProposal(workspace: string, channelId: string, viewer: User, messageId: string, state: AskProposalState): Promise<Result<AskProposalState>>;
690 /**
691 * Clears the viewer's own direct message with @g1t: every message in
692 * it, g1t's answers and their steps, reactions and the notifications
693 * from it are deleted for good, and an answer still coming is thrown
694 * away. `cleared` is how many messages went; 0 when it was empty, so
695 * clearing again is harmless. Refused for any other conversation.
696 */
697 g1tClear(workspace: string, channelId: string, viewer: User): Promise<Result<{ cleared: number }>>;
698};
699
700/**
701 * For g1t's own services only, never the site on a person's behalf: posts
702 * and edits messages as @g1t in a direct message with it. The chat service
703 * answers these at `POST /internal/<method>`, which no route from the site
704 * reaches, and refuses anything that came in from the internet.
705 */
706export type ChatInternalApi = {
707 /** Posts `body` as @g1t in a direct message with it, in the workspace with this slug. */
708 g1tPost(workspace: string, channelId: string, body: string): Promise<Result<ChatMessage>>;
709 /** Changes the text of one of @g1t's messages there. */
710 g1tEdit(workspace: string, channelId: string, id: string, body: string): Promise<Result<ChatMessage>>;
711};
712
713export function chatInternalClient(service: ServiceBinding): ChatInternalApi {
714 const call = async <T>(method: string, args: object): Promise<T> => {
715 const response = await service.fetch(`https://service/internal/${method}`, {
716 method: "POST",
717 headers: { "content-type": "application/json" },
718 body: JSON.stringify(args),
719 });
720 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
721 return (await response.json()) as T;
722 };
723 return {
724 g1tPost: (workspace, channelId, body) => call("g1t_post", { workspace, channel_id: channelId, body }),
725 g1tEdit: (workspace, channelId, id, body) => call("g1t_edit", { workspace, channel_id: channelId, id, body }),
726 };
727}
728
729async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
730 const response = await service.fetch(`https://service/rpc/${method}`, {
731 method: "POST",
732 headers: { "content-type": "application/json" },
733 body: JSON.stringify(args),
734 });
735 if (!response.ok) {
736 throw new Error(`${method} failed with status ${response.status}`);
737 }
738 return (await response.json()) as T;
739}
740
741export function chatClient(service: ServiceBinding): ChatApi {
742 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
743 return {
744 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
745 activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }),
746 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
747 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
748 browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }),
749 createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
750 updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }),
751 chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }),
752 setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }),
753 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
754 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
755 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
756 invite: (workspace, channelId, viewer, member) =>
757 call("invite", { workspace, channel_id: channelId, viewer, member }),
758 removeMember: (workspace, channelId, viewer, member) =>
759 call("remove_member", { workspace, channel_id: channelId, viewer, member }),
760 channelInvitations: (viewer) => call("channel_invitations", { viewer }),
761 answerChannelInvitation: (viewer, id, accept) => call("answer_channel_invitation", { viewer, id, accept }),
762 requestAccess: (workspace, name, viewer, note) => call("request_access", { workspace, name, viewer, note }),
763 answerAccessRequest: (workspace, channelId, viewer, id, approve) =>
764 call("answer_access_request", { workspace, channel_id: channelId, viewer, id, approve }),
765 messages: (workspace, channelId, viewer, page) =>
766 call("messages", {
767 workspace,
768 channel_id: channelId,
769 viewer,
770 before: page?.before ?? null,
771 after: page?.after ?? null,
772 limit: page?.limit ?? null,
773 thread_root: page?.thread_root ?? null,
774 }),
775 post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }),
776 edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }),
777 remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }),
778 markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }),
779 setPreferences: (workspace, channelId, viewer, prefs) =>
780 call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
781 react: (workspace, channelId, viewer, messageId, emoji) =>
782 call("react", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
783 unreact: (workspace, channelId, viewer, messageId, emoji) =>
784 call("unreact", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
785 listEmoji: (workspace, viewer) => call("list_emoji", { workspace, viewer }),
786 addEmoji: (workspace, viewer, name, file) => call("add_emoji", { workspace, viewer, name, file }),
787 aliasEmoji: (workspace, viewer, name, target) => call("alias_emoji", { workspace, viewer, name, target }),
788 removeEmoji: (workspace, viewer, name) => call("remove_emoji", { workspace, viewer, name }),
789 setEmojiUpload: (workspace, viewer, value) => call("set_emoji_upload", { workspace, viewer, value }),
790 g1tDm: (workspace, viewer) => call("g1t_dm", { workspace, viewer }),
791 g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }),
792 g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }),
793 g1tStop: (workspace, channelId, viewer) => call("g1t_stop", { workspace, channel_id: channelId, viewer }),
794 g1tProposal: (workspace, channelId, viewer, messageId, state) =>
795 call("g1t_proposal", { workspace, channel_id: channelId, viewer, message_id: messageId, state }),
796 g1tClear: (workspace, channelId, viewer) => call("g1t_clear", { workspace, channel_id: channelId, viewer }),
797 };
798}