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.
| @g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so. | 1 | /** |
| 2 | * @g1t welcomes a person who joins its workspace | |
| 3 | * (docs.g1t.sh/guides/agents/, "After that"): the one time an agent | |
| 4 | * speaks first. On `workspace.member_joined` from identity, the | |
| 5 | * workspace's built-in @g1t opens its direct message with the person and | |
| 6 | * says a short welcome there, as a reply billed like any other, once per | |
| 7 | * person per workspace. Never for the workspace's creator (`how` is | |
| 8 | * `created`), never for an agent or a token, and never twice: the desk | |
| 9 | * claims the welcome under one message id, `welcome:<workspace>:<user>`, | |
| 10 | * in `agent_replies`, whose unique index on (agent_id, message_id) lets a | |
| 11 | * repeated event through once. | |
| 12 | */ | |
| 13 | import type { ServiceBinding, User } from "@g1t/contracts"; | |
| 14 | ||
| 15 | // By path, as the tests load this file under Node, which resolves no package index. | |
| 16 | import { chatClient } from "../../../packages/contracts/src/chat.ts"; | |
| 17 | import { identityClient } from "../../../packages/contracts/src/clients.ts"; | |
| 18 | import type { G1tEvent, MemberJoinedData } from "../../../packages/contracts/src/events.ts"; | |
| 19 | import { askerAccess } from "../../../packages/contracts/src/workspace-agents.ts"; | |
| 20 | import { ensureBuiltin } from "./builtin.ts"; | |
| 21 | import type { Desk } from "./desk.ts"; | |
| 22 | import { type Row, periods, selectAgents } from "./store.ts"; | |
| 23 | ||
| 24 | /** Someone to welcome: the person and the workspace they joined. */ | |
| 25 | export type Welcome = { workspace_id: string; workspace: string; user_id: string; username: string }; | |
| 26 | ||
| 27 | /** | |
| 28 | * The message id a welcome is claimed under: one per person per workspace, | |
| 29 | * however often the event arrives. It names no message in chat, which | |
| 30 | * surface.ts knows by the `welcome:` prefix. | |
| 31 | */ | |
| 32 | export function welcomeMessageId(workspaceId: string, userId: string): string { | |
| 33 | return `welcome:${workspaceId}:${userId}`; | |
| 34 | } | |
| 35 | ||
| 36 | function text(value: unknown): string | null { | |
| 37 | return typeof value === "string" && value.trim() ? value.trim() : null; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Who an event says to welcome, or null: only a person joining a | |
| 42 | * workspace by invitation or with an invite code (`how`), never its | |
| 43 | * creator, and never an event that leaves out who or where. | |
| 44 | */ | |
| 45 | export function welcomeFor(event: Pick<G1tEvent, "type" | "data"> | null | undefined): Welcome | null { | |
| 46 | if (!event || event.type !== "workspace.member_joined") return null; | |
| 47 | const data = (event.data ?? {}) as Partial<MemberJoinedData>; | |
| 48 | if (data.how !== "invitation" && data.how !== "joined") return null; | |
| 49 | const workspace_id = text(data.workspaceId); | |
| 50 | const workspace = text(data.workspace)?.toLowerCase() ?? null; | |
| 51 | const user_id = text(data.userId); | |
| 52 | const username = text(data.username); | |
| 53 | if (!workspace_id || !workspace || !user_id || !username) return null; | |
| 54 | return { workspace_id, workspace, user_id, username }; | |
| 55 | } | |
| 56 | ||
| 57 | export type WelcomeEnv = { | |
| 58 | DB: D1Database; | |
| 59 | CHAT: ServiceBinding; | |
| 60 | IDENTITY: ServiceBinding; | |
| 61 | DESKS: DurableObjectNamespace<Desk>; | |
| 62 | }; | |
| 63 | ||
| 64 | /** | |
| 65 | * Welcomes everyone a batch of events says joined a workspace. Returns | |
| 66 | * how many welcomes were handed to a desk; a failure for one person is | |
| 67 | * logged and the rest go on. | |
| 68 | */ | |
| 69 | export async function welcome(env: WelcomeEnv, events: G1tEvent[], now = new Date()): Promise<number> { | |
| 70 | let handed = 0; | |
| 71 | for (const event of events) { | |
| 72 | const joined = welcomeFor(event); | |
| 73 | if (!joined) continue; | |
| 74 | try { | |
| 75 | if (await greet(env, joined, now)) handed++; | |
| 76 | } catch (error) { | |
| 77 | console.error("agents: a new member was not welcomed", joined.workspace, joined.username, String(error)); | |
| 78 | } | |
| 79 | } | |
| 80 | return handed; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Opens @g1t's direct message with the person and hands the desk the | |
| 85 | * welcome. False when there is nobody to welcome (no live account, or | |
| 86 | * not a person), no @g1t, or it has welcomed them already. | |
| 87 | */ | |
| 88 | async function greet(env: WelcomeEnv, joined: Welcome, now: Date): Promise<boolean> { | |
| 89 | const [person] = await identityClient(env.IDENTITY) | |
| 90 | .usersForAudience([joined.user_id]) | |
| 91 | .catch(() => [] as User[]); | |
| 92 | if (!person || (person.kind ?? "user") !== "user") return false; | |
| 93 | await ensureBuiltin(env.DB, joined.workspace_id); | |
| 94 | const g1t = await env.DB.prepare(selectAgents("a.workspace_id = ?3 AND a.builtin = 1")).bind(...periods(now), joined.workspace_id).first<Row>(); | |
| 95 | if (!g1t || g1t.archived_at) return false; | |
| 96 | // Said once. The desk's claim on this id is what holds; this read spares a repeat the DM and the wake. | |
| 97 | const messageId = welcomeMessageId(joined.workspace_id, joined.user_id); | |
| 98 | const said = await env.DB.prepare("SELECT 1 FROM agent_replies WHERE agent_id = ? AND message_id = ?").bind(g1t.id, messageId).first(); | |
| 99 | if (said) return false; | |
| 100 | const dm = await chatClient(env.CHAT).openDm(joined.workspace, person, [{ kind: "agent", id: g1t.id }]); | |
| 101 | if (!dm.ok) throw new Error(dm.error.message); | |
| 102 | const desk = env.DESKS.get(env.DESKS.idFromName(g1t.id)); | |
| 103 | await desk.take({ | |
| 104 | workspace: joined.workspace, | |
| 105 | workspace_id: joined.workspace_id, | |
| 106 | channel_id: dm.value.id, | |
| 107 | channel_kind: "dm", | |
| 108 | channel_name: null, | |
| 109 | agent_id: g1t.id, | |
| 110 | message_id: messageId, | |
| 111 | thread_root: null, | |
| 112 | asked_by: person.id, | |
| 113 | hops: 0, | |
| 114 | asker: askerAccess(person, joined.workspace), | |
| 115 | welcome: true, | |
| 116 | }); | |
| 117 | return true; | |
| 118 | } |