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
3// The bot-builder: the create_bot tool that closes the loop "ask a bot for a
4// BOT in chat → a standing charter runs on schedule and messages you". The
5// member-app builder (appbuild.go) is the template — same executor seam in
6// modelReply, same live status line, same instance-key blob with
7// agent-set ownership — but where an app build generates CODE, a bot build
8// assembles a CHARTER and proves it:
9//
10// 1. The conversational brain interviews the member and calls create_bot
11// with the persona, schedule, sources, and standing prompt.
12// 2. Sources are resolved to real feeds — the member names publications,
13// never URLs (discover.go reads the feed URL out of each site's HTML).
14// 3. THE DRY RUN IS THE GATE: the task is executed once, now. If it can't
15// produce a result, nothing is created — the member sees why instead of
16// a bot that fails silently at 8am.
17// 4. On success the charter is stored (member_bots, owner = the requester),
18// a dedicated titled thread (owner + host) is created, the first alarm
19// is armed, and the dry run's output lands in the new thread as the
20// bot's first edition — proof, not promise.
21//
22// Updates reuse the tool with bot_id (ownership enforced here, where the
23// plaintext lives, exactly like app updates); remove_bot retires a charter.
24
25import (
26 "crypto/rand"
27 "encoding/hex"
28 "fmt"
29 "strings"
30 "time"
31
32 "lchat/internal/e2ee"
33)
34
35// createBotTool is offered only by the charter host (replyTools, brain.go).
36func createBotTool() anthTool {
37 return anthTool{
38 Name: "create_bot",
39 Description: "Create (or update) a personal BOT for this member: an autonomous agent that runs " +
40 "on a schedule, reads its allowed sources, and posts its result into its own chat — e.g. a " +
41 "morning news digest. This is different from create_app (an interactive surface they open): " +
42 "a bot acts when nobody's looking. Before calling, make sure you know: what it should do " +
43 "(the standing task), when (time of day + their timezone — ask if unstated), and which " +
44 "publications/sites to read (their words are fine; feeds are discovered automatically — " +
45 "NEVER ask for an RSS/feed URL). The setup takes a minute — it runs a real first edition as " +
46 "a test before anything is created — and you post the outcome when it's done, so your " +
47 "accompanying text should only say you've started.",
48 InputSchema: map[string]any{
49 "type": "object",
50 "properties": map[string]any{
51 "bot_id": map[string]any{
52 "type": "string",
53 "description": "ONLY to UPDATE an existing bot — the exact opaque id from your earlier reply, or " +
54 "from your context when you're chatting in the bot's own chat (looks like \"bot-1a2b3c4d\"). " +
55 "NEVER invent one. Omit to create.",
56 },
57 "name": map[string]any{
58 "type": "string",
59 "description": "Short lowercase identifier: letters, digits, dashes; starts with a letter; max 24 chars. e.g. \"morning-news\"",
60 },
61 "title": map[string]any{
62 "type": "string",
63 "description": "The bot's name as the member will see it — on its messages and its chat. Max 40 chars. e.g. \"Morning News\"",
64 },
65 "glyph": map[string]any{
66 "type": "string",
67 "description": "One emoji used as the bot's avatar. e.g. \"📰\"",
68 },
69 "at": map[string]any{
70 "type": "string",
71 "description": "Daily run time as HH:MM (24h) in the tz given. Use for \"every morning at 8\". Exactly one of at / every.",
72 },
73 "tz": map[string]any{
74 "type": "string",
75 "description": "IANA timezone for `at`, e.g. \"Europe/Dublin\". Ask the member if you don't know theirs.",
76 },
77 "every": map[string]any{
78 "type": "string",
79 "description": "Interval schedule as a Go duration (\"30m\", \"4h\"). Use for \"every few hours\". Exactly one of at / every.",
80 },
81 "sources": map[string]any{
82 "type": "array",
83 "description": "The sites to read, as the member named them — a domain or homepage URL per entry " +
84 "(e.g. \"nytimes.com\", \"rte.ie\", \"theguardian.com\"). Feeds are discovered from each site's own HTML. Max 6.",
85 "items": map[string]any{"type": "string"},
86 },
87 "prompt": map[string]any{
88 "type": "string",
89 "description": "The standing instruction the bot runs each time, written like a good system prompt: " +
90 "what to fetch, what to produce, tone and length. e.g. \"Fetch today's headlines from each " +
91 "source; write a 6-10 item digest, one line each, grouped by theme, with the source in brackets.\"",
92 },
93 },
94 "required": []any{"name", "title", "glyph", "prompt"},
95 },
96 }
97}
98
99// removeBotTool retires a bot the member owns.
100func removeBotTool() anthTool {
101 return anthTool{
102 Name: "remove_bot",
103 Description: "Retire one of this member's bots — it stops running and its charter is deleted " +
104 "(its chat and history stay). Use the exact bot id from an earlier reply or from your " +
105 "context in the bot's own chat. Only do this when the member clearly asked to stop/remove " +
106 "the bot.",
107 InputSchema: map[string]any{
108 "type": "object",
109 "properties": map[string]any{"bot_id": map[string]any{"type": "string"}},
110 "required": []any{"bot_id"},
111 },
112 }
113}
114
115type botBuildReq struct {
116 BotID string
117 Name string
118 Title string
119 Glyph string
120 Schedule ScheduleSpec
121 Sources []string
122 Prompt string
123}
124
125// parseBotBuildReq validates the model's tool input before any work is spent.
126func parseBotBuildReq(input map[string]any) (botBuildReq, error) {
127 str := func(k string) string { v, _ := input[k].(string); return strings.TrimSpace(v) }
128 req := botBuildReq{
129 BotID: str("bot_id"), Name: str("name"), Title: str("title"), Glyph: str("glyph"),
130 Schedule: ScheduleSpec{At: str("at"), TZ: str("tz"), Every: str("every")},
131 Prompt: str("prompt"),
132 }
133 if !appNameRe.MatchString(req.Name) {
134 return req, fmt.Errorf("bot name must be lowercase letters/digits/dashes, starting with a letter (got %q)", req.Name)
135 }
136 if req.Title == "" || len(req.Title) > 40 {
137 return req, fmt.Errorf("title must be 1-40 characters")
138 }
139 if req.Glyph == "" || len(req.Glyph) > 16 {
140 return req, fmt.Errorf("glyph must be one emoji")
141 }
142 if req.Prompt == "" || len(req.Prompt) > 4000 {
143 return req, fmt.Errorf("prompt must be 1-4000 characters")
144 }
145 if req.BotID != "" && !appIDRe.MatchString(req.BotID) {
146 return req, fmt.Errorf("bad bot_id")
147 }
148 if err := req.Schedule.validate(); err != nil {
149 return req, err
150 }
151 if raw, ok := input["sources"].([]any); ok {
152 if len(raw) > 6 {
153 return req, fmt.Errorf("at most 6 sources")
154 }
155 for _, s := range raw {
156 site, _ := s.(string)
157 site = strings.TrimSpace(site)
158 if site == "" || len(site) > 200 {
159 return req, fmt.Errorf("bad source %q", site)
160 }
161 req.Sources = append(req.Sources, site)
162 }
163 }
164 return req, nil
165}
166
167// resolveSources turns member-named sites into a fetch allowlist: each site's
168// advertised feeds (up to 2 per site), or — when a site advertises none — the
169// homepage itself (the model can still read the HTML; the dry run judges).
170// caveats are plain-English exceptions for the settle message ("bbc.com
171// doesn't share a feed, so it reads the front page instead") — a member wants
172// to know what their bot reads, never a transcript of discovery errors (those
173// go to the log).
174func (r *Runtime) resolveSources(sources []string) (allow []string, caveats []string) {
175 for _, site := range sources {
176 feeds, err := discoverFeeds(site)
177 if err != nil {
178 // No advertised feed (or unreachable): let the dry run try the page
179 // itself. Normalise to an https URL prefix.
180 r.logf("bot build: %s: feed discovery: %v", site, err)
181 page := site
182 if !strings.Contains(page, "://") {
183 page = "https://" + page
184 }
185 allow = append(allow, page)
186 caveats = append(caveats, fmt.Sprintf("%s doesn't share a feed, so it reads the front page instead", hostLabel(site)))
187 continue
188 }
189 if len(feeds) > 2 {
190 feeds = feeds[:2]
191 }
192 allow = append(allow, feeds...)
193 }
194 return allow, caveats
195}
196
197// hostLabel is a site as a member said it — bare host, no scheme/path/www.
198func hostLabel(site string) string {
199 s := strings.TrimSpace(site)
200 if i := strings.Index(s, "://"); i >= 0 {
201 s = s[i+3:]
202 }
203 if i := strings.IndexAny(s, "/?#"); i >= 0 {
204 s = s[:i]
205 }
206 return strings.TrimPrefix(s, "www.")
207}
208
209// joinAnd joins words the way a sentence would: "a", "a and b", "a, b and c".
210func joinAnd(words []string) string {
211 switch len(words) {
212 case 0:
213 return ""
214 case 1:
215 return words[0]
216 default:
217 return strings.Join(words[:len(words)-1], ", ") + " and " + words[len(words)-1]
218 }
219}
220
221// runBotBuild is the whole pipeline, on its own goroutine (like runAppBuild):
222// resolve sources → dry-run the task (the gate) → store the charter → create
223// the dedicated thread → arm the alarm → deliver the dry run as the first
224// edition. Progress rides one status line updated in place. The caller must
225// have reserved the build slot via beginBuild; this releases it on return.
226func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, requesterUID string) {
227 defer r.endBuild(tc.ID, requesterUID)
228
229 start := time.Now()
230 status := tc.actionLine(fmt.Sprintf("🛠️ Setting up %s %s… (about a minute)", req.Title, req.Glyph))
231
232 stop := make(chan struct{})
233 done := make(chan struct{})
234 phase := make(chan string, 4)
235 go func() {
236 defer close(done)
237 tc.Typing(15)
238 tick := time.NewTicker(5 * time.Second)
239 defer tick.Stop()
240 current := "getting ready"
241 for {
242 select {
243 case p := <-phase:
244 current = p
245 case <-tick.C:
246 tc.Typing(15)
247 status.set(fmt.Sprintf("🛠️ Setting up %s %s — %s… (%ds)",
248 req.Title, req.Glyph, current, int(time.Since(start).Seconds())))
249 case <-stop:
250 tc.Typing(0)
251 return
252 }
253 }
254 }()
255 stopped := false
256 stopBeat := func() {
257 if stopped {
258 return
259 }
260 stopped = true
261 close(stop)
262 <-done
263 }
264 defer stopBeat()
265
266 settle := func(breadcrumb, message string, actions ...map[string]any) {
267 stopBeat()
268 status.set(breadcrumb)
269 r.postReply(tc.ID, tc.key, Reply{Text: message, Actions: actions})
270 }
271 // Failure copy speaks the member's situation: a failed CREATE made
272 // nothing; a failed UPDATE changed nothing — the bot they have keeps
273 // running exactly as it was, and saying "nothing was created" there reads
274 // like the bot is gone. The vars flip once the update path loads the
275 // existing charter (fail is a closure, so later calls see the flip).
276 doing := "set up"
277 cantIntro := "I couldn't set that bot up — "
278 nothing := "Nothing was created."
279 fail := func(format string, args ...any) {
280 msg := fmt.Sprintf(format, args...)
281 r.logf("bot build %q: %s", req.Name, msg)
282 settle(fmt.Sprintf("❌ Couldn't %s %s %s.", doing, req.Title, req.Glyph),
283 cantIntro+msg+" Feel free to ask me to try again.")
284 }
285
286 if requesterUID == "" {
287 fail("I couldn't tell who's asking, so I can't create a bot safely. Try again in a moment.")
288 return
289 }
290 instKey, err := r.instanceKey()
291 if err != nil {
292 // Ask online holders to grant (an open tab wraps it on the spot), so
293 // "try again in a minute" is literally all it takes.
294 r.nudgeInstanceKey()
295 fail(fmt.Sprintf("I don't hold this space's shared key yet (%v). It's granted automatically as members use the app — try again in a minute.", err))
296 return
297 }
298 selfID, selfKey, ok := r.selfThread()
299 if !ok {
300 fail("my scheduler isn't ready yet — try again in a minute.")
301 return
302 }
303
304 // Update path: load the existing charter, enforce ownership, keep the slug
305 // and the thread (renaming the slug would orphan nothing, but stability is
306 // kinder; the thread is the member's history).
307 var existing *Charter
308 if req.BotID != "" {
309 c, err := r.fetchCharter(req.BotID, instKey)
310 if err != nil {
311 fail(fmt.Sprintf("I couldn't load the bot to update (%v).", err))
312 return
313 }
314 switch {
315 case c == nil:
316 // The model supplied an id that doesn't exist — a common slip where
317 // it invents one for a NEW bot. Treat as a create.
318 r.logf("bot build: bot_id %q not found — creating a new bot instead", req.BotID)
319 req.BotID = ""
320 case c.Owner == "" || c.Owner != requesterUID:
321 fail("that bot isn't one I can change for you — only the person it was made for can, by asking me themselves.")
322 return
323 default:
324 existing = c
325 req.Name = c.Name
326 doing = "update"
327 cantIntro = "I couldn't make that change — "
328 nothing = "Your bot is unchanged and still running as before."
329 }
330 }
331 if req.BotID == "" {
332 if entries, err := r.listCharters(instKey); err == nil {
333 req.Name = uniqueAppName(req.Name, charterNames(entries))
334 }
335 }
336
337 // Sources → allowlist. A bot with no sources runs promptly on model
338 // knowledge alone (a daily poem needs no feed), so an empty list is legal.
339 phase <- "finding the feeds"
340 allow, caveats := r.resolveSources(req.Sources)
341
342 charter := &Charter{
343 V: charterVersion, Name: req.Name, Title: req.Title, Glyph: req.Glyph,
344 Schedule: req.Schedule,
345 Task: TaskSpec{
346 Prompt: req.Prompt,
347 Post: &PostSpec{Card: true},
348 },
349 Owner: requesterUID, By: requester, BuiltBy: r.Session.Name,
350 }
351 if len(allow) > 0 {
352 charter.Task.Tools = []string{"fetch"}
353 charter.Task.Fetch = &FetchSpec{Allow: allow}
354 }
355 if existing != nil {
356 charter.Thread = existing.Thread
357 }
358 if err := validateCharter(charter); err != nil {
359 fail(fmt.Sprintf("the charter didn't validate (%v).", err))
360 return
361 }
362
363 // THE DRY RUN IS THE GATE: run the task once, now. No result → no bot.
364 // sourcesDown is checked FIRST: an all-sources-failed run that also came
365 // back textless should name the real problem, not "empty".
366 phase <- "running a first edition as a test"
367 sample, err := r.execTask(charter.Title, &charter.Task)
368 if err != nil {
369 fail(fmt.Sprintf("the test run failed (%v). %s", err, nothing))
370 return
371 }
372 if sample.sourcesDown() {
373 // The model reached NONE of the sources: the "edition" is an apology,
374 // not proof. Same gate as empty — never mint a bot that's born failing.
375 fail("the test run couldn't read any of its sources — they may be down or blocking readers right now. " + nothing)
376 return
377 }
378 if strings.TrimSpace(sample.text) == "" {
379 fail("the test run came back empty — the sources may not be readable. " + nothing)
380 return
381 }
382
383 // The dedicated thread: the bot's own chat with its owner, titled after it.
384 if charter.Thread == "" {
385 phase <- "opening its chat"
386 threadID, _, err := r.createCharterThread(requesterUID, req.Title+" "+req.Glyph)
387 if err != nil {
388 fail(fmt.Sprintf("I couldn't open the bot's chat (%v).", err))
389 return
390 }
391 charter.Thread = threadID
392 }
393
394 id := req.BotID
395 verb := "is live"
396 if id == "" {
397 var buf [6]byte
398 if _, err := rand.Read(buf[:]); err != nil {
399 fail("couldn't mint a bot id.")
400 return
401 }
402 id = "bot-" + hex.EncodeToString(buf[:])
403 } else {
404 verb = "is updated"
405 }
406 if err := r.putCharter(id, charter, instKey); err != nil {
407 fail(fmt.Sprintf("storing the bot was rejected (%v).", err))
408 return
409 }
410
411 // A renamed bot renames its chat — but ONLY when the persona actually
412 // changed: a member's hand-picked group name must survive an unrelated
413 // schedule or source tweak.
414 if existing != nil && (existing.Title != req.Title || existing.Glyph != req.Glyph) {
415 r.renameCharterThread(charter.Thread, req.Title+" "+req.Glyph)
416 }
417
418 // Re-arm cleanly: drop any pending alarm for this bot (an update may have
419 // changed the schedule), then arm the next occurrence. A fresh charter also
420 // starts with a clean slate — the dry run just proved it works, so any
421 // backoff from its previous shape is stale.
422 r.dropCharterAlarms(selfID, selfKey, id)
423 r.clearCharterFails(id)
424 r.armCharter(selfID, selfKey, id, charter.Schedule.next(time.Now()))
425 // The store changed shape — refresh the instance's charter wake-time so a
426 // hibernating instance is woken for the new/updated schedule
427 // (chartersched.go reportCharterDue).
428 r.recomputeCharterDue(instKey)
429
430 // Deliver the dry run as the first edition — proof, not promise. The key
431 // comes back from createCharterThread only on the create path; on updates
432 // (and any refetch) read it via the threads endpoint to stay off r.keys.
433 if key, err := r.wrappedKeyFor(charter.Thread); err == nil {
434 r.postTaskResult(charter.Thread, key, charter.Title, &charter.Task, sample.text, sample.card)
435 } else {
436 r.logf("bot build %q: first-edition post: %v", req.Name, err)
437 }
438
439 // The confirmation is for a PERSON: what it does, where it lives, when the
440 // next one lands, in plain words — no URLs (they'd drag a link preview into
441 // the chat), no error transcripts (the log has those), and the opaque id
442 // rides the muted breadcrumb line, where the brain can still read it back
443 // for later updates without it cluttering the real message. Told from
444 // where the member is standing: an ask made IN the bot's own chat isn't
445 // pointed at "your new chat" — the fresh edition just posted right here.
446 inHome := tc.ID == charter.Thread
447 var sentences string
448 switch {
449 case inHome:
450 sentences = fmt.Sprintf("✨ %s %s %s — that fresh edition above is the proof, and the next arrives %s.",
451 req.Title, req.Glyph, verb, charter.Schedule.Summary())
452 case existing != nil:
453 sentences = fmt.Sprintf("✨ %s %s %s — a fresh edition just landed in its **%s** chat, and the next arrives %s.",
454 req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary())
455 default:
456 sentences = fmt.Sprintf("✨ %s %s %s — its first edition is waiting in your new **%s** chat, and the next arrives %s.",
457 req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary())
458 }
459 if len(req.Sources) > 0 {
460 labels := make([]string, len(req.Sources))
461 for i, s := range req.Sources {
462 labels[i] = hostLabel(s)
463 }
464 sentences += " It reads " + joinAnd(labels) + "."
465 }
466 if len(caveats) == 1 {
467 sentences += " One thing to know: " + caveats[0] + "."
468 } else if len(caveats) > 1 {
469 sentences += " Worth knowing: " + joinAnd(caveats) + "."
470 }
471 sentences += " Want to change it — or stop it? Just ask me here."
472 r.logf("bot build %q: stored as %s (%d sources)", req.Name, id, len(allow))
473 if inHome {
474 settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id), sentences)
475 return
476 }
477 settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id),
478 sentences,
479 map[string]any{"type": "open_thread", "thread": charter.Thread, "label": "Open " + req.Title + " " + req.Glyph})
480}
481
482// runBotRemove retires a charter (the remove_bot tool): ownership checked
483// where the plaintext lives, alarms cleaned up, the thread left in place.
484func (r *Runtime) runBotRemove(tc *ThreadCtx, botID, requesterUID string) Reply {
485 if requesterUID == "" {
486 return Reply{Text: "I couldn't tell who's asking, so I can't remove a bot safely."}
487 }
488 instKey, err := r.instanceKey()
489 if err != nil {
490 return Reply{Text: fmt.Sprintf("I can't reach the bot store right now (%v).", err)}
491 }
492 c, err := r.fetchCharter(botID, instKey)
493 if err != nil || c == nil {
494 return Reply{Text: "I couldn't find that bot — it may already be gone."}
495 }
496 if c.Owner != requesterUID {
497 return Reply{Text: "That bot isn't yours to remove — only the person it was made for can."}
498 }
499 if err := r.deleteCharter(botID); err != nil {
500 return Reply{Text: fmt.Sprintf("Removing it failed (%v) — try again in a moment.", err)}
501 }
502 if selfID, selfKey, ok := r.selfThread(); ok {
503 r.dropCharterAlarms(selfID, selfKey, botID)
504 }
505 r.clearCharterFails(botID)
506 // The retired charter may have been the earliest — refresh the instance's
507 // charter wake-time so it doesn't wake a hibernating space for a bot that
508 // no longer exists.
509 r.recomputeCharterDue(instKey)
510 return Reply{Text: fmt.Sprintf("Done — %s %s is retired and won't run again. Its chat and everything it posted stay yours; hide the chat if you like.", c.Title, c.Glyph)}
511}
512
513// wrappedKeyFor unwraps a thread's key straight from the threads endpoint —
514// the builder goroutine's way to a key without touching r.keys (which belongs
515// to the frame goroutine).
516func (r *Runtime) wrappedKeyFor(threadID string) ([]byte, error) {
517 var tl struct {
518 Threads []struct {
519 ID string `json:"id"`
520 Wrapped string `json:"wrapped"`
521 } `json:"threads"`
522 }
523 if err := r.get("/api/threads", &tl); err != nil {
524 return nil, err
525 }
526 for _, t := range tl.Threads {
527 if t.ID != threadID || t.Wrapped == "" {
528 continue
529 }
530 keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, r.Session.UserPriv)
531 if err != nil {
532 return nil, err
533 }
534 return e2ee.ImportKey(keyB64)
535 }
536 return nil, fmt.Errorf("no key for thread %s", threadID)
537}
538