server: bot connected-services — the Go sidecar is now a full drop-in (#311)
Ports runtime.mjs's handleConnect / handleOAuthExchange + agent/dnsimple.mjs to Go (internal/bot/connect.go + dnsimple.go). A member seals a time-boxed grant to the bot's key; the bot opens it with its own private key (a grant sealed to a different bot won't open — it stays silent), lists DNSimple domains or runs the confidential OAuth exchange via the shared broker, and seals the result back to the member. connect.* payloads are routed at the runtime level (before the recipe Handler), matching Node — capability plumbing, not chat behaviour. Same env as the Node sidecar (LCHAT_CONNECT_BROKER_URL/_TOKEN, LCHAT_DNSIMPLE_BASE) and same wire shapes (connect.op/oauth → connect.result/token). With this the three shipped bots run entirely from the lchat binary with no Node; agent/ stays as the reference.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+610
−14
bot_connect_test.go+212 −0docs/bots.md+14 −6docs/connected-services.md+5 −0internal/bot/connect.go+152 −0internal/bot/dnsimple.go+196 −0internal/bot/runtime.go+31 −8
diff --git a/bot_connect_test.go b/bot_connect_test.go| new file mode 100644 |
| index 0000000..07cea02 |
| --- /dev/null |
| +++ b/bot_connect_test.go |
| @@ -0,0 +1,212 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/bot" |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// postTyped posts an arbitrary (already-shaped) payload encrypted under the |
| +// thread key — used to inject connect.op / connect.oauth messages the way the |
| +// browser (shell.js) does. |
| +func postTyped(t *testing.T, tsURL string, sess *bot.Session, threadID string, key []byte, payload map[string]any) { |
| + t.Helper() |
| + blob, err := e2ee.EncryptJSON(key, payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) |
| + resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("post typed: %d", resp.StatusCode) |
| + } |
| +} |
| + |
| +// typedReply returns the first message in the thread whose decrypted payload has |
| +// the given type (connect.result / connect.token). |
| +func typedReply(t *testing.T, s *Server, threadID string, key []byte, typ string) map[string]any { |
| + t.Helper() |
| + msgs, err := s.db.messagesAfter(threadID, 0, 200) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range msgs { |
| + var p map[string]any |
| + if e2ee.DecryptJSON(key, m.Blob, &p) == nil && p["type"] == typ { |
| + return p |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// fakeDNSimple serves the minimal endpoints listDomains touches. |
| +func fakeDNSimple(t *testing.T) *httptest.Server { |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.URL.Path == "/v2/whoami": |
| + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"account": map[string]any{"id": 42}}}) |
| + case strings.HasSuffix(r.URL.Path, "/domains"): |
| + json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{ |
| + {"name": "example.com", "state": "registered", "auto_renew": true, "expires_on": "2027-01-01", "account_id": 42}, |
| + }}) |
| + case strings.HasSuffix(r.URL.Path, "/prices"): |
| + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"renewal_price": 14.5}}) |
| + default: |
| + http.Error(w, "not found", 404) |
| + } |
| + })) |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +// TestBotConnectListDomains drives a connect.op (list) end-to-end: the member |
| +// seals a DNSimple token to the bot in a grant, the bot opens it, lists domains |
| +// against a fake DNSimple, and replies with a non-rendering connect.result the |
| +// member matches by rid. |
| +func TestBotConnectListDomains(t *testing.T) { |
| + dns := fakeDNSimple(t) |
| + t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL) |
| + |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| + |
| + ready := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, |
| + Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} |
| + go rt.Run(ctx) |
| + awaitReady(t, ready) |
| + |
| + // The member seals { service, token, exp } to the bot's public key — only |
| + // the bot can open it — exactly like shell.js's requestData grant. |
| + grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{ |
| + "service": "dnsimple", "token": "scoped-tok", "exp": time.Now().Add(2 * time.Minute).UnixMilli(), |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| + postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| + "type": "connect.op", "service": "dnsimple", "op": "list", "grant": grant, |
| + "rid": "rid-1", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| + }) |
| + |
| + waitFor(t, "connect.result", func() bool { return typedReply(t, s, dmID, dmKey, "connect.result") != nil }) |
| + res := typedReply(t, s, dmID, dmKey, "connect.result") |
| + if res["rid"] != "rid-1" || res["op"] != "list" { |
| + t.Fatalf("bad result envelope: %+v", res) |
| + } |
| + domains, _ := res["domains"].([]any) |
| + if len(domains) != 1 { |
| + t.Fatalf("want 1 domain, got %+v", res["domains"]) |
| + } |
| + d0 := domains[0].(map[string]any) |
| + if d0["name"] != "example.com" || d0["price"].(float64) != 14.5 { |
| + t.Fatalf("bad domain row: %+v", d0) |
| + } |
| + |
| + // A grant sealed to someone else must NOT open — the bot stays silent (no |
| + // second result), proving the capability is bound to the bot's key. |
| + other, _ := e2ee.GenerateUserKeypair() |
| + foreign, _ := e2ee.SealForPub(other.PublicKey, map[string]any{"service": "dnsimple", "token": "x", "exp": time.Now().Add(time.Minute).UnixMilli()}) |
| + postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| + "type": "connect.op", "service": "dnsimple", "op": "list", "grant": foreign, |
| + "rid": "rid-2", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| + }) |
| + // Post a second, addressed command AFTER the foreign grant. When the bot |
| + // answers that (proving it processed past the foreign op), the foreign op |
| + // must have produced no connect.result — a clean ordering check, no sleep. |
| + postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{"name": "Owner", "text": "/help", "ts": time.Now().UnixMilli()}) |
| + waitFor(t, "command after foreign grant", func() bool { |
| + return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 |
| + }) |
| + for _, m := range mustMsgs(t, s, dmID) { |
| + var p map[string]any |
| + if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-2" { |
| + t.Fatal("bot answered a grant not sealed to it") |
| + } |
| + } |
| +} |
| + |
| +func mustMsgs(t *testing.T, s *Server, threadID string) []Message { |
| + t.Helper() |
| + msgs, err := s.db.messagesAfter(threadID, 0, 200) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return msgs |
| +} |
| + |
| +// TestBotConnectOAuth drives the confidential OAuth exchange: the member sends |
| +// {code, state}, the bot exchanges via a fake broker, seals the token back to |
| +// the member, and the member opens it — the server only ever saw sealed bytes. |
| +func TestBotConnectOAuth(t *testing.T) { |
| + var gotAuth string |
| + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + var body map[string]string |
| + json.NewDecoder(r.Body).Decode(&body) |
| + if body["code"] != "the-code" { |
| + http.Error(w, `{"message":"bad code"}`, 400) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]string{"access_token": "member-token"}) |
| + })) |
| + t.Cleanup(broker.Close) |
| + t.Setenv("LCHAT_CONNECT_BROKER_URL", broker.URL) |
| + t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "instance-bearer") |
| + |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| + |
| + ready := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, |
| + Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} |
| + go rt.Run(ctx) |
| + awaitReady(t, ready) |
| + |
| + ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| + postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| + "type": "connect.oauth", "service": "dnsimple", "code": "the-code", "state": "st", |
| + "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| + }) |
| + |
| + waitFor(t, "connect.token", func() bool { return typedReply(t, s, dmID, dmKey, "connect.token") != nil }) |
| + tok := typedReply(t, s, dmID, dmKey, "connect.token") |
| + sealed, _ := tok["sealed"].(string) |
| + if sealed == "" { |
| + t.Fatalf("no sealed token: %+v", tok) |
| + } |
| + if gotAuth != "Bearer instance-bearer" { |
| + t.Fatalf("broker not authed with the instance token: %q", gotAuth) |
| + } |
| + // Only the member can open it. |
| + var opened struct { |
| + Secret string `json:"secret"` |
| + } |
| + if err := e2ee.OpenSealed(sealed, owner.UserPriv, &opened); err != nil { |
| + t.Fatalf("member couldn't open the sealed token: %v", err) |
| + } |
| + if opened.Secret != "member-token" { |
| + t.Fatalf("wrong token handed back: %q", opened.Secret) |
| + } |
| +} |
diff --git a/docs/bots.md b/docs/bots.md| index 3d9451b..83f199b 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -140,12 +140,20 @@ the command floor to conversational replies — same layered system prompt |
| (non-overridable safety preamble → console persona → fellow-helpers line → |
| docs), cache-marked system block, adaptive thinking, the chip tools |
| (drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal, |
| -and the listen-mode "name" classifier gate — all as in `runtime.mjs`. **The Go |
| -port now reaches Node parity for the three shipped bots** (Announcer, the |
| -deterministic floor, and conversational Eleven/Claude); what stays in Node is |
| -only the connected-services capability ops (`handleConnect`/`handleOAuthExchange`) |
| -and Eleven's onboarding welcomer, which is deliberately moving client-side as |
| -data (`docs/conversation-scripts.md`), not into the sidecar. |
| +and the listen-mode "name" classifier gate — all as in `runtime.mjs`. **And |
| +connected-services** (`internal/bot/connect.go` + `dnsimple.go`): the capability |
| +ops `handleConnect` / `handleOAuthExchange` are ported — a member seals a |
| +time-boxed grant to the bot's key, the bot opens it with its own private key |
| +(a grant sealed to a different bot won't open, so it stays silent), lists |
| +DNSimple domains or runs the confidential OAuth exchange via the shared broker, |
| +and seals the result back to the member. **The Go sidecar is now a full drop-in |
| +for the Node one**: everything the three shipped bots do — Announcer, |
| +deterministic floor, conversational Eleven/Claude, and connected-services — |
| +runs from the single `lchat` binary with no Node. The one thing deliberately |
| +*not* in the sidecar is Eleven's onboarding welcomer, which is moving |
| +client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the |
| +reference implementation; session/state/recipe files are Node-compatible so |
| +either can take over the other's identity in place. |
| ## What exists today (`agent/`) |
diff --git a/docs/connected-services.md b/docs/connected-services.md| index ea1d82a..ac3f96e 100644 |
| --- a/docs/connected-services.md |
| +++ b/docs/connected-services.md |
| @@ -45,6 +45,11 @@ from the broker's `GET /config`, so **a stock instance needs no DNSimple config |
| all**; the broker URL defaults to `connect.elevenmessenger.com` |
| (`LCHAT_CONNECT_BROKER_URL` overrides for a self-hosted broker). |
| +The sidecar side is ported to Go (`internal/bot/connect.go` + `dnsimple.go`), so |
| +`lchat bot run` handles `connect.op`/`connect.oauth` with no Node — same env |
| +(`LCHAT_CONNECT_BROKER_URL`/`_TOKEN`, `LCHAT_DNSIMPLE_BASE`), same wire shapes. |
| +`agent/dnsimple.mjs` + `runtime.mjs` remain the reference. |
| + |
| | Compromised | Holds | Usable to an attacker? | |
| |---|---|---| |
| | Redirect relay (public endpoint) | nothing | single-use codes only → **no** | |
diff --git a/internal/bot/connect.go b/internal/bot/connect.go| new file mode 100644 |
| index 0000000..27dfedb |
| --- /dev/null |
| +++ b/internal/bot/connect.go |
| @@ -0,0 +1,152 @@ |
| +package bot |
| + |
| +import ( |
| + "os" |
| + "time" |
| + |
| + "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 |
| +} |
| + |
| +func loadConnectConfig() *connectConfig { |
| + return &connectConfig{ |
| + brokerURL: os.Getenv("LCHAT_CONNECT_BROKER_URL"), |
| + brokerToken: os.Getenv("LCHAT_CONNECT_BROKER_TOKEN"), |
| + dnsimple: newDNSimpleClient(os.Getenv("LCHAT_DNSIMPLE_BASE")), |
| + } |
| +} |
| + |
| +// 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"` |
| + Grant string `json:"grant"` // sealed to this bot: {service, token, exp} |
| + 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"` |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// 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": "dnsimple", "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) |
| + } |
| + } |
| + if g.Exp != 0 && g.Exp < time.Now().UnixMilli() { |
| + reply(map[string]any{"error": "That authorization expired — try again."}) |
| + 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: 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. |
| +func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPayload) { |
| + if p.Service != "dnsimple" { |
| + return |
| + } |
| + token, err := r.connect.dnsimple.exchangeViaBroker(p.Code, p.State, r.connect.brokerURL, r.connect.brokerToken) |
| + if err != nil { |
| + r.logf("connect: dnsimple exchange failed: %v", err) |
| + r.postReply(threadID, key, Reply{Text: "Couldn't finish connecting DNSimple: " + 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 |
| + } |
| + sealed, err := e2ee.SealForPub(pub, map[string]string{"secret": token}) |
| + if err != nil { |
| + r.logf("connect: seal token: %v", err) |
| + return |
| + } |
| + if err := r.sendPayload(threadID, key, map[string]any{ |
| + "type": "connect.token", "service": "dnsimple", "sealed": sealed, "name": r.Session.Name, "ts": time.Now().UnixMilli(), |
| + }); err != nil { |
| + r.logf("connect.token: %v", 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 "" |
| +} |
diff --git a/internal/bot/dnsimple.go b/internal/bot/dnsimple.go| new file mode 100644 |
| index 0000000..b97dce1 |
| --- /dev/null |
| +++ b/internal/bot/dnsimple.go |
| @@ -0,0 +1,196 @@ |
| +package bot |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// The Go port of agent/dnsimple.mjs: a tiny DNSimple client for the |
| +// connected-services prototype (docs/connected-services.md). The access token |
| +// arrives inside a time-boxed, sealed capability grant, is held only for the |
| +// duration of one call, and is never persisted on this host. |
| + |
| +// dnsimpleClient talks to the DNSimple API (base overridable for tests via |
| +// LCHAT_DNSIMPLE_BASE) and to the connect broker for the confidential OAuth |
| +// exchange (the broker holds the client_secret; this sidecar never does). |
| +type dnsimpleClient struct { |
| + base string |
| + http *http.Client |
| +} |
| + |
| +func newDNSimpleClient(base string) *dnsimpleClient { |
| + if base == "" { |
| + base = "https://api.dnsimple.com" |
| + } |
| + return &dnsimpleClient{base: strings.TrimRight(base, "/"), http: &http.Client{Timeout: 30 * time.Second}} |
| +} |
| + |
| +// apiGet does an authorized GET and decodes JSON, surfacing DNSimple's error |
| +// message on a non-2xx (matching the Node client's error text). |
| +func (c *dnsimpleClient) apiGet(token, path string, out any) error { |
| + req, err := http.NewRequest("GET", c.base+path, nil) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + req.Header.Set("Accept", "application/json") |
| + resp, err := c.http.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| + if resp.StatusCode/100 != 2 { |
| + if msg := jsonMessage(body); msg != "" { |
| + return fmt.Errorf("%s", msg) |
| + } |
| + return fmt.Errorf("DNSimple %d", resp.StatusCode) |
| + } |
| + if out != nil { |
| + return json.Unmarshal(body, out) |
| + } |
| + return nil |
| +} |
| + |
| +// jsonMessage pulls a {"message": "..."} field out of an error body, if present. |
| +func jsonMessage(body []byte) string { |
| + var e struct { |
| + Message string `json:"message"` |
| + } |
| + _ = json.Unmarshal(body, &e) |
| + return e.Message |
| +} |
| + |
| +// whoami resolves the single account the token acts for. A user token spanning |
| +// several accounts has no single account id — we refuse it, since registrar |
| +// calls are account-scoped and we won't guess which account to spend from. |
| +func (c *dnsimpleClient) whoami(token string) (int64, error) { |
| + var d struct { |
| + Data struct { |
| + Account struct { |
| + ID int64 `json:"id"` |
| + } `json:"account"` |
| + } `json:"data"` |
| + } |
| + if err := c.apiGet(token, "/v2/whoami", &d); err != nil { |
| + return 0, err |
| + } |
| + if d.Data.Account.ID == 0 { |
| + return 0, fmt.Errorf("that token isn't scoped to a single account — create an account access token") |
| + } |
| + return d.Data.Account.ID, nil |
| +} |
| + |
| +// Domain is one row for the DNSimple app's table. |
| +type Domain struct { |
| + Name string `json:"name"` |
| + State string `json:"state"` |
| + AutoRenew bool `json:"auto_renew"` |
| + ExpiresOn string `json:"expires_on"` |
| + AccountID int64 `json:"account_id"` |
| + Price *float64 `json:"price,omitempty"` |
| +} |
| + |
| +// listDomains returns the account's domains with best-effort per-domain renewal |
| +// pricing (bounded concurrency — the list payload omits price). A pricing |
| +// failure leaves that row's price nil ("—" in the app), never fails the list. |
| +func (c *dnsimpleClient) listDomains(token string) ([]Domain, error) { |
| + account, err := c.whoami(token) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var d struct { |
| + Data []struct { |
| + Name string `json:"name"` |
| + State string `json:"state"` |
| + AutoRenew bool `json:"auto_renew"` |
| + ExpiresOn string `json:"expires_on"` |
| + AccountID int64 `json:"account_id"` |
| + } `json:"data"` |
| + } |
| + if err := c.apiGet(token, fmt.Sprintf("/v2/%d/domains?per_page=100", account), &d); err != nil { |
| + return nil, err |
| + } |
| + rows := make([]Domain, 0, len(d.Data)) |
| + for _, r := range d.Data { |
| + if r.Name == "" { |
| + continue |
| + } |
| + acct := r.AccountID |
| + if acct == 0 { |
| + acct = account |
| + } |
| + rows = append(rows, Domain{Name: r.Name, State: r.State, AutoRenew: r.AutoRenew, ExpiresOn: r.ExpiresOn, AccountID: acct}) |
| + } |
| + // Fetch renewal prices with at most 8 requests in flight. |
| + sem := make(chan struct{}, 8) |
| + var wg sync.WaitGroup |
| + for i := range rows { |
| + wg.Add(1) |
| + go func(i int) { |
| + defer wg.Done() |
| + sem <- struct{}{} |
| + defer func() { <-sem }() |
| + var p struct { |
| + Data struct { |
| + RenewalPrice *float64 `json:"renewal_price"` |
| + RegistrationPrice *float64 `json:"registration_price"` |
| + } `json:"data"` |
| + } |
| + path := fmt.Sprintf("/v2/%d/registrar/domains/%s/prices", account, url.PathEscape(rows[i].Name)) |
| + if c.apiGet(token, path, &p) != nil { |
| + return // pricing is best-effort |
| + } |
| + if p.Data.RenewalPrice != nil { |
| + rows[i].Price = p.Data.RenewalPrice |
| + } else if p.Data.RegistrationPrice != nil { |
| + rows[i].Price = p.Data.RegistrationPrice |
| + } |
| + }(i) |
| + } |
| + wg.Wait() |
| + return rows, nil |
| +} |
| + |
| +// exchangeViaBroker swaps an authorization code for an access token via the |
| +// connect broker (docs/connected-services.md). DNSimple is confidential-only, |
| +// so the exchange needs the client_secret, which lives ONLY on the broker; this |
| +// sidecar authenticates with a per-instance bearer token and gets back just the |
| +// user's access token (which the caller seals to the member and drops). |
| +func (c *dnsimpleClient) exchangeViaBroker(code, state, brokerURL, brokerToken string) (string, error) { |
| + if brokerURL == "" || brokerToken == "" { |
| + return "", fmt.Errorf("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)") |
| + } |
| + body, _ := json.Marshal(map[string]string{"code": code, "state": state}) |
| + req, err := http.NewRequest("POST", strings.TrimRight(brokerURL, "/")+"/api/exchange", bytes.NewReader(body)) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", "Bearer "+brokerToken) |
| + resp, err := c.http.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer resp.Body.Close() |
| + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| + var d struct { |
| + AccessToken string `json:"access_token"` |
| + Message string `json:"message"` |
| + } |
| + _ = json.Unmarshal(raw, &d) |
| + if resp.StatusCode/100 != 2 || d.AccessToken == "" { |
| + if d.Message != "" { |
| + return "", fmt.Errorf("%s", d.Message) |
| + } |
| + return "", fmt.Errorf("broker exchange %d", resp.StatusCode) |
| + } |
| + return d.AccessToken, nil |
| +} |
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go| index 8691f0e..54a804c 100644 |
| --- a/internal/bot/runtime.go |
| +++ b/internal/bot/runtime.go |
| @@ -46,6 +46,7 @@ type Runtime struct { |
| client *http.Client |
| ai *anthropicClient |
| + connect *connectConfig // connected-services (docs/connected-services.md) |
| keys map[string]threadEntry |
| cursors map[string]int64 // threadId -> last message id we've processed |
| agents map[string]bool // roster labels of ALL agents — never reply to one |
| @@ -164,6 +165,7 @@ func (r *Runtime) Run(ctx context.Context) error { |
| if r.AIKey != "" { |
| r.ai = newAnthropicClient(r.AIKey) |
| } |
| + r.connect = loadConnectConfig() |
| backoff := time.Second |
| for { |
| start := time.Now() |
| @@ -306,8 +308,23 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) { |
| if r.agents[p.Name] { |
| return // bots never talk to bots (see loadAgents) |
| } |
| + // A direct capability invocation (a sealed connected-services grant), not a |
| + // chat trigger — handled here whether or not the bot has a recipe/brain, |
| + // exactly as the Node runtime does (connect.go). |
| + if p.Type == "connect.op" || p.Type == "connect.oauth" { |
| + var cp connectPayload |
| + if e2ee.DecryptJSON(entry.key, blob, &cp) != nil { |
| + return |
| + } |
| + if cp.Type == "connect.op" { |
| + r.handleConnect(threadID, entry.key, &cp) |
| + } else { |
| + r.handleOAuthExchange(threadID, entry.key, &cp) |
| + } |
| + return |
| + } |
| if p.Type != "" { |
| - return // a typed protocol payload (connect.*, app event), not chat text |
| + return // any other typed payload (a mini-app event), not chat text |
| } |
| if r.Handler == nil { |
| return |
| @@ -344,8 +361,8 @@ func (r *Runtime) triggered(tc *ThreadCtx, text string) bool { |
| return false |
| } |
| -// postReply encrypts and sends one reply payload — the same wire shape the Node |
| -// sidecar posts, so clients can't tell the implementations apart. |
| +// postReply encrypts and sends one chat reply payload — the same wire shape the |
| +// Node sidecar posts, so clients can't tell the implementations apart. |
| func (r *Runtime) postReply(threadID string, key []byte, rep Reply) { |
| if rep.Text == "" && len(rep.Actions) > 0 { |
| rep.Text = "Tap to apply:" |
| @@ -357,14 +374,20 @@ func (r *Runtime) postReply(threadID string, key []byte, rep Reply) { |
| if len(rep.Actions) > 0 { |
| payload["actions"] = rep.Actions |
| } |
| + if err := r.sendPayload(threadID, key, payload); err != nil { |
| + r.logf("send reply: %v", err) |
| + } |
| +} |
| + |
| +// sendPayload encrypts an arbitrary payload under the thread key and sends it as |
| +// a msg frame. Shared by chat replies (postReply) and the non-rendering |
| +// connected-services replies (connect.result / connect.token). |
| +func (r *Runtime) sendPayload(threadID string, key []byte, payload map[string]any) error { |
| blob, err := e2ee.EncryptJSON(key, payload) |
| if err != nil { |
| - r.logf("encrypt reply: %v", err) |
| - return |
| - } |
| - if err := r.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob}); err != nil { |
| - r.logf("send reply: %v", err) |
| + return err |
| } |
| + return r.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob}) |
| } |
| func (r *Runtime) send(v any) error { |