diff --git a/cmd/provision/main.go b/cmd/provision/main.go index 464f5db..d2e7e57 100644 --- a/cmd/provision/main.go +++ b/cmd/provision/main.go @@ -27,6 +27,7 @@ import ( const usage = `provision -- reconcile a Telegram presence with its declared spec + provision preflight --spec check everything short of contacting telegram provision plan --spec [--check] show what would change; writes nothing provision apply --spec execute an approved plan @@ -54,6 +55,8 @@ func main() { err = cmdReconcile(os.Args[2:], false) case "apply": err = cmdReconcile(os.Args[2:], true) + case "preflight": + err = cmdPreflight(os.Args[2:]) case "session": err = cmdSession(os.Args[2:]) case "-h", "--help", "help": @@ -99,7 +102,7 @@ func cmdReconcile(args []string, doApply bool) error { ctx := context.Background() if *offline { - p, err := plan.Compute(sp, digest, rs, offlineLive{}) + p, err := plan.Compute(*specPath, sp, digest, rs, offlineLive{}) if err != nil { return err } @@ -125,7 +128,7 @@ func cmdReconcile(args []string, doApply bool) error { // No authenticator: reconciliation must never prompt. If the session is // gone, that is a runbook step, not something to paper over mid-run. return client.Run(ctx, nil, func(ctx context.Context, c *tgc.Client) error { - p, err := plan.Compute(sp, digest, rs, tgc.Live{Ctx: ctx, Client: c, Resolved: rs}) + p, err := plan.Compute(*specPath, sp, digest, rs, tgc.Live{Ctx: ctx, Client: c, Resolved: rs}) if err != nil { return err } diff --git a/cmd/provision/preflight.go b/cmd/provision/preflight.go new file mode 100644 index 0000000..6e18a1b --- /dev/null +++ b/cmd/provision/preflight.go @@ -0,0 +1,170 @@ +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: + bad("openbao unreachable or token rejected: %v", err) + case !found: + bad("no app credentials at %s\n write them with:\n"+ + " bao kv put %s api_id= api_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://; 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 +} diff --git a/docs/seeding-runbook.md b/docs/seeding-runbook.md index 4123b14..5343c3d 100644 --- a/docs/seeding-runbook.md +++ b/docs/seeding-runbook.md @@ -34,6 +34,20 @@ are just interfaces that were never built. --- +## Check everything first + +Before spending a phone number, run the dry run. It exercises the spec, the +avatar, the OpenBao wiring, the credentials and the resulting plan, and it +neither writes anything nor contacts Telegram: + +```bash +go run ./cmd/provision preflight --spec ../pr-hall-of-helix/presence/telegram.yaml +``` + +Every failure it reports is one that would otherwise have surfaced after an +account was registered or a BotFather conversation was under way. Run it again +after each step below; it is cheap and it is the only check that costs nothing. + ## Before you start Have these ready. Stopping halfway through to find one is how the session @@ -162,10 +176,9 @@ description, creates the test channel, adds the bot as administrator with `post_messages` only, generates the redaction salt if it is absent, and writes `presence/resolved/hall-of-helix.yaml`. -Two things it will *not* do on this first run, both by design and both reported -in the plan. The **public channel is held** until a rendering has been checked in -the test channel. The **avatar is deferred**: `/setuserpic` needs a photo upload -that is not implemented, so set it by hand in @BotFather, or wait. +One thing it will *not* do on this first run, by design and reported in the +plan: the **public channel is held** until a rendering has been checked in the +test channel. **Read the plan before approving it.** That is the human judgement this design keeps — not clicking through BotFather, but deciding whether the diff is what diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 0f72127..0a07f0f 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -50,7 +50,7 @@ func Run(ctx context.Context, p *plan.Plan, o Options) error { if err := ensureSalt(ctx, o); err != nil { return err } - botUser, err := ensureBot(ctx, o) + botUser, err := ensureBot(ctx, o, p) if err != nil { return err } @@ -85,10 +85,17 @@ func ensureSalt(ctx context.Context, o Options) error { return nil } -func ensureBot(ctx context.Context, o Options) (tg.InputUserClass, error) { +func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, error) { if o.State.Bot.ID != 0 { fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username) - return resolveBot(ctx, o, o.State.Bot.Username) + user, err := resolveBot(ctx, o, o.State.Bot.Username) + if err != nil { + return nil, err + } + if err := ensureAvatar(ctx, o, p, nil); err != nil { + return nil, err + } + return user, nil } conv, err := o.Client.BotFather(ctx) @@ -136,6 +143,10 @@ func ensureBot(ctx context.Context, o Options) (tg.InputUserClass, error) { } fmt.Fprintln(o.Out, " set bot about text and description") + if err := ensureAvatar(ctx, o, p, conv); err != nil { + return nil, err + } + user, err := resolveBot(ctx, o, username) if err != nil { return nil, err @@ -143,6 +154,27 @@ func ensureBot(ctx context.Context, o Options) (tg.InputUserClass, error) { return user, state.Save(o.StatePath, o.State) } +// ensureAvatar sends the picture when the plan found one that differs from what +// is recorded. The digest is written only after BotFather confirms, so a failed +// upload is retried next run rather than being remembered as done. +func ensureAvatar(ctx context.Context, o Options, p *plan.Plan, conv *tgc.Conversation) error { + if p.Avatar == nil || o.State.Bot.AvatarDigest == p.Avatar.Digest { + return nil + } + if conv == nil { + var err error + if conv, err = o.Client.BotFather(ctx); err != nil { + return err + } + } + if err := conv.SetAvatar(ctx, o.State.Bot.Username, p.Avatar.Path); err != nil { + return err + } + o.State.Bot.AvatarDigest = p.Avatar.Digest + fmt.Fprintf(o.Out, " set bot picture from %s\n", p.Avatar.Path) + return state.Save(o.StatePath, o.State) +} + func resolveBot(ctx context.Context, o Options, username string) (tg.InputUserClass, error) { res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ Username: username, diff --git a/internal/avatar/avatar.go b/internal/avatar/avatar.go new file mode 100644 index 0000000..ec03222 --- /dev/null +++ b/internal/avatar/avatar.go @@ -0,0 +1,135 @@ +// Package avatar validates and fingerprints the image a presence declares. +// +// It is checked before anything reaches Telegram. A bot's picture is the first +// thing a stranger sees, and an image that is rejected halfway through a +// BotFather conversation leaves the bot registered with no picture and the +// conversation in a state the tool did not plan for. +package avatar + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" +) + +// Telegram's own limits, plus a floor of its own. Telegram accepts smaller +// images, but a profile picture is rendered at a range of sizes and upscaling a +// small one looks worse everywhere than not having one. +const ( + MinDimension = 512 + MaxBytes = 10 << 20 // 10 MiB +) + +type Image struct { + Path string + Digest string // content address: replacing the file is what triggers an update + Width int + Height int + Bytes int +} + +// Square reports whether the image needs no cropping. Telegram centre-crops a +// non-square picture to a circle, so a rectangular one silently loses its edges. +func (i Image) Square() bool { return i.Width == i.Height } + +// CropNote describes what a viewer will actually see, for a plan to print. +func (i Image) CropNote() string { + if i.Square() { + return "" + } + side := i.Width + if i.Height < side { + side = i.Height + } + lost := (i.Width - side) + (i.Height - side) + return fmt.Sprintf("%dx%d is not square: telegram will centre-crop to %dx%d, "+ + "losing %dpx of the longer side", i.Width, i.Height, side, side, lost) +} + +// ErrMissing is returned when the declared file does not exist, so callers can +// tell "not provided yet" from "provided and wrong". +var ErrMissing = errors.New("avatar file does not exist") + +// Load reads the image declared by a spec. +// +// The path is repo-relative, as the schema says: it is resolved against the root +// of the repository containing the spec, not against the spec's own directory. +// That is what "presence/assets/x.png" means to someone reading the campaign +// repo, and resolving it any other way makes a correct-looking spec fail. +func Load(specPath, declared string) (Image, error) { + full := resolve(specPath, declared) + raw, err := os.ReadFile(full) + if errors.Is(err, os.ErrNotExist) { + return Image{Path: full}, fmt.Errorf("%w: %s", ErrMissing, full) + } + if err != nil { + return Image{Path: full}, err + } + if len(raw) > MaxBytes { + return Image{Path: full}, fmt.Errorf("avatar is %d bytes, over telegram's %d limit", + len(raw), MaxBytes) + } + + w, h, err := pngDimensions(raw) + if err != nil { + return Image{Path: full}, fmt.Errorf("%s: %w", full, err) + } + if w < MinDimension || h < MinDimension { + return Image{Path: full}, fmt.Errorf( + "avatar is %dx%d; at least %dpx on each side is wanted, because a profile "+ + "picture is rendered at many sizes and upscaling shows", w, h, MinDimension) + } + + sum := sha256.Sum256(raw) + return Image{ + Path: full, + Digest: "sha256:" + hex.EncodeToString(sum[:]), + Width: w, Height: h, Bytes: len(raw), + }, nil +} + +// resolve finds the file a repo-relative path names. It walks up from the spec +// looking for a repository root, and falls back to the spec's directory when +// there is none -- a spec outside a repository still has to work in tests. +func resolve(specPath, declared string) string { + if filepath.IsAbs(declared) { + return declared + } + dir := filepath.Dir(specPath) + if abs, err := filepath.Abs(dir); err == nil { + for d := abs; ; { + if _, err := os.Stat(filepath.Join(d, ".git")); err == nil { + return filepath.Join(d, declared) + } + parent := filepath.Dir(d) + if parent == d { + break + } + d = parent + } + } + return filepath.Join(dir, declared) +} + +// pngDimensions reads the IHDR header. Only PNG is accepted: the format is +// declared in the spec's own file extension convention, and guessing at others +// invites a file that uploads but renders badly. +func pngDimensions(raw []byte) (int, int, error) { + const sig = "\x89PNG\r\n\x1a\n" + if len(raw) < 24 || string(raw[:8]) != sig { + return 0, 0, errors.New("not a PNG; the avatar must be a PNG") + } + if string(raw[12:16]) != "IHDR" { + return 0, 0, errors.New("PNG has no IHDR header where one is required") + } + w := binary.BigEndian.Uint32(raw[16:20]) + h := binary.BigEndian.Uint32(raw[20:24]) + if w == 0 || h == 0 { + return 0, 0, errors.New("PNG declares a zero dimension") + } + return int(w), int(h), nil +} diff --git a/internal/avatar/avatar_test.go b/internal/avatar/avatar_test.go new file mode 100644 index 0000000..2425584 --- /dev/null +++ b/internal/avatar/avatar_test.go @@ -0,0 +1,134 @@ +package avatar + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + "testing" +) + +func png(t *testing.T, w, h uint32, pad int) []byte { + t.Helper() + var b bytes.Buffer + b.WriteString("\x89PNG\r\n\x1a\n") + b.Write([]byte{0, 0, 0, 13}) + b.WriteString("IHDR") + binary.Write(&b, binary.BigEndian, w) + binary.Write(&b, binary.BigEndian, h) + b.Write(make([]byte, pad)) + return b.Bytes() +} + +// newRepo makes a temp directory that is unambiguously a repository root. +// Without the marker, resolution walks up into whatever repository happens to +// contain the temp directory -- this machine has a /tmp/.git, which is exactly +// the ambient surprise these tests must not depend on. +func newRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write(t, dir, ".git/HEAD", []byte("ref: refs/heads/main\n")) + return dir +} + +func write(t *testing.T, dir, name string, data []byte) string { + t.Helper() + p := filepath.Join(dir, name) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// The schema documents the avatar path as repo-relative. Resolving it against +// the spec's own directory instead makes a correct spec fail, which is exactly +// what happened with the real campaign spec. +func TestPathIsRepoRelativeNotSpecRelative(t *testing.T) { + repo := newRepo(t) + write(t, repo, "assets/logo.png", png(t, 600, 600, 64)) + specPath := write(t, repo, "presence/telegram.yaml", []byte("presence: {}\n")) + + img, err := Load(specPath, "assets/logo.png") + if err != nil { + t.Fatalf("repo-relative path did not resolve: %v", err) + } + if img.Width != 600 || !img.Square() { + t.Errorf("got %dx%d", img.Width, img.Height) + } +} + +func TestMissingIsDistinguishable(t *testing.T) { + repo := newRepo(t) + spec := write(t, repo, "spec.yaml", []byte("x")) + _, err := Load(spec, "nope.png") + if !errors.Is(err, ErrMissing) { + t.Fatalf("a missing file must be distinguishable from a bad one: %v", err) + } +} + +func TestRejectsBadImages(t *testing.T) { + repo := newRepo(t) + spec := write(t, repo, "spec.yaml", []byte("x")) + + write(t, repo, "small.png", png(t, 128, 128, 16)) + if _, err := Load(spec, "small.png"); err == nil { + t.Error("accepted an image below the minimum dimension") + } else if errors.Is(err, ErrMissing) { + t.Error("a too-small image is not a missing one") + } + + write(t, repo, "notpng.png", []byte("GIF89a and then some padding bytes here")) + if _, err := Load(spec, "notpng.png"); err == nil { + t.Error("accepted a non-PNG") + } + + write(t, repo, "zero.png", png(t, 0, 600, 16)) + if _, err := Load(spec, "zero.png"); err == nil { + t.Error("accepted a zero dimension") + } +} + +// The digest is what decides whether a picture is re-sent, so identical bytes +// must give an identical digest and different bytes must not. +func TestDigestIsContentAddressed(t *testing.T) { + repo := newRepo(t) + spec := write(t, repo, "spec.yaml", []byte("x")) + write(t, repo, "a.png", png(t, 600, 600, 32)) + write(t, repo, "b.png", png(t, 600, 600, 32)) + write(t, repo, "c.png", png(t, 600, 600, 33)) + + a, _ := Load(spec, "a.png") + b, _ := Load(spec, "b.png") + c, _ := Load(spec, "c.png") + if a.Digest != b.Digest { + t.Error("identical content gave different digests") + } + if a.Digest == c.Digest { + t.Error("different content gave the same digest") + } +} + +// A non-square image is accepted but must say what a viewer will lose. +func TestCropNote(t *testing.T) { + repo := newRepo(t) + spec := write(t, repo, "spec.yaml", []byte("x")) + write(t, repo, "wide.png", png(t, 907, 885, 32)) + img, err := Load(spec, "wide.png") + if err != nil { + t.Fatal(err) + } + if img.Square() { + t.Fatal("907x885 is not square") + } + if note := img.CropNote(); note == "" { + t.Error("a non-square image should explain the crop") + } + + write(t, repo, "sq.png", png(t, 900, 900, 32)) + sq, _ := Load(spec, "sq.png") + if sq.CropNote() != "" { + t.Errorf("a square image needs no note, got %q", sq.CropNote()) + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 62dc3a3..10f002f 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -7,9 +7,11 @@ package plan import ( + "errors" "fmt" "strings" + "github.com/tegwick/fluid-telegram/internal/avatar" "github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/state" ) @@ -67,7 +69,13 @@ type Action struct { type Plan struct { Campaign string SpecDigest string + SpecPath string Actions []Action + + // Avatar is the validated image, when the spec declares one and it loaded. + // apply uses it rather than re-reading the file, so the thing that was + // checked is the thing that is sent. + Avatar *avatar.Image } func (p *Plan) add(k Kind, target, detail string) { @@ -112,8 +120,10 @@ type Live interface { } // Compute diffs the spec against the resolved state and live observation. -func Compute(sp *spec.Presence, digest string, rs *state.Resolved, live Live) (*Plan, error) { - p := &Plan{Campaign: sp.Campaign, SpecDigest: digest} +// specPath locates the spec so that relative asset paths resolve against the +// repository that declares them. +func Compute(specPath string, sp *spec.Presence, digest string, rs *state.Resolved, live Live) (*Plan, error) { + p := &Plan{Campaign: sp.Campaign, SpecDigest: digest, SpecPath: specPath} if rs.Campaign != "" && rs.Campaign != sp.Campaign { p.addWhy(Block, "campaign", fmt.Sprintf("state is for %q, spec is for %q", rs.Campaign, sp.Campaign), @@ -148,16 +158,7 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { p.add(Attempt, "bot", fmt.Sprintf("register %q via BotFather, username from %d candidate(s): %s", sp.Bot.Name, len(sp.Bot.UsernamePreference), strings.Join(sp.Bot.UsernamePreference, ", "))) p.add(Create, "bot.profile", "set name, about text and description") - if sp.Bot.Avatar != "" { - // apply does not set the avatar yet: /setuserpic needs a photo - // upload, which is FT-WP-0002 T04's remaining piece. Say so, rather - // than promising an action that would be silently skipped -- a plan - // nobody can trust line by line is not worth reading. - p.addWhy(Defer, "bot.avatar", sp.Bot.Avatar, - "Not applied yet: setting a bot's picture needs a photo upload, which "+ - "is not implemented. Set it by hand in @BotFather with /setuserpic, "+ - "or leave it until the upload lands.") - } + planAvatar(p, sp, rs) return nil } @@ -175,9 +176,48 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { // Profile fields are safe to reassert: BotFather takes them idempotently and // the tool does not know what a person may have changed by hand. p.add(Update, "bot.profile", "reassert name, about text and description from the spec") + planAvatar(p, sp, rs) return nil } +// planAvatar decides whether the picture needs sending. It is content +// addressed: replacing the file is what triggers an update, because a timestamp +// says when something was touched and a digest says whether it differs. +func planAvatar(p *Plan, sp *spec.Presence, rs *state.Resolved) { + if sp.Bot.Avatar == "" { + return + } + img, err := avatar.Load(p.SpecPath, sp.Bot.Avatar) + if errors.Is(err, avatar.ErrMissing) { + p.addWhy(Defer, "bot.avatar", sp.Bot.Avatar, + "The spec declares an avatar but the file is not there. Everything else "+ + "applies; add the file and run again to set the picture.") + return + } + if err != nil { + p.addWhy(Block, "bot.avatar", sp.Bot.Avatar, err.Error()+ + ". The picture is checked before the conversation starts, so that a "+ + "rejected image cannot leave the bot registered without one.") + return + } + p.Avatar = &img + + if rs.Bot.AvatarDigest == img.Digest { + return // unchanged + } + kind := Create + detail := fmt.Sprintf("%s (%dx%d, %d KiB)", sp.Bot.Avatar, img.Width, img.Height, img.Bytes/1024) + if rs.Bot.AvatarDigest != "" { + kind = Update + detail = "replace picture with " + detail + } + if note := img.CropNote(); note != "" { + p.addWhy(kind, "bot.avatar", detail, note) + return + } + p.add(kind, "bot.avatar", detail) +} + func planChannels(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { // Test first, always. The ordering is the guarantee, not a convention. for _, name := range []string{spec.Test, spec.Live} { diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 480f064..d25db8c 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -1,6 +1,7 @@ package plan import ( + "os" "strings" "testing" "time" @@ -47,7 +48,7 @@ func find(p *Plan, kind Kind, target string) *Action { // First run: everything is created, and the public channel is blocked because // no rendering has been checked yet. func TestFirstRunCreatesAndBlocksPublic(t *testing.T) { - p, err := Compute(sp(), "sha256:x", &state.Resolved{}, fake{}) + p, err := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{}) if err != nil { t.Fatal(err) } @@ -92,7 +93,7 @@ func provisioned(withTestPublication bool) *state.Resolved { // idempotent profile reassertion. func TestConvergedRunIsQuiet(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} - p, err := Compute(sp(), "sha256:x", provisioned(true), f) + p, err := Compute("", sp(), "sha256:x", provisioned(true), f) if err != nil { t.Fatal(err) } @@ -110,7 +111,7 @@ func TestConvergedRunIsQuiet(t *testing.T) { func TestWidenedRightsBlock(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, rights: map[int64][]string{-200: {spec.PostMessages, "can_delete_messages"}}} - p, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) a := find(p, Block, "channel.public.admin") if a == nil { t.Fatalf("widened rights must block:\n%s", p.Render()) @@ -124,7 +125,7 @@ func TestWidenedRightsBlock(t *testing.T) { func TestLostRightsBlock(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, rights: map[int64][]string{-100: {}}} - p, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "channel.test.admin") == nil { t.Fatalf("a demoted bot must block:\n%s", p.Render()) } @@ -133,7 +134,7 @@ func TestLostRightsBlock(t *testing.T) { // A username that changed underneath us is not reconciled. func TestUsernameTakeoverBlocks(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} - p, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "channel.public.username") == nil { t.Fatalf("a changed username must block:\n%s", p.Render()) } @@ -143,7 +144,7 @@ func TestUsernameTakeoverBlocks(t *testing.T) { // token already in OpenBao. func TestMissingBotBlocks(t *testing.T) { f := fake{botExists: false} - p, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "bot") == nil { t.Fatalf("an unreachable bot must block:\n%s", p.Render()) } @@ -154,7 +155,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) { s := sp() delete(s.Channels, spec.Live) f := fake{botExists: true} - p, _ := Compute(s, "sha256:x", provisioned(true), f) + p, _ := Compute("", s, "sha256:x", provisioned(true), f) a := find(p, Warn, "channel.public") if a == nil { t.Fatalf("expected a warning:\n%s", p.Render()) @@ -170,7 +171,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) { func TestCampaignMismatchBlocks(t *testing.T) { rs := provisioned(true) rs.Campaign = "some-other-campaign" - p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true}) + p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true}) if find(p, Block, "campaign") == nil { t.Fatalf("campaign mismatch must block:\n%s", p.Render()) } @@ -179,7 +180,7 @@ func TestCampaignMismatchBlocks(t *testing.T) { func TestRenderShowsRefusalsFirst(t *testing.T) { rs := provisioned(true) rs.Campaign = "some-other-campaign" - p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true}) + p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true}) out := p.Render() if !strings.Contains(out, "nothing will be applied") { t.Errorf("a blocked plan should say so:\n%s", out) @@ -192,12 +193,12 @@ func TestRenderShowsRefusalsFirst(t *testing.T) { // A deferral is not a block: the two must not collapse into each other, or a // first run can never apply anything. func TestDeferIsNotBlock(t *testing.T) { - p, _ := Compute(sp(), "sha256:x", &state.Resolved{}, fake{}) + p, _ := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{}) if p.Blocked() { t.Fatal("a deferral must not block the plan") } f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} - p2, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p2, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if !p2.Blocked() { t.Fatal("real drift must block") } @@ -206,7 +207,7 @@ func TestDeferIsNotBlock(t *testing.T) { // Once the test channel is verified, the public channel stops being deferred. func TestVerifiedTestChannelReleasesPublic(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} - p, _ := Compute(sp(), "sha256:x", provisioned(true), f) + p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Defer, "channel.public") != nil { t.Errorf("public should no longer be deferred:\n%s", p.Render()) } @@ -217,17 +218,52 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) { // plan defers it with an explanation instead of claiming a create. func TestAvatarIsDeferredNotPromised(t *testing.T) { s := sp() - s.Bot.Avatar = "presence/assets/avatar.png" - p, _ := Compute(s, "sha256:x", &state.Resolved{}, fake{}) + s.Bot.Avatar = "does-not-exist.png" + p, _ := Compute("", s, "sha256:x", &state.Resolved{}, fake{}) if find(p, Create, "bot.avatar") != nil { - t.Fatal("plan promised an avatar create that apply does not perform") + t.Fatal("plan promised an avatar it could not load") } a := find(p, Defer, "bot.avatar") if a == nil { - t.Fatalf("expected the avatar to be deferred:\n%s", p.Render()) + t.Fatalf("a missing avatar should defer, not block:\n%s", p.Render()) } - if !strings.Contains(a.Why, "setuserpic") { - t.Errorf("deferral should say how to do it by hand: %q", a.Why) + if !strings.Contains(a.Why, "not there") { + t.Errorf("deferral should say the file is missing: %q", a.Why) + } + if p.Blocked() { + t.Error("a missing avatar must not stop everything else applying") + } +} + +// A real image is planned as a create, content-addressed. +func TestAvatarPlannedFromRealFile(t *testing.T) { + const real = "../../../pr-hall-of-helix/presence/telegram.yaml" + if _, err := os.Stat(real); err != nil { + t.Skip("campaign repo not in this checkout") + } + s := sp() + s.Bot.Avatar = "assets/helix-forge.png" + p, _ := Compute(real, s, "sha256:x", &state.Resolved{}, fake{}) + if p.Avatar == nil { + t.Fatalf("expected a loaded avatar:\n%s", p.Render()) + } + if find(p, Create, "bot.avatar") == nil { + t.Fatalf("expected an avatar create:\n%s", p.Render()) + } + + // Once recorded, the same file must not be re-sent: the digest is what + // decides, so an unchanged picture is silence. + rs := &state.Resolved{Bot: state.Bot{AvatarDigest: p.Avatar.Digest}} + p2, _ := Compute(real, s, "sha256:x", rs, fake{}) + if find(p2, Create, "bot.avatar") != nil || find(p2, Update, "bot.avatar") != nil { + t.Errorf("an unchanged avatar should propose nothing:\n%s", p2.Render()) + } + + // A different digest means the file was replaced, which is an update. + rs.Bot.AvatarDigest = "sha256:something-else" + p3, _ := Compute(real, s, "sha256:x", rs, fake{}) + if find(p3, Update, "bot.avatar") == nil { + t.Errorf("a replaced avatar should update:\n%s", p3.Render()) } } diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 6e7db43..dfbae86 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -38,9 +38,16 @@ type Store struct { func NewFromEnv(campaign string) (*Store, error) { addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR")) token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN")) - if addr == "" || token == "" { + // Name the variable that is actually missing. "set both" sends someone + // checking the one they already set. + switch { + case addr == "" && token == "": return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " + "(see docs/seeding-runbook.md)") + case addr == "": + return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)") + case token == "": + return nil, fmt.Errorf("BAO_TOKEN is not set (BAO_ADDR is %s)", addr) } mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret") return &Store{ diff --git a/internal/tg/botfather.go b/internal/tg/botfather.go index 73a2568..db9e885 100644 --- a/internal/tg/botfather.go +++ b/internal/tg/botfather.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/gotd/td/telegram/uploader" "github.com/gotd/td/tg" ) @@ -205,3 +206,49 @@ func firstLine(s string) string { } return s } + +// SetAvatar sends the bot's profile picture through BotFather. +// +// A bot's picture cannot be set through any API: /setuserpic is a conversation +// in which you send a photo, so the file is uploaded and then sent as a message +// like a person would send it. +func (conv *Conversation) SetAvatar(ctx context.Context, username, path string) error { + reply, err := conv.Ask(ctx, "/setuserpic") + if err != nil { + return err + } + if strings.Contains(strings.ToLower(reply), "choose a bot") || + strings.Contains(reply, "/") { + if _, err := conv.Ask(ctx, "@"+username); err != nil { + return err + } + } + + pause() + up := uploader.NewUploader(conv.c.api) + file, err := up.FromPath(ctx, path) + if err != nil { + return fmt.Errorf("upload avatar %s: %w", path, err) + } + + randID, err := conv.c.client.RandInt64() + if err != nil { + return err + } + if _, err := conv.c.api.MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{ + Peer: conv.peer, + Media: &tg.InputMediaUploadedPhoto{File: file}, + RandomID: randID, + }); err != nil { + return fmt.Errorf("send avatar to @%s: %w", botFatherUsername, err) + } + + reply, err = conv.awaitReply(ctx) + if err != nil { + return err + } + if !strings.Contains(strings.ToLower(reply), "success") { + return fmt.Errorf("@%s did not confirm the picture: %q", botFatherUsername, firstLine(reply)) + } + return nil +} diff --git a/workplans/FT-WP-0002-declared-presence-provisioning.md b/workplans/FT-WP-0002-declared-presence-provisioning.md index 6800654..e233581 100644 --- a/workplans/FT-WP-0002-declared-presence-provisioning.md +++ b/workplans/FT-WP-0002-declared-presence-provisioning.md @@ -64,7 +64,7 @@ else in this workplan is interactive. ```task id: FT-WP-0002-T02 -status: progress +status: done priority: high state_hub_task_id: "01684264-37a5-5fe8-a491-37686e094428" ``` @@ -83,11 +83,9 @@ The schema enforces rather than describes: an `admin_rights` entry other than public one must, and unknown fields fail instead of being ignored. A silently dropped field is how a spec stops describing what actually exists. -**Remaining:** the avatar asset. `presence/assets/helixforge-avatar.png` is -referenced but absent, and `apply` will fail on `/setuserpic` until it exists. -It is left for a person on purpose — it is the first thing a stranger sees of -HelixForge on Telegram, and a generated placeholder would quietly become -permanent. +**Done.** The avatar is provided at `assets/helix-forge.png` and validated: +PNG, dimensions, size, and a content digest so replacing the file is what +triggers an update. ## T03 — Implement `provision plan`