eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "os"
10 "strings"
11 "time"
12
13 "lchat/internal/e2ee"
14)
15
16// Announcer is the engine behind a recipe's `changelog` rule
17// (docs/bot-recipes.md): it polls the instance's embedded changelog
18// (/api/changelog) and posts entries it hasn't announced yet into every
19// group/DM the bot has been added to. It powered the retired default
20// "Announcer" identity; today the app announces its own updates server-side
21// (announce.go β€” a push to admins + the Admin app's update stream), and this
22// engine stays for operators whose custom bots explicitly opt into posting
23// release notes to groups.
24//
25// Deliberately write-only: it never fetches or decrypts a single message β€” it
26// unwraps thread keys only to *encrypt* what it posts, and it calls no model
27// API. "This bot is in the group" means "it can post here", not "it reads
28// this room".
29//
30// State (which entries have been announced) lives at <session>.state.json
31// ({"announced":[ids]}), so a restart never replays entries.
32type Announcer struct {
33 Base string
34 Session *Session
35 // StatePath defaults to <session path>.state.json via StatePathFor.
36 StatePath string
37 // Logf receives progress lines ("announced 2 entries to 3/3 threads").
38 Logf func(format string, args ...any)
39
40 client *http.Client
41 kindDeclared bool
42}
43
44// StatePathFor mirrors the Node announcer's convention.
45func StatePathFor(sessionPath string) string { return sessionPath + ".state.json" }
46
47type announcerState struct {
48 Announced []string `json:"announced"`
49}
50
51// Tick runs one poll: declare our kind (once, best-effort), fetch the
52// changelog, and post anything new to every thread we're in. On the very first
53// run it baselines to the current changelog without posting, so a fresh
54// announcer announces what ships *after* it, not history.
55func (a *Announcer) Tick() error {
56 if a.client == nil {
57 // Marked like all sidecar-to-instance traffic (sidecarmark.go), so a
58 // recipe's announce poller can't hold a hibernating instance awake.
59 a.client = &http.Client{Timeout: 30 * time.Second, Transport: markTransport{}}
60 }
61 a.declareKind()
62
63 var cl struct {
64 Entries []struct {
65 ID string `json:"id"`
66 Text string `json:"text"`
67 } `json:"entries"`
68 }
69 if err := a.get("/api/changelog", &cl); err != nil {
70 return fmt.Errorf("changelog: %w", err)
71 }
72
73 raw, err := os.ReadFile(a.StatePath)
74 if os.IsNotExist(err) {
75 ids := make([]string, 0, len(cl.Entries))
76 for _, e := range cl.Entries {
77 ids = append(ids, e.ID)
78 }
79 a.logf("baselined at %d entries", len(ids))
80 return a.saveState(announcerState{Announced: ids})
81 }
82 if err != nil {
83 return err
84 }
85 var state announcerState
86 if err := json.Unmarshal(raw, &state); err != nil {
87 return fmt.Errorf("bad state file %s: %w", a.StatePath, err)
88 }
89 seen := make(map[string]bool, len(state.Announced))
90 for _, id := range state.Announced {
91 seen[id] = true
92 }
93 var fresh []string // entry texts, oldest first (changelog order)
94 for _, e := range cl.Entries {
95 if e.ID != "" && e.Text != "" && !seen[e.ID] {
96 fresh = append(fresh, e.Text)
97 state.Announced = append(state.Announced, e.ID)
98 }
99 }
100 if len(fresh) == 0 {
101 return nil
102 }
103
104 text := "πŸš€ Just shipped:\n\n" + strings.Join(fresh, "\n\n")
105 var tl struct {
106 Threads []struct {
107 ID string `json:"id"`
108 Kind string `json:"kind"`
109 Wrapped string `json:"wrapped"`
110 } `json:"threads"`
111 }
112 if err := a.get("/api/threads", &tl); err != nil {
113 return fmt.Errorf("threads: %w", err)
114 }
115 posted, targets := 0, 0
116 for _, t := range tl.Threads {
117 if t.Wrapped == "" || (t.Kind != "group" && t.Kind != "dm") {
118 continue
119 }
120 targets++
121 if err := a.post(t.ID, t.Wrapped, text); err != nil {
122 a.logf("thread %s: %v", t.ID, err)
123 continue
124 }
125 posted++
126 }
127 // Mark announced even with zero targets: entries shipped while the bot sat
128 // in no groups are old news by the time someone adds it β€” don't replay them.
129 if err := a.saveState(state); err != nil {
130 return err
131 }
132 a.logf("announced %d entries to %d/%d threads", len(fresh), posted, targets)
133 return nil
134}
135
136// Run is the poll loop, until ctx is cancelled. Tick errors are logged and
137// retried next tick.
138func (a *Announcer) Run(ctx context.Context, interval time.Duration) {
139 for {
140 if err := a.Tick(); err != nil {
141 a.logf("tick failed: %v", err)
142 }
143 select {
144 case <-time.After(interval):
145 case <-ctx.Done():
146 return
147 }
148 }
149}
150
151// post encrypts one message under the thread's key and posts it β€” the only
152// thing the announcer ever does with a thread key.
153func (a *Announcer) post(threadID, wrapped, text string) error {
154 keyB64, err := e2ee.UnwrapThreadKey(wrapped, a.Session.UserPriv)
155 if err != nil {
156 return err
157 }
158 key, err := e2ee.ImportKey(keyB64)
159 if err != nil {
160 return err
161 }
162 blob, err := e2ee.EncryptJSON(key, map[string]any{
163 "name": a.Session.Name, "text": text, "ts": time.Now().UnixMilli(),
164 })
165 if err != nil {
166 return err
167 }
168 body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
169 resp, err := a.client.Post(a.Base+"/api/messages?token="+url.QueryEscape(a.Session.Token),
170 "application/json", strings.NewReader(string(body)))
171 if err != nil {
172 return err
173 }
174 defer resp.Body.Close()
175 if resp.StatusCode != http.StatusOK {
176 return fmt.Errorf("server %d", resp.StatusCode)
177 }
178 return nil
179}
180
181// declareKind self-reports "bot" (deterministic β€” no model) so the UI badges
182// us BOT, not AI. Best-effort; retried on a later tick if it fails.
183func (a *Announcer) declareKind() {
184 if a.kindDeclared {
185 return
186 }
187 body, _ := json.Marshal(map[string]string{"token": a.Session.Token, "kind": "bot"})
188 resp, err := a.client.Post(a.Base+"/api/bot-config", "application/json", strings.NewReader(string(body)))
189 if err != nil {
190 a.logf("declare kind: %v", err)
191 return
192 }
193 resp.Body.Close()
194 a.kindDeclared = resp.StatusCode == http.StatusOK
195}
196
197func (a *Announcer) get(path string, out any) error {
198 resp, err := a.client.Get(a.Base + path + "?token=" + url.QueryEscape(a.Session.Token))
199 if err != nil {
200 return err
201 }
202 defer resp.Body.Close()
203 if resp.StatusCode != http.StatusOK {
204 return fmt.Errorf("%s: server %d", path, resp.StatusCode)
205 }
206 return json.NewDecoder(resp.Body).Decode(out)
207}
208
209func (a *Announcer) saveState(s announcerState) error {
210 raw, err := json.Marshal(s)
211 if err != nil {
212 return err
213 }
214 return os.WriteFile(a.StatePath, raw, 0o600)
215}
216
217func (a *Announcer) logf(format string, args ...any) {
218 if a.Logf != nil {
219 a.Logf("announcer: "+format, args...)
220 }
221}
222