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.

181 linesCodeBlameRaw
1import { type AskPage, type Result, parsePrincipalKey } from "@g1t/contracts";
2
3import type { Route } from "./+types/api";
4import { RESERVED_CHANNEL_NAMES, channelName, channelPath } from "../../../lib/chat";
5import { personFrom, principalsFrom } from "../../../lib/chat.server";
6import { chat } from "../../../lib/services.server";
7import { assertSameOrigin, requireUser } from "../../../lib/session.server";
8
9/**
10 * What a chat page asks for as it runs, as JSON, without loading a page:
11 * the sidebar again, a thread's replies, older messages, the channels to
12 * join (`?browse=1`, for the phone app); and everything it
13 * sends: messages, read marks, stars, new channels and direct messages,
14 * and the actions pressed on cards.
15 * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services'
16 * own shape. Guests (people outside the workspace) reach it too: the chat
17 * service answers them with only what they may see and do.
18 */
19export async function loader({ params, context, request }: Route.LoaderArgs) {
20 const viewer = requireUser(context, request);
21 const slug = params.owner.toLowerCase();
22 const url = new URL(request.url);
23 const channel = url.searchParams.get("channel");
24 const answer = await safely<unknown>(() => {
25 // The workspace's own emoji, for the picker and for drawing :name: (components/emoji).
26 if (url.searchParams.get("emoji")) return chat.listEmoji(slug, viewer);
27 // The channels to find and join, as Browse lists them; for the phone app.
28 if (url.searchParams.get("browse")) return chat.browse(slug, viewer, { archived: false });
29 if (!channel) return chat.sidebar(slug, viewer);
30 return chat.messages(slug, channel, viewer, {
31 thread_root: url.searchParams.get("thread"),
32 before: url.searchParams.get("before"),
33 after: url.searchParams.get("after"),
34 limit: Number(url.searchParams.get("limit")) || 60,
35 });
36 });
37 return Response.json(answer, { headers: { "cache-control": "no-store" } });
38}
39
40type Sent = {
41 intent?: string;
42 channel_id?: string;
43 body?: string;
44 thread_root?: string | null;
45 id?: string;
46 starred?: boolean;
47 muted?: boolean;
48 name?: string;
49 topic?: string;
50 private?: boolean;
51 members?: string[];
52 member?: string;
53 message_id?: string;
54 emoji?: string;
55 archived?: boolean;
56 /** A question for @g1t asked from a page (the top bar's Ask): where they were. */
57 page?: unknown;
58 /** `workspace` or `public`: who besides its members may open a channel. */
59 visibility?: string;
60 /** Accepting or declining an invitation; letting someone in on a request. */
61 accept?: boolean;
62 approve?: boolean;
63 /** A channel's name, asking to be let in; and what to tell whoever answers. */
64 note?: string;
65};
66
67export async function action({ params, context, request }: Route.ActionArgs) {
68 assertSameOrigin(request);
69 const viewer = requireUser(context, request);
70 const slug = params.owner.toLowerCase();
71 const sent = (await request.json().catch(() => ({}))) as Sent;
72 const channel = sent.channel_id ?? "";
73 const answer = await safely(async (): Promise<Result<unknown>> => {
74 switch (sent.intent) {
75 case "post": {
76 const body = (sent.body ?? "").trim();
77 if (!body) return { ok: false, error: { code: "invalid", message: "Write something to send." } };
78 // The chat service keeps `page` only on a question in a direct message with @g1t, and only what Ask reads of it.
79 const page = sent.page && typeof sent.page === "object" ? (sent.page as AskPage) : null;
80 return chat.post(slug, channel, viewer, { body, thread_root: sent.thread_root ?? null, page });
81 }
82 // @g1t: the viewer's direct message with g1t, and what Ask's panel and Chat do in it.
83 case "g1t_dm": {
84 const opened = await chat.g1tDm(slug, viewer);
85 return opened.ok ? { ok: true, value: { channel: opened.value, to: channelPath(slug, opened.value) } } : opened;
86 }
87 case "g1t_reset":
88 return chat.g1tReset(slug, channel, viewer);
89 case "g1t_retry":
90 return chat.g1tRetry(slug, channel, viewer);
91 case "g1t_stop":
92 return chat.g1tStop(slug, channel, viewer);
93 // Deletes the viewer's own conversation with @g1t for good; the chat service refuses any other.
94 case "g1t_clear":
95 return chat.g1tClear(slug, channel, viewer);
96 case "edit":
97 return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim());
98 case "remove":
99 return chat.remove(slug, channel, viewer, sent.id ?? "");
100 case "read":
101 return chat.markRead(slug, channel, viewer, sent.id ?? "");
102 case "react":
103 return chat.react(slug, channel, viewer, sent.message_id ?? "", sent.emoji ?? "");
104 case "unreact":
105 return chat.unreact(slug, channel, viewer, sent.message_id ?? "", sent.emoji ?? "");
106 case "preferences":
107 return chat.setPreferences(slug, channel, viewer, { starred: sent.starred, muted: sent.muted });
108 case "join":
109 return chat.join(slug, channel, viewer);
110 case "leave":
111 return chat.leave(slug, channel, viewer);
112 case "invite": {
113 // A person by username (`user:<username>`), in the workspace or not: anyone else is
114 // sent an invitation to join as a guest; or by the account id chat names them with.
115 const parsed = parsePrincipalKey(sent.member ?? "");
116 const principal = (await personFrom(sent.member ?? "")) ?? (parsed?.kind === "user" && parsed.id.startsWith("usr_") ? parsed : null);
117 if (!principal) return { ok: false, error: { code: "not_found", message: "There's no one on g1t by that name." } };
118 return chat.invite(slug, channel, viewer, principal);
119 }
120 // Takes someone out of the channel, or takes back their invitation.
121 case "remove_member": {
122 const parsed = parsePrincipalKey(sent.member ?? "");
123 if (!parsed || parsed.kind !== "user") return { ok: false, error: { code: "invalid", message: "Choose someone to take out." } };
124 return chat.removeMember(slug, channel, viewer, parsed);
125 }
126 case "answer_invitation":
127 return chat.answerChannelInvitation(viewer, sent.id ?? "", sent.accept === true);
128 // Asking to be let into a channel by the name in its link; the answer is the same whatever there is.
129 case "request_access":
130 return chat.requestAccess(slug, sent.name ?? "", viewer, typeof sent.note === "string" ? sent.note : null);
131 case "answer_request":
132 return chat.answerAccessRequest(slug, channel, viewer, sent.id ?? "", sent.approve === true);
133 case "create_channel": {
134 const name = channelName(sent.name ?? "");
135 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
136 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
137 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
138 }
139 const visibility = sent.visibility === "public" || sent.visibility === "workspace" || sent.visibility === "private" ? sent.visibility : undefined;
140 const made = await chat.createChannel(slug, viewer, { name, topic: sent.topic?.trim() || null, private: Boolean(sent.private), visibility });
141 return made.ok ? { ok: true, value: { channel: made.value, to: channelPath(slug, made.value) } } : made;
142 }
143 case "update_channel": {
144 // A rename, a new topic, archiving or unarchiving, or who may open it; the chat service checks who may.
145 const change: { name?: string; topic?: string | null; archived?: boolean; visibility?: "workspace" | "public" } = {};
146 if (typeof sent.name === "string") {
147 const name = channelName(sent.name);
148 if (!name) return { ok: false, error: { code: "invalid", message: "Give the channel a name." } };
149 if ((RESERVED_CHANNEL_NAMES as readonly string[]).includes(name)) {
150 return { ok: false, error: { code: "invalid", message: `#${name} is taken by g1t. Try another name.` } };
151 }
152 change.name = name;
153 }
154 if (typeof sent.topic === "string") change.topic = sent.topic.trim() || null;
155 if (typeof sent.archived === "boolean") change.archived = sent.archived;
156 if (sent.visibility === "workspace" || sent.visibility === "public") change.visibility = sent.visibility;
157 const updated = await chat.updateChannel(slug, channel, viewer, change);
158 return updated.ok ? { ok: true, value: { channel: updated.value, to: channelPath(slug, updated.value) } } : updated;
159 }
160 case "dm": {
161 const members = await principalsFrom(slug, viewer, sent.members ?? []);
162 if (members.length === 0) return { ok: false, error: { code: "invalid", message: "Choose who to message." } };
163 const opened = await chat.openDm(slug, viewer, members);
164 return opened.ok ? { ok: true, value: { channel: opened.value, to: channelPath(slug, opened.value) } } : opened;
165 }
166 default:
167 return { ok: false, error: { code: "invalid", message: "Unknown request." } };
168 }
169 });
170 return Response.json(answer, { headers: { "cache-control": "no-store" } });
171}
172
173/** A service's answer, or a failure that says chat did not answer. */
174async function safely<T>(call: () => Promise<Result<T>>): Promise<Result<T>> {
175 try {
176 return await call();
177 } catch (error) {
178 console.error("chat: the chat service did not answer", error);
179 return { ok: false, error: { code: "conflict", message: "Chat didn't answer. Try again in a moment." } };
180 }
181}