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
This commit is contained in:
tegwick 2026-09-04 22:12:56 +02:00
parent 014ee5c746
commit 7347bd6302
11 changed files with 661 additions and 46 deletions

135
internal/avatar/avatar.go Normal file
View file

@ -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
}

View file

@ -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())
}
}