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