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
768 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
237export 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 */
262export 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. */
276export 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 */
282export 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
294export 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. */
304export 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 */
310export 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 */
317export 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. */
334export 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. */
351export 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 */
362export 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. */
365export type EmojiFile = { data: string };
366
367/** The largest custom emoji image, in bytes, and the widest or tallest, in pixels. */
368export const MAX_EMOJI_BYTES = 256 * 1024;
369export const MAX_EMOJI_SIDE = 512;
370
371/** One row of the Chat sidebar. */
372export 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
384export 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. */
404export 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. */
407export 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. */
415export 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 */
432export 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
443export 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 */
464export type NewChannel = { name: string; topic?: string | null; private?: boolean; visibility?: ChannelVisibility };
465
466export 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 */
478export 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 */
504export 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 */
511export 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 */
535export type ChannelAccessRequest = {
536 id: string;
537 channel_id: string;
538 requester: MemberProfile;
539 note: string | null;
540 created_at: string;
541};
542
543export 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 */
678export 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
685export 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
701async 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
713export 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}