fluid-telegram/internal/avatar/avatar.go
tegwick 7347bd6302 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

135 lines
4.3 KiB
Go

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