eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11 "sync"
12 "time"
13)
14
15// The Go port of agent/dnsimple.mjs: a tiny DNSimple client for the
16// connected-services prototype (docs/connected-services.md). The access token
17// arrives inside a time-boxed, sealed capability grant, is held only for the
18// duration of one call, and is never persisted on this host.
19
20// dnsimpleClient talks to the DNSimple API (base overridable for tests via
21// LCHAT_DNSIMPLE_BASE) and to the connect broker for the confidential OAuth
22// exchange (the broker holds the client_secret; this sidecar never does).
23type dnsimpleClient struct {
24 base string
25 http *http.Client
26}
27
28func newDNSimpleClient(base string) *dnsimpleClient {
29 if base == "" {
30 base = "https://api.dnsimple.com"
31 }
32 return &dnsimpleClient{base: strings.TrimRight(base, "/"), http: &http.Client{Timeout: 30 * time.Second}}
33}
34
35// apiGet does an authorized GET and decodes JSON, surfacing DNSimple's error
36// message on a non-2xx (matching the Node client's error text).
37func (c *dnsimpleClient) apiGet(token, path string, out any) error {
38 req, err := http.NewRequest("GET", c.base+path, nil)
39 if err != nil {
40 return err
41 }
42 req.Header.Set("Authorization", "Bearer "+token)
43 req.Header.Set("Accept", "application/json")
44 resp, err := c.http.Do(req)
45 if err != nil {
46 return err
47 }
48 defer resp.Body.Close()
49 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
50 if resp.StatusCode/100 != 2 {
51 if msg := jsonMessage(body); msg != "" {
52 return fmt.Errorf("%s", msg)
53 }
54 return fmt.Errorf("DNSimple %d", resp.StatusCode)
55 }
56 if out != nil {
57 return json.Unmarshal(body, out)
58 }
59 return nil
60}
61
62// jsonMessage pulls a {"message": "..."} field out of an error body, if present.
63func jsonMessage(body []byte) string {
64 var e struct {
65 Message string `json:"message"`
66 }
67 _ = json.Unmarshal(body, &e)
68 return e.Message
69}
70
71// whoami resolves the single account the token acts for. A user token spanning
72// several accounts has no single account id — we refuse it, since registrar
73// calls are account-scoped and we won't guess which account to spend from.
74func (c *dnsimpleClient) whoami(token string) (int64, error) {
75 var d struct {
76 Data struct {
77 Account struct {
78 ID int64 `json:"id"`
79 } `json:"account"`
80 } `json:"data"`
81 }
82 if err := c.apiGet(token, "/v2/whoami", &d); err != nil {
83 return 0, err
84 }
85 if d.Data.Account.ID == 0 {
86 return 0, fmt.Errorf("that token isn't scoped to a single account — create an account access token")
87 }
88 return d.Data.Account.ID, nil
89}
90
91// Domain is one row for the DNSimple app's table.
92type Domain struct {
93 Name string `json:"name"`
94 State string `json:"state"`
95 AutoRenew bool `json:"auto_renew"`
96 ExpiresOn string `json:"expires_on"`
97 AccountID int64 `json:"account_id"`
98 Price *float64 `json:"price,omitempty"`
99}
100
101// listDomains returns the account's domains with best-effort per-domain renewal
102// pricing (bounded concurrency — the list payload omits price). A pricing
103// failure leaves that row's price nil ("—" in the app), never fails the list.
104func (c *dnsimpleClient) listDomains(token string) ([]Domain, error) {
105 account, err := c.whoami(token)
106 if err != nil {
107 return nil, err
108 }
109 var d struct {
110 Data []struct {
111 Name string `json:"name"`
112 State string `json:"state"`
113 AutoRenew bool `json:"auto_renew"`
114 ExpiresOn string `json:"expires_on"`
115 AccountID int64 `json:"account_id"`
116 } `json:"data"`
117 }
118 if err := c.apiGet(token, fmt.Sprintf("/v2/%d/domains?per_page=100", account), &d); err != nil {
119 return nil, err
120 }
121 rows := make([]Domain, 0, len(d.Data))
122 for _, r := range d.Data {
123 if r.Name == "" {
124 continue
125 }
126 acct := r.AccountID
127 if acct == 0 {
128 acct = account
129 }
130 rows = append(rows, Domain{Name: r.Name, State: r.State, AutoRenew: r.AutoRenew, ExpiresOn: r.ExpiresOn, AccountID: acct})
131 }
132 // Fetch renewal prices with at most 8 requests in flight.
133 sem := make(chan struct{}, 8)
134 var wg sync.WaitGroup
135 for i := range rows {
136 wg.Add(1)
137 go func(i int) {
138 defer wg.Done()
139 sem <- struct{}{}
140 defer func() { <-sem }()
141 var p struct {
142 Data struct {
143 RenewalPrice *float64 `json:"renewal_price"`
144 RegistrationPrice *float64 `json:"registration_price"`
145 } `json:"data"`
146 }
147 path := fmt.Sprintf("/v2/%d/registrar/domains/%s/prices", account, url.PathEscape(rows[i].Name))
148 if c.apiGet(token, path, &p) != nil {
149 return // pricing is best-effort
150 }
151 if p.Data.RenewalPrice != nil {
152 rows[i].Price = p.Data.RenewalPrice
153 } else if p.Data.RegistrationPrice != nil {
154 rows[i].Price = p.Data.RegistrationPrice
155 }
156 }(i)
157 }
158 wg.Wait()
159 return rows, nil
160}
161
162// tokenPair is what the broker's exchange/refresh answer: an access token,
163// and — for providers with expiring user tokens (GitHub Apps) — the rotated
164// refresh token and lifetime.
165type tokenPair struct {
166 Access string `json:"access_token"`
167 Refresh string `json:"refresh_token"`
168 ExpiresIn int64 `json:"expires_in"` // seconds; 0 = non-expiring
169}
170
171// sealable renders the pair as the member-blob shape (docs/integrations.md):
172// what the client stores sealed to its own key and what a renewed pair rides
173// back as. Absent fields are omitted so non-expiring providers keep the
174// legacy {secret} shape.
175func (p *tokenPair) sealable() map[string]any {
176 out := map[string]any{"secret": p.Access}
177 if p.Refresh != "" {
178 out["refresh"] = p.Refresh
179 }
180 if p.ExpiresIn > 0 {
181 out["expires_at"] = time.Now().UnixMilli() + p.ExpiresIn*1000
182 }
183 return out
184}
185
186// exchangeViaBroker swaps an authorization code for a token pair via the
187// connect broker (docs/connected-services.md), for any service the broker has
188// an OAuth app for; refreshViaBroker rotates an expiring pair the same way.
189// These providers are confidential clients — both calls need the
190// client_secret, which lives ONLY on the broker; this sidecar authenticates
191// with a per-instance bearer token and gets back just the member's tokens
192// (which the caller seals to the member and drops).
193func exchangeViaBroker(service, code, state, brokerURL, brokerToken string) (*tokenPair, error) {
194 return brokerTokenCall("/api/exchange", map[string]string{"service": service, "code": code, "state": state}, brokerURL, brokerToken)
195}
196
197func refreshViaBroker(service, refresh, brokerURL, brokerToken string) (*tokenPair, error) {
198 return brokerTokenCall("/api/refresh", map[string]string{"service": service, "refresh_token": refresh}, brokerURL, brokerToken)
199}
200
201func brokerTokenCall(path string, payload map[string]string, brokerURL, brokerToken string) (*tokenPair, error) {
202 if brokerURL == "" || brokerToken == "" {
203 return nil, fmt.Errorf("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)")
204 }
205 body, _ := json.Marshal(payload)
206 req, err := http.NewRequest("POST", strings.TrimRight(brokerURL, "/")+path, bytes.NewReader(body))
207 if err != nil {
208 return nil, err
209 }
210 req.Header.Set("Content-Type", "application/json")
211 req.Header.Set("Authorization", "Bearer "+brokerToken)
212 resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
213 if err != nil {
214 return nil, err
215 }
216 defer resp.Body.Close()
217 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
218 var d struct {
219 tokenPair
220 Message string `json:"message"`
221 }
222 _ = json.Unmarshal(raw, &d)
223 if resp.StatusCode/100 != 2 || d.Access == "" {
224 if d.Message != "" {
225 return nil, fmt.Errorf("%s", d.Message)
226 }
227 return nil, fmt.Errorf("broker %s %d", strings.TrimPrefix(path, "/api/"), resp.StatusCode)
228 }
229 return &d.tokenPair, nil
230}
231