// 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/gotd/td/tg" "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 *secrets.Store Client *tgc.Client 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 } botUser, err := ensureBot(ctx, o, p) if err != nil { return err } if err := ensureChannels(ctx, o, botUser); 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) (tg.InputUserClass, error) { if o.State.Bot.ID != 0 { fmt.Fprintf(o.Out, " ok bot @%s\n", 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) if err != nil { return nil, 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 nil, err } if token == "" { return nil, 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 nil, 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 nil, err } if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil { return nil, err } 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 } 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, }) if err != nil { return nil, fmt.Errorf("resolve @%s: %w", username, err) } for _, u := range res.Users { if user, ok := u.(*tg.User); ok { o.State.Bot.ID = user.ID return &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}, nil } } return nil, fmt.Errorf("@%s did not resolve to a user", username) } func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error { // 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 _, done := o.State.Channels[name]; done { fmt.Fprintf(o.Out, " ok channel %s\n", name) continue } ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description) if err != nil { return err } rec := state.Channel{ChatID: ch.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, ch.ChatID) if sc.Visibility == spec.Public { claimed := "" for _, cand := range sc.UsernamePreference { ok, err := o.Client.SetUsername(ctx, ch, 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.Client.PromoteBot(ctx, ch, bot, 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 }