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:
parent
014ee5c746
commit
7347bd6302
11 changed files with 661 additions and 46 deletions
|
|
@ -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} {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue