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
provision preflight --spec <path> check everything short of contacting telegram
provision plan --spec <path> [--check] show what would change; writes nothing
provision apply --spec <path> 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
}

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

View file

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

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

View file

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

View file

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

View file

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

View file

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