Two things that would have gone wrong on the first live run. The plan promised "create bot.avatar" and apply had no avatar code at all: /setuserpic needs a photo upload that is not implemented. A plan that promises an action apply silently skips is worse than one that admits the gap, because it makes every other line less trustworthy. The avatar is now deferred, with instructions for setting it by hand. The runbook wrote secrets to "<interface-path>/telegram/...", which is not what the code computes. An operator following it would have put api_id and api_hash somewhere the tool never looks, and found out at the first connection attempt. Both documents now carry the real paths -- <mount>/fluid-telegram/<campaign>/telegram/<key> -- with a copy-pasteable bao kv put for the one secret written by hand, and a verify step before the session is minted. 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
289 lines
9.5 KiB
Go
289 lines
9.5 KiB
Go
// Package plan computes what provisioning would do, without doing any of it.
|
|
//
|
|
// A plan is read by a person before it is applied, so it is written to be read:
|
|
// it says what it will attempt, what it cannot know in advance, and what it
|
|
// refuses. The refusals are the important part -- a reconciler that quietly
|
|
// works around a situation it does not understand is worse than one that stops.
|
|
package plan
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/spec"
|
|
"github.com/tegwick/fluid-telegram/internal/state"
|
|
)
|
|
|
|
type Kind int
|
|
|
|
const (
|
|
// Create makes something that does not exist.
|
|
Create Kind = iota
|
|
// Update changes something that does, in a way that is safe to repeat.
|
|
Update
|
|
// Attempt is a Create whose outcome cannot be known in advance -- claiming a
|
|
// username, for instance. The plan promises the attempt, not the result.
|
|
Attempt
|
|
// Warn is something the operator should know that the tool will not act on.
|
|
Warn
|
|
// Defer is an action held back until a precondition the design expects to be
|
|
// met later. It is not an error: the rest of the plan still applies. The
|
|
// public channel waiting on a checked rendering is the case this exists for,
|
|
// and it is normal on every first run.
|
|
Defer
|
|
// Block is a refusal caused by the world disagreeing with the state file.
|
|
// A plan containing one applies nothing, because the tool no longer knows
|
|
// what it is looking at.
|
|
Block
|
|
)
|
|
|
|
func (k Kind) String() string {
|
|
switch k {
|
|
case Create:
|
|
return "create"
|
|
case Update:
|
|
return "update"
|
|
case Attempt:
|
|
return "attempt"
|
|
case Warn:
|
|
return "warn"
|
|
case Defer:
|
|
return "defer"
|
|
case Block:
|
|
return "BLOCK"
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
type Action struct {
|
|
Kind Kind
|
|
Target string
|
|
Detail string
|
|
// Why is present on Warn and Block, where the reason matters more than the
|
|
// action. An operator who is told only "blocked" will look for a way around.
|
|
Why string
|
|
}
|
|
|
|
type Plan struct {
|
|
Campaign string
|
|
SpecDigest string
|
|
Actions []Action
|
|
}
|
|
|
|
func (p *Plan) add(k Kind, target, detail string) {
|
|
p.Actions = append(p.Actions, Action{k, target, detail, ""})
|
|
}
|
|
func (p *Plan) addWhy(k Kind, target, detail, why string) {
|
|
p.Actions = append(p.Actions, Action{k, target, detail, why})
|
|
}
|
|
|
|
// Blocked reports whether the plan may not be applied.
|
|
func (p *Plan) Blocked() bool {
|
|
for _, a := range p.Actions {
|
|
if a.Kind == Block {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Empty reports whether applying would change nothing. Warnings and deferrals do
|
|
// not count as changes: a converged presence with a standing warning, or with the
|
|
// public channel still waiting on its first checked rendering, is still converged.
|
|
func (p *Plan) Empty() bool {
|
|
for _, a := range p.Actions {
|
|
if a.Kind != Warn && a.Kind != Defer {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Live is what the provisioner can observe about the presence right now. It is
|
|
// an interface so that plan computation stays pure and testable: the MTProto
|
|
// client is one implementation, a fake is another.
|
|
type Live interface {
|
|
// BotExists reports whether a bot with this username is ours and reachable.
|
|
BotExists(username string) (bool, error)
|
|
// ChannelAdminRights returns the rights our bot actually holds on a chat.
|
|
ChannelAdminRights(chatID int64) ([]string, error)
|
|
// ChannelUsername returns the username a chat currently carries.
|
|
ChannelUsername(chatID int64) (string, error)
|
|
}
|
|
|
|
// 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}
|
|
|
|
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),
|
|
"The campaign slug names the state file and the OpenBao subtree. Changing it "+
|
|
"does not rename a presence, it points at a different one, so the tool will "+
|
|
"not guess which was meant.")
|
|
return p, nil
|
|
}
|
|
|
|
if err := planBot(p, sp, rs, live); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := planChannels(p, sp, rs, live); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Nothing here deletes. Removing a channel from the spec destroys its
|
|
// subscribers and its post history irreversibly, and no spec is trusted
|
|
// with that.
|
|
for name := range rs.Channels {
|
|
if _, ok := sp.Channels[name]; !ok {
|
|
p.addWhy(Warn, "channel."+name, "present in state, absent from spec",
|
|
"Not deleted. Deleting a channel destroys its subscribers and history "+
|
|
"irreversibly; remove it by hand if that is genuinely what you want.")
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
|
|
if rs.Bot.ID == 0 {
|
|
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.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
ok, err := live.BotExists(rs.Bot.Username)
|
|
if err != nil {
|
|
return fmt.Errorf("check bot: %w", err)
|
|
}
|
|
if !ok {
|
|
p.addWhy(Block, "bot", fmt.Sprintf("@%s is in the resolved state but is not reachable", rs.Bot.Username),
|
|
"Either the bot was deleted or the operator session no longer has access to it. "+
|
|
"Both mean the state file is describing something that is not there, and "+
|
|
"re-creating the bot would silently orphan the token in OpenBao.")
|
|
return nil
|
|
}
|
|
// 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")
|
|
return nil
|
|
}
|
|
|
|
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} {
|
|
c, ok := sp.Channels[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
rc, provisioned := rs.Channels[name]
|
|
|
|
if name == spec.Live && !rs.TestChannelVerified(spec.Test) {
|
|
p.addWhy(Defer, "channel.public", "held until the test channel has a checked rendering",
|
|
"Nothing reaches the public channel until a person has seen a rendering in "+
|
|
"the test channel. This is expected on a first run and does not stop the "+
|
|
"rest of the plan; publish once to the test channel, then plan again.")
|
|
continue
|
|
}
|
|
|
|
if !provisioned {
|
|
p.add(Create, "channel."+name, fmt.Sprintf("%s channel %q", c.Visibility, c.Title))
|
|
if c.Visibility == spec.Public {
|
|
p.add(Attempt, "channel."+name+".username",
|
|
"claim from: "+strings.Join(c.UsernamePreference, ", "))
|
|
}
|
|
p.add(Create, "channel."+name+".admin",
|
|
"add bot as administrator with post_messages only")
|
|
continue
|
|
}
|
|
|
|
rights, err := live.ChannelAdminRights(rc.ChatID)
|
|
if err != nil {
|
|
return fmt.Errorf("check rights on %s: %w", name, err)
|
|
}
|
|
if !hasOnly(rights, spec.PostMessages) {
|
|
p.addWhy(Block, "channel."+name+".admin",
|
|
fmt.Sprintf("bot holds %v, expected [%s]", rights, spec.PostMessages),
|
|
"Rights drifted. Widening is a permission this system is not allowed to "+
|
|
"exercise; losing post_messages means it cannot publish. Either way a "+
|
|
"person decides what happened before a tool changes it back.")
|
|
}
|
|
|
|
if c.Visibility == spec.Public {
|
|
cur, err := live.ChannelUsername(rc.ChatID)
|
|
if err != nil {
|
|
return fmt.Errorf("check username on %s: %w", name, err)
|
|
}
|
|
if cur != rc.Username {
|
|
p.addWhy(Block, "channel."+name+".username",
|
|
fmt.Sprintf("is @%s, state says @%s", cur, rc.Username),
|
|
"A channel's public username changing under us is not something to "+
|
|
"reconcile. It may have been taken over, or the state may be stale.")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasOnly(got []string, want string) bool {
|
|
return len(got) == 1 && got[0] == want
|
|
}
|
|
|
|
// Render writes the plan the way an operator reads it: refusals first, because
|
|
// they decide whether the rest matters.
|
|
func (p *Plan) Render() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "presence plan for %q\n", p.Campaign)
|
|
fmt.Fprintf(&b, "spec %s\n\n", p.SpecDigest)
|
|
|
|
if p.Empty() && !p.Blocked() {
|
|
b.WriteString(" no changes -- the declared presence matches what exists\n")
|
|
}
|
|
for _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update} {
|
|
for _, a := range p.Actions {
|
|
if a.Kind != k {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " %-8s %-26s %s\n", a.Kind, a.Target, a.Detail)
|
|
if a.Why != "" {
|
|
for _, line := range wrap(a.Why, 72) {
|
|
fmt.Fprintf(&b, " %s\n", line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if p.Blocked() {
|
|
b.WriteString("\nnothing will be applied while a BLOCK stands: the presence does not\n" +
|
|
"match the state file, and a person decides what happened before a tool acts\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func wrap(s string, width int) []string {
|
|
var out []string
|
|
line := ""
|
|
for _, w := range strings.Fields(s) {
|
|
if line != "" && len(line)+1+len(w) > width {
|
|
out = append(out, line)
|
|
line = ""
|
|
}
|
|
if line == "" {
|
|
line = w
|
|
} else {
|
|
line += " " + w
|
|
}
|
|
}
|
|
if line != "" {
|
|
out = append(out, line)
|
|
}
|
|
return out
|
|
}
|