Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
2026-09-04 22:12:56 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/avatar"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/plan"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/secrets"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/spec"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/state"
|
|
|
|
|
tgc "github.com/tegwick/fluid-telegram/internal/tg"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// cmdPreflight exercises everything up to the point of contacting Telegram.
|
|
|
|
|
//
|
|
|
|
|
// It exists because the expensive failures in this tool all happen after a
|
|
|
|
|
// phone number has been spent and a conversation has started. Spec, OpenBao
|
|
|
|
|
// wiring, credentials, the avatar and the plan can all be wrong in ways that are
|
|
|
|
|
// free to discover beforehand, and this finds them in one run.
|
|
|
|
|
//
|
|
|
|
|
// It never writes and never contacts Telegram.
|
|
|
|
|
func cmdPreflight(args []string) error {
|
|
|
|
|
fs := flag.NewFlagSet("preflight", flag.ExitOnError)
|
|
|
|
|
specPath := fs.String("spec", "", "path to the presence spec")
|
|
|
|
|
root := fs.String("root", ".", "repo root holding presence/resolved/")
|
|
|
|
|
fs.Parse(args)
|
|
|
|
|
if *specPath == "" {
|
|
|
|
|
return errors.New("--spec is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
var failed, warned int
|
|
|
|
|
ok := func(format string, a ...any) { fmt.Printf(" ok "+format+"\n", a...) }
|
|
|
|
|
warn := func(format string, a ...any) { warned++; fmt.Printf(" warn "+format+"\n", a...) }
|
|
|
|
|
bad := func(format string, a ...any) { failed++; fmt.Printf(" FAIL "+format+"\n", a...) }
|
|
|
|
|
|
|
|
|
|
fmt.Print("preflight -- nothing is written and telegram is not contacted\n\n")
|
|
|
|
|
|
|
|
|
|
// 1. The spec.
|
|
|
|
|
sp, digest, err := spec.Load(*specPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
bad("spec: %v", err)
|
|
|
|
|
return report(failed, warned)
|
|
|
|
|
}
|
|
|
|
|
ok("spec loads and validates (campaign %q, %s)", sp.Campaign, shortDigest(digest))
|
|
|
|
|
|
|
|
|
|
// 2. The avatar, before any conversation could be left half-finished.
|
|
|
|
|
switch img, err := avatar.Load(*specPath, sp.Bot.Avatar); {
|
|
|
|
|
case sp.Bot.Avatar == "":
|
|
|
|
|
warn("no avatar declared; the bot will have no picture")
|
|
|
|
|
case errors.Is(err, avatar.ErrMissing):
|
|
|
|
|
warn("avatar declared but missing: %s", sp.Bot.Avatar)
|
|
|
|
|
case err != nil:
|
|
|
|
|
bad("avatar: %v", err)
|
|
|
|
|
default:
|
|
|
|
|
ok("avatar %s (%dx%d, %d KiB)", sp.Bot.Avatar, img.Width, img.Height, img.Bytes/1024)
|
|
|
|
|
if note := img.CropNote(); note != "" {
|
|
|
|
|
warn("avatar %s", note)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. OpenBao: configured, reachable, and the token actually works. A token
|
|
|
|
|
// that is merely present is not a token that can read.
|
|
|
|
|
store, err := secrets.NewFromEnv(sp.Campaign)
|
|
|
|
|
if err != nil {
|
|
|
|
|
bad("openbao: %v", err)
|
|
|
|
|
return report(failed, warned)
|
|
|
|
|
}
|
|
|
|
|
ok("openbao configured (%s)", os.Getenv("BAO_ADDR"))
|
|
|
|
|
|
|
|
|
|
appFields, found, err := store.Get(ctx, secrets.KeyOperatorApp)
|
|
|
|
|
switch {
|
|
|
|
|
case err != nil:
|
2026-09-04 22:27:01 +02:00
|
|
|
if strings.Contains(err.Error(), "403") {
|
|
|
|
|
bad("openbao rejected the token (403). If `bao token lookup` also fails,\n"+
|
|
|
|
|
" the token has expired -- run `bao login`. If lookup succeeds,\n"+
|
|
|
|
|
" the token lacks a policy for %s", store.Ref(secrets.KeyOperatorApp))
|
|
|
|
|
} else {
|
|
|
|
|
bad("openbao unreachable: %v", err)
|
|
|
|
|
}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
2026-09-04 22:12:56 +02:00
|
|
|
case !found:
|
|
|
|
|
bad("no app credentials at %s\n write them with:\n"+
|
|
|
|
|
" bao kv put %s api_id=<n> api_hash=<hash>\n"+
|
|
|
|
|
" see docs/seeding-runbook.md step 2",
|
|
|
|
|
store.Ref(secrets.KeyOperatorApp), kvPath(store))
|
|
|
|
|
default:
|
|
|
|
|
if _, err := tgc.LoadCredentials(ctx, store); err != nil {
|
|
|
|
|
bad("app credentials are present but unusable: %v", err)
|
|
|
|
|
} else {
|
|
|
|
|
ok("app credentials readable (api_id %s...)", prefix(appFields["api_id"], 3))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. The session. Absent is the expected state before the first bootstrap,
|
|
|
|
|
// so it is reported rather than failed.
|
|
|
|
|
if _, found, err := store.Get(ctx, secrets.KeyOperatorSession); err != nil {
|
|
|
|
|
bad("session check: %v", err)
|
|
|
|
|
} else if !found {
|
|
|
|
|
warn("no operator session yet -- run `provision session bootstrap --campaign %s`", sp.Campaign)
|
|
|
|
|
} else {
|
|
|
|
|
ok("operator session present (validity is only knowable by connecting)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. The salt, which must never be regenerated once it exists.
|
|
|
|
|
if _, found, err := store.Get(ctx, secrets.KeyRedactionSalt); err == nil {
|
|
|
|
|
if found {
|
|
|
|
|
ok("redaction salt already present; apply will not touch it")
|
|
|
|
|
} else {
|
|
|
|
|
ok("no redaction salt yet; apply will create one, once")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. The plan that would result.
|
|
|
|
|
rs, err := state.Load(state.Path(*root, sp.Campaign))
|
|
|
|
|
if err != nil {
|
|
|
|
|
bad("resolved state: %v", err)
|
|
|
|
|
return report(failed, warned)
|
|
|
|
|
}
|
|
|
|
|
p, err := plan.Compute(*specPath, sp, digest, rs, offlineLive{})
|
|
|
|
|
if err != nil {
|
|
|
|
|
bad("plan: %v", err)
|
|
|
|
|
return report(failed, warned)
|
|
|
|
|
}
|
|
|
|
|
if p.Blocked() {
|
|
|
|
|
bad("the resulting plan is blocked")
|
|
|
|
|
} else {
|
|
|
|
|
ok("plan computes cleanly")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Printf("\n%s\n", indent(p.Render()))
|
|
|
|
|
fmt.Println("note: computed offline, so nothing was observed live.")
|
|
|
|
|
return report(failed, warned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func report(failed, warned int) error {
|
|
|
|
|
fmt.Println()
|
|
|
|
|
if failed > 0 {
|
|
|
|
|
return fmt.Errorf("%d check(s) failed, %d warning(s)", failed, warned)
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("preflight passed with %d warning(s)\n", warned)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func kvPath(s *secrets.Store) string {
|
|
|
|
|
// Ref renders as bao:<mount>/<prefix>/<key>; the bao CLI wants it without
|
|
|
|
|
// the scheme, and without the KV v2 "data" segment the HTTP API needs.
|
|
|
|
|
return strings.TrimPrefix(s.Ref(secrets.KeyOperatorApp), "bao:")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func prefix(s string, n int) string {
|
|
|
|
|
if len(s) <= n {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s[:n]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func indent(s string) string {
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
|
|
|
|
|
b.WriteString(" " + line + "\n")
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func shortDigest(d string) string {
|
|
|
|
|
d = strings.TrimPrefix(d, "sha256:")
|
|
|
|
|
if len(d) > 12 {
|
|
|
|
|
d = d[:12]
|
|
|
|
|
}
|
|
|
|
|
return "sha256:" + d
|
|
|
|
|
}
|