Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Chat and workspace agents: channels, DMs and named agents you talk to | 1 | /** |
| 2 | * The chat service: a workspace's channels, direct messages, threads and | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 3 | * messages. People and agents are members alike (docs.g1t.sh/guides/chat/). |
| Chat and workspace agents: channels, DMs and named agents you talk to | 4 | * |
| 5 | * Reached through service bindings: `POST /rpc/<method>` with snake_case | |
| 6 | * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's | |
| 7 | * socket, which the site forwards after checking the session. Each channel | |
| 8 | * has a room (src/room.ts) that delivers what happens in it live. | |
| 9 | * | |
| 10 | * Workspaces are kept by id, so renaming one changes nothing here; who is | |
| 11 | * in a workspace comes from the viewer's memberships, as in every service. | |
| 12 | */ | |
| 13 | ||
| 14 | import { | |
| 15 | CHAT_MAX_HOPS, | |
| 16 | askerAccess, | |
| 17 | CHAT_VIEWER_HEADER, | |
| 18 | fail, | |
| 19 | identityClient, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 20 | idFloor, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 21 | newId, |
| 22 | ok, | |
| 23 | openD1, | |
| 24 | parsePrincipalKey, | |
| 25 | principalKey, | |
| 26 | workspaceAgentsClient, | |
| 27 | type AgentDelivery, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 28 | type AgentFoundMessage, |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 29 | type AgentHandOff, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 30 | type AgentPostMessage, |
| 31 | type AskerAccess, | |
| 32 | type Channel, | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 33 | type ChannelChange, |
| 34 | type ChannelDetail, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 35 | type ChannelMember, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 36 | type ChatAudience, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 37 | type ChatLiveEvent, |
| 38 | type ChatMessage, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 39 | type ChatReaction, |
| 40 | type CustomEmoji, | |
| 41 | type EmojiFile, | |
| 42 | type EmojiList, | |
| 43 | type EmojiUpload, | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 44 | type ChatSettings, |
| 45 | type ChatSettingsView, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 46 | type ChatActivity, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 47 | type ChatSidebar, |
| 48 | type ChatSidebarEntry, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 49 | CONVERSATION_PEOPLE_SHOWN, |
| 50 | type ConversationForAgent, | |
| 51 | type HandOffResult, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 52 | type Member, |
| 53 | type MemberProfile, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 54 | type CardActionResult, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 55 | type MessageCard, |
| 56 | type MessagePage, | |
| 57 | type NewChannel, | |
| 58 | type PostMessage, | |
| 59 | type Principal, | |
| 60 | type Result, | |
| 61 | type ServiceBinding, | |
| 62 | type User, | |
| 63 | type Viewer, | |
| 64 | type Workspace, | |
| 65 | type WorkspaceAgent, | |
| 66 | } from "@g1t/contracts"; | |
| 67 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 68 | import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 69 | import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts"; |
| Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how. | 70 | import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 71 | import { |
| 72 | MAX_REACTIONS_PER_MESSAGE, | |
| 73 | emojiImage, | |
| 74 | emojiName, | |
| 75 | fromBase64, | |
| 76 | mayRemove, | |
| 77 | mayUpload, | |
| 78 | reactionEmoji, | |
| 79 | roomForReaction, | |
| 80 | tallyReactions, | |
| 81 | type ReactionRow, | |
| 82 | type ReactionTally, | |
| 83 | } from "./emoji.ts"; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 84 | import { cleanCard } from "./cards.ts"; |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 85 | import { mentionedHandles, mentionsColumn, plainOutside } from "./mentions.ts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 86 | import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts"; |
| 87 | import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts"; | |
| 88 | import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts"; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 89 | import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 90 | import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 91 | // Live notifications and counts (services/notify). |
| 92 | import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts"; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 93 | |
| 94 | export { ChannelRoom } from "./room.ts"; | |
| 95 | ||
| 96 | // The hop limit here is the one in the contract. | |
| 97 | const SAME_HOP_LIMIT: typeof CHAT_MAX_HOPS = MAX_HOPS; | |
| 98 | void SAME_HOP_LIMIT; | |
| 99 | ||
| 100 | type Env = { | |
| 101 | DB: D1Database; | |
| 102 | IDENTITY: ServiceBinding; | |
| 103 | AGENTS: ServiceBinding; | |
| 104 | ROOMS: DurableObjectNamespace<ChannelRoom>; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 105 | /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */ |
| 106 | AVATARS: KVNamespace; | |
| 107 | /** Live notifications and unread counts (services/notify); absent, nobody is told. */ | |
| 108 | NOTIFY?: ServiceBinding; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 109 | }; |
| 110 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 111 | type EmojiRow = { |
| 112 | workspace_id: string; | |
| 113 | name: string; | |
| 114 | alias_of: string | null; | |
| 115 | file: string; | |
| 116 | content_type: CustomEmoji["content_type"]; | |
| 117 | bytes: number; | |
| 118 | created_by: string; | |
| 119 | created_at: string; | |
| 120 | deleted_at: string | null; | |
| 121 | }; | |
| 122 | ||
| 123 | /** The viewer's role in a workspace, or null when they are not in it. */ | |
| 124 | function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null { | |
| 125 | return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null; | |
| 126 | } | |
| 127 | ||
| 128 | async function sha256(bytes: Uint8Array): Promise<string> { | |
| 129 | const digest = await crypto.subtle.digest("SHA-256", bytes); | |
| 130 | return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 131 | } | |
| 132 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 133 | type ChannelRow = { |
| 134 | id: string; | |
| 135 | workspace_id: string; | |
| 136 | kind: "channel" | "dm"; | |
| 137 | name: string | null; | |
| 138 | topic: string | null; | |
| 139 | private: number; | |
| 140 | dm_key: string | null; | |
| 141 | created_by: string; | |
| 142 | created_at: string; | |
| 143 | archived_at: string | null; | |
| 144 | last_message_at: string | null; | |
| 145 | }; | |
| 146 | ||
| 147 | type MemberRow = { | |
| 148 | channel_id: string; | |
| 149 | principal: string; | |
| 150 | role: "owner" | "member"; | |
| 151 | starred: number; | |
| 152 | muted: number; | |
| 153 | last_read_id: string | null; | |
| 154 | joined_at: string; | |
| 155 | }; | |
| 156 | ||
| 157 | type MessageRow = { | |
| 158 | id: string; | |
| 159 | channel_id: string; | |
| 160 | author: string; | |
| 161 | kind: "text" | "card"; | |
| 162 | body: string; | |
| 163 | card: string | null; | |
| 164 | mentions: string; | |
| 165 | thread_root: string | null; | |
| 166 | reply_count: number; | |
| 167 | last_reply_at: string | null; | |
| 168 | created_at: string; | |
| 169 | edited_at: string | null; | |
| 170 | deleted_at: string | null; | |
| 171 | }; | |
| 172 | ||
| 173 | /** What a method found out about the channel it was asked about. */ | |
| 174 | type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null }; | |
| 175 | ||
| 176 | /** The most unread messages one sidebar reads to count; past it, counts are "at least". */ | |
| 177 | const MAX_UNREAD_ROWS = 5_000; | |
| 178 | /** The longest channel topic. */ | |
| 179 | const MAX_TOPIC = 250; | |
| 180 | /** How many others a direct message's sidebar entry shows. */ | |
| 181 | const DM_FACES = 4; | |
| 182 | ||
| 183 | const now = () => new Date().toISOString(); | |
| 184 | ||
| 185 | function isMember(viewer: Viewer, workspace: string): boolean { | |
| 186 | return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase()); | |
| 187 | } | |
| 188 | ||
| 189 | function isOwner(viewer: Viewer, workspace: string): boolean { | |
| 190 | return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner"); | |
| 191 | } | |
| 192 | ||
| 193 | function userKey(viewer: User): string { | |
| 194 | return principalKey({ kind: "user", id: viewer.id }); | |
| 195 | } | |
| 196 | ||
| 197 | function toChannel(row: ChannelRow): Channel { | |
| 198 | return { | |
| 199 | id: row.id, | |
| 200 | workspace_id: row.workspace_id, | |
| 201 | kind: row.kind, | |
| 202 | name: row.kind === "dm" ? null : row.name, | |
| 203 | topic: row.topic, | |
| 204 | private: row.kind === "dm" || !!row.private, | |
| 205 | created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by }, | |
| 206 | created_at: row.created_at, | |
| 207 | archived_at: row.archived_at, | |
| 208 | last_message_at: row.last_message_at, | |
| 209 | }; | |
| 210 | } | |
| 211 | ||
| 212 | /** An asker handed back by the agents service, or null when what was sent is not one. */ | |
| 213 | function cleanAsker(asker: unknown): AskerAccess | null { | |
| 214 | if (!asker || typeof asker !== "object") return null; | |
| 215 | const a = asker as Record<string, unknown>; | |
| 216 | if (typeof a.username !== "string" || !["owner", "member", "outside"].includes(String(a.role))) return null; | |
| 217 | return { username: a.username, role: a.role as AskerAccess["role"], can_write: a.can_write === true }; | |
| 218 | } | |
| 219 | ||
| 220 | function bytesOf(text: string): number { | |
| 221 | return new TextEncoder().encode(text).length; | |
| 222 | } | |
| 223 | ||
| 224 | class Chat { | |
| 225 | private readonly workspaces = new Map<string, Promise<Workspace | null>>(); | |
| 226 | private readonly people = new Map<string, Promise<Map<string, Member>>>(); | |
| 227 | private readonly usernames = new Map<string, string>(); | |
| 228 | private readonly agents = new Map<string, WorkspaceAgent | null>(); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 229 | private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>(); |
| Chat and workspace agents: channels, DMs and named agents you talk to | 230 | |
| 231 | /** `defer` runs work after the answer is sent: the request's waitUntil. */ | |
| 232 | constructor( | |
| 233 | private readonly env: Env, | |
| 234 | private readonly defer: (work: Promise<unknown>) => void = () => {}, | |
| 235 | ) {} | |
| 236 | ||
| 237 | private get db() { | |
| 238 | return this.env.DB; | |
| 239 | } | |
| 240 | ||
| 241 | // ── Who and where ─────────────────────────────────────────────────────── | |
| 242 | ||
| 243 | private workspace(slug: string): Promise<Workspace | null> { | |
| 244 | const key = slug.toLowerCase(); | |
| 245 | let found = this.workspaces.get(key); | |
| 246 | if (!found) { | |
| 247 | found = identityClient(this.env.IDENTITY).getWorkspace(key); | |
| 248 | this.workspaces.set(key, found); | |
| 249 | } | |
| 250 | return found; | |
| 251 | } | |
| 252 | ||
| 253 | /** The workspace's people by username, with their names and avatars; asked once per request. */ | |
| 254 | private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> { | |
| 255 | let found = this.people.get(workspace.id); | |
| 256 | if (!found) { | |
| 257 | // Asked as the workspace itself, so it works for agents' calls too. | |
| 258 | const actor: User = { | |
| 259 | id: workspace.id, | |
| 260 | username: workspace.slug, | |
| 261 | kind: "workspace", | |
| 262 | verified: true, | |
| 263 | workspaces: [{ slug: workspace.slug, role: "member" }], | |
| 264 | }; | |
| 265 | found = identityClient(this.env.IDENTITY) | |
| 266 | .listMembers(slug, actor) | |
| 267 | .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : [])) | |
| 268 | .catch((error) => { | |
| 269 | console.error("chat could not list members of", slug, error); | |
| 270 | return new Map<string, Member>(); | |
| 271 | }); | |
| 272 | this.people.set(workspace.id, found); | |
| 273 | } | |
| 274 | return found; | |
| 275 | } | |
| 276 | ||
| 277 | /** Agents by id; ones the agents service does not know are null. Asked once per request. */ | |
| 278 | private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> { | |
| 279 | const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id)); | |
| 280 | if (wanted.length) { | |
| 281 | let found: WorkspaceAgent[] = []; | |
| 282 | try { | |
| 283 | found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted); | |
| 284 | } catch (error) { | |
| 285 | console.error("chat could not resolve agents", error); | |
| 286 | } | |
| 287 | for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null); | |
| 288 | } | |
| 289 | return new Map(ids.map((id) => [id, this.agents.get(id) ?? null])); | |
| 290 | } | |
| 291 | ||
| 292 | /** An agent of this workspace that is not archived, or null. */ | |
| 293 | private async liveAgent(workspace: Workspace, id: string): Promise<WorkspaceAgent | null> { | |
| 294 | const agent = (await this.agentsById([id])).get(id) ?? null; | |
| 295 | return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null; | |
| 296 | } | |
| 297 | ||
| 298 | /** How each member key shows, for one workspace. */ | |
| 299 | private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> { | |
| 300 | const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p); | |
| 301 | const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id); | |
| 302 | const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id); | |
| 303 | const unnamed = userIds.filter((id) => !this.usernames.has(id)); | |
| 304 | const [named, people, agents] = await Promise.all([ | |
| 305 | unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>), | |
| 306 | userIds.length ? this.members(slug, workspace) : new Map<string, Member>(), | |
| 307 | this.agentsById(agentIds), | |
| 308 | ]); | |
| 309 | for (const [id, username] of Object.entries(named)) this.usernames.set(id, username); | |
| 310 | const out = new Map<string, MemberProfile>(); | |
| 311 | for (const p of principals) { | |
| 312 | if (p.kind === "user") { | |
| 313 | const username = this.usernames.get(p.id) ?? null; | |
| 314 | const person = username ? people.get(username) : undefined; | |
| 315 | out.set(principalKey(p), { | |
| 316 | ...p, | |
| 317 | name: username ?? "ghost", | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 318 | display_username: person?.display_username ?? null, |
| 319 | // Their display name, else their username as they wrote it | |
| 320 | // (`memberName` reads this the same way). | |
| 321 | display_name: person?.name?.trim() || person?.display_username || username || "Former member", | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 322 | avatar: person?.avatar ?? null, |
| 323 | role: null, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 324 | title: null, |
| 325 | avatar_seed: null, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 326 | }); |
| 327 | } else { | |
| 328 | const agent = agents.get(p.id) ?? null; | |
| 329 | out.set(principalKey(p), { | |
| 330 | ...p, | |
| 331 | name: agent?.handle ?? p.id, | |
| 332 | display_name: agent?.display_name ?? "Former agent", | |
| 333 | avatar: agent?.avatar ?? null, | |
| 334 | role: agent?.role ?? null, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 335 | title: agent?.title || null, |
| 336 | avatar_seed: agent?.avatar_seed ?? null, | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 337 | look: agent?.look ?? null, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 338 | }); |
| 339 | } | |
| 340 | } | |
| 341 | return out; | |
| 342 | } | |
| 343 | ||
| 344 | private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> { | |
| 345 | return (await this.profiles(slug, workspace, [key])).get(key)!; | |
| 346 | } | |
| 347 | ||
| 348 | /** Whether `principal` may be added to a conversation in this workspace. */ | |
| 349 | private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> { | |
| 350 | if (principal.kind === "agent") return !!(await this.liveAgent(workspace, principal.id)); | |
| 351 | if (!this.usernames.has(principal.id)) { | |
| 352 | const named = await identityClient(this.env.IDENTITY).usernames([principal.id]); | |
| 353 | for (const [id, username] of Object.entries(named)) this.usernames.set(id, username); | |
| 354 | } | |
| 355 | const username = this.usernames.get(principal.id); | |
| 356 | return !!username && (await this.members(slug, workspace)).has(username); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * The viewer's workspace, checked: they must belong to it, as in every | |
| 361 | * other service. | |
| 362 | */ | |
| 363 | private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> { | |
| 364 | if (!viewer) return fail("unauthenticated", "Sign in to use chat."); | |
| 365 | if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat."); | |
| 366 | const workspace = await this.workspace(slug); | |
| 367 | return workspace ? ok(workspace) : fail("not_found", "No such workspace."); | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * A channel the viewer may read (`read`: any public one in their | |
| 372 | * workspace, or one they are in) or write in (`member`: one they are in). | |
| 373 | * A private channel or direct message they are not in is not found, so | |
| 374 | * its existence does not leak. | |
| 375 | */ | |
| 376 | private async place( | |
| 377 | slug: string, | |
| 378 | channelId: string, | |
| 379 | viewer: Viewer, | |
| 380 | need: "read" | "member", | |
| 381 | ): Promise<Result<Place>> { | |
| 382 | const found = await this.viewerWorkspace(slug, viewer); | |
| 383 | if (!found.ok) return found; | |
| 384 | const workspace = found.value; | |
| 385 | const [channel, member] = await Promise.all([ | |
| 386 | this.db | |
| 387 | .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?") | |
| 388 | .bind(String(channelId ?? ""), workspace.id) | |
| 389 | .first<ChannelRow>(), | |
| 390 | this.db | |
| 391 | .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?") | |
| 392 | .bind(String(channelId ?? ""), userKey(viewer!)) | |
| 393 | .first<MemberRow>(), | |
| 394 | ]); | |
| 395 | if (!channel) return fail("not_found", "No such channel."); | |
| 396 | const open = channel.kind === "channel" && !channel.private; | |
| 397 | if (!member && !open) return fail("not_found", "No such channel."); | |
| 398 | if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`); | |
| 399 | return ok({ slug: slug.toLowerCase(), workspace, channel, member }); | |
| 400 | } | |
| 401 | ||
| 402 | private room(channelId: string) { | |
| 403 | return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId)); | |
| 404 | } | |
| 405 | ||
| 406 | /** Tells everyone looking at a channel, after the answer is sent. */ | |
| 407 | private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void { | |
| 408 | this.defer( | |
| 409 | this.room(channelId) | |
| 410 | .broadcast(event, except) | |
| 411 | .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)), | |
| 412 | ); | |
| 413 | } | |
| 414 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 415 | /** |
| 416 | * Messages as they go out, with their reactions. `me` (a member key) is | |
| 417 | * the viewer an answer is for; null for what everyone in a room gets, | |
| 418 | * where no reaction is anyone's own. | |
| 419 | */ | |
| 420 | private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> { | |
| 421 | const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me); | |
| 422 | const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by)); | |
| 423 | const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 424 | return rows.map((row) => { |
| 425 | const gone = !!row.deleted_at; | |
| 426 | return { | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 427 | reactions: gone |
| 428 | ? [] | |
| 429 | : (reactions.get(row.id) ?? []).map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })), | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 430 | id: row.id, |
| 431 | channel_id: row.channel_id, | |
| 432 | author: profiles.get(row.author)!, | |
| 433 | kind: row.kind, | |
| 434 | body: gone ? "" : row.body, | |
| 435 | card: gone || !row.card ? null : (JSON.parse(row.card) as MessageCard), | |
| 436 | thread_root: row.thread_root, | |
| 437 | reply_count: row.reply_count, | |
| 438 | last_reply_at: row.last_reply_at, | |
| 439 | created_at: row.created_at, | |
| 440 | edited_at: row.edited_at, | |
| 441 | deleted_at: row.deleted_at, | |
| 442 | }; | |
| 443 | }); | |
| 444 | } | |
| 445 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 446 | /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */ |
| 447 | private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> { | |
| 448 | if (!ids.length) return new Map(); | |
| 449 | const rows = await this.db | |
| 450 | .prepare( | |
| 451 | "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))", | |
| 452 | ) | |
| 453 | .bind(JSON.stringify(ids)) | |
| 454 | .all<ReactionRow>(); | |
| 455 | return tallyReactions(rows.results, me); | |
| 456 | } | |
| 457 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 458 | private async messageRow(channelId: string, id: string): Promise<MessageRow | null> { |
| 459 | return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>(); | |
| 460 | } | |
| 461 | ||
| 462 | /** Sends a message as it now is to everyone looking at its channel. */ | |
| 463 | private rebroadcast(place: Place, id: string): void { | |
| 464 | this.defer( | |
| 465 | (async () => { | |
| 466 | const row = await this.messageRow(place.channel.id, id); | |
| 467 | if (!row) return; | |
| 468 | const [message] = await this.toMessages(place.slug, place.workspace, [row]); | |
| 469 | await this.room(place.channel.id).broadcast({ type: "message.updated", message }); | |
| 470 | })().catch((error) => console.error("chat could not rebroadcast", id, error)), | |
| 471 | ); | |
| 472 | } | |
| 473 | ||
| 474 | // ── The sidebar ───────────────────────────────────────────────────────── | |
| 475 | ||
| 476 | /** | |
| 477 | * Puts a person in the workspace's #general, once. A new workspace has | |
| 478 | * no channels, so the first sidebar anyone in it asks for creates | |
| 479 | * #general; and everyone who asks for the sidebar is put in it the first | |
| 480 | * time, so a new workspace has somewhere to talk and a new member lands | |
| 481 | * where everyone is. Someone who leaves it is not put back | |
| 482 | * (`general_joined`). A private channel someone named `general` is never | |
| 483 | * joined this way. | |
| 484 | */ | |
| 485 | private async ensureGeneral(workspace: Workspace, me: string): Promise<void> { | |
| 486 | const seen = await this.db | |
| 487 | .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?") | |
| 488 | .bind(workspace.id, me) | |
| 489 | .first(); | |
| 490 | if (seen) return; | |
| 491 | const at = now(); | |
| 492 | await this.db | |
| 493 | .prepare( | |
| 494 | "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)", | |
| 495 | ) | |
| 496 | .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at) | |
| 497 | .run(); | |
| 498 | const general = await this.db | |
| 499 | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?") | |
| 500 | .bind(workspace.id, GENERAL) | |
| 501 | .first<ChannelRow>(); | |
| 502 | const statements = [ | |
| 503 | this.db | |
| 504 | .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)") | |
| 505 | .bind(workspace.id, me, at), | |
| 506 | ]; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 507 | // The workspace's default channels (#general unless its owners chose |
| 508 | // others): public and not archived only, whatever was kept. | |
| 509 | const settings = await this.settings(workspace, general?.id ?? null); | |
| 510 | const defaults = settings.default_channels.length | |
| 511 | ? await this.db | |
| 512 | .prepare( | |
| 513 | "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))", | |
| 514 | ) | |
| 515 | .bind(workspace.id, JSON.stringify(settings.default_channels)) | |
| 516 | .all<ChannelRow>() | |
| 517 | : { results: [] as ChannelRow[] }; | |
| 518 | for (const channel of defaults.results) { | |
| 519 | statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at)); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 520 | } |
| 521 | await this.db.batch(statements); | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * Adds a member. Someone joining starts with everything already said | |
| 526 | * read, so a long channel does not greet them with its whole history as | |
| 527 | * unread. | |
| 528 | */ | |
| 529 | private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement { | |
| 530 | return this.db | |
| 531 | .prepare( | |
| 532 | "INSERT OR IGNORE INTO channel_members (channel_id, principal, role, last_read_id, joined_at) VALUES (?1, ?2, ?3, (SELECT MAX(id) FROM messages WHERE channel_id = ?1), ?4)", | |
| 533 | ) | |
| 534 | .bind(channelId, principal, role, at); | |
| 535 | } | |
| 536 | ||
| 537 | async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> { | |
| 538 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 539 | if (!found.ok) return found; | |
| 540 | const workspace = found.value; | |
| 541 | const slug = a.workspace.toLowerCase(); | |
| 542 | const me = userKey(a.viewer!); | |
| 543 | await this.ensureGeneral(workspace, me); | |
| 544 | ||
| 545 | const [joined, unread, dmOthers, browsable] = await Promise.all([ | |
| 546 | this.db | |
| 547 | .prepare( | |
| 548 | `SELECT c.*, m.starred, m.muted, m.last_read_id | |
| 549 | FROM channel_members m JOIN channels c ON c.id = m.channel_id | |
| 550 | WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`, | |
| 551 | ) | |
| 552 | .bind(me, workspace.id) | |
| 553 | .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(), | |
| 554 | this.db | |
| 555 | .prepare( | |
| 556 | `SELECT msg.channel_id, msg.id, msg.author, msg.mentions | |
| 557 | FROM channel_members m | |
| 558 | JOIN channels c ON c.id = m.channel_id | |
| 559 | JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '') | |
| 560 | WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL | |
| 561 | AND msg.deleted_at IS NULL AND msg.author != ?1 | |
| 562 | LIMIT ${MAX_UNREAD_ROWS}`, | |
| 563 | ) | |
| 564 | .bind(me, workspace.id) | |
| 565 | .all<UnreadRow>(), | |
| 566 | this.db | |
| 567 | .prepare( | |
| 568 | `SELECT o.channel_id, o.principal | |
| 569 | FROM channel_members m | |
| 570 | JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm' | |
| 571 | JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal | |
| 572 | WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL | |
| 573 | ORDER BY o.joined_at, o.principal`, | |
| 574 | ) | |
| 575 | .bind(me, workspace.id) | |
| 576 | .all<{ channel_id: string; principal: string }>(), | |
| 577 | this.db | |
| 578 | .prepare( | |
| 579 | `SELECT COUNT(*) AS n FROM channels c | |
| 580 | WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL | |
| 581 | AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`, | |
| 582 | ) | |
| 583 | .bind(workspace.id, me) | |
| 584 | .first<{ n: number }>(), | |
| 585 | ]); | |
| 586 | ||
| 587 | const others = new Map<string, string[]>(); | |
| 588 | for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]); | |
| 589 | const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]); | |
| 590 | const self = profiles.get(me) ?? null; | |
| 591 | const counts = tally( | |
| 592 | unread.results, | |
| 593 | me, | |
| 594 | self?.name ?? a.viewer!.username, | |
| 595 | new Map(joined.results.map((row) => [row.id, row.last_read_id])), | |
| 596 | ); | |
| 597 | ||
| 598 | const entries: ChatSidebarEntry[] = joined.results.map((row) => { | |
| 599 | const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean); | |
| 600 | const count = counts.get(row.id) ?? { unread: 0, mentions: 0 }; | |
| 601 | return { | |
| 602 | channel: toChannel(row), | |
| 603 | title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""), | |
| 604 | others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [], | |
| 605 | starred: !!row.starred, | |
| 606 | muted: !!row.muted, | |
| 607 | unread: count.unread, | |
| 608 | mentions: count.mentions, | |
| 609 | }; | |
| 610 | }); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 611 | const settings = await this.settings(workspace); |
| 612 | return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) }); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 613 | } |
| 614 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 615 | /** |
| 616 | * What was said in `[from, until)` where the viewer can read: every | |
| 617 | * public channel of the workspace, and the private channels and direct | |
| 618 | * messages they are in (as `place` reads). Text messages and thread | |
| 619 | * replies not deleted; never a card. Message ids sort by time, so each | |
| 620 | * conversation's span is one range of `messages_by_channel`, read in one | |
| 621 | * round trip for them all. Counts only, nothing of what was said. | |
| 622 | */ | |
| 623 | async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> { | |
| 624 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 625 | if (!found.ok) return found; | |
| 626 | const workspace = found.value; | |
| 627 | const span = activitySpan(a.from, a.until); | |
| 628 | if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until."); | |
| 629 | const me = userKey(a.viewer!); | |
| 630 | const rows = await this.db | |
| 631 | .prepare( | |
| 632 | `SELECT msg.channel_id, msg.author, COUNT(*) AS n | |
| 633 | FROM channels c | |
| 634 | JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4 | |
| 635 | WHERE c.workspace_id = ?1 AND c.archived_at IS NULL | |
| 636 | AND ( | |
| 637 | (c.kind = 'channel' AND c.private = 0) | |
| 638 | OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2) | |
| 639 | ) | |
| 640 | AND msg.deleted_at IS NULL AND msg.kind = 'text' | |
| 641 | GROUP BY msg.channel_id, msg.author`, | |
| 642 | ) | |
| 643 | .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs)) | |
| 644 | .all<ActivityRow>(); | |
| 645 | return ok(chatActivity(rows.results, { from: span.from, until: span.until })); | |
| 646 | } | |
| 647 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 648 | // ── Channels ──────────────────────────────────────────────────────────── |
| 649 | ||
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 650 | async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> { |
| Chat and workspace agents: channels, DMs and named agents you talk to | 651 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); |
| 652 | if (!found.ok) return found; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 653 | const { slug, workspace, channel, member } = found.value; |
| 654 | const [rows, settings] = await Promise.all([ | |
| 655 | this.db | |
| 656 | .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal") | |
| 657 | .bind(channel.id) | |
| 658 | .all<MemberRow>(), | |
| 659 | this.settings(workspace), | |
| 660 | ]); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 661 | const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal)); |
| 662 | return ok({ | |
| 663 | channel: toChannel(channel), | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 664 | can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null), |
| Chat and workspace agents: channels, DMs and named agents you talk to | 665 | members: rows.results.map((row) => ({ |
| 666 | channel_id: row.channel_id, | |
| 667 | member: profiles.get(row.principal)!, | |
| 668 | role: row.role, | |
| 669 | starred: !!row.starred, | |
| 670 | muted: !!row.muted, | |
| 671 | last_read_id: row.last_read_id, | |
| 672 | joined_at: row.joined_at, | |
| 673 | })), | |
| 674 | }); | |
| 675 | } | |
| 676 | ||
| 677 | /** A channel by its name, as the site's URLs name them; read like `channel`. */ | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 678 | async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> { |
| Chat and workspace agents: channels, DMs and named agents you talk to | 679 | const found = await this.viewerWorkspace(a.workspace, a.viewer); |
| 680 | if (!found.ok) return found; | |
| 681 | const named = channelName(a.name ?? ""); | |
| 682 | if (!named.ok) return fail("not_found", "No such channel."); | |
| 683 | const row = await this.db | |
| 684 | .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?") | |
| 685 | .bind(found.value.id, named.name) | |
| 686 | .first<{ id: string }>(); | |
| 687 | if (!row) return fail("not_found", "No such channel."); | |
| 688 | // A private channel the viewer is not in stays not found there. | |
| 689 | return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer }); | |
| 690 | } | |
| 691 | ||
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 692 | /** |
| 693 | * Every public channel, and the private ones the viewer is in; a private | |
| 694 | * one they are not in stays unseen. With `archived`, the archived ones. | |
| 695 | */ | |
| 696 | async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 697 | const found = await this.viewerWorkspace(a.workspace, a.viewer); |
| 698 | if (!found.ok) return found; | |
| 699 | const rows = await this.db | |
| 700 | .prepare( | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 701 | `SELECT * FROM channels c |
| 702 | WHERE c.workspace_id = ?1 AND c.kind = 'channel' | |
| 703 | AND (c.archived_at IS NULL) = (?3 = 0) | |
| 704 | AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)) | |
| 705 | ORDER BY c.name`, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 706 | ) |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 707 | .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0) |
| Chat and workspace agents: channels, DMs and named agents you talk to | 708 | .all<ChannelRow>(); |
| 709 | return ok(rows.results.map(toChannel)); | |
| 710 | } | |
| 711 | ||
| 712 | async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> { | |
| 713 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 714 | if (!found.ok) return found; | |
| 715 | const workspace = found.value; | |
| 716 | const named = channelName(a.input?.name ?? ""); | |
| 717 | if (!named.ok) return fail("invalid", named.message); | |
| 718 | const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : ""; | |
| 719 | if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 720 | const isPrivate = !!a.input?.private; |
| 721 | if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) { | |
| 722 | return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`); | |
| 723 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 724 | const taken = await this.db |
| 725 | .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?") | |
| 726 | .bind(workspace.id, named.name) | |
| 727 | .first(); | |
| 728 | if (taken) return fail("conflict", `#${named.name} already exists.`); | |
| 729 | const me = userKey(a.viewer!); | |
| 730 | const row: ChannelRow = { | |
| 731 | id: newId("chn"), | |
| 732 | workspace_id: workspace.id, | |
| 733 | kind: "channel", | |
| 734 | name: named.name, | |
| 735 | topic: topic || null, | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 736 | private: isPrivate ? 1 : 0, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 737 | dm_key: null, |
| 738 | created_by: me, | |
| 739 | created_at: now(), | |
| 740 | archived_at: null, | |
| 741 | last_message_at: null, | |
| 742 | }; | |
| 743 | try { | |
| 744 | await this.db.batch([ | |
| 745 | this.db | |
| 746 | .prepare( | |
| 747 | "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)", | |
| 748 | ) | |
| 749 | .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at), | |
| 750 | this.joinStatement(row.id, me, "owner", row.created_at), | |
| 751 | ]); | |
| 752 | } catch (error) { | |
| 753 | if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`); | |
| 754 | throw error; | |
| 755 | } | |
| 756 | return ok(toChannel(row)); | |
| 757 | } | |
| 758 | ||
| 759 | async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> { | |
| 760 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 761 | if (!found.ok) return found; | |
| 762 | const workspace = found.value; | |
| 763 | const slug = a.workspace.toLowerCase(); | |
| 764 | const me = userKey(a.viewer!); | |
| 765 | const asked = Array.isArray(a.members) ? a.members : []; | |
| 766 | const principals: Principal[] = []; | |
| 767 | for (const member of asked) { | |
| 768 | const p = member && parsePrincipalKey(`${member.kind}:${member.id}`); | |
| 769 | if (!p) return fail("invalid", "Each member is a person or an agent, by id."); | |
| 770 | principals.push(p); | |
| 771 | } | |
| 772 | const members = dmMembers(me, principals.map(principalKey)); | |
| 773 | if (members.length > MAX_DM_MEMBERS) { | |
| 774 | return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people and agents. Make a private channel instead.`); | |
| 775 | } | |
| 776 | const key = dmKey(members); | |
| 777 | const existing = await this.db | |
| 778 | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?") | |
| 779 | .bind(workspace.id, key) | |
| 780 | .first<ChannelRow>(); | |
| 781 | if (existing) return ok(toChannel(existing)); | |
| 782 | ||
| 783 | for (const member of members) { | |
| 784 | if (member === me) continue; | |
| 785 | const p = parsePrincipalKey(member)!; | |
| 786 | if (!(await this.belongs(slug, workspace, p))) { | |
| 787 | return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace."); | |
| 788 | } | |
| Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how. | 789 | // A member's personal agent: only in the DM of the two of them. |
| 790 | if (p.kind === "agent") { | |
| 791 | const agent = await this.liveAgent(workspace, p.id); | |
| 792 | const refused = agent ? personalAgentRefusal(agent, { kind: "dm", members }) : null; | |
| 793 | if (refused) return fail("forbidden", refused); | |
| 794 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 795 | } |
| 796 | const at = now(); | |
| 797 | await this.db | |
| 798 | .prepare( | |
| 799 | "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)", | |
| 800 | ) | |
| 801 | .bind(newId("chn"), workspace.id, key, me, at) | |
| 802 | .run(); | |
| 803 | // Read back by key: if two people opened it at once, both get the one that won. | |
| 804 | const channel = await this.db | |
| 805 | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?") | |
| 806 | .bind(workspace.id, key) | |
| 807 | .first<ChannelRow>(); | |
| 808 | if (!channel) return fail("conflict", "The direct message could not be opened. Try again."); | |
| 809 | await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at))); | |
| 810 | return ok(toChannel(channel)); | |
| 811 | } | |
| 812 | ||
| 813 | async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> { | |
| 814 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 815 | if (!found.ok) return found; | |
| 816 | const { channel, member } = found.value; | |
| 817 | if (member) return ok(null); | |
| 818 | if (channel.archived_at) return fail("invalid", "This channel is archived."); | |
| 819 | await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run(); | |
| 820 | return ok(null); | |
| 821 | } | |
| 822 | ||
| 823 | async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> { | |
| 824 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 825 | if (!found.ok) return found; | |
| 826 | const { channel, member } = found.value; | |
| 827 | if (!member) return ok(null); | |
| 828 | if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead."); | |
| 829 | const me = userKey(a.viewer!); | |
| 830 | await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run(); | |
| 831 | // Out of a private channel, they may no longer read it, live either. | |
| 832 | if (channel.private) { | |
| 833 | this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error))); | |
| 834 | } | |
| 835 | return ok(null); | |
| 836 | } | |
| 837 | ||
| 838 | async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> { | |
| 839 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "member"); | |
| 840 | if (!found.ok) return found; | |
| 841 | const { slug, workspace, channel } = found.value; | |
| 842 | if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it."); | |
| 843 | if (channel.archived_at) return fail("invalid", "This channel is archived."); | |
| 844 | const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`); | |
| 845 | if (!p) return fail("invalid", "Invite a person or an agent, by id."); | |
| 846 | if (!(await this.belongs(slug, workspace, p))) { | |
| 847 | return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace."); | |
| 848 | } | |
| Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how. | 849 | if (p.kind === "agent") { |
| 850 | const agent = await this.liveAgent(workspace, p.id); | |
| 851 | const refused = agent ? personalAgentRefusal(agent, { kind: "channel", members: null }) : null; | |
| 852 | if (refused) return fail("forbidden", refused); | |
| 853 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 854 | await this.joinStatement(channel.id, principalKey(p), "member", now()).run(); |
| 855 | return ok(null); | |
| 856 | } | |
| 857 | ||
| 858 | async setPreferences(a: { | |
| 859 | workspace: string; | |
| 860 | channel_id: string; | |
| 861 | viewer: Viewer; | |
| 862 | prefs: { starred?: boolean; muted?: boolean }; | |
| 863 | }): Promise<Result<null>> { | |
| 864 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "member"); | |
| 865 | if (!found.ok) return found; | |
| 866 | const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null; | |
| 867 | const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null; | |
| 868 | await this.db | |
| 869 | .prepare( | |
| 870 | "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?", | |
| 871 | ) | |
| 872 | .bind(starred, muted, found.value.channel.id, userKey(a.viewer!)) | |
| 873 | .run(); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 874 | // Notify: the badge counts the conversation again, or leaves it out, in every tab. |
| 875 | if (muted !== null) { | |
| 876 | this.defer( | |
| 877 | notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) => | |
| 878 | console.error("chat could not notify a mute", error), | |
| 879 | ), | |
| 880 | ); | |
| 881 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 882 | return ok(null); |
| 883 | } | |
| 884 | ||
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 885 | // ── What owners decide (src/settings.ts) ──────────────────────────────── |
| 886 | ||
| 887 | /** The row of a workspace's chat settings, read once per request. */ | |
| 888 | private settingsRow(workspace: Workspace): Promise<SettingsRow | null> { | |
| 889 | let found = this.settingsRows.get(workspace.id); | |
| 890 | if (!found) { | |
| 891 | found = this.db | |
| 892 | .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?") | |
| 893 | .bind(workspace.id) | |
| 894 | .first<SettingsRow>(); | |
| 895 | this.settingsRows.set(workspace.id, found); | |
| 896 | } | |
| 897 | return found; | |
| 898 | } | |
| 899 | ||
| 900 | /** | |
| 901 | * A workspace's chat settings. Default channels never chosen are | |
| 902 | * #general, looked up unless `generalId` is given. | |
| 903 | */ | |
| 904 | private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> { | |
| 905 | const row = await this.settingsRow(workspace); | |
| 906 | if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null); | |
| 907 | const general = await this.db | |
| 908 | .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'") | |
| 909 | .bind(workspace.id, GENERAL) | |
| 910 | .first<{ id: string }>(); | |
| 911 | return settingsOf(row, general?.id ?? null); | |
| 912 | } | |
| 913 | ||
| 914 | async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> { | |
| 915 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 916 | if (!found.ok) return found; | |
| 917 | const workspace = found.value; | |
| 918 | const [settings, channels] = await Promise.all([ | |
| 919 | this.settings(workspace), | |
| 920 | this.db | |
| 921 | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name") | |
| 922 | .bind(workspace.id) | |
| 923 | .all<ChannelRow>(), | |
| 924 | ]); | |
| 925 | return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) }); | |
| 926 | } | |
| 927 | ||
| 928 | async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> { | |
| 929 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 930 | if (!found.ok) return found; | |
| 931 | const workspace = found.value; | |
| 932 | if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings."); | |
| 933 | const checked = settingsChange(a.change); | |
| 934 | if (!checked.ok) return fail("invalid", checked.message); | |
| 935 | const change = checked.change; | |
| 936 | if (change.default_channels) { | |
| 937 | // Only the workspace's public channels that are not archived: a private | |
| 938 | // one would put people somewhere they were never invited. | |
| 939 | const rows = await this.db | |
| 940 | .prepare( | |
| 941 | "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))", | |
| 942 | ) | |
| 943 | .bind(workspace.id, JSON.stringify(change.default_channels)) | |
| 944 | .all<{ id: string }>(); | |
| 945 | const open = new Set(rows.results.map((r) => r.id)); | |
| 946 | if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived."); | |
| 947 | } | |
| 948 | const next: ChatSettings = { ...(await this.settings(workspace)), ...change }; | |
| 949 | const row = rowFor(next); | |
| 950 | await this.db | |
| 951 | .prepare( | |
| 952 | `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels) | |
| 953 | VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 954 | ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`, | |
| 955 | ) | |
| 956 | .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels) | |
| 957 | .run(); | |
| 958 | this.settingsRows.delete(workspace.id); | |
| 959 | return ok(next); | |
| 960 | } | |
| 961 | ||
| 962 | /** | |
| 963 | * Renames, archives or unarchives a channel, or changes its topic. The | |
| 964 | * workspace's `manage_channels` setting says who may do the first three; | |
| 965 | * any member may change the topic. #general stays #general, and stays. | |
| 966 | */ | |
| 967 | async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> { | |
| 968 | // Archived or not, a channel is found the same way; a private one only by its members. | |
| 969 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 970 | if (!found.ok) return found; | |
| 971 | const { slug, workspace, channel, member } = found.value; | |
| 972 | if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change."); | |
| 973 | const change = a.change ?? {}; | |
| 974 | const next: ChannelRow = { ...channel }; | |
| 975 | if (change.name !== undefined || change.archived !== undefined) { | |
| 976 | const settings = await this.settings(workspace); | |
| 977 | if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) { | |
| 978 | return fail( | |
| 979 | "forbidden", | |
| 980 | settings.manage_channels === "owners" | |
| 981 | ? "Only workspace owners can rename or archive channels here." | |
| 982 | : "Only this channel's owners and workspace owners can rename or archive it.", | |
| 983 | ); | |
| 984 | } | |
| 985 | if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived."); | |
| 986 | } | |
| 987 | if (change.name !== undefined) { | |
| 988 | const named = channelName(String(change.name ?? "")); | |
| 989 | if (!named.ok) return fail("invalid", named.message); | |
| 990 | if (named.name !== channel.name) { | |
| 991 | const taken = await this.db | |
| 992 | .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?") | |
| 993 | .bind(workspace.id, named.name, channel.id) | |
| 994 | .first(); | |
| 995 | if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`); | |
| 996 | } | |
| 997 | next.name = named.name; | |
| 998 | } | |
| 999 | if (change.topic !== undefined) { | |
| 1000 | if (!member) return fail("forbidden", `Join #${channel.name} first.`); | |
| 1001 | const topic = typeof change.topic === "string" ? change.topic.trim() : ""; | |
| 1002 | if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`); | |
| 1003 | next.topic = topic || null; | |
| 1004 | } | |
| 1005 | if (change.archived !== undefined) { | |
| 1006 | next.archived_at = change.archived ? (channel.archived_at ?? now()) : null; | |
| 1007 | } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) { | |
| 1008 | return fail("invalid", "This channel is archived. Unarchive it first."); | |
| 1009 | } | |
| 1010 | try { | |
| 1011 | await this.db | |
| 1012 | .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?") | |
| 1013 | .bind(next.name, next.topic, next.archived_at, channel.id) | |
| 1014 | .run(); | |
| 1015 | } catch (error) { | |
| 1016 | if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`); | |
| 1017 | throw error; | |
| 1018 | } | |
| 1019 | const updated = toChannel(next); | |
| 1020 | this.broadcast(channel.id, { type: "channel.updated", channel: updated }); | |
| 1021 | return ok(updated); | |
| 1022 | } | |
| 1023 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 1024 | // ── Messages ──────────────────────────────────────────────────────────── |
| 1025 | ||
| 1026 | /** | |
| 1027 | * Newest first, a page at a time. With `thread_root`, that thread's | |
| 1028 | * replies, and the message they reply to as the oldest once the page | |
| 1029 | * reaches the start of the thread; without, the channel's top-level | |
| 1030 | * messages. A deleted message stays only while replies hang off it. | |
| 1031 | */ | |
| 1032 | async messages(a: { | |
| 1033 | workspace: string; | |
| 1034 | channel_id: string; | |
| 1035 | viewer: Viewer; | |
| 1036 | before?: string | null; | |
| 1037 | after?: string | null; | |
| 1038 | limit?: number | null; | |
| 1039 | thread_root?: string | null; | |
| 1040 | }): Promise<Result<MessagePage>> { | |
| 1041 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1042 | if (!found.ok) return found; | |
| 1043 | const { slug, workspace, channel } = found.value; | |
| 1044 | const size = pageSize(a.limit); | |
| 1045 | // Ids are lowercase letters, digits and `_`, so `~` sorts after every | |
| 1046 | // one: the first page reads from the newest as a range of the index. | |
| 1047 | const before = typeof a.before === "string" && a.before ? a.before : "~"; | |
| 1048 | const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null; | |
| 1049 | if (typeof a.after === "string" && a.after) { | |
| 1050 | // Catching up after a reconnect: what came after, oldest first. Deleted | |
| 1051 | // ones too, so the client drops them; read one past the page to know | |
| 1052 | // whether there is more. | |
| 1053 | const newer = root | |
| 1054 | ? await this.db | |
| 1055 | .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?") | |
| 1056 | .bind(root, channel.id, a.after, size + 1) | |
| 1057 | .all<MessageRow>() | |
| 1058 | : await this.db | |
| 1059 | .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?") | |
| 1060 | .bind(channel.id, a.after, size + 1) | |
| 1061 | .all<MessageRow>(); | |
| 1062 | const page = pageOf(newer.results, size); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1063 | return ok({ messages: await this.toMessages(slug, workspace, page.rows, userKey(a.viewer!)), older: null, newer: page.older }); |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1064 | } |
| 1065 | const rows = root | |
| 1066 | ? await this.db | |
| 1067 | .prepare( | |
| 1068 | `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3 | |
| 1069 | ORDER BY id DESC LIMIT ?4`, | |
| 1070 | ) | |
| 1071 | .bind(root, channel.id, before, size + 1) | |
| 1072 | .all<MessageRow>() | |
| 1073 | : await this.db | |
| 1074 | .prepare( | |
| 1075 | `SELECT * FROM messages | |
| 1076 | WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2 | |
| 1077 | AND (deleted_at IS NULL OR reply_count > 0) | |
| 1078 | ORDER BY id DESC LIMIT ?3`, | |
| 1079 | ) | |
| 1080 | .bind(channel.id, before, size + 1) | |
| 1081 | .all<MessageRow>(); | |
| 1082 | const page = pageOf(rows.results, size); | |
| 1083 | let list = page.rows; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 1084 | // The message a thread is under: the oldest once the page reaches the |
| 1085 | // start, and, on the first page, as `root` however long the thread is, | |
| 1086 | // so a session's card stays at the top of its thread. | |
| 1087 | const firstPage = before === "~"; | |
| 1088 | const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null; | |
| 1089 | if (rootRow && page.older === null) list = [...list, rootRow]; | |
| 1090 | const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!)); | |
| 1091 | if (!rootRow || !firstPage) return ok({ messages, older: page.older }); | |
| 1092 | const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null; | |
| 1093 | return ok({ messages, older: page.older, root: shown }); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1094 | } |
| 1095 | ||
| 1096 | /** | |
| 1097 | * Writes a message and everything that follows from it: the thread's | |
| 1098 | * reply count, the channel's last activity, the author's own read mark, | |
| 1099 | * the meter; then, after answering, tells the room and wakes the agents | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1100 | * it is for: those a person's message is for (src/delivery.ts), or, for |
| 1101 | * an agent's, only `wake` (a hand-off's colleague), never by mention. | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1102 | */ |
| 1103 | private async write( | |
| 1104 | place: Place, | |
| 1105 | author: string, | |
| 1106 | input: { body: string; card: MessageCard | null; thread_root: string | null }, | |
| 1107 | chain: Chain<AskerAccess>, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1108 | wake: Wake[] = [], |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1109 | ): Promise<Result<ChatMessage>> { |
| 1110 | const { channel, workspace } = place; | |
| 1111 | if (channel.archived_at) return fail("invalid", "This channel is archived."); | |
| 1112 | let threadRoot: string | null = null; | |
| 1113 | if (input.thread_root) { | |
| 1114 | const root = await this.messageRow(channel.id, input.thread_root); | |
| 1115 | if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to."); | |
| 1116 | // A reply to a reply goes in the same thread. | |
| 1117 | threadRoot = root.thread_root ?? root.id; | |
| 1118 | } | |
| 1119 | const at = now(); | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1120 | // An agent's mention of someone who isn't here reaches nobody, so it reads as a plain name. |
| 1121 | const body = author.startsWith("agent:") ? await this.agentText(place, input.body) : input.body; | |
| 1122 | const handles = mentionedHandles(body); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1123 | const row: MessageRow = { |
| 1124 | id: newId("msg"), | |
| 1125 | channel_id: channel.id, | |
| 1126 | author, | |
| 1127 | kind: input.card ? "card" : "text", | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1128 | body, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1129 | card: input.card ? JSON.stringify(input.card) : null, |
| 1130 | mentions: mentionsColumn(handles), | |
| 1131 | thread_root: threadRoot, | |
| 1132 | reply_count: 0, | |
| 1133 | last_reply_at: null, | |
| 1134 | created_at: at, | |
| 1135 | edited_at: null, | |
| 1136 | deleted_at: null, | |
| 1137 | }; | |
| 1138 | const statements = [ | |
| 1139 | this.db | |
| 1140 | .prepare( | |
| 1141 | "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1142 | ) | |
| 1143 | .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at), | |
| 1144 | this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id), | |
| 1145 | // What you wrote, you have read. | |
| 1146 | this.db | |
| 1147 | .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?") | |
| 1148 | .bind(row.id, channel.id, author), | |
| 1149 | this.db | |
| 1150 | .prepare( | |
| 1151 | `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?) | |
| 1152 | ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`, | |
| 1153 | ) | |
| 1154 | .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")), | |
| 1155 | ]; | |
| 1156 | if (threadRoot) { | |
| 1157 | statements.push( | |
| 1158 | this.db | |
| 1159 | .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?") | |
| 1160 | .bind(at, threadRoot), | |
| 1161 | ); | |
| 1162 | } | |
| 1163 | await this.db.batch(statements); | |
| 1164 | ||
| 1165 | const [message] = await this.toMessages(place.slug, workspace, [row]); | |
| 1166 | this.broadcast(channel.id, { type: "message.created", message }); | |
| 1167 | if (threadRoot) this.rebroadcast(place, threadRoot); | |
| 1168 | this.defer( | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1169 | (wake.length ? this.deliverTo(place, row, chain, wake) : this.wake(place, row, handles, chain)).catch((error) => |
| 1170 | console.error("chat could not hand", row.id, "to agents", error), | |
| 1171 | ), | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1172 | ); |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1173 | // Notify: counts for everyone in the conversation, a notification for those it is for. |
| 1174 | this.defer( | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 1175 | notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles, asked_by: chain.asked_by }).catch( |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1176 | (error) => console.error("chat could not notify about", row.id, error), |
| 1177 | ), | |
| 1178 | ); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1179 | return ok(message); |
| 1180 | } | |
| 1181 | ||
| 1182 | /** Hands a new message to the agents it is for (src/delivery.ts). */ | |
| 1183 | private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> { | |
| 1184 | const { channel, workspace } = place; | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1185 | // Only a person's message wakes anyone, and in a channel only by mention. |
| 1186 | if (!row.author.startsWith("user:") || chain.quiet) return; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1187 | if (channel.kind === "channel" && !handles.length) return; |
| 1188 | const members = await this.db | |
| 1189 | .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'") | |
| 1190 | .bind(channel.id) | |
| 1191 | .all<{ principal: string }>(); | |
| 1192 | const ids = members.results.map((m) => m.principal.slice("agent:".length)); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1193 | let found = await this.agentsById(ids); |
| 1194 | const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at); | |
| 1195 | if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) { | |
| 1196 | // Mentioning @g1t brings it in: every workspace has it, nobody invites it. | |
| 1197 | const builtin = await workspaceAgentsClient(this.env.AGENTS) | |
| 1198 | .builtin(place.slug, workspace.id) | |
| 1199 | .catch((error: unknown) => { | |
| 1200 | console.error("chat could not find @g1t for", place.slug, error); | |
| 1201 | return null; | |
| 1202 | }); | |
| 1203 | if (builtin?.ok) { | |
| 1204 | await this.joinStatement(channel.id, `agent:${builtin.value.id}`, "member", now()).run(); | |
| 1205 | this.agents.set(builtin.value.id, builtin.value); | |
| 1206 | ids.push(builtin.value.id); | |
| 1207 | found = await this.agentsById(ids); | |
| 1208 | } | |
| 1209 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1210 | if (!ids.length) return; |
| 1211 | const agents = [...found.values()].filter( | |
| 1212 | (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at, | |
| 1213 | ); | |
| 1214 | const wakes = deliveries({ | |
| 1215 | author: row.author, | |
| 1216 | hops: chain.hops, | |
| 1217 | channelKind: channel.kind, | |
| 1218 | agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })), | |
| 1219 | mentioned: handles, | |
| 1220 | }); | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1221 | await this.deliverTo(place, row, chain, wakes); |
| 1222 | } | |
| 1223 | ||
| 1224 | /** Hands a message to these agents (src/delivery.ts `delivery`). */ | |
| 1225 | private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> { | |
| 1226 | const { channel, workspace } = place; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1227 | const client = workspaceAgentsClient(this.env.AGENTS); |
| 1228 | await Promise.all( | |
| 1229 | wakes.map((wake) => | |
| 1230 | client | |
| 1231 | .deliver( | |
| 1232 | delivery( | |
| 1233 | { | |
| 1234 | workspace: place.slug, | |
| 1235 | workspace_id: workspace.id, | |
| 1236 | channel_id: channel.id, | |
| 1237 | channel_kind: channel.kind, | |
| 1238 | channel_name: channel.kind === "dm" ? null : channel.name, | |
| 1239 | }, | |
| 1240 | wake, | |
| 1241 | row, | |
| 1242 | chain, | |
| 1243 | ) satisfies AgentDelivery, | |
| 1244 | ) | |
| 1245 | .then((result) => { | |
| 1246 | if (!result.ok) console.error("agents refused delivery to", wake.agent_id, result.error.message); | |
| 1247 | }) | |
| 1248 | .catch((error) => console.error("chat could not deliver to", wake.agent_id, error)), | |
| 1249 | ), | |
| 1250 | ); | |
| 1251 | } | |
| 1252 | ||
| 1253 | async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> { | |
| 1254 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1255 | if (!found.ok) return found; | |
| 1256 | const body = messageBody(a.message?.body); | |
| 1257 | if (!body.ok) return fail("invalid", body.message); | |
| 1258 | const me = userKey(a.viewer!); | |
| 1259 | let place = found.value; | |
| 1260 | if (!place.member) { | |
| 1261 | // Saying something in a public channel joins it, as reading does not. | |
| 1262 | if (place.channel.archived_at) return fail("invalid", "This channel is archived."); | |
| 1263 | await this.joinStatement(place.channel.id, me, "member", now()).run(); | |
| 1264 | place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow }; | |
| 1265 | } | |
| 1266 | return this.write( | |
| 1267 | place, | |
| 1268 | me, | |
| 1269 | { body: body.body, card: null, thread_root: a.message?.thread_root ?? null }, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1270 | // A person's message starts a chain; a workflow job's token starts none. |
| 1271 | { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [], quiet: !!a.viewer!.token?.job }, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1272 | ); |
| 1273 | } | |
| 1274 | ||
| 1275 | async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> { | |
| 1276 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1277 | if (!found.ok) return found; | |
| 1278 | const place = found.value; | |
| 1279 | const row = await this.messageRow(place.channel.id, a.id); | |
| 1280 | if (!row || row.deleted_at) return fail("not_found", "No such message."); | |
| 1281 | if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message."); | |
| 1282 | const body = messageBody(a.body, !!row.card); | |
| 1283 | if (!body.ok) return fail("invalid", body.message); | |
| 1284 | const at = now(); | |
| 1285 | const mentions = mentionsColumn(mentionedHandles(body.body)); | |
| 1286 | await this.db | |
| 1287 | .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?") | |
| 1288 | .bind(body.body, mentions, at, row.id) | |
| 1289 | .run(); | |
| 1290 | const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]); | |
| 1291 | this.broadcast(place.channel.id, { type: "message.updated", message }); | |
| 1292 | return ok(message); | |
| 1293 | } | |
| 1294 | ||
| 1295 | /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */ | |
| 1296 | async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> { | |
| 1297 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1298 | if (!found.ok) return found; | |
| 1299 | const place = found.value; | |
| 1300 | const row = await this.messageRow(place.channel.id, a.id); | |
| 1301 | if (!row || row.deleted_at) return fail("not_found", "No such message."); | |
| 1302 | if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) { | |
| 1303 | return fail("forbidden", "Only its author or a workspace owner can delete a message."); | |
| 1304 | } | |
| 1305 | const statements = [ | |
| 1306 | this.db | |
| 1307 | .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?") | |
| 1308 | .bind(now(), row.id), | |
| 1309 | ]; | |
| 1310 | if (row.thread_root) { | |
| 1311 | statements.push( | |
| 1312 | this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root), | |
| 1313 | ); | |
| 1314 | } | |
| 1315 | await this.db.batch(statements); | |
| 1316 | this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id }); | |
| 1317 | if (row.thread_root) this.rebroadcast(place, row.thread_root); | |
| 1318 | return ok(null); | |
| 1319 | } | |
| 1320 | ||
| 1321 | async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> { | |
| 1322 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1323 | if (!found.ok) return found; | |
| 1324 | const { channel, member } = found.value; | |
| 1325 | // Someone reading a public channel they have not joined keeps no read state. | |
| 1326 | if (!member) return ok(null); | |
| 1327 | const id = typeof a.id === "string" ? a.id : ""; | |
| 1328 | if (!id) return fail("invalid", "Say which message was read."); | |
| 1329 | // Only forward: reading an old thread does not mark newer messages unread. | |
| 1330 | const changed = await this.db | |
| 1331 | .prepare( | |
| 1332 | "UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)", | |
| 1333 | ) | |
| 1334 | .bind(id, channel.id, member.principal) | |
| 1335 | .run(); | |
| 1336 | if (changed.meta.changes) { | |
| 1337 | this.broadcast(channel.id, { | |
| 1338 | type: "read", | |
| 1339 | channel_id: channel.id, | |
| 1340 | principal: { kind: "user", id: a.viewer!.id }, | |
| 1341 | last_read_id: id, | |
| 1342 | }); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1343 | // Notify: the read drops the counts in every tab of theirs. |
| 1344 | this.defer( | |
| 1345 | notifyRead(this.db, this.env.NOTIFY, { slug: found.value.slug, channel_id: channel.id, user_id: a.viewer!.id, username: a.viewer!.username, last_read_id: id }).catch( | |
| 1346 | (error) => console.error("chat could not notify a read", error), | |
| 1347 | ), | |
| 1348 | ); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1349 | } |
| 1350 | return ok(null); | |
| 1351 | } | |
| 1352 | ||
| 1353 | // ── Agents ────────────────────────────────────────────────────────────── | |
| 1354 | ||
| 1355 | /** The channel and agent for an agent's call: the agent must be of the workspace and in the channel. */ | |
| 1356 | private async agentPlace(slug: string, channelId: string, agentId: string): Promise<Result<{ place: Place; agent: WorkspaceAgent }>> { | |
| 1357 | const workspace = await this.workspace(String(slug ?? "")); | |
| 1358 | if (!workspace) return fail("not_found", "No such workspace."); | |
| 1359 | const agent = await this.liveAgent(workspace, String(agentId ?? "")); | |
| 1360 | if (!agent) return fail("not_found", "No such agent in this workspace."); | |
| 1361 | const key = principalKey({ kind: "agent", id: agent.id }); | |
| 1362 | const [channel, member] = await Promise.all([ | |
| 1363 | this.db | |
| 1364 | .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?") | |
| 1365 | .bind(String(channelId ?? ""), workspace.id) | |
| 1366 | .first<ChannelRow>(), | |
| 1367 | this.db.prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?").bind(String(channelId ?? ""), key).first<MemberRow>(), | |
| 1368 | ]); | |
| 1369 | if (!channel) return fail("not_found", "No such channel."); | |
| 1370 | if (!member) return fail("forbidden", "The agent is not a member of this channel."); | |
| 1371 | return ok({ place: { slug: slug.toLowerCase(), workspace, channel, member }, agent }); | |
| 1372 | } | |
| 1373 | ||
| 1374 | async postAsAgent(a: { workspace: string; channel_id: string; agent_id: string; message: AgentPostMessage }): Promise<Result<ChatMessage>> { | |
| 1375 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1376 | if (!found.ok) return found; | |
| 1377 | const { place, agent } = found.value; | |
| 1378 | const card = a.message?.card == null ? null : cleanCard(a.message.card); | |
| 1379 | if (a.message?.card != null && !card) return fail("invalid", "A card needs a kind and a title."); | |
| 1380 | const body = messageBody(a.message?.body ?? "", !!card); | |
| 1381 | if (!body.ok) return fail("invalid", body.message); | |
| 1382 | const hops = typeof a.message?.hops === "number" && a.message.hops >= 0 ? Math.floor(a.message.hops) : 0; | |
| 1383 | const askedBy = typeof a.message?.asked_by === "string" && a.message.asked_by ? a.message.asked_by : agent.created_by; | |
| 1384 | return this.write( | |
| 1385 | place, | |
| 1386 | principalKey({ kind: "agent", id: agent.id }), | |
| 1387 | { body: body.body, card, thread_root: a.message?.thread_root ?? null }, | |
| 1388 | // The asker carries on from the delivery the agent is answering; | |
| 1389 | // without one, agents it wakes treat the asker as unable to change code. | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1390 | { hops, asked_by: askedBy, asker: cleanAsker(a.message?.asker), chain: chainFor(a.message?.chain, agent.id) }, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1391 | ); |
| 1392 | } | |
| 1393 | ||
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1394 | /** Everyone in a conversation, as member keys. */ |
| 1395 | private async memberKeys(channelId: string): Promise<string[]> { | |
| 1396 | const rows = await this.db.prepare("SELECT principal FROM channel_members WHERE channel_id = ?").bind(channelId).all<{ principal: string }>(); | |
| 1397 | return rows.results.map((row) => row.principal); | |
| 1398 | } | |
| 1399 | ||
| 1400 | /** An agent's text as it is kept: mentions of anyone not in the conversation lose their `@` (src/mentions.ts). */ | |
| 1401 | private async agentText(place: Place, body: string): Promise<string> { | |
| 1402 | if (!body.includes("@")) return body; | |
| 1403 | const profiles = await this.profiles(place.slug, place.workspace, await this.memberKeys(place.channel.id)); | |
| 1404 | return plainOutside(body, new Set([...profiles.values()].map((p) => p.name.toLowerCase()))); | |
| 1405 | } | |
| 1406 | ||
| 1407 | /** What an agent is told about where it is answering, every turn. */ | |
| 1408 | async conversationForAgent(a: { workspace: string; channel_id: string; agent_id: string; asked_by?: string | null }): Promise<Result<ConversationForAgent>> { | |
| 1409 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1410 | if (!found.ok) return found; | |
| 1411 | const { place } = found.value; | |
| 1412 | const rows = await this.db | |
| 1413 | .prepare("SELECT principal FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal") | |
| 1414 | .bind(place.channel.id) | |
| 1415 | .all<{ principal: string }>(); | |
| 1416 | const keys = rows.results.map((row) => row.principal); | |
| 1417 | const agents = keys.filter((key) => key.startsWith("agent:")); | |
| 1418 | const people = keys.filter((key) => key.startsWith("user:")); | |
| 1419 | // The person who asked first, then the earliest to join, up to the cap. | |
| 1420 | const asker = typeof a.asked_by === "string" && a.asked_by ? `user:${a.asked_by}` : null; | |
| 1421 | const shown = [...(asker && people.includes(asker) ? [asker] : []), ...people.filter((key) => key !== asker)].slice(0, CONVERSATION_PEOPLE_SHOWN); | |
| 1422 | const profiles = await this.profiles(place.slug, place.workspace, [...agents, ...shown]); | |
| 1423 | return ok({ | |
| 1424 | channel: toChannel(place.channel), | |
| 1425 | members: [...agents, ...shown].map((key) => profiles.get(key)!).filter(Boolean), | |
| 1426 | people: people.length, | |
| 1427 | agents: agents.length, | |
| 1428 | }); | |
| 1429 | } | |
| 1430 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 1431 | /** |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1432 | * An agent hands work to a colleague agent for the person who asked |
| 1433 | * (`handOffAsAgent` in @g1t/contracts). The brief is the agent's message, | |
| 1434 | * so everyone where it lands sees the work move; it wakes the colleague | |
| 1435 | * and nobody else. | |
| 1436 | */ | |
| 1437 | async handOffAsAgent(a: { workspace: string; channel_id: string; agent_id: string; hand_off: AgentHandOff }): Promise<Result<HandOffResult>> { | |
| 1438 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1439 | if (!found.ok) return found; | |
| 1440 | const { place, agent } = found.value; | |
| 1441 | const input = a.hand_off ?? ({} as AgentHandOff); | |
| 1442 | const brief = messageBody(input.brief); | |
| 1443 | if (!brief.ok) return fail("invalid", brief.message); | |
| 1444 | const colleague = await this.liveAgent(place.workspace, String(input.colleague_id ?? "")); | |
| 1445 | if (!colleague) return fail("not_found", "No such agent in this workspace."); | |
| Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how. | 1446 | const personal = personalAgentRefusal(colleague, { kind: "hand_off", members: null }); |
| 1447 | if (personal) return fail("invalid", personal); | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1448 | const given = typeof input.hops === "number" && input.hops >= 0 ? Math.floor(input.hops) : 0; |
| 1449 | const before = chainFor(input.chain, agent.id).slice(0, -1); | |
| 1450 | const refused = handOffRefusal({ agent: agent.id, colleague, chain: before, hops: given }); | |
| 1451 | if (refused) return fail("invalid", refused); | |
| 1452 | // Work is handed on for someone in this conversation, never for a stranger to it. | |
| 1453 | const askedBy = typeof input.asked_by === "string" ? input.asked_by : ""; | |
| 1454 | const keys = await this.memberKeys(place.channel.id); | |
| 1455 | const askerKey = principalKey({ kind: "user", id: askedBy }); | |
| 1456 | if (!askedBy || !keys.includes(askerKey)) return fail("forbidden", "Only the person who asked, in this conversation, can have work handed on."); | |
| 1457 | const me = principalKey({ kind: "agent", id: agent.id }); | |
| 1458 | const them = principalKey({ kind: "agent", id: colleague.id }); | |
| 1459 | const chain: Chain<AskerAccess> = { hops: given + 1, asked_by: askedBy, asker: cleanAsker(input.asker), chain: [...before, agent.id] }; | |
| 1460 | const wake: Wake[] = [{ agent_id: colleague.id, hops: given + 1 }]; | |
| 1461 | const threadRoot = typeof input.thread_root === "string" && input.thread_root ? input.thread_root : null; | |
| 1462 | ||
| 1463 | if (handOffPlace({ channelKind: place.channel.kind, members: keys.length, colleagueHere: keys.includes(them) }) === "here") { | |
| 1464 | const posted = await this.write(place, me, { body: brief.body, card: null, thread_root: threadRoot }, chain, wake); | |
| 1465 | return posted.ok ? ok({ where: "here", channel_id: place.channel.id, message_id: posted.value.id, opened: false }) : posted; | |
| 1466 | } | |
| 1467 | ||
| 1468 | // The group DM of the person, the agent and the colleague: the same three always get the same one. | |
| 1469 | if (!(await this.belongs(place.slug, place.workspace, { kind: "user", id: askedBy }))) { | |
| 1470 | return fail("forbidden", "Only members of the workspace can have work handed on to its agents."); | |
| 1471 | } | |
| 1472 | const members = dmMembers(askerKey, [me, them]); | |
| 1473 | const key = dmKey(members); | |
| 1474 | let dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>(); | |
| 1475 | const opened = !dm; | |
| 1476 | if (!dm) { | |
| 1477 | const at = now(); | |
| 1478 | await this.db | |
| 1479 | .prepare("INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)") | |
| 1480 | .bind(newId("chn"), place.workspace.id, key, askerKey, at) | |
| 1481 | .run(); | |
| 1482 | dm = await this.db.prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?").bind(place.workspace.id, key).first<ChannelRow>(); | |
| 1483 | if (!dm) return fail("conflict", "The group message could not be opened. Try again."); | |
| 1484 | await this.db.batch(members.map((member) => this.joinStatement(dm!.id, member, "member", at))); | |
| 1485 | } | |
| 1486 | const there: Place = { slug: place.slug, workspace: place.workspace, channel: dm, member: { channel_id: dm.id, principal: me } as MemberRow }; | |
| 1487 | const posted = await this.write(there, me, { body: brief.body, card: null, thread_root: null }, chain, wake); | |
| 1488 | if (!posted.ok) return posted; | |
| 1489 | // Where the work went, here, where it was asked for. It wakes nobody. | |
| 1490 | const named = await this.profiles(place.slug, place.workspace, [askerKey]); | |
| 1491 | const person = named.get(askerKey); | |
| 1492 | await this.write( | |
| 1493 | place, | |
| 1494 | me, | |
| 1495 | { | |
| 1496 | body: "", | |
| 1497 | card: { | |
| 1498 | kind: "handoff", | |
| 1499 | title: `${agent.display_name} handed this to ${colleague.display_name}`, | |
| 1500 | detail: `${colleague.display_name} works on it in a group message with ${person ? person.display_name : "the person who asked"} and ${agent.display_name}.`, | |
| 1501 | state: null, | |
| 1502 | href: `/${place.slug}/-/chat/dm/${dm.id}`, | |
| 1503 | }, | |
| 1504 | thread_root: threadRoot, | |
| 1505 | }, | |
| 1506 | { ...chain, hops: given }, | |
| 1507 | ).catch((error: unknown) => console.error("chat could not post where a hand-off went", error)); | |
| 1508 | return ok({ where: "group_dm", channel_id: dm.id, message_id: posted.value.id, opened }); | |
| 1509 | } | |
| 1510 | ||
| 1511 | /** | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 1512 | * A person presses an action on a card. They must be able to read the |
| 1513 | * conversation; the card must offer the action; its owner (agents) | |
| 1514 | * decides whether this person may, does it, and updates the card. | |
| 1515 | */ | |
| 1516 | async cardAction(a: { | |
| 1517 | workspace: string; | |
| 1518 | channel_id: string; | |
| 1519 | viewer: Viewer; | |
| 1520 | message_id: string; | |
| 1521 | action_id: string; | |
| 1522 | input?: string | null; | |
| 1523 | }): Promise<Result<CardActionResult>> { | |
| 1524 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1525 | if (!found.ok) return found; | |
| 1526 | const place = found.value; | |
| 1527 | const row = await this.messageRow(place.channel.id, String(a.message_id ?? "")); | |
| 1528 | if (!row || row.deleted_at || !row.card) return fail("not_found", "No such card."); | |
| 1529 | const card = JSON.parse(row.card) as MessageCard; | |
| 1530 | const action = card.actions?.find((x) => x.id === a.action_id); | |
| 1531 | if (!action || action.href || card.owner !== "agents") return fail("invalid", "That card has no such action."); | |
| 1532 | const input = typeof a.input === "string" ? a.input.slice(0, 4000) : null; | |
| 1533 | if (action.input && !input?.trim()) return fail("invalid", `${action.input.label || "A value"} is needed.`); | |
| 1534 | return workspaceAgentsClient(this.env.AGENTS).cardAction({ | |
| 1535 | workspace: place.slug, | |
| 1536 | channel_id: place.channel.id, | |
| 1537 | message_id: row.id, | |
| 1538 | viewer: a.viewer!, | |
| 1539 | card: { kind: card.kind, ref: card.ref ?? null }, | |
| 1540 | action_id: action.id, | |
| 1541 | input, | |
| 1542 | }); | |
| 1543 | } | |
| 1544 | ||
| 1545 | /** | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 1546 | * Changes a message the agent posted (a session's live card, say): its |
| 1547 | * body, its card, or both. Only the agent's own messages; it wakes | |
| 1548 | * nobody, and everyone in the conversation sees it change. | |
| 1549 | */ | |
| 1550 | async updateAsAgent(a: { | |
| 1551 | workspace: string; | |
| 1552 | channel_id: string; | |
| 1553 | agent_id: string; | |
| 1554 | id: string; | |
| 1555 | change: { body?: string; card?: MessageCard | null }; | |
| 1556 | }): Promise<Result<ChatMessage>> { | |
| 1557 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1558 | if (!found.ok) return found; | |
| 1559 | const { place, agent } = found.value; | |
| 1560 | const row = await this.messageRow(place.channel.id, String(a.id ?? "")); | |
| 1561 | if (!row || row.deleted_at) return fail("not_found", "No such message."); | |
| 1562 | if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages."); | |
| 1563 | const change = a.change ?? {}; | |
| 1564 | const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card); | |
| 1565 | if (change.card && !card) return fail("invalid", "A card needs a kind and a title."); | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 1566 | const body = messageBody(change.body === undefined ? row.body : await this.agentText(place, String(change.body ?? "")), !!card); |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 1567 | if (!body.ok) return fail("invalid", body.message); |
| 1568 | const cardJson = card ? JSON.stringify(card) : null; | |
| 1569 | const at = now(); | |
| 1570 | // A card's live state is not an edit a person made: no "edited" mark for it. | |
| 1571 | const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at; | |
| 1572 | await this.db | |
| 1573 | .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?") | |
| 1574 | .bind(body.body, cardJson, card ? "card" : "text", edited, row.id) | |
| 1575 | .run(); | |
| 1576 | const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]); | |
| 1577 | this.broadcast(place.channel.id, { type: "message.updated", message }); | |
| 1578 | return ok(message); | |
| 1579 | } | |
| 1580 | ||
| 1581 | /** | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1582 | * What an agent reads before replying, oldest first: a thread (its root, |
| 1583 | * then its latest replies), or the channel's latest top-level messages. | |
| 1584 | * Only where the agent is a member, so it reads only what was said where | |
| 1585 | * it was invited. Deleted messages are left out, save a thread's root. | |
| 1586 | */ | |
| 1587 | async historyForAgent(a: { | |
| 1588 | workspace: string; | |
| 1589 | channel_id: string; | |
| 1590 | agent_id: string; | |
| 1591 | thread_root?: string | null; | |
| 1592 | limit?: number | null; | |
| 1593 | }): Promise<Result<ChatMessage[]>> { | |
| 1594 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1595 | if (!found.ok) return found; | |
| 1596 | const { place } = found.value; | |
| 1597 | const size = historySize(a.limit); | |
| 1598 | if (typeof a.thread_root === "string" && a.thread_root) { | |
| 1599 | const asked = await this.messageRow(place.channel.id, a.thread_root); | |
| 1600 | if (!asked) return fail("not_found", "No such thread."); | |
| 1601 | // Asked from a reply: its whole thread. | |
| 1602 | const root = asked.thread_root ? await this.messageRow(place.channel.id, asked.thread_root) : asked; | |
| 1603 | if (!root) return fail("not_found", "No such thread."); | |
| 1604 | const replies = await this.db | |
| 1605 | .prepare( | |
| 1606 | "SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?", | |
| 1607 | ) | |
| 1608 | .bind(root.id, place.channel.id, Math.max(0, size - 1)) | |
| 1609 | .all<MessageRow>(); | |
| 1610 | return ok(await this.toMessages(place.slug, place.workspace, historyOf(replies.results, root))); | |
| 1611 | } | |
| 1612 | const rows = await this.db | |
| 1613 | .prepare( | |
| 1614 | "SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id < '~' AND deleted_at IS NULL ORDER BY id DESC LIMIT ?", | |
| 1615 | ) | |
| 1616 | .bind(place.channel.id, size) | |
| 1617 | .all<MessageRow>(); | |
| 1618 | return ok(await this.toMessages(place.slug, place.workspace, historyOf(rows.results))); | |
| 1619 | } | |
| 1620 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1621 | // ── What an agent may read (src/audience.ts) ─────────────────────────── |
| 1622 | ||
| 1623 | /** The people in a conversation, by user id. */ | |
| 1624 | private async peopleIn(channelId: string): Promise<string[]> { | |
| 1625 | const rows = await this.db | |
| 1626 | .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'") | |
| 1627 | .bind(channelId) | |
| 1628 | .all<{ principal: string }>(); | |
| 1629 | return rows.results.map((row) => row.principal.slice("user:".length)); | |
| 1630 | } | |
| 1631 | ||
| 1632 | /** A conversation of this workspace and who reads it, worked out here, never taken from a caller. */ | |
| 1633 | private async audienceOf(slug: string, channelId: string): Promise<Result<{ workspace: Workspace; channel: ChannelRow; audience: ChatAudience }>> { | |
| 1634 | const workspace = await this.workspace(String(slug ?? "").toLowerCase()); | |
| 1635 | if (!workspace) return fail("not_found", "No such workspace."); | |
| 1636 | const channel = await this.db | |
| 1637 | .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?") | |
| 1638 | .bind(String(channelId ?? ""), workspace.id) | |
| 1639 | .first<ChannelRow>(); | |
| 1640 | if (!channel) return fail("not_found", "No such conversation."); | |
| 1641 | const people = await this.peopleIn(channel.id); | |
| 1642 | return ok({ workspace, channel, audience: { kind: audienceKind(channel), member_user_ids: people, member_count: people.length } }); | |
| 1643 | } | |
| 1644 | ||
| 1645 | async audience(a: { workspace: string; channel_id: string }): Promise<Result<ChatAudience>> { | |
| 1646 | const found = await this.audienceOf(a.workspace, a.channel_id); | |
| 1647 | return found.ok ? ok(found.value.audience) : found; | |
| 1648 | } | |
| 1649 | ||
| 1650 | /** | |
| 1651 | * The conversations of the workspace the audience of `channelId` may all | |
| 1652 | * read: public channels, and the ones every person in it is in (a DM only | |
| 1653 | * with exactly them). At most 500, most recently active first. | |
| 1654 | */ | |
| 1655 | private async readableFor(workspace: Workspace, audience: ChatAudience): Promise<Map<string, ChannelRow>> { | |
| 1656 | const people = audience.member_user_ids; | |
| 1657 | const rows = isShared({ kind: audience.kind, user_ids: people }) || !people.length | |
| 1658 | ? await this.db | |
| 1659 | .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 ORDER BY last_message_at DESC LIMIT 500") | |
| 1660 | .bind(workspace.id) | |
| 1661 | .all<ChannelRow>() | |
| 1662 | : await this.db | |
| 1663 | .prepare( | |
| 1664 | `SELECT * FROM channels WHERE workspace_id = ?1 AND ( | |
| 1665 | (kind = 'channel' AND private = 0) | |
| 1666 | OR id IN (SELECT channel_id FROM channel_members WHERE principal IN (${people.map((_, i) => `?${i + 2}`).join(", ")}) | |
| 1667 | GROUP BY channel_id HAVING COUNT(DISTINCT principal) = ${people.length}) | |
| 1668 | ) ORDER BY last_message_at DESC LIMIT 500`, | |
| 1669 | ) | |
| 1670 | .bind(workspace.id, ...people.map((id) => `user:${id}`)) | |
| 1671 | .all<ChannelRow>(); | |
| 1672 | const out = new Map<string, ChannelRow>(); | |
| 1673 | for (const channel of rows.results) { | |
| 1674 | // The query finds candidates; the rule decides, a DM's people included. | |
| 1675 | const target = { kind: channel.kind, private: channel.private, user_ids: channel.kind === "channel" && !channel.private ? [] : await this.peopleIn(channel.id) }; | |
| 1676 | if (readableBy(target, { kind: audience.kind, user_ids: people })) out.set(channel.id, channel); | |
| 1677 | } | |
| 1678 | return out; | |
| 1679 | } | |
| 1680 | ||
| 1681 | private async found(slug: string, workspace: Workspace, channels: Map<string, ChannelRow>, rows: MessageRow[]): Promise<AgentFoundMessage[]> { | |
| 1682 | const messages = await this.toMessages(slug, workspace, rows); | |
| 1683 | return messages.map((message) => { | |
| 1684 | const channel = channels.get(message.channel_id)!; | |
| 1685 | return { channel_id: channel.id, channel: channel.kind === "dm" ? null : channel.name, message }; | |
| 1686 | }); | |
| 1687 | } | |
| 1688 | ||
| 1689 | async searchForAgent(a: { workspace: string; channel_id: string; query: string; limit?: number | null }): Promise<Result<AgentFoundMessage[]>> { | |
| 1690 | const found = await this.audienceOf(a.workspace, a.channel_id); | |
| 1691 | if (!found.ok) return found; | |
| 1692 | const pattern = likePattern(a.query); | |
| 1693 | if (!pattern) return fail("invalid", "Search for at least two characters."); | |
| 1694 | const { workspace, audience } = found.value; | |
| 1695 | const channels = await this.readableFor(workspace, audience); | |
| 1696 | if (!channels.size) return ok([]); | |
| 1697 | const ids = [...channels.keys()]; | |
| 1698 | const limit = Math.min(20, Math.max(1, Math.floor(Number(a.limit) || 20))); | |
| 1699 | const rows = await this.db | |
| 1700 | .prepare( | |
| 1701 | `SELECT * FROM messages WHERE channel_id IN (${ids.map(() => "?").join(", ")}) AND deleted_at IS NULL AND body LIKE ? ESCAPE '\\' | |
| 1702 | ORDER BY id DESC LIMIT ?`, | |
| 1703 | ) | |
| 1704 | .bind(...ids, pattern, limit) | |
| 1705 | .all<MessageRow>(); | |
| 1706 | return ok(await this.found(a.workspace.toLowerCase(), workspace, channels, rows.results)); | |
| 1707 | } | |
| 1708 | ||
| 1709 | async threadForAgent(a: { workspace: string; channel_id: string; target_channel_id: string; id: string }): Promise<Result<AgentFoundMessage[]>> { | |
| 1710 | const found = await this.audienceOf(a.workspace, a.channel_id); | |
| 1711 | if (!found.ok) return found; | |
| 1712 | const { workspace, audience } = found.value; | |
| 1713 | // The same answer for a conversation that is not there and one the audience may not read. | |
| 1714 | const hidden = fail("not_found", "Not available in this conversation."); | |
| 1715 | const target = await this.db | |
| 1716 | .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?") | |
| 1717 | .bind(String(a.target_channel_id ?? ""), workspace.id) | |
| 1718 | .first<ChannelRow>(); | |
| 1719 | if (!target) return hidden; | |
| 1720 | const people = target.kind === "channel" && !target.private ? [] : await this.peopleIn(target.id); | |
| 1721 | if (!readableBy({ kind: target.kind, private: target.private, user_ids: people }, { kind: audience.kind, user_ids: audience.member_user_ids })) return hidden; | |
| 1722 | const asked = await this.messageRow(target.id, String(a.id ?? "")); | |
| 1723 | if (!asked) return hidden; | |
| 1724 | const root = asked.thread_root ? await this.messageRow(target.id, asked.thread_root) : asked; | |
| 1725 | if (!root) return hidden; | |
| 1726 | const replies = await this.db | |
| 1727 | .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 49") | |
| 1728 | .bind(root.id, target.id) | |
| 1729 | .all<MessageRow>(); | |
| 1730 | const rows = historyOf(replies.results, root).filter((row) => !row.deleted_at); | |
| 1731 | return ok(await this.found(a.workspace.toLowerCase(), workspace, new Map([[target.id, target]]), rows)); | |
| 1732 | } | |
| 1733 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 1734 | async agentTyping(a: { workspace: string; channel_id: string; agent_id: string }): Promise<Result<null>> { |
| 1735 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1736 | if (!found.ok) return found; | |
| 1737 | const { place, agent } = found.value; | |
| 1738 | const key = principalKey({ kind: "agent", id: agent.id }); | |
| 1739 | const member = await this.profile(place.slug, place.workspace, key); | |
| 1740 | this.broadcast(place.channel.id, { | |
| 1741 | type: "typing", | |
| 1742 | channel_id: place.channel.id, | |
| 1743 | member, | |
| 1744 | until: new Date(Date.now() + AGENT_TYPING_MS).toISOString(), | |
| 1745 | }); | |
| 1746 | return ok(null); | |
| 1747 | } | |
| 1748 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1749 | // ── Reactions (src/emoji.ts) ──────────────────────────────────────────── |
| 1750 | ||
| 1751 | /** One message's reactions now, as `me` sees them. */ | |
| 1752 | private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> { | |
| 1753 | const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? []; | |
| 1754 | const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by)); | |
| 1755 | return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })); | |
| 1756 | } | |
| 1757 | ||
| 1758 | /** | |
| 1759 | * Adds or takes back `who`'s reaction. Each member reacts once with each | |
| 1760 | * emoji, a message holds at most 50 different ones, and a workspace's own | |
| 1761 | * emoji must exist to be used (taking one back never needs it to). The | |
| 1762 | * room hears of each change. | |
| 1763 | */ | |
| 1764 | private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> { | |
| 1765 | const parsed = reactionEmoji(input); | |
| 1766 | if (!parsed.ok) return fail("invalid", parsed.message); | |
| 1767 | const row = await this.messageRow(place.channel.id, String(messageId ?? "")); | |
| 1768 | if (!row || row.deleted_at) return fail("not_found", "No such message."); | |
| 1769 | let changed = 0; | |
| 1770 | if (remove) { | |
| 1771 | const done = await this.db | |
| 1772 | .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?") | |
| 1773 | .bind(row.id, who, parsed.emoji) | |
| 1774 | .run(); | |
| 1775 | changed = done.meta.changes; | |
| 1776 | } else { | |
| 1777 | if (parsed.custom) { | |
| 1778 | const known = await this.db | |
| 1779 | .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL") | |
| 1780 | .bind(place.workspace.id, parsed.custom) | |
| 1781 | .first(); | |
| 1782 | if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`); | |
| 1783 | } | |
| 1784 | const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>(); | |
| 1785 | if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) { | |
| 1786 | return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`); | |
| 1787 | } | |
| 1788 | const done = await this.db | |
| 1789 | .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)") | |
| 1790 | .bind(row.id, who, parsed.emoji, now()) | |
| 1791 | .run(); | |
| 1792 | changed = done.meta.changes; | |
| 1793 | } | |
| 1794 | if (changed) { | |
| 1795 | const member = await this.profile(place.slug, place.workspace, who); | |
| 1796 | this.broadcast(place.channel.id, { | |
| 1797 | type: remove ? "reaction.removed" : "reaction.added", | |
| 1798 | channel_id: place.channel.id, | |
| 1799 | message_id: row.id, | |
| 1800 | emoji: parsed.emoji, | |
| 1801 | member, | |
| 1802 | }); | |
| 1803 | } | |
| 1804 | return ok(await this.reactionsFor(place, row.id, who)); | |
| 1805 | } | |
| 1806 | ||
| 1807 | async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> { | |
| 1808 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1809 | if (!found.ok) return found; | |
| 1810 | return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false); | |
| 1811 | } | |
| 1812 | ||
| 1813 | async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> { | |
| 1814 | const found = await this.place(a.workspace, a.channel_id, a.viewer, "read"); | |
| 1815 | if (!found.ok) return found; | |
| 1816 | return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true); | |
| 1817 | } | |
| 1818 | ||
| 1819 | /** An agent's reaction counts like anyone's; it must be in the channel. */ | |
| 1820 | async reactAsAgent(a: { | |
| 1821 | workspace: string; | |
| 1822 | channel_id: string; | |
| 1823 | agent_id: string; | |
| 1824 | message_id: string; | |
| 1825 | emoji: string; | |
| 1826 | remove?: boolean; | |
| 1827 | }): Promise<Result<ChatReaction[]>> { | |
| 1828 | const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id); | |
| 1829 | if (!found.ok) return found; | |
| 1830 | const { place, agent } = found.value; | |
| 1831 | return this.reactTo(place, principalKey({ kind: "agent", id: agent.id }), a.message_id, a.emoji, a.remove === true); | |
| 1832 | } | |
| 1833 | ||
| 1834 | // ── A workspace's own emoji (src/emoji.ts) ────────────────────────────── | |
| 1835 | ||
| 1836 | private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> { | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 1837 | return settingsOf(await this.settingsRow(workspace)).emoji_upload; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1838 | } |
| 1839 | ||
| 1840 | private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> { | |
| 1841 | const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by)); | |
| 1842 | return rows.map((row) => ({ | |
| 1843 | name: row.name, | |
| 1844 | alias_of: row.alias_of, | |
| 1845 | file: row.file, | |
| 1846 | content_type: row.content_type, | |
| 1847 | bytes: row.bytes, | |
| 1848 | created_by: profiles.get(row.created_by)!, | |
| 1849 | created_at: row.created_at, | |
| 1850 | })); | |
| 1851 | } | |
| 1852 | ||
| 1853 | private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> { | |
| 1854 | return this.db | |
| 1855 | .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL") | |
| 1856 | .bind(workspace.id, name) | |
| 1857 | .first<EmojiRow>(); | |
| 1858 | } | |
| 1859 | ||
| 1860 | async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> { | |
| 1861 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 1862 | if (!found.ok) return found; | |
| 1863 | const workspace = found.value; | |
| 1864 | const [rows, setting] = await Promise.all([ | |
| 1865 | this.db | |
| 1866 | .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name") | |
| 1867 | .bind(workspace.id) | |
| 1868 | .all<EmojiRow>(), | |
| 1869 | this.emojiUpload(workspace), | |
| 1870 | ]); | |
| 1871 | const role = roleOf(a.viewer, a.workspace); | |
| 1872 | return ok({ | |
| 1873 | emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results), | |
| 1874 | emoji_upload: setting, | |
| 1875 | can_upload: mayUpload(setting, role), | |
| 1876 | can_manage: role === "owner", | |
| 1877 | }); | |
| 1878 | } | |
| 1879 | ||
| 1880 | /** Who may add one: checked against the workspace's setting. */ | |
| 1881 | private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> { | |
| 1882 | const setting = await this.emojiUpload(workspace); | |
| 1883 | if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace."); | |
| 1884 | return ok(null); | |
| 1885 | } | |
| 1886 | ||
| 1887 | async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> { | |
| 1888 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 1889 | if (!found.ok) return found; | |
| 1890 | const workspace = found.value; | |
| 1891 | const allowed = await this.mayAdd(workspace, a.viewer, a.workspace); | |
| 1892 | if (!allowed.ok) return allowed; | |
| 1893 | const named = emojiName(a.name); | |
| 1894 | if (!named.ok) return fail("invalid", named.message); | |
| 1895 | if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`); | |
| 1896 | const bytes = fromBase64(a.file?.data); | |
| 1897 | if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB."); | |
| 1898 | const checked = emojiImage(bytes); | |
| 1899 | if (!checked.ok) return fail("invalid", checked.message); | |
| 1900 | const file = await sha256(bytes); | |
| 1901 | // Kept by its hash, so the same image stored twice is one file; its | |
| 1902 | // type is the one read from its bytes (usercontent serves only that). | |
| 1903 | await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } }); | |
| 1904 | const row: EmojiRow = { | |
| 1905 | workspace_id: workspace.id, | |
| 1906 | name: named.name, | |
| 1907 | alias_of: null, | |
| 1908 | file, | |
| 1909 | content_type: checked.image.content_type, | |
| 1910 | bytes: bytes.length, | |
| 1911 | created_by: userKey(a.viewer!), | |
| 1912 | created_at: now(), | |
| 1913 | deleted_at: null, | |
| 1914 | }; | |
| 1915 | const added = await this.insertEmoji(row); | |
| 1916 | if (!added.ok) return added; | |
| 1917 | const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]); | |
| 1918 | return ok(emoji!); | |
| 1919 | } | |
| 1920 | ||
| 1921 | private async insertEmoji(row: EmojiRow): Promise<Result<null>> { | |
| 1922 | try { | |
| 1923 | await this.db | |
| 1924 | .prepare( | |
| 1925 | "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1926 | ) | |
| 1927 | .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at) | |
| 1928 | .run(); | |
| 1929 | return ok(null); | |
| 1930 | } catch (error) { | |
| 1931 | if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`); | |
| 1932 | throw error; | |
| 1933 | } | |
| 1934 | } | |
| 1935 | ||
| 1936 | async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> { | |
| 1937 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 1938 | if (!found.ok) return found; | |
| 1939 | const workspace = found.value; | |
| 1940 | const allowed = await this.mayAdd(workspace, a.viewer, a.workspace); | |
| 1941 | if (!allowed.ok) return allowed; | |
| 1942 | const named = emojiName(a.name); | |
| 1943 | if (!named.ok) return fail("invalid", named.message); | |
| 1944 | const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase(); | |
| 1945 | let target = await this.liveEmoji(workspace, targetName); | |
| 1946 | // An alias of an alias names the emoji itself, so removing one never strands another. | |
| 1947 | if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of); | |
| 1948 | if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`); | |
| 1949 | if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`); | |
| 1950 | const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null }; | |
| 1951 | const added = await this.insertEmoji(row); | |
| 1952 | if (!added.ok) return added; | |
| 1953 | const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]); | |
| 1954 | return ok(emoji!); | |
| 1955 | } | |
| 1956 | ||
| 1957 | async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> { | |
| 1958 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 1959 | if (!found.ok) return found; | |
| 1960 | const workspace = found.value; | |
| 1961 | const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase(); | |
| 1962 | const row = await this.liveEmoji(workspace, name); | |
| 1963 | if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`); | |
| 1964 | if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) { | |
| 1965 | return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it."); | |
| 1966 | } | |
| 1967 | // An emoji goes with its aliases; an alias goes alone. | |
| 1968 | await this.db | |
| 1969 | .prepare( | |
| 1970 | "UPDATE custom_emoji SET deleted_at = ?1 WHERE workspace_id = ?2 AND deleted_at IS NULL AND (name = ?3 OR (?4 = 0 AND alias_of = ?3))", | |
| 1971 | ) | |
| 1972 | .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0) | |
| 1973 | .run(); | |
| 1974 | // Its image goes once nothing live shows it, in any workspace. | |
| 1975 | this.defer( | |
| 1976 | (async () => { | |
| 1977 | const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first(); | |
| 1978 | if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`); | |
| 1979 | })().catch((error) => console.error("chat could not forget emoji", row.file, error)), | |
| 1980 | ); | |
| 1981 | return ok(null); | |
| 1982 | } | |
| 1983 | ||
| 1984 | async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> { | |
| 1985 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 1986 | if (!found.ok) return found; | |
| 1987 | if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji."); | |
| 1988 | if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners."); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 1989 | // The same setting as Settings → Chat (`setChatSettings`). |
| 1990 | const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } }); | |
| 1991 | return set.ok ? ok(set.value.emoji_upload) : set; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1992 | } |
| 1993 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 1994 | // ── The live socket ───────────────────────────────────────────────────── |
| 1995 | ||
| 1996 | /** | |
| 1997 | * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The | |
| 1998 | * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the | |
| 1999 | * session; trusted only because this Worker is reachable through service | |
| 2000 | * bindings alone (`workers_dev` is off and it has no routes). Checked | |
| 2001 | * like any read, then handed to the channel's room. | |
| 2002 | */ | |
| 2003 | async live(request: Request): Promise<Response> { | |
| 2004 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 2005 | return new Response("Expected a WebSocket upgrade\n", { status: 426 }); | |
| 2006 | } | |
| 2007 | let viewer: Viewer = null; | |
| 2008 | try { | |
| 2009 | viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer; | |
| 2010 | } catch { | |
| 2011 | viewer = null; | |
| 2012 | } | |
| 2013 | if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 }); | |
| 2014 | const url = new URL(request.url); | |
| 2015 | const channelId = url.searchParams.get("channel") ?? ""; | |
| 2016 | let slug = (url.searchParams.get("workspace") ?? "").toLowerCase(); | |
| 2017 | if (!slug) { | |
| 2018 | // Not named: whichever of the viewer's workspaces holds the channel. | |
| 2019 | const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>(); | |
| 2020 | if (row) { | |
| 2021 | const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug))); | |
| 2022 | slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? ""; | |
| 2023 | } | |
| 2024 | } | |
| 2025 | const found = await this.place(slug, channelId, viewer, "read"); | |
| 2026 | if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 }); | |
| 2027 | const { workspace, channel } = found.value; | |
| 2028 | const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) }; | |
| 2029 | const headers = new Headers(request.headers); | |
| 2030 | headers.delete(CHAT_VIEWER_HEADER); | |
| 2031 | headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who)); | |
| 2032 | return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers })); | |
| 2033 | } | |
| 2034 | } | |
| 2035 | ||
| 2036 | /** One RPC method's answer. */ | |
| 2037 | async function answer(service: Chat, method: string, args: any): Promise<Response> { | |
| 2038 | switch (method) { | |
| 2039 | case "sidebar": | |
| 2040 | return Response.json(await service.sidebar(args)); | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 2041 | case "activity": |
| 2042 | return Response.json(await service.activity(args)); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 2043 | case "channel": |
| 2044 | return Response.json(await service.channel(args)); | |
| 2045 | case "channel_by_name": | |
| 2046 | return Response.json(await service.channelByName(args)); | |
| 2047 | case "browse": | |
| 2048 | return Response.json(await service.browse(args)); | |
| 2049 | case "create_channel": | |
| 2050 | return Response.json(await service.createChannel(args)); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 2051 | case "update_channel": |
| 2052 | return Response.json(await service.updateChannel(args)); | |
| 2053 | case "chat_settings": | |
| 2054 | return Response.json(await service.chatSettings(args)); | |
| 2055 | case "set_chat_settings": | |
| 2056 | return Response.json(await service.setChatSettings(args)); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 2057 | case "open_dm": |
| 2058 | return Response.json(await service.openDm(args)); | |
| 2059 | case "join": | |
| 2060 | return Response.json(await service.join(args)); | |
| 2061 | case "leave": | |
| 2062 | return Response.json(await service.leave(args)); | |
| 2063 | case "invite": | |
| 2064 | return Response.json(await service.invite(args)); | |
| 2065 | case "messages": | |
| 2066 | return Response.json(await service.messages(args)); | |
| 2067 | case "post": | |
| 2068 | return Response.json(await service.post(args)); | |
| 2069 | case "edit": | |
| 2070 | return Response.json(await service.edit(args)); | |
| 2071 | case "remove": | |
| 2072 | return Response.json(await service.remove(args)); | |
| 2073 | case "mark_read": | |
| 2074 | return Response.json(await service.markRead(args)); | |
| 2075 | case "set_preferences": | |
| 2076 | return Response.json(await service.setPreferences(args)); | |
| 2077 | case "post_as_agent": | |
| 2078 | return Response.json(await service.postAsAgent(args)); | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2079 | case "card_action": |
| 2080 | return Response.json(await service.cardAction(args)); | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 2081 | case "update_as_agent": |
| 2082 | return Response.json(await service.updateAsAgent(args)); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 2083 | case "agent_typing": |
| 2084 | return Response.json(await service.agentTyping(args)); | |
| 2085 | case "history_for_agent": | |
| 2086 | return Response.json(await service.historyForAgent(args)); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 2087 | case "audience": |
| 2088 | return Response.json(await service.audience(args)); | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 2089 | case "conversation_for_agent": |
| 2090 | return Response.json(await service.conversationForAgent(args)); | |
| 2091 | case "hand_off_as_agent": | |
| 2092 | return Response.json(await service.handOffAsAgent(args)); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 2093 | case "search_for_agent": |
| 2094 | return Response.json(await service.searchForAgent(args)); | |
| 2095 | case "thread_for_agent": | |
| 2096 | return Response.json(await service.threadForAgent(args)); | |
| 2097 | case "react": | |
| 2098 | return Response.json(await service.react(args)); | |
| 2099 | case "unreact": | |
| 2100 | return Response.json(await service.unreact(args)); | |
| 2101 | case "react_as_agent": | |
| 2102 | return Response.json(await service.reactAsAgent(args)); | |
| 2103 | case "list_emoji": | |
| 2104 | return Response.json(await service.listEmoji(args)); | |
| 2105 | case "add_emoji": | |
| 2106 | return Response.json(await service.addEmoji(args)); | |
| 2107 | case "alias_emoji": | |
| 2108 | return Response.json(await service.aliasEmoji(args)); | |
| 2109 | case "remove_emoji": | |
| 2110 | return Response.json(await service.removeEmoji(args)); | |
| 2111 | case "set_emoji_upload": | |
| 2112 | return Response.json(await service.setEmojiUpload(args)); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 2113 | default: |
| 2114 | return new Response("Unknown method\n", { status: 404 }); | |
| 2115 | } | |
| 2116 | } | |
| 2117 | ||
| 2118 | export default { | |
| 2119 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 2120 | const url = new URL(request.url); | |
| 2121 | if (request.method === "GET" && url.pathname === "/live") { | |
| 2122 | return new Chat(env, (work) => ctx.waitUntil(work)).live(request); | |
| 2123 | } | |
| 2124 | const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/); | |
| 2125 | if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 }); | |
| 2126 | // A replica near the caller when it asks for one (@g1t/contracts d1.ts). | |
| 2127 | const opened = openD1(env.DB, request); | |
| 2128 | const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work)); | |
| 2129 | const args = (await request.json().catch(() => ({}))) as any; | |
| 2130 | return opened.finish(await answer(service, match[1], args)); | |
| 2131 | }, | |
| 2132 | } satisfies ExportedHandler<Env>; |
This file's history is long; its oldest lines are credited to the oldest commit read.