221 lines
6.7 KiB
Go
221 lines
6.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/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)
|
||
|
|
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) (tg.InputUserClass, error) {
|
||
|
|
if o.State.Bot.ID != 0 {
|
||
|
|
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username)
|
||
|
|
return resolveBot(ctx, o, o.State.Bot.Username)
|
||
|
|
}
|
||
|
|
|
||
|
|
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")
|
||
|
|
|
||
|
|
user, err := resolveBot(ctx, o, username)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return user, 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
|
||
|
|
}
|