Skip to content
118 linesCodeBlameRaw

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 */
13import type { ServiceBinding, User } from "@g1t/contracts";
14
15// By path, as the tests load this file under Node, which resolves no package index.
16import { chatClient } from "../../../packages/contracts/src/chat.ts";
17import { identityClient } from "../../../packages/contracts/src/clients.ts";
18import type { G1tEvent, MemberJoinedData } from "../../../packages/contracts/src/events.ts";
19import { askerAccess } from "../../../packages/contracts/src/workspace-agents.ts";
20import { ensureBuiltin } from "./builtin.ts";
21import type { Desk } from "./desk.ts";
22import { type Row, periods, selectAgents } from "./store.ts";
23
24/** Someone to welcome: the person and the workspace they joined. */
25export 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 */
32export function welcomeMessageId(workspaceId: string, userId: string): string {
33 return `welcome:${workspaceId}:${userId}`;
34}
35
36function 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 */
45export 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
57export 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 */
69export 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 */
88async 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}