package bot import ( "errors" "fmt" "os" "strings" "sync" "time" "lchat/integrations" "lchat/internal/e2ee" ) // Connected-services capability ops (docs/connected-services.md): the Go port // of runtime.mjs's handleConnect / handleOAuthExchange. These are protocol // plumbing, not chat behaviour — they run regardless of the bot's recipe or // whether it has a model brain, so the runtime routes connect.* payloads here // directly (before the recipe Handler ever sees them). // // The trust story is the whole point: a member seals a time-boxed grant to // THIS bot's public key; we open it with our own private key (a grant sealed // to a different bot won't open, so we stay silent), act once, and drop the // token. The lchat server only ever relays the sealed blobs. // connectConfig is read from the same env the Node sidecar uses. The broker // holds DNSimple's client_secret; unset, the OAuth exchange can't run (but // grant-based ops still can, since those carry their own token). type connectConfig struct { brokerURL string brokerToken string dnsimple *dnsimpleClient refreshes *refreshGroup // serializes token refreshes per credential } // defaultBrokerURL is the shared connect broker; an instance sets only its // per-instance LCHAT_CONNECT_BROKER_TOKEN, so the URL defaults here (matching // the Node runtime, which defaults it inline). Override with // LCHAT_CONNECT_BROKER_URL for a self-hosted broker. const defaultBrokerURL = "https://connect.elevenmessenger.com" // maxOpResultBytes caps one registry-op result: it must fit inside a single // encrypted message (maxBlobBytes, 64 KB ciphertext), and base64 costs 4/3 — // 40 KB of result is ~54 KB encrypted, leaving envelope headroom. Manifests // keep real results below this via `pick` (a 100-repo GitHub page projects to // ~26 KB). const maxOpResultBytes = 40 << 10 func loadConnectConfig() *connectConfig { brokerURL := os.Getenv("LCHAT_CONNECT_BROKER_URL") if brokerURL == "" { brokerURL = defaultBrokerURL } return &connectConfig{ brokerURL: brokerURL, brokerToken: os.Getenv("LCHAT_CONNECT_BROKER_TOKEN"), dnsimple: newDNSimpleClient(os.Getenv("LCHAT_DNSIMPLE_BASE")), refreshes: newRefreshGroup(), } } // connectPayload is the decrypted shape of a connect.op / connect.oauth message. type connectPayload struct { Type string `json:"type"` Service string `json:"service"` Op string `json:"op"` Domain string `json:"domain"` Args map[string]string `json:"args"` // registry-op arguments (integrations.Execute validates them) Grant string `json:"grant"` // sealed to this bot: {service, token, exp, access} RID string `json:"rid"` // request id for a requestData round-trip Code string `json:"code"` // OAuth State string `json:"state"` // OAuth UID string `json:"uid"` // sender's user id (to seal the token back) Name string `json:"name"` Src string `json:"src"` // "chip" = a tapped bot proposal (its failure gets a spoken explanation) } // grant is what a member sealed to the bot for one capability op. type grant struct { Service string `json:"service"` Token string `json:"token"` Exp int64 `json:"exp"` // unix millis; 0 = no expiry Access string `json:"access"` // "read" | "write"; empty = legacy grant, read-only // Expiring-token providers (GitHub Apps): the refresh token and the access // token's own expiry ride along so the bot can rotate a stale pair mid-op // and hand the new pair back (sealed to the member) on the result. Refresh string `json:"refresh"` TokenExp int64 `json:"token_expires_at"` // unix millis; 0 = non-expiring // Kind is how the credential was born ("pat" | "oauth"; empty = legacy). // Mixed-scheme providers need it: Linear's pasted keys go in the header // bare, its OAuth tokens as Bearer — ExecuteAs picks per token. Kind string `json:"kind"` } // handleConnect runs a capability op against a sealed grant. It replies ONLY // with a non-rendering connect.result (matched by rid) — the DNSimple app // renders the data itself, so a capability op is never visible as a bot chat // message. A rid-less op (fire-and-forget) gets no reply. func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) { var g grant if e2ee.OpenSealed(p.Grant, r.Session.UserPriv, &g) != nil { return // not sealed to us — silently ignore (it isn't ours) } reply := func(result map[string]any) { if p.RID == "" { return } out := map[string]any{"type": "connect.result", "service": p.Service, "op": p.Op, "rid": p.RID, "ts": time.Now().UnixMilli()} for k, v := range result { out[k] = v } if err := r.sendPayload(threadID, key, out); err != nil { r.logf("connect.result: %v", err) } } // A chip is this bot's own proposal, so its failure is answered in the // bot's voice: the raw error still goes back on the result (the instant // notice), and the brain follows with a plain-language explanation in the // same thread. Chip ops only — an app surface (the GitHub app's Refresh) // renders its own inline errors, and a bot message there would be noise. fail := func(msg string) { reply(map[string]any{"error": msg}) if p.Src == "chip" { go r.explainConnectError(threadID, key, p, msg) } } if g.Exp != 0 && g.Exp < time.Now().UnixMilli() { reply(map[string]any{"error": "That authorization expired — try again."}) return } // Registry-driven path: any service/op pair the integrations registry // declares runs through the pinned executor. Anything else falls through // to the legacy dnsimple-only path below — deliberately including the // legacy op id "list", which the dnsimple manifest does NOT declare (its // op is "domains.list"), so existing DNSimple-app clients keep working. if m := integrations.MustRegistry().Get(p.Service); m != nil { if op := m.Op(p.Op); op != nil { if g.Service != p.Service { reply(map[string]any{"error": "That authorization is for \"" + g.Service + "\", not " + p.Service + "."}) return } // A read grant cannot run a write op: the member scoped what they // consented to, and an empty access is a legacy (read) grant. if op.Access == "write" && g.Access != "write" { reply(map[string]any{"error": "That authorization is read-only."}) return } // Expiring-token providers: rotate a stale pair before calling // (with a little slack), and hand the fresh pair back on the // result so the member's stored credential stays alive. Rotation // is serialized per credential — providers' refresh tokens are // single-use, so a concurrent second op shares the first refresh // instead of burning a dead token. token := g.Token var renewed *tokenPair if g.Refresh != "" && g.TokenExp != 0 && time.Now().UnixMilli() > g.TokenExp-60_000 { pair, err := r.refreshGrant(&g, p.UID) if err != nil { fail(err.Error()) return } token, renewed = pair.Access, pair } data, err := integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind) // A 401 on an un-refreshed expiring pair means the expiry stamp // lied (revoked-and-reissued, clock skew): rotate once and retry. var se *integrations.StatusError if err != nil && errors.As(err, &se) && se.Code == 401 && g.Refresh != "" && renewed == nil { pair, rerr := r.refreshGrant(&g, p.UID) if rerr == nil { token, renewed = pair.Access, pair data, err = integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind) } } if err != nil { fail(err.Error()) return } // An op result rides inside ONE encrypted message (64 KB blob cap, // hub.go), so cap what one op can push into a thread. Truncating // would hand the app broken JSON — refuse honestly instead; ops // bound their responses with the manifest's `pick` projection. if len(data) > maxOpResultBytes { fail("That result is too large to bring into the chat — narrow the request.") return } out := map[string]any{"data": data} // The renewed pair rides on the result, sealed to the MEMBER (never // stored here) — the requesting device re-stores it, so the next op // starts from the fresh pair. if renewed != nil { if pub := r.senderPublicKey(p.UID); pub != "" { if sealed, err := e2ee.SealForPub(pub, renewed.sealable()); err == nil { out["renewed"] = sealed } } } reply(out) return } } if g.Service != "dnsimple" { reply(map[string]any{"error": "Can't act on \"" + g.Service + "\" yet."}) return } if p.Op != "list" { reply(map[string]any{"error": "Unknown action \"" + p.Op + "\"."}) return } domains, err := r.connect.dnsimple.listDomains(g.Token) if err != nil { reply(map[string]any{"error": err.Error()}) return } reply(map[string]any{"domains": domains}) } // handleOAuthExchange finishes the confidential OAuth dance for any service // the broker has an app for: run the broker exchange (no secret on this // host), seal the token to the MEMBER's public key so only they can open it, // hand it straight back, and keep no copy. The service must be a registry // service (or legacy dnsimple) — the broker refuses anything it has no app // for regardless. func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPayload) { if p.Service == "" { return } name := p.Service if m := integrations.MustRegistry().Get(p.Service); m != nil { name = m.Name } else if p.Service != "dnsimple" { return // not a service we know — stay silent (it isn't ours) } pair, err := exchangeViaBroker(p.Service, p.Code, p.State, r.connect.brokerURL, r.connect.brokerToken) if err != nil { r.logf("connect: %s exchange failed: %v", p.Service, err) r.postReply(threadID, key, Reply{Text: "Couldn't finish connecting " + name + ": " + err.Error()}) return } pub := r.senderPublicKey(p.UID) if pub == "" { r.postReply(threadID, key, Reply{Text: "Connected, but I couldn't find your key to hand it back — try again."}) return } // The whole pair (access + refresh + expiry, when the provider issues // them) is sealed to the member; a non-expiring provider keeps the legacy // {secret} shape. sealed, err := e2ee.SealForPub(pub, pair.sealable()) if err != nil { r.logf("connect: seal token: %v", err) return } if err := r.sendPayload(threadID, key, map[string]any{ "type": "connect.token", "service": p.Service, "sealed": sealed, "name": r.Session.Name, "ts": time.Now().UnixMilli(), }); err != nil { r.logf("connect.token: %v", err) } } // explainConnectError has the brain own a failed chip op conversationally: // the chip was this bot's proposal, so its failure gets a short plain-language // reply in-thread — what went wrong and the concrete fix (the knowledge base // carries the decoder for the common cases, e.g. GitHub's 404-means-not- // installed). The raw error already went back on the connect.result; a bot // with no brain just leaves it at that. func (r *Runtime) explainConnectError(threadID string, key []byte, p *connectPayload, errMsg string) { if r.ai == nil { return } resp, err := r.ai.create(anthRequest{ Model: r.model, MaxTokens: 1024, System: r.systemBlocks(), Messages: []anthMessage{{Role: "user", Content: "[automatic system note — not written by the member] " + "The member just tapped your \"" + p.Op + "\" chip for " + p.Service + " and the operation FAILED. " + "The raw error (already shown to them as a notice): " + errMsg + " — " + "In one short, friendly message: acknowledge it didn't work, explain the likely cause in plain " + "language, and give the concrete fix. No tool calls, no new chips, no apologising twice."}}, }) if err != nil { r.logf("connect explain: %v", err) return } if text := strings.TrimSpace(resp.text()); text != "" { r.postReply(threadID, key, Reply{Text: text}) } } // refreshGrant rotates the grant's expiring pair via the broker. It refuses // to rotate when the fresh pair couldn't be handed back (no resolvable member // key) — rotation consumes the stored refresh token, so losing the result // would sever the connection outright. func (r *Runtime) refreshGrant(g *grant, uid string) (*tokenPair, error) { if uid == "" || r.senderPublicKey(uid) == "" { return nil, fmt.Errorf("that %s connection's token expired and I couldn't hand a fresh one back — reconnect it in Integrations", g.Service) } return r.connect.refreshes.do(g.Refresh, func() (*tokenPair, error) { return refreshViaBroker(g.Service, g.Refresh, r.connect.brokerURL, r.connect.brokerToken) }) } // refreshGroup coalesces concurrent refreshes of the SAME credential: refresh // tokens are single-use (GitHub rotates one on every call), so two // near-simultaneous ops must share one rotation rather than the loser burning // a dead token. Nothing is retained once a flight lands — the fresh pair // lives only in the results handed to the waiting ops. type refreshGroup struct { mu sync.Mutex calls map[string]*refreshCall } type refreshCall struct { done chan struct{} pair *tokenPair err error } func newRefreshGroup() *refreshGroup { return &refreshGroup{calls: map[string]*refreshCall{}} } func (rg *refreshGroup) do(key string, fn func() (*tokenPair, error)) (*tokenPair, error) { rg.mu.Lock() if c, ok := rg.calls[key]; ok { rg.mu.Unlock() <-c.done return c.pair, c.err } c := &refreshCall{done: make(chan struct{})} rg.calls[key] = c rg.mu.Unlock() c.pair, c.err = fn() close(c.done) rg.mu.Lock() delete(rg.calls, key) rg.mu.Unlock() return c.pair, c.err } // senderPublicKey looks up a roster member's public key by user id (to seal the // OAuth token back to them). func (r *Runtime) senderPublicKey(uid string) string { if uid == "" { return "" } var rl struct { Roster []struct { UserID string `json:"user_id"` PublicKey string `json:"public_key"` } `json:"roster"` } if err := r.get("/api/roster", &rl); err != nil { r.logf("connect: roster: %v", err) return "" } for _, e := range rl.Roster { if e.UserID == uid { return e.PublicKey } } return "" }