fluid-telegram/internal/plan/plan.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

329 lines
11 KiB
Go

// Package plan computes what provisioning would do, without doing any of it.
//
// A plan is read by a person before it is applied, so it is written to be read:
// it says what it will attempt, what it cannot know in advance, and what it
// refuses. The refusals are the important part -- a reconciler that quietly
// works around a situation it does not understand is worse than one that stops.
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"
)
type Kind int
const (
// Create makes something that does not exist.
Create Kind = iota
// Update changes something that does, in a way that is safe to repeat.
Update
// Attempt is a Create whose outcome cannot be known in advance -- claiming a
// username, for instance. The plan promises the attempt, not the result.
Attempt
// Warn is something the operator should know that the tool will not act on.
Warn
// Defer is an action held back until a precondition the design expects to be
// met later. It is not an error: the rest of the plan still applies. The
// public channel waiting on a checked rendering is the case this exists for,
// and it is normal on every first run.
Defer
// Block is a refusal caused by the world disagreeing with the state file.
// A plan containing one applies nothing, because the tool no longer knows
// what it is looking at.
Block
)
func (k Kind) String() string {
switch k {
case Create:
return "create"
case Update:
return "update"
case Attempt:
return "attempt"
case Warn:
return "warn"
case Defer:
return "defer"
case Block:
return "BLOCK"
}
return "?"
}
type Action struct {
Kind Kind
Target string
Detail string
// Why is present on Warn and Block, where the reason matters more than the
// action. An operator who is told only "blocked" will look for a way around.
Why string
}
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) {
p.Actions = append(p.Actions, Action{k, target, detail, ""})
}
func (p *Plan) addWhy(k Kind, target, detail, why string) {
p.Actions = append(p.Actions, Action{k, target, detail, why})
}
// Blocked reports whether the plan may not be applied.
func (p *Plan) Blocked() bool {
for _, a := range p.Actions {
if a.Kind == Block {
return true
}
}
return false
}
// Empty reports whether applying would change nothing. Warnings and deferrals do
// not count as changes: a converged presence with a standing warning, or with the
// public channel still waiting on its first checked rendering, is still converged.
func (p *Plan) Empty() bool {
for _, a := range p.Actions {
if a.Kind != Warn && a.Kind != Defer {
return false
}
}
return true
}
// Live is what the provisioner can observe about the presence right now. It is
// an interface so that plan computation stays pure and testable: the MTProto
// client is one implementation, a fake is another.
type Live interface {
// BotExists reports whether a bot with this username is ours and reachable.
BotExists(username string) (bool, error)
// ChannelAdminRights returns the rights our bot actually holds on a chat.
ChannelAdminRights(chatID int64) ([]string, error)
// ChannelUsername returns the username a chat currently carries.
ChannelUsername(chatID int64) (string, error)
}
// Compute diffs the spec against the resolved state and live observation.
// 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),
"The campaign slug names the state file and the OpenBao subtree. Changing it "+
"does not rename a presence, it points at a different one, so the tool will "+
"not guess which was meant.")
return p, nil
}
if err := planBot(p, sp, rs, live); err != nil {
return nil, err
}
if err := planChannels(p, sp, rs, live); err != nil {
return nil, err
}
// Nothing here deletes. Removing a channel from the spec destroys its
// subscribers and its post history irreversibly, and no spec is trusted
// with that.
for name := range rs.Channels {
if _, ok := sp.Channels[name]; !ok {
p.addWhy(Warn, "channel."+name, "present in state, absent from spec",
"Not deleted. Deleting a channel destroys its subscribers and history "+
"irreversibly; remove it by hand if that is genuinely what you want.")
}
}
return p, nil
}
func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
if rs.Bot.ID == 0 {
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")
planAvatar(p, sp, rs)
return nil
}
ok, err := live.BotExists(rs.Bot.Username)
if err != nil {
return fmt.Errorf("check bot: %w", err)
}
if !ok {
p.addWhy(Block, "bot", fmt.Sprintf("@%s is in the resolved state but is not reachable", rs.Bot.Username),
"Either the bot was deleted or the operator session no longer has access to it. "+
"Both mean the state file is describing something that is not there, and "+
"re-creating the bot would silently orphan the token in OpenBao.")
return nil
}
// 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} {
c, ok := sp.Channels[name]
if !ok {
continue
}
rc, provisioned := rs.Channels[name]
if name == spec.Live && !rs.TestChannelVerified(spec.Test) {
p.addWhy(Defer, "channel.public", "held until the test channel has a checked rendering",
"Nothing reaches the public channel until a person has seen a rendering in "+
"the test channel. This is expected on a first run and does not stop the "+
"rest of the plan; publish once to the test channel, then plan again.")
continue
}
if !provisioned {
p.add(Create, "channel."+name, fmt.Sprintf("%s channel %q", c.Visibility, c.Title))
if c.Visibility == spec.Public {
p.add(Attempt, "channel."+name+".username",
"claim from: "+strings.Join(c.UsernamePreference, ", "))
}
p.add(Create, "channel."+name+".admin",
"add bot as administrator with post_messages only")
continue
}
rights, err := live.ChannelAdminRights(rc.ChatID)
if err != nil {
return fmt.Errorf("check rights on %s: %w", name, err)
}
if !hasOnly(rights, spec.PostMessages) {
p.addWhy(Block, "channel."+name+".admin",
fmt.Sprintf("bot holds %v, expected [%s]", rights, spec.PostMessages),
"Rights drifted. Widening is a permission this system is not allowed to "+
"exercise; losing post_messages means it cannot publish. Either way a "+
"person decides what happened before a tool changes it back.")
}
if c.Visibility == spec.Public {
cur, err := live.ChannelUsername(rc.ChatID)
if err != nil {
return fmt.Errorf("check username on %s: %w", name, err)
}
if cur != rc.Username {
p.addWhy(Block, "channel."+name+".username",
fmt.Sprintf("is @%s, state says @%s", cur, rc.Username),
"A channel's public username changing under us is not something to "+
"reconcile. It may have been taken over, or the state may be stale.")
}
}
}
return nil
}
func hasOnly(got []string, want string) bool {
return len(got) == 1 && got[0] == want
}
// Render writes the plan the way an operator reads it: refusals first, because
// they decide whether the rest matters.
func (p *Plan) Render() string {
var b strings.Builder
fmt.Fprintf(&b, "presence plan for %q\n", p.Campaign)
fmt.Fprintf(&b, "spec %s\n\n", p.SpecDigest)
if p.Empty() && !p.Blocked() {
b.WriteString(" no changes -- the declared presence matches what exists\n")
}
for _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update} {
for _, a := range p.Actions {
if a.Kind != k {
continue
}
fmt.Fprintf(&b, " %-8s %-26s %s\n", a.Kind, a.Target, a.Detail)
if a.Why != "" {
for _, line := range wrap(a.Why, 72) {
fmt.Fprintf(&b, " %s\n", line)
}
}
}
}
if p.Blocked() {
b.WriteString("\nnothing will be applied while a BLOCK stands: the presence does not\n" +
"match the state file, and a person decides what happened before a tool acts\n")
}
return b.String()
}
func wrap(s string, width int) []string {
var out []string
line := ""
for _, w := range strings.Fields(s) {
if line != "" && len(line)+1+len(w) > width {
out = append(out, line)
line = ""
}
if line == "" {
line = w
} else {
line += " " + w
}
}
if line != "" {
out = append(out, line)
}
return out
}