// 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: BotAPIChatID(ch.ChatID), TokenRef: tokenRef, }, nil } // BotAPIChatID converts a channel id as the provisioner records it (the // MTProto channel id, positive) into the form the Bot API addresses it by // (-100 followed by the id). An id that is already negative is in Bot API // form and is returned unchanged. func BotAPIChatID(id int64) int64 { if id > 0 { return -1000000000000 - id } return id } // 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 }