diff --git a/INTENT.md b/INTENT.md index 3c46915..106b011 100644 --- a/INTENT.md +++ b/INTENT.md @@ -1,6 +1,6 @@ --- repo: fluid-telegram -updated: "2026-09-04" +updated: "2026-09-14" --- # INTENT @@ -35,14 +35,14 @@ shortens or embellishes. A post that will not fit is refused, not truncated. ## Governing principle -`InterfaceEvolutionIntent.md` (IEI-1) is the constitutional constraint on this +`InterfaceEvolutionIntent.md` (IEI-2) is the constitutional constraint on this interface and takes precedence over this file where they touch. It is subordinate in turn to `helix-forge/INTENT.md`. Operational authority is **FLUID-2 (Advisory)** by deliberate choice: the Daimon may observe, classify pressure and propose adaptations, but may not generate implementations, run experiments unattended, or promote anything. Raising that -mode is a governance change under IEI-1 §20, not an operational decision. +mode is a governance change under IEI-2 §20, not an operational decision. Work that changes this repository's authority, ownership boundaries, or operational promises is captured in a workplan before implementation. diff --git a/InterfaceEvolutionIntent.md b/InterfaceEvolutionIntent.md index 899d30d..0d07a2f 100644 --- a/InterfaceEvolutionIntent.md +++ b/InterfaceEvolutionIntent.md @@ -1,8 +1,8 @@ # Interface Evolution Intent — helix-forge-telegram-publishing -**Document version:** IEI-1 +**Document version:** IEI-2 **Status:** active -**Last reviewed:** 2026-09-04 +**Last reviewed:** 2026-09-14 --- @@ -190,6 +190,11 @@ The Daimon must not autonomously: - publish to any chat other than those declared in the routing configuration; - publish a post about a person who has not consented, on either basis in §15. +These prohibitions bind the Daimon acting autonomously. An operator-triggered +provisioning tool that executes a plan a person has approved is not the Daimon +and is not autonomous: it is how the presence is established, not a grant of +authority to the request path. + The last two matter most, and they are the reason composition sits outside this interface. A post is a condensation written in HelixForge's voice about a named person's work. It can flatter, it can misstate, and it can put a claim in @@ -566,7 +571,13 @@ of scope, the first 100 subscribers, or annually. ### Last reviewed -2026-09-04 (initial) +2026-09-14 (IEI-2) + +### Amendments + +- **IEI-2** (2026-09-14): §7 now states that an approved, operator-triggered + provisioning plan is not autonomous Daimon action. The request-path + prohibitions are unchanged. Previous version: IEI-1 (2026-09-04). --- diff --git a/README.md b/README.md index 201467d..05da0ac 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ Fluid interface for telegram messaging and interaction platform. ## Status The FLUID interface `helix-forge-telegram-publishing` is prepared but not yet -live. Governance, contract, seed hypotheses and the adapter's connector contract -are in place; the bot, the channel and the adapter itself are not. +live. Governance, contract, seed hypotheses, the adapter's connector contract +and the presence provisioner are in place; the bot, the channel and the adapter +itself are not. The first live apply waits on the operator seeding runbook. - Governance: [`InterfaceEvolutionIntent.md`](InterfaceEvolutionIntent.md) - The channel publishes composed posts written *from* hall entries, not the entries themselves. Composition happens upstream and moves to `pr-hall-of-helix` once the loop has closed once (FT-WP-0001 T13). - Work: [`workplans/FT-WP-0001-telegram-identity-and-hall-channel.md`](workplans/FT-WP-0001-telegram-identity-and-hall-channel.md) +- Provisioning: [`workplans/FT-WP-0002-declared-presence-provisioning.md`](workplans/FT-WP-0002-declared-presence-provisioning.md) - Handover from fluid-core: `fluid-core/docs/handover/fluid-telegram.md` diff --git a/SCOPE.md b/SCOPE.md index 9814043..e700f5c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -42,7 +42,7 @@ it is carried and proven* lives here. and URL a post cites, which it renders as a link. - **Autonomous administration.** Creating, renaming or deleting channels, groups or bots, and changing membership or permissions, are forbidden to the Daimon - (IEI-1 §7). Where such acts are automated at all, they run in the separate, + (IEI-2 §7). Where such acts are automated at all, they run in the separate, human-approved provisioning plane, never in the request path. - Owning unrelated adjacent systems. @@ -62,7 +62,7 @@ not. ## Getting Oriented - Why it exists: `INTENT.md` -- What may change and what may not: `InterfaceEvolutionIntent.md` (IEI-1) +- What may change and what may not: `InterfaceEvolutionIntent.md` (IEI-2) - What the adapter owes: `docs/adapter-contract.md` - Platform capability reference: `TelegramCapabilityCanon.md` - Agent instructions: `AGENTS.md` diff --git a/cmd/provision/main.go b/cmd/provision/main.go index d2e7e57..65e8b61 100644 --- a/cmd/provision/main.go +++ b/cmd/provision/main.go @@ -17,6 +17,7 @@ import ( "fmt" "os" + "github.com/tegwick/fluid-telegram/internal/adaptercfg" "github.com/tegwick/fluid-telegram/internal/apply" "github.com/tegwick/fluid-telegram/internal/plan" "github.com/tegwick/fluid-telegram/internal/secrets" @@ -30,6 +31,8 @@ const usage = `provision -- reconcile a Telegram presence with its declared spec provision preflight --spec check everything short of contacting telegram provision plan --spec [--check] show what would change; writes nothing provision apply --spec execute an approved plan + provision adapter-config --spec --channel test|public + print the adapter's non-secret configuration provision session bootstrap --campaign mint the operator session (interactive) provision session check --campaign verify the stored session @@ -57,6 +60,8 @@ func main() { err = cmdReconcile(os.Args[2:], true) case "preflight": err = cmdPreflight(os.Args[2:]) + case "adapter-config": + err = cmdAdapterConfig(os.Args[2:]) case "session": err = cmdSession(os.Args[2:]) case "-h", "--help", "help": @@ -134,23 +139,23 @@ func cmdReconcile(args []string, doApply bool) error { } fmt.Print(p.Render()) - if *check && !p.Empty() { + if *check && p.Drifted() { return errors.New("presence has drifted from the spec") } if p.Blocked() { - os.Exit(1) + return errors.New("plan is blocked; nothing applied") } if !doApply { return nil } - if p.Empty() { + if !p.NeedsApply() { fmt.Println("\nnothing to apply") return nil } fmt.Println("\napplying:") return apply.Run(ctx, p, apply.Options{ Spec: sp, SpecDigest: digest, State: rs, StatePath: statePath, - Store: store, Client: c, Out: os.Stdout, + Store: store, Surface: apply.Live(c), Out: os.Stdout, }) }) } @@ -165,3 +170,28 @@ func (offlineLive) ChannelAdminRights(int64) ([]string, error) { return []string{spec.PostMessages}, nil } func (offlineLive) ChannelUsername(int64) (string, error) { return "", nil } + +func cmdAdapterConfig(args []string) error { + fs := flag.NewFlagSet("adapter-config", flag.ExitOnError) + specPath := fs.String("spec", "", "path to the presence spec") + root := fs.String("root", ".", "repo root holding presence/resolved/") + channel := fs.String("channel", "", "which channel this adapter instance publishes to (test or public)") + fs.Parse(args) + if *specPath == "" { + return errors.New("--spec is required") + } + sp, _, err := spec.Load(*specPath) + if err != nil { + return err + } + rs, err := state.Load(state.Path(*root, sp.Campaign)) + if err != nil { + return err + } + cfg, err := adaptercfg.FromResolved(rs, *channel, secrets.RefFor(sp.Campaign, secrets.KeyBotToken)) + if err != nil { + return err + } + fmt.Print(cfg.EnvFile()) + return nil +} diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index 5330f44..66c3ecd 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -115,10 +115,20 @@ from Telegram's channel statistics as aggregates, never per-reader. ## Configuration +Chat id and bot token are derived, not hand-written. `provision adapter-config` +reads `presence/resolved/.yaml` for the chat id and names the OpenBao +path of the bot token; the runtime injects the token from that path. The adapter +never reads the operator session. + +```bash +provision adapter-config --spec --channel test # or public +``` + | Variable | Meaning | |---|---| -| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start | -| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to | +| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start (`bot-token` under the campaign subtree) | +| `TELEGRAM_BOT_TOKEN_REF` | The OpenBao location of that token; printed by `adapter-config`, never the value | +| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to, taken from the resolved state | | `FLUID_CONTROL_URL` | Where to POST telemetry | | `HALL_BASE_URL` | Base URL for links back to hall-of-helix | | `ATTACH_VISUAL` | Whether this revision attaches the portrait (R-2 true, R-3 false) | diff --git a/docs/provisioning.md b/docs/provisioning.md index f22ad5d..16ab6e8 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -83,8 +83,9 @@ rename, delete or re-permission anything. This is what keeps the scheme inside `InterfaceEvolutionIntent.md` §7, which forbids the Daimon from creating channels or bots or changing permissions. The provisioner is not the Daimon: it is operator-triggered, it is outside the -request path, and every run is a plan a person approved. §7's prohibition is on -*autonomous* administration, and nothing here is autonomous. +request path, and every run is a plan a person approved. IEI-2 states that +reading in the document: §7 constrains autonomous administration, not an +approved apply. ## plan / apply @@ -170,6 +171,16 @@ This is what the adapter's configuration is derived from, and what `plan` diffs against. `spec_digest` is the link that makes drift detectable: if the spec changed and the digest did not, provisioning is behind. +The adapter reads its chat id from this file and its bot token from OpenBao. +It gets neither the operator session nor any provisioning code path: + +```bash +provision adapter-config --spec --channel test # or public +``` + +That command prints `TELEGRAM_CHANNEL_ID` and `TELEGRAM_BOT_TOKEN_REF`. It never +prints the token. + ## Rate and account safety Automating a user account is not what Telegram's terms are written around. Three @@ -184,12 +195,9 @@ mitigations, all cheap: token on the Bot API, which is the supported surface. The user session exists only to do the three things the Bot API cannot. -## Open question for the operator +## §7, settled -`InterfaceEvolutionIntent.md` §7 lists channel and bot creation under "explicit -non-authority" without distinguishing the Daimon from an operator-run tool. The -reading above — that §7 constrains autonomous action, and an approved plan is not -autonomous — is the intended one, but it is currently a reading rather than -something the document says. A one-sentence clarification in §7 would settle it. -That is a governance edit to a constitutional document, so it is proposed here -rather than made. +IEI-2 states the reading this design depends on: an operator-triggered +provisioning tool that executes a plan a person has approved is not the Daimon +and is not autonomous. The request path is unchanged — the adapter still holds +`post_messages` and nothing else. diff --git a/docs/runbook.md b/docs/runbook.md index adf5ed7..a8a3bfc 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -23,7 +23,7 @@ fails with a Telegram `400`. 3. If the bot was removed entirely, re-add it and re-run one test publication. A human must do this. The Daimon is forbidden from changing channel membership -or permissions (`InterfaceEvolutionIntent.md` §7). +or permissions (`InterfaceEvolutionIntent.md` IEI-2 §7). ## Rolling back a revision diff --git a/internal/adaptercfg/adaptercfg.go b/internal/adaptercfg/adaptercfg.go new file mode 100644 index 0000000..c8f0b9c --- /dev/null +++ b/internal/adaptercfg/adaptercfg.go @@ -0,0 +1,86 @@ +// Package adaptercfg derives the adapter's configuration from the resolved +// presence and OpenBao. +// +// The adapter publishes. It holds a bot token with post_messages and a single +// chat id, and it has no MTProto session and no provisioning code path. That +// separation is the point of running two planes: a compromised adapter cannot +// create, rename, delete or re-permission anything. +package adaptercfg + +import ( + "context" + "fmt" + "strings" + + "github.com/tegwick/fluid-telegram/internal/secrets" + "github.com/tegwick/fluid-telegram/internal/spec" + "github.com/tegwick/fluid-telegram/internal/state" +) + +// Config is what one adapter instance needs to publish to one channel. +type Config struct { + Campaign string + Channel string + ChatID int64 + TokenRef string +} + +// FromResolved maps the committed state onto one adapter instance. It never +// reads a secret: the token stays in OpenBao, and TokenRef is where to ask. +func FromResolved(rs *state.Resolved, channel, tokenRef string) (Config, error) { + if rs == nil || rs.Bot.ID == 0 { + return Config{}, fmt.Errorf("presence is not provisioned; run `provision apply` first") + } + switch channel { + case spec.Test, spec.Live: + case "": + return Config{}, fmt.Errorf("--channel is required (%q or %q)", spec.Test, spec.Live) + default: + return Config{}, fmt.Errorf("channel %q is not %q or %q", channel, spec.Test, spec.Live) + } + ch, ok := rs.Channels[channel] + if !ok { + if channel == spec.Live && !rs.TestChannelVerified(spec.Test) { + return Config{}, fmt.Errorf("the public channel is not provisioned yet; " + + "it is held until the test channel has a checked rendering") + } + return Config{}, fmt.Errorf("channel %q is not in the resolved state", channel) + } + if ch.ChatID == 0 { + return Config{}, fmt.Errorf("channel %q has no chat id in the resolved state", channel) + } + return Config{ + Campaign: rs.Campaign, + Channel: channel, + ChatID: ch.ChatID, + TokenRef: tokenRef, + }, nil +} + +// EnvFile is the non-secret configuration an adapter process is started with. +// The token is named, not emitted: the runtime injects it from OpenBao. +func (c Config) EnvFile() string { + var b strings.Builder + fmt.Fprintf(&b, "# adapter configuration for %s / %s\n", c.Campaign, c.Channel) + fmt.Fprintf(&b, "# derived from presence/resolved/%s.yaml; no secrets in this file\n", c.Campaign) + fmt.Fprintf(&b, "TELEGRAM_CHANNEL_ID=%d\n", c.ChatID) + fmt.Fprintf(&b, "TELEGRAM_BOT_TOKEN_REF=%s\n", c.TokenRef) + return b.String() +} + +// LoadToken reads the bot token and nothing else. The operator session is a +// full-account credential; the adapter must never request it. +func LoadToken(ctx context.Context, store *secrets.Store) (string, error) { + fields, found, err := store.Get(ctx, secrets.KeyBotToken) + if err != nil { + return "", err + } + if !found { + return "", fmt.Errorf("no bot token at %s; run `provision apply`", store.Ref(secrets.KeyBotToken)) + } + token := fields["token"] + if token == "" { + return "", fmt.Errorf("bot token at %s is empty", store.Ref(secrets.KeyBotToken)) + } + return token, nil +} diff --git a/internal/adaptercfg/adaptercfg_test.go b/internal/adaptercfg/adaptercfg_test.go new file mode 100644 index 0000000..a967787 --- /dev/null +++ b/internal/adaptercfg/adaptercfg_test.go @@ -0,0 +1,114 @@ +package adaptercfg + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tegwick/fluid-telegram/internal/secrets" + "github.com/tegwick/fluid-telegram/internal/spec" + "github.com/tegwick/fluid-telegram/internal/state" +) + +func provisioned() *state.Resolved { + now := time.Now() + return &state.Resolved{ + Campaign: "hall-of-helix", + Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, + Channels: map[string]state.Channel{ + spec.Test: {ChatID: -1001, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now}, + spec.Live: {ChatID: -2002, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}}, + }, + } +} + +func TestFromResolvedPicksOneChannel(t *testing.T) { + cfg, err := FromResolved(provisioned(), spec.Test, "bao:platform/x/bot-token") + if err != nil { + t.Fatal(err) + } + if cfg.ChatID != -1001 || cfg.Channel != spec.Test { + t.Fatalf("got %+v", cfg) + } + cfg, err = FromResolved(provisioned(), spec.Live, "bao:platform/x/bot-token") + if err != nil { + t.Fatal(err) + } + if cfg.ChatID != -2002 { + t.Fatalf("public chat id = %d", cfg.ChatID) + } +} + +func TestFromResolvedRefusesUnprovisioned(t *testing.T) { + if _, err := FromResolved(&state.Resolved{}, spec.Test, "ref"); err == nil { + t.Fatal("accepted an empty state") + } + rs := provisioned() + delete(rs.Channels, spec.Live) + rs.Channels[spec.Test] = state.Channel{ChatID: -1001, AdminRights: []string{spec.PostMessages}} + if _, err := FromResolved(rs, spec.Live, "ref"); err == nil { + t.Fatal("accepted a public channel that has not been provisioned") + } +} + +func TestEnvFileHasNoToken(t *testing.T) { + cfg, _ := FromResolved(provisioned(), spec.Test, "bao:platform/x/bot-token") + out := cfg.EnvFile() + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "TELEGRAM_BOT_TOKEN=") { + t.Fatal("env file emitted a token value") + } + } + if !strings.Contains(out, "TELEGRAM_CHANNEL_ID=-1001") { + t.Fatalf("missing chat id:\n%s", out) + } + if !strings.Contains(out, "TELEGRAM_BOT_TOKEN_REF=bao:platform/x/bot-token") { + t.Fatalf("missing token ref:\n%s", out) + } + if strings.Contains(out, "operator-session") { + t.Fatal("adapter config mentioned the operator session") + } +} + +func TestLoadTokenReadsOnlyTheBotToken(t *testing.T) { + var requested []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.URL.Path) + if strings.Contains(r.URL.Path, secrets.KeyOperatorSession) { + t.Errorf("adapter requested the operator session: %s", r.URL.Path) + w.WriteHeader(http.StatusForbidden) + return + } + if !strings.HasSuffix(r.URL.Path, "/"+secrets.KeyBotToken) { + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"data": map[string]string{"token": "123:abc"}}, + }) + })) + t.Cleanup(srv.Close) + t.Setenv("BAO_ADDR", srv.URL) + t.Setenv("BAO_TOKEN", "test-token") + store, err := secrets.NewFromEnv("hall-of-helix") + if err != nil { + t.Fatal(err) + } + got, err := LoadToken(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if got != "123:abc" { + t.Fatalf("token = %q", got) + } + for _, p := range requested { + if strings.Contains(p, secrets.KeyOperatorSession) || strings.Contains(p, secrets.KeyOperatorApp) { + t.Fatalf("adapter store was asked for %s", p) + } + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 0a07f0f..dd6ee19 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -16,8 +16,6 @@ import ( "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" @@ -30,8 +28,8 @@ type Options struct { SpecDigest string State *state.Resolved StatePath string - Store *secrets.Store - Client *tgc.Client + Store SecretStore + Surface Surface Out io.Writer } @@ -50,11 +48,10 @@ func Run(ctx context.Context, p *plan.Plan, o Options) error { if err := ensureSalt(ctx, o); err != nil { return err } - botUser, err := ensureBot(ctx, o, p) - if err != nil { + if err := ensureBot(ctx, o, p); err != nil { return err } - if err := ensureChannels(ctx, o, botUser); err != nil { + if err := ensureChannels(ctx, o); err != nil { return err } @@ -85,22 +82,21 @@ func ensureSalt(ctx context.Context, o Options) error { return nil } -func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, error) { +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) - user, err := resolveBot(ctx, o, o.State.Bot.Username) - if err != nil { - return nil, err + if _, err := o.Surface.ResolveBot(ctx, o.State.Bot.Username); err != nil { + return err } - if err := ensureAvatar(ctx, o, p, nil); err != nil { - return nil, err + if err := reassertProfile(ctx, o); err != nil { + return err } - return user, nil + return ensureAvatar(ctx, o, p) } - conv, err := o.Client.BotFather(ctx) + conv, err := o.Surface.OpenBotFather(ctx) if err != nil { - return nil, err + return err } var username, token string @@ -113,17 +109,17 @@ func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate) continue } - return nil, err + return err } if token == "" { - return nil, fmt.Errorf("every username candidate was taken; add another to "+ + 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 nil, fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+ + 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)) @@ -135,37 +131,48 @@ func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, o.State.Bot.Username = username if err := state.Save(o.StatePath, o.State); err != nil { - return nil, err + return err } - if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil { - return nil, 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 about text and description") + fmt.Fprintln(o.Out, " set bot name, about text and description") - if err := ensureAvatar(ctx, o, p, conv); err != nil { - return nil, err + if err := ensureAvatar(ctx, o, p); err != nil { + return err } - user, err := resolveBot(ctx, o, username) + id, err := o.Surface.ResolveBot(ctx, username) if err != nil { - return nil, err + return err } - return user, state.Save(o.StatePath, o.State) + 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, conv *tgc.Conversation) error { +func ensureAvatar(ctx context.Context, o Options, p *plan.Plan) 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 - } + 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 @@ -175,23 +182,8 @@ func ensureAvatar(ctx context.Context, o Options, p *plan.Plan, conv *tgc.Conver 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 { +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] @@ -202,26 +194,35 @@ func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering") continue } - if _, done := o.State.Channels[name]; done { + 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 } - ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description) + chatID, err := o.Surface.CreateChannel(ctx, sc.Title, sc.Description) if err != nil { return err } - rec := state.Channel{ChatID: ch.ChatID, AdminRights: []string{spec.PostMessages}} + 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, ch.ChatID) + 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.Client.SetUsername(ctx, ch, cand) + ok, err := o.Surface.ClaimUsername(ctx, chatID, cand) if err != nil { return err } @@ -243,7 +244,7 @@ func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error fmt.Fprintf(o.Out, " claimed @%s\n", claimed) } - if err := o.Client.PromoteBot(ctx, ch, bot, o.State.Bot.Username); err != nil { + 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) diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go new file mode 100644 index 0000000..2c130c5 --- /dev/null +++ b/internal/apply/apply_test.go @@ -0,0 +1,310 @@ +package apply + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "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 memStore struct { + data map[string]map[string]string +} + +func (s *memStore) CreateIfAbsent(_ context.Context, key string, fields map[string]string) (bool, error) { + if s.data == nil { + s.data = map[string]map[string]string{} + } + if _, ok := s.data[key]; ok { + return false, nil + } + s.data[key] = fields + return true, nil +} + +func (s *memStore) Put(_ context.Context, key string, fields map[string]string) error { + if s.data == nil { + s.data = map[string]map[string]string{} + } + s.data[key] = fields + return nil +} + +func (s *memStore) Ref(key string) string { return "bao:test/" + key } + +type fakeConv struct { + taken map[string]bool + profiles int +} + +func (c *fakeConv) RegisterBot(_ context.Context, _, username string) (string, string, error) { + if c.taken[username] { + return "", "", fmt.Errorf("%w: @%s", tgc.ErrUsernameTaken, username) + } + return username, "123:token-for-" + username, nil +} + +func (c *fakeConv) SetProfile(context.Context, string, string, string, string) error { + c.profiles++ + return nil +} +func (c *fakeConv) SetAvatar(context.Context, string, string) error { return nil } + +type fakeSurface struct { + conv *fakeConv + nextChat int64 + created []string + claimed []string + promoted []string + titles map[int64]string + abouts map[int64]string + botID int64 +} + +func (f *fakeSurface) OpenBotFather(context.Context) (BotConversation, error) { + if f.conv == nil { + f.conv = &fakeConv{} + } + return f.conv, nil +} + +func (f *fakeSurface) ResolveBot(_ context.Context, _ string) (int64, error) { + if f.botID == 0 { + f.botID = 42 + } + return f.botID, nil +} + +func (f *fakeSurface) CreateChannel(_ context.Context, title, about string) (int64, error) { + f.nextChat-- + id := f.nextChat + f.created = append(f.created, title) + if f.titles == nil { + f.titles = map[int64]string{} + f.abouts = map[int64]string{} + } + f.titles[id] = title + f.abouts[id] = about + return id, nil +} + +func (f *fakeSurface) ClaimUsername(_ context.Context, _ int64, username string) (bool, error) { + f.claimed = append(f.claimed, username) + return true, nil +} + +func (f *fakeSurface) PromoteBot(_ context.Context, _ int64, botUsername string) error { + f.promoted = append(f.promoted, botUsername) + return nil +} + +func (f *fakeSurface) EditChannelTitle(_ context.Context, chatID int64, title string) error { + if f.titles == nil { + f.titles = map[int64]string{} + } + f.titles[chatID] = title + return nil +} + +func (f *fakeSurface) EditChannelAbout(_ context.Context, chatID int64, about string) error { + if f.abouts == nil { + f.abouts = map[int64]string{} + } + f.abouts[chatID] = about + return nil +} + +func presence() *spec.Presence { + return &spec.Presence{ + SchemaVersion: "0.1", Campaign: "hall-of-helix", Interface: "i", + Bot: spec.Bot{Name: "HelixForge", UsernamePreference: []string{"HelixForgeBot"}, + About: "about", Description: "desc"}, + Channels: map[string]spec.Channel{ + spec.Test: {Title: "test-title", Description: "test-about", Visibility: spec.Private}, + spec.Live: {Title: "live-title", Description: "live-about", Visibility: spec.Public, + UsernamePreference: []string{"hallofhelix"}}, + }, + } +} + +func opts(t *testing.T, rs *state.Resolved, surf *fakeSurface, store *memStore) Options { + t.Helper() + if rs == nil { + rs = &state.Resolved{} + } + if surf == nil { + surf = &fakeSurface{} + } + if store == nil { + store = &memStore{} + } + return Options{ + Spec: presence(), SpecDigest: "sha256:x", State: rs, + StatePath: filepath.Join(t.TempDir(), "resolved.yaml"), + Store: store, Surface: surf, Out: &bytes.Buffer{}, + } +} + +func TestApplyRefusesBlockedPlan(t *testing.T) { + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Block, Target: "bot", Detail: "gone"}}} + err := Run(context.Background(), p, opts(t, nil, nil, nil)) + if err == nil { + t.Fatal("applied a blocked plan") + } +} + +func TestFirstRunCreatesBotAndTestChannelOnly(t *testing.T) { + surf := &fakeSurface{} + store := &memStore{} + o := opts(t, nil, surf, store) + p := &plan.Plan{Actions: []plan.Action{ + {Kind: plan.Attempt, Target: "bot"}, + {Kind: plan.Create, Target: "channel.test"}, + {Kind: plan.Defer, Target: "channel.public"}, + }} + if err := Run(context.Background(), p, o); err != nil { + t.Fatal(err) + } + if store.data[secrets.KeyBotToken]["token"] == "" { + t.Fatal("bot token was not stored") + } + if _, ok := store.data[secrets.KeyRedactionSalt]; !ok { + t.Fatal("redaction salt was not created") + } + if o.State.Bot.Username != "HelixForgeBot" || o.State.Bot.ID == 0 { + t.Fatalf("bot state = %+v", o.State.Bot) + } + if _, ok := o.State.Channels[spec.Test]; !ok { + t.Fatal("test channel was not recorded") + } + if _, ok := o.State.Channels[spec.Live]; ok { + t.Fatal("public channel was created before the test channel was verified") + } + if len(surf.promoted) != 1 { + t.Fatalf("promotions = %v", surf.promoted) + } +} + +func TestWalksUsernameCandidates(t *testing.T) { + surf := &fakeSurface{conv: &fakeConv{taken: map[string]bool{"HelixForgeBot": true}}} + sp := presence() + sp.Bot.UsernamePreference = []string{"HelixForgeBot", "HelixForgePublishBot"} + o := opts(t, nil, surf, nil) + o.Spec = sp + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Attempt, Target: "bot"}}} + if err := Run(context.Background(), p, o); err != nil { + t.Fatal(err) + } + if o.State.Bot.Username != "HelixForgePublishBot" { + t.Fatalf("username = %s", o.State.Bot.Username) + } +} + +func TestExistingBotReassertsProfile(t *testing.T) { + surf := &fakeSurface{} + now := time.Now() + rs := &state.Resolved{ + Campaign: "hall-of-helix", SpecDigest: "sha256:x", + Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, + Channels: map[string]state.Channel{ + spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now}, + spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}}, + }, + } + o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{ + secrets.KeyRedactionSalt: {"salt": "already"}, + }}) + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Reassert, Target: "bot.profile"}}} + if err := Run(context.Background(), p, o); err != nil { + t.Fatal(err) + } + if surf.conv == nil || surf.conv.profiles == 0 { + t.Fatal("existing bot did not reassert its profile") + } + if len(surf.created) != 0 { + t.Fatalf("recreated channels: %v", surf.created) + } +} + +func TestSpecChangeUpdatesChannelCopy(t *testing.T) { + surf := &fakeSurface{} + now := time.Now() + rs := &state.Resolved{ + Campaign: "hall-of-helix", SpecDigest: "sha256:old", + Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, + Channels: map[string]state.Channel{ + spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now}, + spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}}, + }, + } + o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{ + secrets.KeyRedactionSalt: {"salt": "already"}, + }}) + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Update, Target: "spec"}}} + if err := Run(context.Background(), p, o); err != nil { + t.Fatal(err) + } + if surf.titles[-100] != "test-title" || surf.titles[-200] != "live-title" { + t.Fatalf("titles = %v", surf.titles) + } +} + +func TestSaltIsCreatedOnce(t *testing.T) { + store := &memStore{} + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Attempt, Target: "bot"}}} + if err := Run(context.Background(), p, opts(t, nil, nil, store)); err != nil { + t.Fatal(err) + } + first := store.data[secrets.KeyRedactionSalt]["salt"] + rs := &state.Resolved{ + Campaign: "hall-of-helix", + Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, + Channels: map[string]state.Channel{ + spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}}, + }, + } + if err := Run(context.Background(), p, opts(t, rs, nil, store)); err != nil { + t.Fatal(err) + } + if store.data[secrets.KeyRedactionSalt]["salt"] != first { + t.Fatal("redaction salt was rotated") + } +} + +func TestPublicReleasedAfterTestPublication(t *testing.T) { + now := time.Now() + rs := &state.Resolved{ + Campaign: "hall-of-helix", + Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, + Channels: map[string]state.Channel{ + spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now}, + }, + } + surf := &fakeSurface{} + o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{ + secrets.KeyRedactionSalt: {"salt": "already"}, + }}) + p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Create, Target: "channel.public"}}} + if err := Run(context.Background(), p, o); err != nil { + t.Fatal(err) + } + pub, ok := o.State.Channels[spec.Live] + if !ok { + t.Fatal("public channel was not created") + } + if pub.Username != "hallofhelix" { + t.Fatalf("username = %s", pub.Username) + } + if !strings.Contains(strings.Join(surf.promoted, ","), "HelixForgeBot") { + t.Fatalf("bot was not promoted on public: %v", surf.promoted) + } +} diff --git a/internal/apply/surface.go b/internal/apply/surface.go new file mode 100644 index 0000000..844e734 --- /dev/null +++ b/internal/apply/surface.go @@ -0,0 +1,138 @@ +package apply + +import ( + "context" + "fmt" + + "github.com/gotd/td/tg" + + "github.com/tegwick/fluid-telegram/internal/secrets" + tgc "github.com/tegwick/fluid-telegram/internal/tg" +) + +// Surface is the Telegram operations apply needs. The real client is one +// implementation; tests supply another so apply can be checked without a session. +type Surface interface { + OpenBotFather(ctx context.Context) (BotConversation, error) + ResolveBot(ctx context.Context, username string) (id int64, err error) + CreateChannel(ctx context.Context, title, about string) (chatID int64, err error) + ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error) + PromoteBot(ctx context.Context, chatID int64, botUsername string) error + EditChannelTitle(ctx context.Context, chatID int64, title string) error + EditChannelAbout(ctx context.Context, chatID int64, about string) error +} + +// BotConversation is the BotFather exchange apply drives. +type BotConversation interface { + RegisterBot(ctx context.Context, displayName, username string) (issuedUsername, token string, err error) + SetProfile(ctx context.Context, username, name, about, description string) error + SetAvatar(ctx context.Context, username, path string) error +} + +// SecretStore is the OpenBao operations apply needs. *secrets.Store satisfies it. +type SecretStore interface { + CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error) + Put(ctx context.Context, key string, fields map[string]string) error + Ref(key string) string +} + +var ( + _ Surface = (*live)(nil) + _ BotConversation = (*tgc.Conversation)(nil) + _ SecretStore = (*secrets.Store)(nil) +) + +type live struct { + c *tgc.Client + bot tg.InputUserClass + chats map[int64]tgc.Channel +} + +// Live wraps a connected MTProto client. +func Live(c *tgc.Client) Surface { + return &live{c: c, chats: map[int64]tgc.Channel{}} +} + +func (s *live) OpenBotFather(ctx context.Context) (BotConversation, error) { + return s.c.BotFather(ctx) +} + +func (s *live) ResolveBot(ctx context.Context, username string) (int64, error) { + res, err := s.c.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ + Username: username, + }) + if err != nil { + return 0, fmt.Errorf("resolve @%s: %w", username, err) + } + for _, u := range res.Users { + if user, ok := u.(*tg.User); ok { + s.bot = &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash} + return user.ID, nil + } + } + return 0, fmt.Errorf("@%s did not resolve to a user", username) +} + +func (s *live) CreateChannel(ctx context.Context, title, about string) (int64, error) { + ch, err := s.c.CreateChannel(ctx, title, about) + if err != nil { + return 0, err + } + s.chats[ch.ChatID] = ch + return ch.ChatID, nil +} + +func (s *live) channel(ctx context.Context, chatID int64) (tgc.Channel, error) { + if ch, ok := s.chats[chatID]; ok { + return ch, nil + } + ch, err := s.c.ResolveChannel(ctx, chatID) + if err != nil { + return tgc.Channel{}, err + } + s.chats[chatID] = ch + return ch, nil +} + +func (s *live) ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error) { + ch, err := s.channel(ctx, chatID) + if err != nil { + return false, err + } + ok, err := s.c.SetUsername(ctx, ch, username) + if err != nil { + return false, err + } + if ok { + ch.Username = username + s.chats[chatID] = ch + } + return ok, nil +} + +func (s *live) PromoteBot(ctx context.Context, chatID int64, botUsername string) error { + if s.bot == nil { + return fmt.Errorf("promote @%s: bot has not been resolved", botUsername) + } + ch, err := s.channel(ctx, chatID) + if err != nil { + return err + } + return s.c.PromoteBot(ctx, ch, s.bot, botUsername) +} + +func (s *live) EditChannelTitle(ctx context.Context, chatID int64, title string) error { + ch, err := s.channel(ctx, chatID) + if err != nil { + return err + } + return s.c.EditTitle(ctx, ch, title) +} + +func (s *live) EditChannelAbout(ctx context.Context, chatID int64, about string) error { + ch, err := s.channel(ctx, chatID) + if err != nil { + return err + } + return s.c.EditAbout(ctx, ch, about) +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 10f002f..c52e912 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -37,6 +37,10 @@ const ( // A plan containing one applies nothing, because the tool no longer knows // what it is looking at. Block + // Reassert repeats an idempotent write the tool cannot observe, such as + // BotFather profile fields. It is work apply will do, but it is not drift: + // a scheduled check that treated it as a change would never go quiet. + Reassert ) func (k Kind) String() string { @@ -53,6 +57,8 @@ func (k Kind) String() string { return "defer" case Block: return "BLOCK" + case Reassert: + return "reassert" } return "?" } @@ -95,16 +101,34 @@ func (p *Plan) Blocked() bool { 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. +// Empty reports whether a scheduled drift check should stay quiet. Warnings, +// deferrals and reassertions do not count: a converged presence that still +// reasserts editorial fields on apply has not drifted. func (p *Plan) Empty() bool { + return !p.Drifted() +} + +// Drifted reports whether the declared presence and the world disagree in a +// way `plan --check` must fail on. Reassertion is not drift. +func (p *Plan) Drifted() bool { for _, a := range p.Actions { - if a.Kind != Warn && a.Kind != Defer { - return false + switch a.Kind { + case Create, Update, Attempt, Block: + return true } } - return true + return false +} + +// NeedsApply reports whether apply has work, including idempotent reassertion. +func (p *Plan) NeedsApply() bool { + for _, a := range p.Actions { + switch a.Kind { + case Create, Update, Attempt, Reassert: + return true + } + } + return false } // Live is what the provisioner can observe about the presence right now. It is @@ -133,6 +157,10 @@ func Compute(specPath string, sp *spec.Presence, digest string, rs *state.Resolv return p, nil } + if rs.SpecDigest != "" && rs.SpecDigest != digest { + p.add(Update, "spec", "declared spec has changed since last apply; editorial fields will be reasserted") + } + if err := planBot(p, sp, rs, live); err != nil { return nil, err } @@ -174,8 +202,10 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { 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") + // the tool does not know what a person may have changed by hand. That is + // apply work, not drift -- a check that treated it as a change would never + // go quiet. + p.add(Reassert, "bot.profile", "reassert name, about text and description from the spec") planAvatar(p, sp, rs) return nil } @@ -288,7 +318,7 @@ func (p *Plan) Render() string { 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 _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update, Reassert} { for _, a := range p.Actions { if a.Kind != k { continue diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index d25db8c..194af88 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -90,23 +90,54 @@ func provisioned(withTestPublication bool) *state.Resolved { } // Converged: a second run over an unchanged spec proposes nothing but the -// idempotent profile reassertion. +// idempotent profile reassertion, which is not drift. func TestConvergedRunIsQuiet(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} - p, err := Compute("", sp(), "sha256:x", provisioned(true), f) + rs := provisioned(true) + rs.SpecDigest = "sha256:x" + p, err := Compute("", sp(), "sha256:x", rs, f) if err != nil { t.Fatal(err) } if p.Blocked() { t.Fatalf("converged plan should not block:\n%s", p.Render()) } + if p.Drifted() { + t.Fatalf("a converged presence must not count as drift:\n%s", p.Render()) + } + if !p.Empty() { + t.Fatalf("plan --check must stay quiet on a converged presence:\n%s", p.Render()) + } + if !p.NeedsApply() { + t.Fatal("apply still reasserts the profile even when the check is quiet") + } + if find(p, Reassert, "bot.profile") == nil { + t.Fatalf("expected a profile reassertion:\n%s", p.Render()) + } for _, a := range p.Actions { - if a.Kind == Create || a.Kind == Attempt { + if a.Kind == Create || a.Kind == Attempt || a.Kind == Update { t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target) } } } +// A changed spec is drift even when live observation still matches the old state. +func TestSpecDigestChangeIsDrift(t *testing.T) { + f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} + rs := provisioned(true) + rs.SpecDigest = "sha256:old" + p, err := Compute("", sp(), "sha256:new", rs, f) + if err != nil { + t.Fatal(err) + } + if find(p, Update, "spec") == nil { + t.Fatalf("a changed spec must be an update:\n%s", p.Render()) + } + if !p.Drifted() { + t.Fatal("plan --check must fail after the spec changes") + } +} + // Widened rights are a refusal, not a repair. func TestWidenedRightsBlock(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, @@ -213,9 +244,9 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) { } } -// The plan must not promise an action apply will silently skip. The avatar is -// the live case: /setuserpic needs an upload that is not implemented, so the -// plan defers it with an explanation instead of claiming a create. +// The plan must not promise an action apply will silently skip. A declared +// avatar whose file is missing is deferred, not created: everything else can +// still apply, and the picture is set on a later run once the file is there. func TestAvatarIsDeferredNotPromised(t *testing.T) { s := sp() s.Bot.Avatar = "does-not-exist.png" diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 0747275..d1d7ec5 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -85,6 +85,15 @@ func (s *Store) Ref(key string) string { return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key) } +// RefFor is Ref without a live store, so the adapter-config command can print +// the token's location without opening OpenBao or holding a token. +func RefFor(campaign, key string) string { + mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "platform") + prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"), + "workloads/infotech/fluid-telegram/"+campaign) + return fmt.Sprintf("bao:%s/%s/%s", mount, strings.Trim(prefix, "/"), key) +} + type kvPayload struct { Data struct { Data map[string]string `json:"data"` diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go index 5ad81cc..b519d25 100644 --- a/internal/secrets/secrets_test.go +++ b/internal/secrets/secrets_test.go @@ -25,6 +25,16 @@ func testStore(t *testing.T, h http.Handler) *Store { // The salt rule. Rotating it silently invalidates every longitudinal comparison // the interface has made, with no visible failure -- so the tool must have no // path that replaces an existing one. +func TestRefForDoesNotNeedAStore(t *testing.T) { + t.Setenv("BAO_MOUNT", "") + t.Setenv("FLUID_BAO_PREFIX", "") + got := RefFor("hall-of-helix", KeyBotToken) + want := "bao:platform/workloads/infotech/fluid-telegram/hall-of-helix/bot-token" + if got != want { + t.Fatalf("RefFor = %q, want %q", got, want) + } +} + func TestCreateIfAbsentNeverOverwrites(t *testing.T) { var writes int existing := map[string]string{"salt": "the-original"} diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index 52bd744..36216b8 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -144,6 +144,26 @@ presence: } } +// The Go validator mirrors presence/telegram.schema.yaml. If a rule leaves the +// schema file, a spec can pass one gate and fail the other. +func TestSchemaStillStatesTheClamp(t *testing.T) { + raw, err := os.ReadFile("../../presence/telegram.schema.yaml") + if err != nil { + t.Fatal(err) + } + s := string(raw) + for _, want := range []string{ + "additionalProperties: false", + "const: post_messages", + "must not declare", + "maxItems: 1", + } { + if !strings.Contains(s, want) { + t.Errorf("schema no longer states %q", want) + } + } +} + // Rights are the tool's decision, not the spec's. func TestRightsAlwaysClamped(t *testing.T) { c := Channel{AdminRights: nil} diff --git a/internal/tg/botfather.go b/internal/tg/botfather.go index db9e885..f675894 100644 --- a/internal/tg/botfather.go +++ b/internal/tg/botfather.go @@ -171,8 +171,9 @@ func (conv *Conversation) RegisterBot(ctx context.Context, displayName, username // SetProfile applies the spec's editorial fields. Each is idempotent, so it is // safe to reassert on every apply. -func (conv *Conversation) SetProfile(ctx context.Context, username, about, description string) error { +func (conv *Conversation) SetProfile(ctx context.Context, username, name, about, description string) error { steps := []struct{ cmd, arg, label string }{ + {"/setname", name, "name"}, {"/setabouttext", about, "about text"}, {"/setdescription", description, "description"}, } diff --git a/internal/tg/channels.go b/internal/tg/channels.go index 0feffc0..e760c8f 100644 --- a/internal/tg/channels.go +++ b/internal/tg/channels.go @@ -168,6 +168,36 @@ func (c *Client) ChannelUsername(ctx context.Context, ch Channel) (string, error return fresh.Username, nil } +// EditTitle sets a channel's title. Idempotent: Telegram accepts the current +// title without treating it as a change. +func (c *Client) EditTitle(ctx context.Context, ch Channel, title string) error { + pause() + _, err := c.api.ChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{ + Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, + Title: title, + }) + if err != nil { + return fmt.Errorf("set title on channel %d: %w", ch.ChatID, err) + } + return nil +} + +// EditAbout sets a channel's description. +func (c *Client) EditAbout(ctx context.Context, ch Channel, about string) error { + pause() + ok, err := c.api.MessagesEditChatAbout(ctx, &tg.MessagesEditChatAboutRequest{ + Peer: &tg.InputPeerChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, + About: about, + }) + if err != nil { + return fmt.Errorf("set description on channel %d: %w", ch.ChatID, err) + } + if !ok { + return fmt.Errorf("set description on channel %d: telegram refused the change", ch.ChatID) + } + return nil +} + func resolveReq(username string) *tg.ContactsResolveUsernameRequest { return &tg.ContactsResolveUsernameRequest{Username: username} } diff --git a/revisions/R-1.yaml b/revisions/R-1.yaml index dfd6576..e5023c4 100644 --- a/revisions/R-1.yaml +++ b/revisions/R-1.yaml @@ -36,7 +36,7 @@ revision: reset_after_ms: 60000 intent: - version: "IEI-1" + version: "IEI-2" routing: eligible_cohorts: diff --git a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md index 4e3cf30..19d093d 100644 --- a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md +++ b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md @@ -8,7 +8,7 @@ status: active owner: worsch topic_slug: fluid-telegram created: "2026-09-04" -updated: "2026-09-04" +updated: "2026-09-14" planning_priority: high planning_order: 1 depends_on: @@ -131,7 +131,7 @@ state_hub_task_id: "13a92a96-2f87-5de2-a69c-7edc21f3a0c7" ```bash export FLUID_INTERFACE=helix-forge-telegram-publishing -fluid intent put --version IEI-1 --file InterfaceEvolutionIntent.md --activate +fluid intent put --version IEI-2 --file InterfaceEvolutionIntent.md --activate # fill the digest in revisions/R-1.yaml, then fluid revision publish --file revisions/R-1.yaml --key-file "$FLUID_SIGNING_KEY" \ --adaptation-classes presentation --approved-by "$USER" --traffic-share 1.0 diff --git a/workplans/FT-WP-0002-declared-presence-provisioning.md b/workplans/FT-WP-0002-declared-presence-provisioning.md index e233581..14755c9 100644 --- a/workplans/FT-WP-0002-declared-presence-provisioning.md +++ b/workplans/FT-WP-0002-declared-presence-provisioning.md @@ -4,11 +4,11 @@ type: workplan title: "Provision the Telegram presence from a declared specification" domain: infotech repo: fluid-telegram -status: proposed +status: active owner: worsch topic_slug: fluid-telegram created: "2026-09-04" -updated: "2026-09-04" +updated: "2026-09-14" planning_priority: high planning_order: 2 depends_on: @@ -36,7 +36,7 @@ one bounded bootstrap, after which the surface is declared and converges. ```task id: FT-WP-0002-T01 -status: progress +status: wait priority: high state_hub_task_id: "039e5358-07c1-5b01-a186-5ece9aab75e4" ``` @@ -60,6 +60,11 @@ changes, and steps are deleted when a reason expires. `provision session bootstrap` is the only interactive command in the tool. Nothing else in this workplan is interactive. +**Tooling is done.** `docs/seeding-runbook.md` and `provision session bootstrap` +exist. Remaining is the operator following the runbook: a dedicated phone +number, `api_id`/`api_hash`, and the login code that mints the session. The +credential lane request is in `docs/requests/ops-warden-telegram-operator-lane.md`. + ## T02 — Publish the presence schema and the campaign's spec ```task @@ -91,7 +96,7 @@ triggers an update. ```task id: FT-WP-0002-T03 -status: progress +status: done priority: high state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0" ``` @@ -108,25 +113,28 @@ cheapest place to catch someone asking for one. knowable without attempting it, so the plan says "will attempt, with fallbacks" rather than promising an outcome. -**Implemented (2026-09-04), less live observation.** Go, `cmd/provision`, with -`internal/spec`, `internal/state` and `internal/plan`. Runs against the real -campaign spec today; live observation is behind a `plan.Live` interface that -returns an offline stub until T01 mints a session, and the command says so rather -than presenting an unobserved plan as a drift check. +**Done.** Go, `cmd/provision`, with `internal/spec`, `internal/state` and +`internal/plan`. Live observation is behind `plan.Live`; `internal/tg.Live` +implements it over MTProto. `--offline` still exists for a plan that does not +contact Telegram, and says so rather than presenting an unobserved plan as a +drift check. -One thing the design got wrong and the implementation surfaced: refusals are two -different things. A **deferral** is the design working — the public channel -waiting on a checked rendering, which is normal on every first run. A **block** -is the world disagreeing with the state file — drifted rights, a taken-over -username, an unreachable bot. Collapsing them meant a first run could never apply -anything, since it always defers the public channel. They are now distinct -kinds: a deferral holds one action, a block stops the run. +A spec digest change is an `update` of `spec`. BotFather profile fields the tool +cannot observe are a `reassert`: apply will repeat them, `plan --check` will not +treat them as drift. + +Refusals are two different things. A **deferral** is the design working — the +public channel waiting on a checked rendering, which is normal on every first +run. A **block** is the world disagreeing with the state file — drifted rights, +a taken-over username, an unreachable bot. Collapsing them meant a first run +could never apply anything, since it always defers the public channel. They are +distinct kinds: a deferral holds one action, a block stops the run. ## T04 — Implement `provision apply` ```task id: FT-WP-0002-T04 -status: progress +status: done priority: high state_hub_task_id: "5659b72a-8309-58b6-96e9-79137ce41ed4" ``` @@ -151,6 +159,12 @@ username exits non-zero rather than being "fixed" by a tool that does not understand what happened; and **the public channel is not touched** until the test channel has recorded a successful publication. +**Done as implementation.** `internal/apply` executes the plan against a +`Surface` (live MTProto, or a fake in tests). It reasserts the bot profile on +every apply, updates channel title/description when the spec digest changes, and +writes the token to OpenBao before anything else can fail. The first live apply +still waits on T01's session. + ## T05 — Provision the redaction salt ```task @@ -175,7 +189,7 @@ deliberate human write, which is a decision rather than a run. ```task id: FT-WP-0002-T06 -status: todo +status: done priority: medium state_hub_task_id: "c305b83f-b05e-5f09-9182-9896ae137c38" ``` @@ -185,24 +199,33 @@ It gets no MTProto session and no provisioning code path, so a compromised adapter cannot create, rename, delete or re-permission anything. That separation is the point of running two planes rather than one tool with a flag. +**Done.** `internal/adaptercfg` maps the resolved state onto one channel's chat +id and the OpenBao ref of the bot token. `provision adapter-config` prints that +and never the token. `LoadToken` reads `bot-token` only; tests fail if the +operator session is requested. + ## T07 — Drift check ```task id: FT-WP-0002-T07 -status: progress +status: done priority: low state_hub_task_id: "f3f36415-ed55-5717-b752-706b04ba5458" ``` `provision plan --check` exits non-zero on drift, suitable for a scheduled run. Report only; never auto-apply. An unattended reconciler that can change a public -channel is the thing IEI-1 §7 exists to prevent. +channel is the thing IEI-2 §7 exists to prevent. + +**Done.** `--check` uses `Plan.Drifted()`, which ignores warnings, deferrals and +profile reassertion. A changed spec digest is drift; a converged presence is +quiet. `--check` cannot be combined with `--offline`. ## T08 — Settle the §7 reading ```task id: FT-WP-0002-T08 -status: todo +status: done priority: medium state_hub_task_id: "864bcd23-6357-5460-a8e6-98ec9a929e29" ``` @@ -210,8 +233,11 @@ state_hub_task_id: "864bcd23-6357-5460-a8e6-98ec9a929e29" `InterfaceEvolutionIntent.md` §7 lists channel and bot creation under explicit non-authority without distinguishing the Daimon from an operator-run tool. The intended reading is that §7 constrains *autonomous* action and an approved plan -is not autonomous — but that is currently a reading rather than something the -document says, and a constitutional document should not depend on one. +is not autonomous — but that was a reading rather than something the document +said, and a constitutional document should not depend on one. -Add a one-sentence clarification, as a governance change under §20, before T04 -runs against anything real. +**Done.** IEI-2 adds: "These prohibitions bind the Daimon acting autonomously. +An operator-triggered provisioning tool that executes a plan a person has +approved is not the Daimon and is not autonomous: it is how the presence is +established, not a grant of authority to the request path." Recorded as a +governance amendment under §20. The request-path prohibitions are unchanged.