// The `lchat bot` subcommand family: the Go bot sidecar (docs/bots.md § // Direction: a Go sidecar). Distinct from `add-bot` (a router-box control // command that stands up the Node sidecar): `lchat bot ...` IS the sidecar, // run wherever the operator wants the bot's keys to live — which need not be // the server box, since it only ever dials out. package main import ( "context" "flag" "fmt" "log" "os" "strings" "lchat/internal/bot" ) func runBotCmd(args []string) { if len(args) < 1 { fmt.Fprintln(os.Stderr, `usage: lchat bot commands: provision claim an agent invite and write the bot's session file run run the live sidecar: connect each identity and follow its recipe (command floor + model + connected-services; the standard pair zero-config)`) os.Exit(2) } switch args[0] { case "provision": runBotProvision(args[1:]) case "announce": // Retired: the server announces its own changelog now (announce.go) — // admins get a push and read the notes in the Admin app's update stream. die("bot announce: retired — the server announces release notes itself; see docs/bots.md") case "run": runBotRun(args[1:]) default: die("bot: unknown command %q", args[0]) } } // runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over // WebSocket as one or more provisioned identities and run each one's recipe // (docs/bot-recipes.md) — behaviour as data from a bounded trigger→action // palette. A bot with no recipe file gets the default: answer the // deterministic command floor when addressed, exactly what the Node sidecar // does with no ANTHROPIC_API_KEY. The model layer plugs into the same seam // next (docs/bots.md). // // One process, many bots: each session file on the command line becomes one // identity with its own connection and recipe (.recipe.json), so a // single `lchat bot run` replaces a whole sidecar's worth of Node. func runBotRun(args []string) { fs := flag.NewFlagSet("bot run", flag.ExitOnError) base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") _ = fs.Parse(reorderFlagsFirst(fs, args)) if *base == "" { die("bot run: set -base (or LCHAT_BASE) to your instance URL") } // Each identity pairs a session path with the one-time invite that can // mint it: a fresh sidecar self-provisions on first boot (#806) instead of // crash-looping until an operator runs `bot provision` by hand — the // behavior add-bot and the activator co-spawn were written to rely on. type botIdentity struct { path string invite string // one-time agent invite; consulted only when the session is absent name string // identity name stamped into a self-provisioned session } var ids []botIdentity if args := fs.Args(); len(args) > 0 { for _, p := range args { ids = append(ids, botIdentity{path: p}) } if len(ids) == 1 { // A lone hand-run session may still self-provision, matching the // Node reference sidecar (it redeems LCHAT_INVITE on first boot). ids[0].invite = os.Getenv("LCHAT_INVITE") ids[0].name = envOr("LCHAT_AGENT_NAME", "Claude") } } else { // No sessions on the command line: source them from the env the sidecar // unit provides, running whichever identities are set — so the generic // lchat-bot@.service template needs no per-host args, and a full pair or an // Eleven-only box both work. Falls back to a lone session.json for a // hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so // a pre-retirement EnvironmentFile still resolves; the session is then // skipped by name below — and never self-provisioned: the identity is // retired.) for _, src := range []struct{ session, invite, name string }{ {"LCHAT_SESSION", "LCHAT_INVITE", "Claude"}, {"LCHAT_ELEVEN_SESSION", "LCHAT_ELEVEN_INVITE", "Eleven"}, {"LCHAT_ANNOUNCER_SESSION", "", "Announcer"}, } { if p := strings.TrimSpace(os.Getenv(src.session)); p != "" { var invite string if src.invite != "" { invite = os.Getenv(src.invite) } ids = append(ids, botIdentity{path: p, invite: invite, name: src.name}) } } if len(ids) == 0 { ids = []botIdentity{{path: "session.json", invite: os.Getenv("LCHAT_INVITE"), name: envOr("LCHAT_AGENT_NAME", "Claude")}} } } var engines []*bot.Engine for _, id := range ids { sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), id.path, id.invite, id.name, log.Printf) if err != nil { die("bot run: %v", err) } if sess == nil { die("bot run: no session at %s — provision it first (lchat bot provision, or set the matching invite env var)", id.path) } path := id.path if sess.Name == "Announcer" { // The Announcer is retired (the server announces its own changelog — // announce.go). Running this identity under the default message recipe // would turn the old write-only bot into a responder, so skip it; the // operator should remove the member from the roster when convenient. log.Printf("bot run: %s: the Announcer is retired — skipping this identity (remove it from the roster; see docs/bots.md)", path) continue } // A broken recipe stops the bot (fail closed) — silently falling back // to the default would run behaviour the operator didn't write. recipe, err := bot.LoadRecipe(bot.RecipePathFor(path)) if err != nil { die("bot run: %v", err) } if recipe == nil { // No recipe file → the built-in default, so the standard pair // (Claude / Eleven) deploys with zero config. recipe = bot.DefaultRecipe() } else { log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path), map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()]) } eng := &bot.Engine{ Base: strings.TrimRight(*base, "/"), Session: sess, SessionPath: path, Recipe: recipe, // With a key the bots upgrade to conversational AI (and badge AI); // without one they run the deterministic floor and badge BOT. AIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")), Logf: log.Printf, } engines = append(engines, eng) } // Elect the CHARTER HOST — the one identity that runs member bots // (docs/member-bots.md). Exactly one per sidecar, or every charter would // fire once per identity. Eleven when present (the builder members // already ask for things); otherwise the first identity. host := 0 for i, eng := range engines { if eng.Session.Name == "Eleven" { host = i break } } for i, eng := range engines { eng.Charters = i == host if eng.Charters { log.Printf("bot run: %s hosts member bots", eng.Session.Name) } go func(eng *bot.Engine) { if err := eng.Run(context.Background()); err != nil { log.Printf("bot run: %s: %v", eng.Session.Name, err) } }(eng) } log.Printf("bot run: %d identity(ies) connecting to %s", len(engines), *base) select {} // run until killed; each runtime reconnects on its own } func runBotProvision(args []string) { fs := flag.NewFlagSet("bot provision", flag.ExitOnError) base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://chat.example.com (env: LCHAT_BASE)") session := fs.String("session", envOr("LCHAT_SESSION", "session.json"), "session file to write (env: LCHAT_SESSION)") name := fs.String("name", envOr("LCHAT_AGENT_NAME", "Claude"), "bot name for the session file (env: LCHAT_AGENT_NAME)") _ = fs.Parse(reorderFlagsFirst(fs, args)) if *base == "" { die("bot provision: set -base (or LCHAT_BASE) to your instance URL") } // The invite fragment is a secret, so read it from the environment only (a // flag would leak it into the process table + shell history) — same rule as // ANTHROPIC_API_KEY in add-bot. fragment := strings.TrimSpace(os.Getenv("LCHAT_INVITE")) if fragment == "" { die("bot provision: set LCHAT_INVITE to the #fragment of a /join/... link (mint an agent invite in the admin console)") } if existing, err := bot.LoadSession(*session); err != nil { die("bot provision: %v", err) } else if existing != nil { die("bot provision: already provisioned (%s) — delete it to re-provision", *session) } s, err := bot.Provision(*base, fragment, *name) if err != nil { die("bot provision: %v", err) } if err := s.Save(*session); err != nil { die("bot provision: %v", err) } fmt.Printf("Provisioned %q. Session written to %s — it holds the bot's private key; keep it secret, back it up.\n", *name, *session) fmt.Println("Now add the bot to a group, or start a DM with it from the app.") }