| 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 | } |