eleven / messenger Public

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>
Paul Campbell 97eddf4f16c2f6413a83a8f95b4912c3e0f7c40e parent d8e9cc2
6 files changed, +610 −14
  • bot_connect_test.go +212 −0
  • docs/bots.md +14 −6
  • docs/connected-services.md +5 −0
  • internal/bot/connect.go +152 −0
  • internal/bot/dnsimple.go +196 −0
  • internal/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 {