| 1 | // The `lchat bot` subcommand family: the Go bot sidecar (docs/bots.md § |
| 2 | // Direction: a Go sidecar). Distinct from `add-bot` (a router-box control |
| 3 | // command that stands up the Node sidecar): `lchat bot ...` IS the sidecar, |
| 4 | // run wherever the operator wants the bot's keys to live — which need not be |
| 5 | // the server box, since it only ever dials out. |
| 6 | package main |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "flag" |
| 11 | "fmt" |
| 12 | "log" |
| 13 | "os" |
| 14 | "strings" |
| 15 | |
| 16 | "lchat/internal/bot" |
| 17 | ) |
| 18 | |
| 19 | func runBotCmd(args []string) { |
| 20 | if len(args) < 1 { |
| 21 | fmt.Fprintln(os.Stderr, `usage: lchat bot <command> |
| 22 | |
| 23 | commands: |
| 24 | provision claim an agent invite and write the bot's session file |
| 25 | run run the live sidecar: connect each identity and follow its recipe |
| 26 | (command floor + model + connected-services; the standard pair zero-config)`) |
| 27 | os.Exit(2) |
| 28 | } |
| 29 | switch args[0] { |
| 30 | case "provision": |
| 31 | runBotProvision(args[1:]) |
| 32 | case "announce": |
| 33 | // Retired: the server announces its own changelog now (announce.go) — |
| 34 | // admins get a push and read the notes in the Admin app's update stream. |
| 35 | die("bot announce: retired — the server announces release notes itself; see docs/bots.md") |
| 36 | case "run": |
| 37 | runBotRun(args[1:]) |
| 38 | default: |
| 39 | die("bot: unknown command %q", args[0]) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over |
| 44 | // WebSocket as one or more provisioned identities and run each one's recipe |
| 45 | // (docs/bot-recipes.md) — behaviour as data from a bounded trigger→action |
| 46 | // palette. A bot with no recipe file gets the default: answer the |
| 47 | // deterministic command floor when addressed, exactly what the Node sidecar |
| 48 | // does with no ANTHROPIC_API_KEY. The model layer plugs into the same seam |
| 49 | // next (docs/bots.md). |
| 50 | // |
| 51 | // One process, many bots: each session file on the command line becomes one |
| 52 | // identity with its own connection and recipe (<session>.recipe.json), so a |
| 53 | // single `lchat bot run` replaces a whole sidecar's worth of Node. |
| 54 | func runBotRun(args []string) { |
| 55 | fs := flag.NewFlagSet("bot run", flag.ExitOnError) |
| 56 | base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") |
| 57 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 58 | |
| 59 | if *base == "" { |
| 60 | die("bot run: set -base (or LCHAT_BASE) to your instance URL") |
| 61 | } |
| 62 | // Each identity pairs a session path with the one-time invite that can |
| 63 | // mint it: a fresh sidecar self-provisions on first boot (#806) instead of |
| 64 | // crash-looping until an operator runs `bot provision` by hand — the |
| 65 | // behavior add-bot and the activator co-spawn were written to rely on. |
| 66 | type botIdentity struct { |
| 67 | path string |
| 68 | invite string // one-time agent invite; consulted only when the session is absent |
| 69 | name string // identity name stamped into a self-provisioned session |
| 70 | } |
| 71 | var ids []botIdentity |
| 72 | if args := fs.Args(); len(args) > 0 { |
| 73 | for _, p := range args { |
| 74 | ids = append(ids, botIdentity{path: p}) |
| 75 | } |
| 76 | if len(ids) == 1 { |
| 77 | // A lone hand-run session may still self-provision, matching the |
| 78 | // Node reference sidecar (it redeems LCHAT_INVITE on first boot). |
| 79 | ids[0].invite = os.Getenv("LCHAT_INVITE") |
| 80 | ids[0].name = envOr("LCHAT_AGENT_NAME", "Claude") |
| 81 | } |
| 82 | } else { |
| 83 | // No sessions on the command line: source them from the env the sidecar |
| 84 | // unit provides, running whichever identities are set — so the generic |
| 85 | // lchat-bot@.service template needs no per-host args, and a full pair or an |
| 86 | // Eleven-only box both work. Falls back to a lone session.json for a |
| 87 | // hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so |
| 88 | // a pre-retirement EnvironmentFile still resolves; the session is then |
| 89 | // skipped by name below — and never self-provisioned: the identity is |
| 90 | // retired.) |
| 91 | for _, src := range []struct{ session, invite, name string }{ |
| 92 | {"LCHAT_SESSION", "LCHAT_INVITE", "Claude"}, |
| 93 | {"LCHAT_ELEVEN_SESSION", "LCHAT_ELEVEN_INVITE", "Eleven"}, |
| 94 | {"LCHAT_ANNOUNCER_SESSION", "", "Announcer"}, |
| 95 | } { |
| 96 | if p := strings.TrimSpace(os.Getenv(src.session)); p != "" { |
| 97 | var invite string |
| 98 | if src.invite != "" { |
| 99 | invite = os.Getenv(src.invite) |
| 100 | } |
| 101 | ids = append(ids, botIdentity{path: p, invite: invite, name: src.name}) |
| 102 | } |
| 103 | } |
| 104 | if len(ids) == 0 { |
| 105 | ids = []botIdentity{{path: "session.json", invite: os.Getenv("LCHAT_INVITE"), name: envOr("LCHAT_AGENT_NAME", "Claude")}} |
| 106 | } |
| 107 | } |
| 108 | var engines []*bot.Engine |
| 109 | for _, id := range ids { |
| 110 | sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), id.path, id.invite, id.name, log.Printf) |
| 111 | if err != nil { |
| 112 | die("bot run: %v", err) |
| 113 | } |
| 114 | if sess == nil { |
| 115 | die("bot run: no session at %s — provision it first (lchat bot provision, or set the matching invite env var)", id.path) |
| 116 | } |
| 117 | path := id.path |
| 118 | if sess.Name == "Announcer" { |
| 119 | // The Announcer is retired (the server announces its own changelog — |
| 120 | // announce.go). Running this identity under the default message recipe |
| 121 | // would turn the old write-only bot into a responder, so skip it; the |
| 122 | // operator should remove the member from the roster when convenient. |
| 123 | log.Printf("bot run: %s: the Announcer is retired — skipping this identity (remove it from the roster; see docs/bots.md)", path) |
| 124 | continue |
| 125 | } |
| 126 | // A broken recipe stops the bot (fail closed) — silently falling back |
| 127 | // to the default would run behaviour the operator didn't write. |
| 128 | recipe, err := bot.LoadRecipe(bot.RecipePathFor(path)) |
| 129 | if err != nil { |
| 130 | die("bot run: %v", err) |
| 131 | } |
| 132 | if recipe == nil { |
| 133 | // No recipe file → the built-in default, so the standard pair |
| 134 | // (Claude / Eleven) deploys with zero config. |
| 135 | recipe = bot.DefaultRecipe() |
| 136 | } else { |
| 137 | log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path), |
| 138 | map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()]) |
| 139 | } |
| 140 | eng := &bot.Engine{ |
| 141 | Base: strings.TrimRight(*base, "/"), |
| 142 | Session: sess, |
| 143 | SessionPath: path, |
| 144 | Recipe: recipe, |
| 145 | // With a key the bots upgrade to conversational AI (and badge AI); |
| 146 | // without one they run the deterministic floor and badge BOT. |
| 147 | AIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")), |
| 148 | Logf: log.Printf, |
| 149 | } |
| 150 | engines = append(engines, eng) |
| 151 | } |
| 152 | // Elect the CHARTER HOST — the one identity that runs member bots |
| 153 | // (docs/member-bots.md). Exactly one per sidecar, or every charter would |
| 154 | // fire once per identity. Eleven when present (the builder members |
| 155 | // already ask for things); otherwise the first identity. |
| 156 | host := 0 |
| 157 | for i, eng := range engines { |
| 158 | if eng.Session.Name == "Eleven" { |
| 159 | host = i |
| 160 | break |
| 161 | } |
| 162 | } |
| 163 | for i, eng := range engines { |
| 164 | eng.Charters = i == host |
| 165 | if eng.Charters { |
| 166 | log.Printf("bot run: %s hosts member bots", eng.Session.Name) |
| 167 | } |
| 168 | go func(eng *bot.Engine) { |
| 169 | if err := eng.Run(context.Background()); err != nil { |
| 170 | log.Printf("bot run: %s: %v", eng.Session.Name, err) |
| 171 | } |
| 172 | }(eng) |
| 173 | } |
| 174 | log.Printf("bot run: %d identity(ies) connecting to %s", len(engines), *base) |
| 175 | select {} // run until killed; each runtime reconnects on its own |
| 176 | } |
| 177 | |
| 178 | func runBotProvision(args []string) { |
| 179 | fs := flag.NewFlagSet("bot provision", flag.ExitOnError) |
| 180 | base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://chat.example.com (env: LCHAT_BASE)") |
| 181 | session := fs.String("session", envOr("LCHAT_SESSION", "session.json"), "session file to write (env: LCHAT_SESSION)") |
| 182 | name := fs.String("name", envOr("LCHAT_AGENT_NAME", "Claude"), "bot name for the session file (env: LCHAT_AGENT_NAME)") |
| 183 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 184 | |
| 185 | if *base == "" { |
| 186 | die("bot provision: set -base (or LCHAT_BASE) to your instance URL") |
| 187 | } |
| 188 | // The invite fragment is a secret, so read it from the environment only (a |
| 189 | // flag would leak it into the process table + shell history) — same rule as |
| 190 | // ANTHROPIC_API_KEY in add-bot. |
| 191 | fragment := strings.TrimSpace(os.Getenv("LCHAT_INVITE")) |
| 192 | if fragment == "" { |
| 193 | die("bot provision: set LCHAT_INVITE to the #fragment of a /join/... link (mint an agent invite in the admin console)") |
| 194 | } |
| 195 | if existing, err := bot.LoadSession(*session); err != nil { |
| 196 | die("bot provision: %v", err) |
| 197 | } else if existing != nil { |
| 198 | die("bot provision: already provisioned (%s) — delete it to re-provision", *session) |
| 199 | } |
| 200 | |
| 201 | s, err := bot.Provision(*base, fragment, *name) |
| 202 | if err != nil { |
| 203 | die("bot provision: %v", err) |
| 204 | } |
| 205 | if err := s.Save(*session); err != nil { |
| 206 | die("bot provision: %v", err) |
| 207 | } |
| 208 | fmt.Printf("Provisioned %q. Session written to %s — it holds the bot's private key; keep it secret, back it up.\n", *name, *session) |
| 209 | fmt.Println("Now add the bot to a group, or start a DM with it from the app.") |
| 210 | } |
| 211 | |