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

View file

@ -27,6 +27,7 @@ import (
const usage = `provision -- reconcile a Telegram presence with its declared spec const usage = `provision -- reconcile a Telegram presence with its declared spec
provision preflight --spec <path> check everything short of contacting telegram
provision plan --spec <path> [--check] show what would change; writes nothing provision plan --spec <path> [--check] show what would change; writes nothing
provision apply --spec <path> execute an approved plan provision apply --spec <path> execute an approved plan
@ -54,6 +55,8 @@ func main() {
err = cmdReconcile(os.Args[2:], false) err = cmdReconcile(os.Args[2:], false)
case "apply": case "apply":
err = cmdReconcile(os.Args[2:], true) err = cmdReconcile(os.Args[2:], true)
case "preflight":
err = cmdPreflight(os.Args[2:])
case "session": case "session":
err = cmdSession(os.Args[2:]) err = cmdSession(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
@ -99,7 +102,7 @@ func cmdReconcile(args []string, doApply bool) error {
ctx := context.Background() ctx := context.Background()
if *offline { if *offline {
p, err := plan.Compute(sp, digest, rs, offlineLive{}) p, err := plan.Compute(*specPath, sp, digest, rs, offlineLive{})
if err != nil { if err != nil {
return err return err
} }
@ -125,7 +128,7 @@ func cmdReconcile(args []string, doApply bool) error {
// No authenticator: reconciliation must never prompt. If the session is // No authenticator: reconciliation must never prompt. If the session is
// gone, that is a runbook step, not something to paper over mid-run. // 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 { 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 { if err != nil {
return err return err
} }

170
cmd/provision/preflight.go Normal file
View file

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

View file

@ -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 ## Before you start
Have these ready. Stopping halfway through to find one is how the session 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 `post_messages` only, generates the redaction salt if it is absent, and writes
`presence/resolved/hall-of-helix.yaml`. `presence/resolved/hall-of-helix.yaml`.
Two things it will *not* do on this first run, both by design and both reported One thing it will *not* do on this first run, by design and reported in the
in the plan. The **public channel is held** until a rendering has been checked in plan: the **public channel is held** until a rendering has been checked in the
the test channel. The **avatar is deferred**: `/setuserpic` needs a photo upload test channel.
that is not implemented, so set it by hand in @BotFather, or wait.
**Read the plan before approving it.** That is the human judgement this design **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 keeps — not clicking through BotFather, but deciding whether the diff is what

View file

@ -50,7 +50,7 @@ func Run(ctx context.Context, p *plan.Plan, o Options) error {
if err := ensureSalt(ctx, o); err != nil { if err := ensureSalt(ctx, o); err != nil {
return err return err
} }
botUser, err := ensureBot(ctx, o) botUser, err := ensureBot(ctx, o, p)
if err != nil { if err != nil {
return err return err
} }
@ -85,10 +85,17 @@ func ensureSalt(ctx context.Context, o Options) error {
return nil 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 { if o.State.Bot.ID != 0 {
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username) 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) 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") 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) user, err := resolveBot(ctx, o, username)
if err != nil { if err != nil {
return nil, err 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) 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) { func resolveBot(ctx context.Context, o Options, username string) (tg.InputUserClass, error) {
res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: username, Username: username,

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

View file

@ -7,9 +7,11 @@
package plan package plan
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/tegwick/fluid-telegram/internal/avatar"
"github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state" "github.com/tegwick/fluid-telegram/internal/state"
) )
@ -67,7 +69,13 @@ type Action struct {
type Plan struct { type Plan struct {
Campaign string Campaign string
SpecDigest string SpecDigest string
SpecPath string
Actions []Action 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) { 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. // 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) { // specPath locates the spec so that relative asset paths resolve against the
p := &Plan{Campaign: sp.Campaign, SpecDigest: digest} // 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 { 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), 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", 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, ", "))) sp.Bot.Name, len(sp.Bot.UsernamePreference), strings.Join(sp.Bot.UsernamePreference, ", ")))
p.add(Create, "bot.profile", "set name, about text and description") p.add(Create, "bot.profile", "set name, about text and description")
if sp.Bot.Avatar != "" { planAvatar(p, sp, rs)
// 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.")
}
return nil 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 // Profile fields are safe to reassert: BotFather takes them idempotently and
// the tool does not know what a person may have changed by hand. // 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") p.add(Update, "bot.profile", "reassert name, about text and description from the spec")
planAvatar(p, sp, rs)
return nil 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 { func planChannels(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
// Test first, always. The ordering is the guarantee, not a convention. // Test first, always. The ordering is the guarantee, not a convention.
for _, name := range []string{spec.Test, spec.Live} { for _, name := range []string{spec.Test, spec.Live} {

View file

@ -1,6 +1,7 @@
package plan package plan
import ( import (
"os"
"strings" "strings"
"testing" "testing"
"time" "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 // First run: everything is created, and the public channel is blocked because
// no rendering has been checked yet. // no rendering has been checked yet.
func TestFirstRunCreatesAndBlocksPublic(t *testing.T) { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -92,7 +93,7 @@ func provisioned(withTestPublication bool) *state.Resolved {
// idempotent profile reassertion. // idempotent profile reassertion.
func TestConvergedRunIsQuiet(t *testing.T) { func TestConvergedRunIsQuiet(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -110,7 +111,7 @@ func TestConvergedRunIsQuiet(t *testing.T) {
func TestWidenedRightsBlock(t *testing.T) { func TestWidenedRightsBlock(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
rights: map[int64][]string{-200: {spec.PostMessages, "can_delete_messages"}}} 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") a := find(p, Block, "channel.public.admin")
if a == nil { if a == nil {
t.Fatalf("widened rights must block:\n%s", p.Render()) t.Fatalf("widened rights must block:\n%s", p.Render())
@ -124,7 +125,7 @@ func TestWidenedRightsBlock(t *testing.T) {
func TestLostRightsBlock(t *testing.T) { func TestLostRightsBlock(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
rights: map[int64][]string{-100: {}}} 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 { if find(p, Block, "channel.test.admin") == nil {
t.Fatalf("a demoted bot must block:\n%s", p.Render()) 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. // A username that changed underneath us is not reconciled.
func TestUsernameTakeoverBlocks(t *testing.T) { func TestUsernameTakeoverBlocks(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} 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 { if find(p, Block, "channel.public.username") == nil {
t.Fatalf("a changed username must block:\n%s", p.Render()) t.Fatalf("a changed username must block:\n%s", p.Render())
} }
@ -143,7 +144,7 @@ func TestUsernameTakeoverBlocks(t *testing.T) {
// token already in OpenBao. // token already in OpenBao.
func TestMissingBotBlocks(t *testing.T) { func TestMissingBotBlocks(t *testing.T) {
f := fake{botExists: false} 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 { if find(p, Block, "bot") == nil {
t.Fatalf("an unreachable bot must block:\n%s", p.Render()) t.Fatalf("an unreachable bot must block:\n%s", p.Render())
} }
@ -154,7 +155,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
s := sp() s := sp()
delete(s.Channels, spec.Live) delete(s.Channels, spec.Live)
f := fake{botExists: true} 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") a := find(p, Warn, "channel.public")
if a == nil { if a == nil {
t.Fatalf("expected a warning:\n%s", p.Render()) t.Fatalf("expected a warning:\n%s", p.Render())
@ -170,7 +171,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
func TestCampaignMismatchBlocks(t *testing.T) { func TestCampaignMismatchBlocks(t *testing.T) {
rs := provisioned(true) rs := provisioned(true)
rs.Campaign = "some-other-campaign" 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 { if find(p, Block, "campaign") == nil {
t.Fatalf("campaign mismatch must block:\n%s", p.Render()) t.Fatalf("campaign mismatch must block:\n%s", p.Render())
} }
@ -179,7 +180,7 @@ func TestCampaignMismatchBlocks(t *testing.T) {
func TestRenderShowsRefusalsFirst(t *testing.T) { func TestRenderShowsRefusalsFirst(t *testing.T) {
rs := provisioned(true) rs := provisioned(true)
rs.Campaign = "some-other-campaign" 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() out := p.Render()
if !strings.Contains(out, "nothing will be applied") { if !strings.Contains(out, "nothing will be applied") {
t.Errorf("a blocked plan should say so:\n%s", out) 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 // A deferral is not a block: the two must not collapse into each other, or a
// first run can never apply anything. // first run can never apply anything.
func TestDeferIsNotBlock(t *testing.T) { func TestDeferIsNotBlock(t *testing.T) {
p, _ := Compute(sp(), "sha256:x", &state.Resolved{}, fake{}) p, _ := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{})
if p.Blocked() { if p.Blocked() {
t.Fatal("a deferral must not block the plan") t.Fatal("a deferral must not block the plan")
} }
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} 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() { if !p2.Blocked() {
t.Fatal("real drift must block") 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. // Once the test channel is verified, the public channel stops being deferred.
func TestVerifiedTestChannelReleasesPublic(t *testing.T) { func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} 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 { if find(p, Defer, "channel.public") != nil {
t.Errorf("public should no longer be deferred:\n%s", p.Render()) 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. // plan defers it with an explanation instead of claiming a create.
func TestAvatarIsDeferredNotPromised(t *testing.T) { func TestAvatarIsDeferredNotPromised(t *testing.T) {
s := sp() s := sp()
s.Bot.Avatar = "presence/assets/avatar.png" s.Bot.Avatar = "does-not-exist.png"
p, _ := Compute(s, "sha256:x", &state.Resolved{}, fake{}) p, _ := Compute("", s, "sha256:x", &state.Resolved{}, fake{})
if find(p, Create, "bot.avatar") != nil { 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") a := find(p, Defer, "bot.avatar")
if a == nil { 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") { if !strings.Contains(a.Why, "not there") {
t.Errorf("deferral should say how to do it by hand: %q", a.Why) 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())
} }
} }

View file

@ -38,9 +38,16 @@ type Store struct {
func NewFromEnv(campaign string) (*Store, error) { func NewFromEnv(campaign string) (*Store, error) {
addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR")) addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR"))
token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN")) 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 " + return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " +
"(see docs/seeding-runbook.md)") "(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") mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
return &Store{ return &Store{

View file

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
) )
@ -205,3 +206,49 @@ func firstLine(s string) string {
} }
return s 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
}

View file

@ -64,7 +64,7 @@ else in this workplan is interactive.
```task ```task
id: FT-WP-0002-T02 id: FT-WP-0002-T02
status: progress status: done
priority: high priority: high
state_hub_task_id: "01684264-37a5-5fe8-a491-37686e094428" 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 public one must, and unknown fields fail instead of being ignored. A silently
dropped field is how a spec stops describing what actually exists. dropped field is how a spec stops describing what actually exists.
**Remaining:** the avatar asset. `presence/assets/helixforge-avatar.png` is **Done.** The avatar is provided at `assets/helix-forge.png` and validated:
referenced but absent, and `apply` will fail on `/setuserpic` until it exists. PNG, dimensions, size, and a content digest so replacing the file is what
It is left for a person on purpose — it is the first thing a stranger sees of triggers an update.
HelixForge on Telegram, and a generated placeholder would quietly become
permanent.
## T03 — Implement `provision plan` ## T03 — Implement `provision plan`