fluid-telegram/internal/apply/apply.go
tegwick 1e9481e108 Finish the provisioner's remaining plane: honest check, adapter config, IEI-2
plan --check treated BotFather profile reassertion as drift, so a scheduled
check could never go quiet. Reassertion is now its own kind; a changed spec
digest is the drift signal. apply reasserts the profile, updates channel copy
when the spec changes, and is tested against a fake Telegram surface.

The adapter reads its chat id from resolved state and its token from OpenBao,
never the operator session. IEI-2 records that an approved provisioning plan
is not autonomous Daimon action.

Assistant: grok
Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
2026-09-14 02:55:58 +02:00

253 lines
7.7 KiB
Go

// Package apply executes an approved plan.
//
// The ordering is the guarantee: the bot exists before a channel needs an
// administrator, the test channel exists before the public one, and the token is
// in OpenBao before anything else can fail. Each step records what it did before
// the next runs, so an interruption leaves state that describes reality rather
// than intent.
package apply
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"time"
"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"
)
type Options struct {
Spec *spec.Presence
SpecDigest string
State *state.Resolved
StatePath string
Store SecretStore
Surface Surface
Out io.Writer
}
// Run applies the plan. It saves state after every step that changed the world,
// because a channel that exists but is not recorded is worse than one that does
// not exist: the next run creates a second.
func Run(ctx context.Context, p *plan.Plan, o Options) error {
if p.Blocked() {
return fmt.Errorf("refusing to apply a blocked plan")
}
if o.State.Channels == nil {
o.State.Channels = map[string]state.Channel{}
}
o.State.Campaign = o.Spec.Campaign
if err := ensureSalt(ctx, o); err != nil {
return err
}
if err := ensureBot(ctx, o, p); err != nil {
return err
}
if err := ensureChannels(ctx, o); err != nil {
return err
}
o.State.SpecDigest = o.SpecDigest
o.State.ProvisionedAt = time.Now().UTC()
return state.Save(o.StatePath, o.State)
}
// ensureSalt generates the redaction salt once, and can never replace one.
//
// docs/observation.md: rotating it silently invalidates every longitudinal
// comparison the interface has made, and does so with no visible failure --
// the numbers keep arriving and quietly stop meaning what they used to.
func ensureSalt(ctx context.Context, o Options) error {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generate redaction salt: %w", err)
}
created, err := o.Store.CreateIfAbsent(ctx, secrets.KeyRedactionSalt,
map[string]string{"salt": hex.EncodeToString(buf)})
if err != nil {
return err
}
if created {
fmt.Fprintf(o.Out, " created redaction salt at %s (never rotated)\n",
o.Store.Ref(secrets.KeyRedactionSalt))
}
return nil
}
func ensureBot(ctx context.Context, o Options, p *plan.Plan) error {
if o.State.Bot.ID != 0 {
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username)
if _, err := o.Surface.ResolveBot(ctx, o.State.Bot.Username); err != nil {
return err
}
if err := reassertProfile(ctx, o); err != nil {
return err
}
return ensureAvatar(ctx, o, p)
}
conv, err := o.Surface.OpenBotFather(ctx)
if err != nil {
return err
}
var username, token string
for _, candidate := range o.Spec.Bot.UsernamePreference {
username, token, err = conv.RegisterBot(ctx, o.Spec.Bot.Name, candidate)
if err == nil {
break
}
if errors.Is(err, tgc.ErrUsernameTaken) {
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate)
continue
}
return err
}
if token == "" {
return fmt.Errorf("every username candidate was taken; add another to "+
"bot.username_preference in the spec (tried %d)", len(o.Spec.Bot.UsernamePreference))
}
// Straight to OpenBao, before anything else can fail. The token is not
// printed, not logged, and not returned past this point.
if err := o.Store.Put(ctx, secrets.KeyBotToken, map[string]string{"token": token}); err != nil {
return fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+
"recover it from the @BotFather chat and write it to %s by hand; do not "+
"re-run, which would create a second bot", username, err,
o.Store.Ref(secrets.KeyBotToken))
}
token = ""
fmt.Fprintf(o.Out, " created bot @%s, token at %s\n", username,
o.Store.Ref(secrets.KeyBotToken))
o.State.Bot.Username = username
if err := state.Save(o.StatePath, o.State); err != nil {
return err
}
if err := conv.SetProfile(ctx, username, o.Spec.Bot.Name, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return err
}
fmt.Fprintln(o.Out, " set bot name, about text and description")
if err := ensureAvatar(ctx, o, p); err != nil {
return err
}
id, err := o.Surface.ResolveBot(ctx, username)
if err != nil {
return err
}
o.State.Bot.ID = id
return state.Save(o.StatePath, o.State)
}
func reassertProfile(ctx context.Context, o Options) error {
conv, err := o.Surface.OpenBotFather(ctx)
if err != nil {
return err
}
if err := conv.SetProfile(ctx, o.State.Bot.Username, o.Spec.Bot.Name, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return err
}
fmt.Fprintln(o.Out, " set bot name, about text and description")
return nil
}
// 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) error {
if p.Avatar == nil || o.State.Bot.AvatarDigest == p.Avatar.Digest {
return nil
}
conv, err := o.Surface.OpenBotFather(ctx)
if 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 ensureChannels(ctx context.Context, o Options) error {
specChanged := o.State.SpecDigest != "" && o.State.SpecDigest != o.SpecDigest
// Test first, always. Not a convention a caller may reorder.
for _, name := range []string{spec.Test, spec.Live} {
sc, ok := o.Spec.Channels[name]
if !ok {
continue
}
if name == spec.Live && !o.State.TestChannelVerified(spec.Test) {
fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering")
continue
}
if rec, done := o.State.Channels[name]; done {
fmt.Fprintf(o.Out, " ok channel %s\n", name)
if specChanged {
if err := o.Surface.EditChannelTitle(ctx, rec.ChatID, sc.Title); err != nil {
return err
}
if err := o.Surface.EditChannelAbout(ctx, rec.ChatID, sc.Description); err != nil {
return err
}
fmt.Fprintf(o.Out, " set channel %s title and description\n", name)
}
continue
}
chatID, err := o.Surface.CreateChannel(ctx, sc.Title, sc.Description)
if err != nil {
return err
}
rec := state.Channel{ChatID: chatID, AdminRights: []string{spec.PostMessages}}
o.State.Channels[name] = rec
if err := state.Save(o.StatePath, o.State); err != nil {
return err
}
fmt.Fprintf(o.Out, " created channel %s (%d)\n", name, chatID)
if sc.Visibility == spec.Public {
claimed := ""
for _, cand := range sc.UsernamePreference {
ok, err := o.Surface.ClaimUsername(ctx, chatID, cand)
if err != nil {
return err
}
if ok {
claimed = cand
break
}
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", cand)
}
if claimed == "" {
return fmt.Errorf("channel %s was created but every username candidate was "+
"taken; claim one by hand or add candidates to the spec, then re-run", name)
}
rec.Username = claimed
o.State.Channels[name] = rec
if err := state.Save(o.StatePath, o.State); err != nil {
return err
}
fmt.Fprintf(o.Out, " claimed @%s\n", claimed)
}
if err := o.Surface.PromoteBot(ctx, chatID, o.State.Bot.Username); err != nil {
return err
}
fmt.Fprintf(o.Out, " granted post_messages to @%s on %s\n", o.State.Bot.Username, name)
}
return nil
}