fluid-telegram/internal/apply/apply.go

253 lines
7.7 KiB
Go
Raw Normal View History

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