Skip to content
118 linesCodeBlameRaw
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}