Skip to content
712 linesCodeBlameRaw
1/**
2 * Chat: channels, direct messages, threads and messages, kept by the chat
3 * service (`services/chat`). People and agents are members alike.
4 *
5 * Wire shapes are snake_case end to end, so the site, the public API and
6 * the live socket all carry the same objects.
7 */
8import type { ServiceBinding } from "./clients";
9import type { User } from "./identity";
10import type { Result } from "./result";
11import type { AgentLook } from "./agent-look";
12import type { AskerAccess } from "./workspace-agents";
13
14/** Who is speaking: a person (by user id) or a workspace agent (by agent id). */
15export type Principal = { kind: "user" | "agent"; id: string };
16
17export function principalKey(principal: Principal): string {
18 return `${principal.kind}:${principal.id}`;
19}
20
21export function parsePrincipalKey(key: string): Principal | null {
22 const at = key.indexOf(":");
23 if (at < 0) return null;
24 const kind = key.slice(0, at);
25 const id = key.slice(at + 1);
26 if ((kind !== "user" && kind !== "agent") || !id) return null;
27 return { kind, id };
28}
29
30/** How a member shows: resolved by the chat service when it answers. */
31export type MemberProfile = Principal & {
32 /** `username` for a person (lowercased: what `@` mentions), `handle` for an agent. */
33 name: string;
34 /**
35 * A person's username as they wrote it (`Ana`), when that differs from
36 * `name`; absent for an agent. Shown in their card and in autocomplete.
37 */
38 display_username?: string | null;
39 /**
40 * What chat shows them as: a person's display name, else their username
41 * in its chosen case; an agent's display name. Read it with `memberName`.
42 */
43 display_name: string;
44 /** Uploaded avatar hash for a person, or null for the letter avatar. */
45 avatar: string | null;
46 /** An agent's one-line role ("Release manager for g1t"). */
47 role: string | null;
48 /**
49 * An agent's title ("QA Engineer"); null for a person. Chat always sets
50 * it; optional so profiles a page makes for itself need not.
51 */
52 title?: string | null;
53 /** What an agent's face is drawn from when it chose none; null for a person. Always set by chat. */
54 avatar_seed?: string | null;
55 /** An agent's chosen face (./agent-look.ts); null for a person, or an agent wearing its seed's face. */
56 look?: AgentLook | null;
57};
58
59/**
60 * A member's handle as it shows (`@Ana`, without the `@`): a person's
61 * username in its chosen case, an agent's handle.
62 */
63export function memberHandle(member: { name: string; display_username?: string | null }): string {
64 const display = member.display_username;
65 return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name;
66}
67
68/**
69 * How a member's name is shown everywhere in chat (messages, the
70 * sidebar, direct-message titles, typing, notifications and pushes): their
71 * display name, else their handle in its chosen case. One rule, so every
72 * surface agrees.
73 */
74export function memberName(member: { name: string; display_name?: string | null; display_username?: string | null }): string {
75 return member.display_name?.trim() || memberHandle(member);
76}
77
78export type ChannelKind = "channel" | "dm";
79
80export type Channel = {
81 id: string;
82 workspace_id: string;
83 kind: ChannelKind;
84 /** Lowercase, no `#`. Null for a direct message. */
85 name: string | null;
86 topic: string | null;
87 private: boolean;
88 created_by: Principal;
89 created_at: string;
90 archived_at: string | null;
91 last_message_at: string | null;
92};
93
94export type ChannelMember = {
95 channel_id: string;
96 member: MemberProfile;
97 role: "owner" | "member";
98 starred: boolean;
99 muted: boolean;
100 last_read_id: string | null;
101 joined_at: string;
102};
103
104/** A card g1t or an agent posts: an event, a task, an approval. */
105export type MessageCard = {
106 /** What it is about, e.g. `pull`, `issue`, `task`, `deploy`, `approval`, `session`, `draft_issue`. */
107 kind: string;
108 title: string;
109 /** A short line under the title: "3/3 checks · +214 −87". */
110 detail: string | null;
111 /** A status shown on the right: "Needs approval", "Merged". */
112 state: string | null;
113 /** Where clicking the card goes, relative to the site. */
114 href: string | null;
115 /** A preview in Markdown under the title: a draft issue's body, a report's first lines. */
116 body?: string | null;
117 /** Labelled facts shown in two columns: "Repository · acme/web", "Cap · $2.00". */
118 fields?: CardField[];
119 /**
120 * What people can do right here. Pressing one goes to the service that
121 * owns the card (`owner`), which checks the person may, does it, and
122 * updates the card in place. A card without `owner` has no actions.
123 */
124 actions?: CardAction[];
125 /** The service whose card this is and that answers its actions: `agents`. */
126 owner?: "agents" | null;
127 /** What the card is about, for its owner: a session id, a draft id. */
128 ref?: string | null;
129};
130
131export type CardField = { label: string; value: string };
132
133/** A button on a card. */
134export type CardAction = {
135 /** Unique on the card: `stop`, `approve`, `file`. */
136 id: string;
137 label: string;
138 style?: "primary" | "danger" | "default";
139 /** Asked before it runs: "Stop this session and everything under it?". */
140 confirm?: string | null;
141 /** A value it needs first, asked inline: an amount in dollars, or a line of text. */
142 input?: { kind: "money" | "text"; label: string; placeholder?: string | null; initial?: string | null } | null;
143 /** A link instead of an action: opens this place in the site. */
144 href?: string | null;
145};
146
147/** What pressing a card's action did, as the person who pressed it is told. */
148export type CardActionResult = { ok: boolean; message: string | null };
149
150export type ChatMessage = {
151 /** Time-sortable (ULID-like), so ordering by id is ordering by time. */
152 id: string;
153 channel_id: string;
154 author: MemberProfile;
155 kind: "text" | "card";
156 body: string;
157 card: MessageCard | null;
158 /** The message this replies under, or null for a top-level message. */
159 thread_root: string | null;
160 reply_count: number;
161 last_reply_at: string | null;
162 created_at: string;
163 edited_at: string | null;
164 deleted_at: string | null;
165 /**
166 * Its reactions, one per emoji in the order each was first used. Chat
167 * always sets it; optional so messages a page makes for itself need not.
168 */
169 reactions?: ChatReaction[];
170};
171
172/**
173 * One emoji's reactions on a message. `emoji` is a Unicode emoji or
174 * `:name:` for one of the workspace's own (`CustomEmoji`).
175 */
176export type ChatReaction = {
177 emoji: string;
178 /** Everyone who reacted with it, people and agents alike. */
179 count: number;
180 /**
181 * Whether the viewer did. In live `message.*` events, which everyone in
182 * the room gets, it is always false: keep your own from what you know.
183 */
184 me: boolean;
185 /** The first ten who reacted with it, for the hover list. */
186 by: MemberProfile[];
187};
188
189/** Who may add a workspace's emoji: every member (the default), or only its owners. */
190export type EmojiUpload = "members" | "admins";
191
192/**
193 * One of a workspace's own emoji, used as `:name:`. Its image is served
194 * from the usercontent origin at `/emoji/<file>` (never from the site).
195 */
196export type CustomEmoji = {
197 name: string;
198 /** For an alias, the emoji it is another name for; it shows that one's image. */
199 alias_of: string | null;
200 /** The SHA-256 of the image's bytes. */
201 file: string;
202 content_type: "image/png" | "image/gif" | "image/webp";
203 bytes: number;
204 created_by: MemberProfile;
205 created_at: string;
206};
207
208export type EmojiList = {
209 emoji: CustomEmoji[];
210 emoji_upload: EmojiUpload;
211 /** Whether the viewer may add emoji and aliases. */
212 can_upload: boolean;
213 /** Whether the viewer is an owner: may remove any emoji and change `emoji_upload`. */
214 can_manage: boolean;
215};
216
217/** Who may do something in a workspace's chat: every member, or only its owners. */
218export type ChatAllowed = "members" | "owners";
219
220/**
221 * Who may rename and archive a channel: its owners (whoever made it) and
222 * the workspace's owners (the default), or the workspace's owners only.
223 */
224export type ChannelManagers = "channel_owners" | "owners";
225
226/**
227 * A workspace's chat settings, which its owners choose (Workspace,
228 * Settings, Chat). The chat service enforces each one; the page only
229 * hides what the viewer may not do.
230 */
231export type ChatSettings = {
232 /** Who may create public channels. */
233 public_channels: ChatAllowed;
234 /** Who may create private channels. */
235 private_channels: ChatAllowed;
236 /** Who may rename, archive and unarchive channels. */
237 manage_channels: ChannelManagers;
238 /** Who may add custom emoji (`admins` is the workspace's owners). */
239 emoji_upload: EmojiUpload;
240 /**
241 * The public channels someone new is put in the first time they open
242 * Chat, by id. #general unless the owners chose otherwise.
243 */
244 default_channels: string[];
245};
246
247/** What the viewer may do in a workspace's chat, from its settings and their role. */
248export type ChatPermissions = {
249 create_public_channels: boolean;
250 create_private_channels: boolean;
251 add_emoji: boolean;
252 /** Whether they may change the workspace's chat settings: owners only. */
253 manage_settings: boolean;
254};
255
256/** The chat settings page: the settings, what the viewer may do, and the public channels to choose defaults from. */
257export type ChatSettingsView = {
258 settings: ChatSettings;
259 can: ChatPermissions;
260 channels: Channel[];
261};
262
263/** A change to a channel: its name, its topic, or whether it is archived. Anything left out stays. */
264export type ChannelChange = { name?: string; topic?: string | null; archived?: boolean };
265
266/** An image for a new emoji, as base64. Its type is read from its bytes, never taken from here. */
267export type EmojiFile = { data: string };
268
269/** The largest custom emoji image, in bytes, and the widest or tallest, in pixels. */
270export const MAX_EMOJI_BYTES = 256 * 1024;
271export const MAX_EMOJI_SIDE = 512;
272
273/** One row of the Chat sidebar. */
274export type ChatSidebarEntry = {
275 channel: Channel;
276 /** "g1t-core", or the other members' names for a direct message. */
277 title: string;
278 /** For a direct message: who else is in it (up to four). */
279 others: MemberProfile[];
280 starred: boolean;
281 muted: boolean;
282 unread: number;
283 mentions: number;
284};
285
286export type ChatSidebar = {
287 entries: ChatSidebarEntry[];
288 /** Public channels in the workspace the viewer has not joined. */
289 browsable: number;
290 /** What the viewer may do, from the workspace's chat settings: the page hides what they may not. */
291 can: ChatPermissions;
292};
293
294/**
295 * `activity`: what was said over a span, in the conversations the viewer
296 * can read (every public channel, and the private channels and direct
297 * messages they are in). Messages are text messages and thread replies
298 * that were not deleted; cards agents post are not messages. Home reads it
299 * for what people and agents said since you were last there.
300 */
301export type ChatActivity = {
302 /** RFC 3339: `[from, until)`. */
303 from: string;
304 until: string;
305 messages: number;
306 /** Conversations with at least one message. */
307 channels: number;
308 /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */
309 authors: { key: string; messages: number }[];
310};
311
312export type MessagePage = {
313 messages: ChatMessage[];
314 /** Pass as `before` to read further back; null at the beginning. */
315 older: string | null;
316 /**
317 * For a thread's first page: the message the thread is under, however
318 * many replies it has, so a thread panel always shows it (a session's
319 * live card, say) at its top.
320 */
321 root?: ChatMessage | null;
322 /**
323 * Set when the page was read with `after`: pass it as `after` again for
324 * the next messages, or null when this page reached the newest.
325 */
326 newer?: string | null;
327};
328
329export type NewChannel = { name: string; topic?: string | null; private?: boolean };
330
331export type PostMessage = { body: string; thread_root?: string | null };
332
333/**
334 * Who will read what is said in a conversation: a direct message's or
335 * private channel's people, or, for a public channel, the whole workspace.
336 * An agent answers there only with what every one of them may see.
337 */
338export type ChatAudience = {
339 kind: "dm" | "private" | "public";
340 /** The people in it (agents left out). For a public channel, its current members, for reference only. */
341 member_user_ids: string[];
342 member_count: number;
343};
344
345/** A message an agent found by searching, with the conversation it is in. */
346export type AgentFoundMessage = {
347 channel_id: string;
348 /** The channel's name, or null for a direct message. */
349 channel: string | null;
350 message: ChatMessage;
351};
352
353/** The most agent-to-agent hops one person's request may start. */
354export const CHAT_MAX_HOPS = 6;
355
356/**
357 * What an agent posts. An agent's message never wakes another agent, even
358 * when it @mentions one: only a hand-off does (`handOffAsAgent`). Its
359 * mentions of anyone who is not a member of the conversation lose their
360 * `@`, so they show as plain names and notify nobody. When it answers a
361 * delivery, it passes that delivery's `hops`, `asked_by`, `asker` and
362 * `chain` back, which a card that waits on the asker uses. Left out: asked
363 * by the person who created the agent.
364 */
365export type AgentPostMessage = PostMessage & {
366 card?: MessageCard | null;
367 hops?: number;
368 asked_by?: string | null;
369 /** The delivery's `asker`, handed on to agents this message wakes. Absent: none (they treat the asker as unable to change code). */
370 asker?: AskerAccess | null;
371 /**
372 * The delivery's `chain`: the agents that handled this request before
373 * the one posting, oldest first. Chat adds the poster, and never hands
374 * the message to the agent that sent the work to it (no ping-pong).
375 */
376 chain?: string[];
377};
378
379/**
380 * A conversation as an agent is told about it before every turn: what it
381 * is and who is in it, so it knows who reads what it says and who doesn't.
382 * Every agent member is listed; people up to `CONVERSATION_PEOPLE_SHOWN`
383 * (the person who asked always among them), with the totals beside.
384 */
385export type ConversationForAgent = {
386 channel: Channel;
387 /** Agents first, then people. */
388 members: MemberProfile[];
389 /** How many people and agents are in it, listed or not. */
390 people: number;
391 agents: number;
392};
393
394/** The most people `conversationForAgent` lists; the rest are a count. */
395export const CONVERSATION_PEOPLE_SHOWN = 20;
396
397/**
398 * An agent handing work to a colleague agent for the person who asked
399 * (docs.g1t.sh/guides/agents/, "Hand off"). The fields after `brief`
400 * are the delivery the agent is answering, passed back as for a post.
401 */
402export type AgentHandOff = {
403 /** The colleague, by agent id. */
404 colleague_id: string;
405 /** What they are asked to do, addressed to them. */
406 brief: string;
407 thread_root?: string | null;
408 hops?: number;
409 asked_by: string;
410 asker?: AskerAccess | null;
411 chain?: string[];
412};
413
414/**
415 * Where a hand-off went. `here`: the colleague is in this channel or group
416 * direct message, and the brief was posted here. `group_dm`: the brief was
417 * posted in the direct message of the person who asked, the agent and the
418 * colleague (`opened` when it was new), and a card linking to it was
419 * posted here.
420 */
421export type HandOffResult = { where: "here" | "group_dm"; channel_id: string; message_id: string; opened: boolean };
422
423/**
424 * What the live socket sends. The site opens
425 * `wss://<site>/<workspace>/chat/live?channel=<id>`; the site checks the
426 * session and forwards the upgrade to the chat service with the viewer.
427 */
428export type ChatLiveEvent =
429 | { type: "message.created"; message: ChatMessage }
430 | { type: "message.updated"; message: ChatMessage }
431 | { type: "message.deleted"; channel_id: string; id: string }
432 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
433 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string }
434 | { type: "channel.updated"; channel: Channel }
435 | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile };
436
437/**
438 * Header the site sets on a forwarded live socket: the viewer, as JSON.
439 * The site forwards the upgrade to the chat service's
440 * `GET /live?workspace=<slug>&channel=<id>` (`workspace` may be left out,
441 * at the cost of looking up each of the viewer's workspaces).
442 */
443export const CHAT_VIEWER_HEADER = "x-g1t-chat-viewer";
444
445/** A channel with its members, and whether the viewer may rename or archive it. */
446export type ChannelDetail = { channel: Channel; members: ChannelMember[]; can_manage: boolean };
447
448export type ChatApi = {
449 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
450 /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */
451 activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>;
452 channel(
453 workspace: string,
454 channelId: string,
455 viewer: User,
456 ): Promise<Result<ChannelDetail>>;
457 /** The same, found by its name in the workspace (`#general` or `general`), as the site's URLs name channels. */
458 channelByName(
459 workspace: string,
460 name: string,
461 viewer: User,
462 ): Promise<Result<ChannelDetail>>;
463 /**
464 * Browse channels: every public channel, and the private ones the viewer
465 * is in. With `archived`, the archived ones instead.
466 */
467 browse(workspace: string, viewer: User, options?: { archived?: boolean }): Promise<Result<Channel[]>>;
468 /** Makes a channel. Who may make a public or a private one is the workspace's setting. */
469 createChannel(workspace: string, viewer: User, input: NewChannel): Promise<Result<Channel>>;
470 /**
471 * Renames, archives or unarchives a channel (the workspace's
472 * `manage_channels` setting says who may), or changes its topic (any
473 * member). #general is never renamed or archived. Everyone looking at it
474 * gets `channel.updated`.
475 */
476 updateChannel(workspace: string, channelId: string, viewer: User, change: ChannelChange): Promise<Result<Channel>>;
477 /** The workspace's chat settings, for any member; only owners may change them. */
478 chatSettings(workspace: string, viewer: User): Promise<Result<ChatSettingsView>>;
479 /** Owners only: changes some of the workspace's chat settings, and answers all of them. */
480 setChatSettings(workspace: string, viewer: User, change: Partial<ChatSettings>): Promise<Result<ChatSettings>>;
481 /**
482 * The direct message between the viewer and these members, created on
483 * first use. The same set of members always gets the same channel.
484 */
485 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
486 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
487 leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
488 /** Adds a person or an agent. An invite grants read, never write. */
489 invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
490 /**
491 * Newest first. Without `thread_root`, the channel's top-level messages;
492 * with it, that thread's replies, plus the message they reply to as the
493 * oldest once the page reaches the start of the thread (`older` null).
494 * With `after` (catching up after a reconnect): the messages after that
495 * id instead, oldest first, deleted ones included so the client can
496 * drop them; `newer` says whether there are more.
497 * Limit 50 by default, 200 at most.
498 */
499 messages(
500 workspace: string,
501 channelId: string,
502 viewer: User,
503 page?: { before?: string | null; after?: string | null; limit?: number; thread_root?: string | null },
504 ): Promise<Result<MessagePage>>;
505 post(workspace: string, channelId: string, viewer: User, message: PostMessage): Promise<Result<ChatMessage>>;
506 edit(workspace: string, channelId: string, viewer: User, id: string, body: string): Promise<Result<ChatMessage>>;
507 remove(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
508 markRead(workspace: string, channelId: string, viewer: User, id: string): Promise<Result<null>>;
509 setPreferences(
510 workspace: string,
511 channelId: string,
512 viewer: User,
513 prefs: { starred?: boolean; muted?: boolean },
514 ): Promise<Result<null>>;
515 /**
516 * Posts as an agent. Only the agents service calls this, for replies and
517 * cards; the agent must be a member of the channel.
518 */
519 postAsAgent(
520 workspace: string,
521 channelId: string,
522 agentId: string,
523 message: AgentPostMessage,
524 ): Promise<Result<ChatMessage>>;
525 /**
526 * Changes a message an agent posted: its body, its card, or both. Only
527 * the agents service calls this, to keep a session's live card current.
528 * Changing a message wakes nobody.
529 */
530 updateAsAgent(
531 workspace: string,
532 channelId: string,
533 agentId: string,
534 id: string,
535 change: { body?: string; card?: MessageCard | null },
536 ): Promise<Result<ChatMessage>>;
537 /**
538 * A person presses an action on a card in a conversation they can read.
539 * Chat hands it to the card's owner with the person, and the owner
540 * decides, acts and updates the card.
541 */
542 cardAction(
543 workspace: string,
544 channelId: string,
545 viewer: User,
546 messageId: string,
547 actionId: string,
548 input?: string | null,
549 ): Promise<Result<CardActionResult>>;
550 /** Shows "is typing" for an agent while it works on a reply. */
551 agentTyping(workspace: string, channelId: string, agentId: string): Promise<Result<null>>;
552 /**
553 * What an agent reads before it replies, oldest first: with
554 * `thread_root`, that thread (its root, then its replies); without, the
555 * channel's or direct message's latest top-level messages. Only the
556 * agents service calls this; the agent must be a member of the channel,
557 * so it reads only what was said where it was invited. `limit` defaults
558 * to 30, at most 100.
559 */
560 historyForAgent(
561 workspace: string,
562 channelId: string,
563 agentId: string,
564 page?: { thread_root?: string | null; limit?: number },
565 ): Promise<Result<ChatMessage[]>>;
566 /** Internal: who reads a conversation. */
567 audience(workspace: string, channelId: string): Promise<Result<ChatAudience>>;
568 /**
569 * Internal, for an agent about to answer in `channelId`, which it must be
570 * a member of: the conversation and its members (`ConversationForAgent`).
571 * `askedBy` (a user id) is always among the people listed.
572 */
573 conversationForAgent(workspace: string, channelId: string, agentId: string, askedBy?: string | null): Promise<Result<ConversationForAgent>>;
574 /**
575 * Internal: `agentId`, answering in `channelId`, hands work to a
576 * colleague agent for the person who asked. If the colleague is in this
577 * channel or group direct message, the brief is posted here; otherwise in
578 * the group direct message of the person, the agent and the colleague
579 * (opened on first use), with a card here linking to it. Either way the
580 * brief wakes the colleague, one hop further along the same chain, and
581 * nobody else. Refused for an agent that is not of the workspace, the
582 * agent itself, @g1t, one already in the chain, past the hop limit, or
583 * when the person who asked is not in this conversation.
584 */
585 handOffAsAgent(workspace: string, channelId: string, agentId: string, handOff: AgentHandOff): Promise<Result<HandOffResult>>;
586 /**
587 * Internal, for an agent replying in `channelId`: messages matching
588 * `query` (newest first, at most 20) from conversations every person in
589 * that conversation's audience is in, and from public channels. Never
590 * another direct message unless it has exactly the same people. The
591 * audience is worked out here from `channelId`, never taken from the
592 * caller.
593 */
594 searchForAgent(workspace: string, channelId: string, query: string, limit?: number): Promise<Result<AgentFoundMessage[]>>;
595 /**
596 * Internal, for an agent replying in `channelId`: a thread (`id`, its
597 * root or any reply) in `targetChannelId`, oldest first, under the same
598 * rule. A conversation the audience may not read is not found, exactly
599 * as one that does not exist.
600 */
601 threadForAgent(workspace: string, channelId: string, targetChannelId: string, id: string): Promise<Result<AgentFoundMessage[]>>;
602 /**
603 * Reacts to a message with `emoji` (one Unicode emoji, or `:name:` for
604 * one of the workspace's own). Once per member per emoji; at most 50
605 * different emoji on a message. Answers the message's reactions now.
606 */
607 react(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
608 unreact(workspace: string, channelId: string, viewer: User, messageId: string, emoji: string): Promise<Result<ChatReaction[]>>;
609 /** Internal, for the agents service: an agent reacts (or, with `remove`, takes it back). It must be in the channel. */
610 reactAsAgent(
611 workspace: string,
612 channelId: string,
613 agentId: string,
614 messageId: string,
615 emoji: string,
616 remove?: boolean,
617 ): Promise<Result<ChatReaction[]>>;
618 /** The workspace's own emoji, by name, and who may add them. */
619 listEmoji(workspace: string, viewer: User): Promise<Result<EmojiList>>;
620 /** Adds an emoji: a PNG, GIF or WebP of at most 256 KB and 512×512. */
621 addEmoji(workspace: string, viewer: User, name: string, file: EmojiFile): Promise<Result<CustomEmoji>>;
622 /** Gives an existing emoji (`target`) another name. */
623 aliasEmoji(workspace: string, viewer: User, name: string, target: string): Promise<Result<CustomEmoji>>;
624 /** Removes an emoji, and its aliases with it. Its creator or an owner may. */
625 removeEmoji(workspace: string, viewer: User, name: string): Promise<Result<null>>;
626 /** Owners only: who may add emoji. */
627 setEmojiUpload(workspace: string, viewer: User, value: EmojiUpload): Promise<Result<EmojiUpload>>;
628};
629
630async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
631 const response = await service.fetch(`https://service/rpc/${method}`, {
632 method: "POST",
633 headers: { "content-type": "application/json" },
634 body: JSON.stringify(args),
635 });
636 if (!response.ok) {
637 throw new Error(`${method} failed with status ${response.status}`);
638 }
639 return (await response.json()) as T;
640}
641
642export function chatClient(service: ServiceBinding): ChatApi {
643 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
644 return {
645 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
646 activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }),
647 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
648 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
649 browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }),
650 createChannel: (workspace, viewer, input) => call("create_channel", { workspace, viewer, input }),
651 updateChannel: (workspace, channelId, viewer, change) => call("update_channel", { workspace, channel_id: channelId, viewer, change }),
652 chatSettings: (workspace, viewer) => call("chat_settings", { workspace, viewer }),
653 setChatSettings: (workspace, viewer, change) => call("set_chat_settings", { workspace, viewer, change }),
654 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
655 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
656 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
657 invite: (workspace, channelId, viewer, member) =>
658 call("invite", { workspace, channel_id: channelId, viewer, member }),
659 messages: (workspace, channelId, viewer, page) =>
660 call("messages", {
661 workspace,
662 channel_id: channelId,
663 viewer,
664 before: page?.before ?? null,
665 after: page?.after ?? null,
666 limit: page?.limit ?? null,
667 thread_root: page?.thread_root ?? null,
668 }),
669 post: (workspace, channelId, viewer, message) => call("post", { workspace, channel_id: channelId, viewer, message }),
670 edit: (workspace, channelId, viewer, id, body) => call("edit", { workspace, channel_id: channelId, viewer, id, body }),
671 remove: (workspace, channelId, viewer, id) => call("remove", { workspace, channel_id: channelId, viewer, id }),
672 markRead: (workspace, channelId, viewer, id) => call("mark_read", { workspace, channel_id: channelId, viewer, id }),
673 setPreferences: (workspace, channelId, viewer, prefs) =>
674 call("set_preferences", { workspace, channel_id: channelId, viewer, prefs }),
675 postAsAgent: (workspace, channelId, agentId, message) =>
676 call("post_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message }),
677 cardAction: (workspace, channelId, viewer, messageId, actionId, input) =>
678 call("card_action", { workspace, channel_id: channelId, viewer, message_id: messageId, action_id: actionId, input: input ?? null }),
679 updateAsAgent: (workspace, channelId, agentId, id, change) =>
680 call("update_as_agent", { workspace, channel_id: channelId, agent_id: agentId, id, change }),
681 agentTyping: (workspace, channelId, agentId) =>
682 call("agent_typing", { workspace, channel_id: channelId, agent_id: agentId }),
683 audience: (workspace, channelId) => call("audience", { workspace, channel_id: channelId }),
684 conversationForAgent: (workspace, channelId, agentId, askedBy) =>
685 call("conversation_for_agent", { workspace, channel_id: channelId, agent_id: agentId, asked_by: askedBy ?? null }),
686 handOffAsAgent: (workspace, channelId, agentId, handOff) =>
687 call("hand_off_as_agent", { workspace, channel_id: channelId, agent_id: agentId, hand_off: handOff }),
688 searchForAgent: (workspace, channelId, query, limit) =>
689 call("search_for_agent", { workspace, channel_id: channelId, query, limit: limit ?? null }),
690 threadForAgent: (workspace, channelId, targetChannelId, id) =>
691 call("thread_for_agent", { workspace, channel_id: channelId, target_channel_id: targetChannelId, id }),
692 react: (workspace, channelId, viewer, messageId, emoji) =>
693 call("react", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
694 unreact: (workspace, channelId, viewer, messageId, emoji) =>
695 call("unreact", { workspace, channel_id: channelId, viewer, message_id: messageId, emoji }),
696 reactAsAgent: (workspace, channelId, agentId, messageId, emoji, remove) =>
697 call("react_as_agent", { workspace, channel_id: channelId, agent_id: agentId, message_id: messageId, emoji, remove: remove ?? false }),
698 listEmoji: (workspace, viewer) => call("list_emoji", { workspace, viewer }),
699 addEmoji: (workspace, viewer, name, file) => call("add_emoji", { workspace, viewer, name, file }),
700 aliasEmoji: (workspace, viewer, name, target) => call("alias_emoji", { workspace, viewer, name, target }),
701 removeEmoji: (workspace, viewer, name) => call("remove_emoji", { workspace, viewer, name }),
702 setEmojiUpload: (workspace, viewer, value) => call("set_emoji_upload", { workspace, viewer, value }),
703 historyForAgent: (workspace, channelId, agentId, page) =>
704 call("history_for_agent", {
705 workspace,
706 channel_id: channelId,
707 agent_id: agentId,
708 thread_root: page?.thread_root ?? null,
709 limit: page?.limit ?? null,
710 }),
711 };
712}