Finish the provisioner's remaining plane: honest check, adapter config, IEI-2
plan --check treated BotFather profile reassertion as drift, so a scheduled check could never go quiet. Reassertion is now its own kind; a changed spec digest is the drift signal. apply reasserts the profile, updates channel copy when the spec changes, and is tested against a fake Telegram surface. The adapter reads its chat id from resolved state and its token from OpenBao, never the operator session. IEI-2 records that an approved provisioning plan is not autonomous Daimon action. Assistant: grok Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
This commit is contained in:
parent
f7af151f37
commit
1e9481e108
23 changed files with 995 additions and 128 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
4
SCOPE.md
4
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`
|
||||
|
|
|
|||
|
|
@ -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 <path> check everything short of contacting telegram
|
||||
provision plan --spec <path> [--check] show what would change; writes nothing
|
||||
provision apply --spec <path> execute an approved plan
|
||||
provision adapter-config --spec <path> --channel test|public
|
||||
print the adapter's non-secret configuration
|
||||
|
||||
provision session bootstrap --campaign <slug> mint the operator session (interactive)
|
||||
provision session check --campaign <slug> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<campaign>.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 <path> --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) |
|
||||
|
|
|
|||
|
|
@ -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 <path> --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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
86
internal/adaptercfg/adaptercfg.go
Normal file
86
internal/adaptercfg/adaptercfg.go
Normal file
|
|
@ -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
|
||||
}
|
||||
114
internal/adaptercfg/adaptercfg_test.go
Normal file
114
internal/adaptercfg/adaptercfg_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
310
internal/apply/apply_test.go
Normal file
310
internal/apply/apply_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
138
internal/apply/surface.go
Normal file
138
internal/apply/surface.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ revision:
|
|||
reset_after_ms: 60000
|
||||
|
||||
intent:
|
||||
version: "IEI-1"
|
||||
version: "IEI-2"
|
||||
|
||||
routing:
|
||||
eligible_cohorts:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue