fluid-telegram/internal/adaptercfg/adaptercfg.go
tegwick b09a5e21d4 Address channels in Bot API form and add a founder-run send script
The provisioner records the MTProto channel id; the Bot API needs -100<id>.
adapter-config now converts it, so the test channel resolves to
-1004427430639. scripts/send-post.sh starts a short-lived adapter with the
bot token injected from OpenBao (never printed) and sends one reviewed post.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 09:36:03 +02:00

97 lines
3.4 KiB
Go

// 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
}