Implement the MTProto client, session bootstrap and apply

Completes the provisioner's write path. internal/tg drives BotFather as a
conversation rather than pretending it is an endpoint, creates channels,
claims usernames with fallbacks, and grants post_messages. internal/apply
sequences it: bot before administrator, test channel before public, and
state saved after every step that changed the world -- a channel that
exists but is unrecorded is worse than one that does not exist, because
the next run creates a second.

The operator session lives in OpenBao, not on disk. gotd's FileStorage
would leave a full-account credential in the working directory, where it
outlives the run and can be committed by accident.

The bot token goes straight from BotFather's reply to OpenBao and is
cleared from memory; if that write fails the error says how to recover by
hand and warns against re-running, since a retry creates a second bot.

Closes T05: the redaction salt is create-if-absent with no overwrite path,
and the test asserts it, because rotating it invalidates every longitudinal
comparison with no visible failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
This commit is contained in:
tegwick 2026-09-04 21:39:30 +02:00
parent 10018a99b6
commit 5ddfee8250
14 changed files with 1544 additions and 57 deletions

220
internal/apply/apply.go Normal file
View file

@ -0,0 +1,220 @@
// Package apply executes an approved plan.
//
// The ordering is the guarantee: the bot exists before a channel needs an
// administrator, the test channel exists before the public one, and the token is
// in OpenBao before anything else can fail. Each step records what it did before
// the next runs, so an interruption leaves state that describes reality rather
// than intent.
package apply
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"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"
"github.com/tegwick/fluid-telegram/internal/state"
tgc "github.com/tegwick/fluid-telegram/internal/tg"
)
type Options struct {
Spec *spec.Presence
SpecDigest string
State *state.Resolved
StatePath string
Store *secrets.Store
Client *tgc.Client
Out io.Writer
}
// Run applies the plan. It saves state after every step that changed the world,
// because a channel that exists but is not recorded is worse than one that does
// not exist: the next run creates a second.
func Run(ctx context.Context, p *plan.Plan, o Options) error {
if p.Blocked() {
return fmt.Errorf("refusing to apply a blocked plan")
}
if o.State.Channels == nil {
o.State.Channels = map[string]state.Channel{}
}
o.State.Campaign = o.Spec.Campaign
if err := ensureSalt(ctx, o); err != nil {
return err
}
botUser, err := ensureBot(ctx, o)
if err != nil {
return err
}
if err := ensureChannels(ctx, o, botUser); err != nil {
return err
}
o.State.SpecDigest = o.SpecDigest
o.State.ProvisionedAt = time.Now().UTC()
return state.Save(o.StatePath, o.State)
}
// ensureSalt generates the redaction salt once, and can never replace one.
//
// docs/observation.md: rotating it silently invalidates every longitudinal
// comparison the interface has made, and does so with no visible failure --
// the numbers keep arriving and quietly stop meaning what they used to.
func ensureSalt(ctx context.Context, o Options) error {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generate redaction salt: %w", err)
}
created, err := o.Store.CreateIfAbsent(ctx, secrets.KeyRedactionSalt,
map[string]string{"salt": hex.EncodeToString(buf)})
if err != nil {
return err
}
if created {
fmt.Fprintf(o.Out, " created redaction salt at %s (never rotated)\n",
o.Store.Ref(secrets.KeyRedactionSalt))
}
return nil
}
func ensureBot(ctx context.Context, o Options) (tg.InputUserClass, error) {
if o.State.Bot.ID != 0 {
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username)
return resolveBot(ctx, o, o.State.Bot.Username)
}
conv, err := o.Client.BotFather(ctx)
if err != nil {
return nil, err
}
var username, token string
for _, candidate := range o.Spec.Bot.UsernamePreference {
username, token, err = conv.RegisterBot(ctx, o.Spec.Bot.Name, candidate)
if err == nil {
break
}
if errors.Is(err, tgc.ErrUsernameTaken) {
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate)
continue
}
return nil, err
}
if token == "" {
return nil, 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"+
"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))
}
token = ""
fmt.Fprintf(o.Out, " created bot @%s, token at %s\n", username,
o.Store.Ref(secrets.KeyBotToken))
o.State.Bot.Username = username
if err := state.Save(o.StatePath, o.State); err != nil {
return nil, err
}
if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return nil, err
}
fmt.Fprintln(o.Out, " set bot about text and description")
user, err := resolveBot(ctx, o, username)
if err != nil {
return nil, err
}
return user, 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 {
// Test first, always. Not a convention a caller may reorder.
for _, name := range []string{spec.Test, spec.Live} {
sc, ok := o.Spec.Channels[name]
if !ok {
continue
}
if name == spec.Live && !o.State.TestChannelVerified(spec.Test) {
fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering")
continue
}
if _, done := o.State.Channels[name]; done {
fmt.Fprintf(o.Out, " ok channel %s\n", name)
continue
}
ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description)
if err != nil {
return err
}
rec := state.Channel{ChatID: ch.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)
if sc.Visibility == spec.Public {
claimed := ""
for _, cand := range sc.UsernamePreference {
ok, err := o.Client.SetUsername(ctx, ch, cand)
if err != nil {
return err
}
if ok {
claimed = cand
break
}
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", cand)
}
if claimed == "" {
return fmt.Errorf("channel %s was created but every username candidate was "+
"taken; claim one by hand or add candidates to the spec, then re-run", name)
}
rec.Username = claimed
o.State.Channels[name] = rec
if err := state.Save(o.StatePath, o.State); err != nil {
return err
}
fmt.Fprintf(o.Out, " claimed @%s\n", claimed)
}
if err := o.Client.PromoteBot(ctx, ch, bot, 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)
}
return nil
}

145
internal/secrets/secrets.go Normal file
View file

@ -0,0 +1,145 @@
// Package secrets is the provisioner's only route to credentials.
//
// Everything sensitive lives in OpenBao: the operator's MTProto session, the
// app credentials, the bot token, and the redaction salt. Nothing here writes a
// secret to disk, and nothing returns one in an error message -- an error says
// which path failed, never what was at it.
package secrets
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Paths under the interface's subtree. The campaign is part of the path so that
// two campaigns on one interface cannot read each other's credentials.
const (
KeyOperatorApp = "operator-app" // api_id, api_hash
KeyOperatorSession = "operator-session" // MTProto session; a full-account credential
KeyBotToken = "bot-token"
KeyRedactionSalt = "redaction-salt"
)
type Store struct {
addr string
token string
mount string
prefix string
hc *http.Client
}
// NewFromEnv builds a store from the ambient OpenBao configuration.
func NewFromEnv(campaign string) (*Store, error) {
addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR"))
token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN"))
if addr == "" || token == "" {
return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " +
"(see docs/seeding-runbook.md)")
}
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
return &Store{
addr: strings.TrimSuffix(addr, "/"),
token: token,
mount: mount,
prefix: "fluid-telegram/" + campaign + "/telegram",
hc: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (s *Store) path(key string) string {
return fmt.Sprintf("%s/v1/%s/data/%s/%s", s.addr, s.mount, s.prefix, key)
}
// Ref is the human-readable location of a secret, safe to print. Used in plans
// and error messages so an operator can find what is missing.
func (s *Store) Ref(key string) string {
return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key)
}
type kvPayload struct {
Data struct {
Data map[string]string `json:"data"`
} `json:"data"`
}
// Get returns the fields at a path. Missing is reported as (nil, false, nil):
// absence is an ordinary state on a first run, not a failure.
func (s *Store) Get(ctx context.Context, key string) (map[string]string, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.path(key), nil)
if err != nil {
return nil, false, err
}
req.Header.Set("X-Vault-Token", s.token)
resp, err := s.hc.Do(req)
if err != nil {
return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotFound:
return nil, false, nil
case http.StatusOK:
default:
return nil, false, fmt.Errorf("read %s: unexpected status %s", s.Ref(key), resp.Status)
}
var p kvPayload
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err)
}
return p.Data.Data, true, nil
}
// Put replaces the fields at a path.
func (s *Store) Put(ctx context.Context, key string, fields map[string]string) error {
body, err := json.Marshal(map[string]any{"data": fields})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.path(key), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("X-Vault-Token", s.token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.hc.Do(req)
if err != nil {
return fmt.Errorf("write %s: %w", s.Ref(key), err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("write %s: unexpected status %s", s.Ref(key), resp.Status)
}
return nil
}
// CreateIfAbsent writes fields only when nothing is there, and reports whether
// it wrote. It has no counterpart that overwrites, and that is deliberate: the
// redaction salt is stored this way, and rotating it silently invalidates every
// longitudinal comparison the interface has made, with no visible failure. A
// tool that can rewrite it is a tool that eventually will.
func (s *Store) CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error) {
_, found, err := s.Get(ctx, key)
if err != nil {
return false, err
}
if found {
return false, nil
}
return true, s.Put(ctx, key, fields)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

View file

@ -0,0 +1,116 @@
package secrets
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testStore(t *testing.T, h http.Handler) *Store {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
t.Setenv("BAO_ADDR", srv.URL)
t.Setenv("BAO_TOKEN", "test-token")
s, err := NewFromEnv("hall-of-helix")
if err != nil {
t.Fatal(err)
}
return s
}
// 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 TestCreateIfAbsentNeverOverwrites(t *testing.T) {
var writes int
existing := map[string]string{"salt": "the-original"}
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
writes++
w.WriteHeader(http.StatusOK)
return
}
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"data": existing}})
}))
created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt,
map[string]string{"salt": "a-replacement"})
if err != nil {
t.Fatal(err)
}
if created {
t.Error("reported creating a salt that already existed")
}
if writes != 0 {
t.Errorf("wrote over an existing salt (%d writes)", writes)
}
}
func TestCreateIfAbsentWritesWhenMissing(t *testing.T) {
var got map[string]any
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt,
map[string]string{"salt": "fresh"})
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("did not create a salt when none existed")
}
if got["data"].(map[string]any)["salt"] != "fresh" {
t.Errorf("wrote %v", got)
}
}
// A missing path is an ordinary first-run state, not an error.
func TestGetMissingIsNotAnError(t *testing.T) {
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
_, found, err := s.Get(context.Background(), KeyBotToken)
if err != nil || found {
t.Fatalf("found=%v err=%v", found, err)
}
}
// Ref is printed in plans and errors, so it must name a location and never
// carry a value.
func TestRefIsSafeToPrint(t *testing.T) {
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"data": map[string]string{"token": "123:SECRET"}}})
}))
ref := s.Ref(KeyBotToken)
if strings.Contains(ref, "SECRET") || strings.Contains(ref, "test-token") {
t.Fatalf("Ref leaked a secret: %q", ref)
}
if !strings.Contains(ref, "hall-of-helix") || !strings.Contains(ref, KeyBotToken) {
t.Errorf("Ref should locate the secret: %q", ref)
}
}
// Errors name the path that failed, never what was at it.
func TestErrorsDoNotCarryValues(t *testing.T) {
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
_, _, err := s.Get(context.Background(), KeyOperatorSession)
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), "test-token") {
t.Fatalf("error leaked the bao token: %v", err)
}
}

View file

@ -0,0 +1,50 @@
package secrets
import (
"context"
"encoding/base64"
"fmt"
)
// SessionStorage keeps the MTProto session in OpenBao and never on disk.
//
// gotd's own FileStorage would put a full-account credential in the working
// directory, where it outlives the run, gets committed by accident, and is
// readable by anything on the box. The session can do everything the operator
// account can do, so it is held exactly where the bot token is.
type SessionStorage struct {
Store *Store
}
const sessionField = "session_b64"
func (s SessionStorage) LoadSession(ctx context.Context) ([]byte, error) {
fields, found, err := s.Store.Get(ctx, KeyOperatorSession)
if err != nil {
return nil, err
}
if !found || fields[sessionField] == "" {
// gotd treats a nil session as "not authenticated yet", which is the
// correct reading of an empty path.
return nil, nil
}
raw, err := base64.StdEncoding.DecodeString(fields[sessionField])
if err != nil {
return nil, fmt.Errorf("stored session at %s is not decodable; re-run "+
"`provision session bootstrap`", s.Store.Ref(KeyOperatorSession))
}
return raw, nil
}
func (s SessionStorage) StoreSession(ctx context.Context, data []byte) error {
return s.Store.Put(ctx, KeyOperatorSession, map[string]string{
sessionField: base64.StdEncoding.EncodeToString(data),
})
}
// AppCredentials are issued by my.telegram.org and cannot be provisioned; see
// docs/seeding-runbook.md step 2.
type AppCredentials struct {
AppID int
AppHash string
}

207
internal/tg/botfather.go Normal file
View file

@ -0,0 +1,207 @@
package tg
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/gotd/td/tg"
)
// BotFather is Telegram's own bot for registering bots. There is no API for
// this: a bot is created by holding a conversation, which is why provisioning
// needs a user session at all.
const botFatherUsername = "BotFather"
// Conversation drives that exchange. It is deliberately literal -- send a
// message, wait for the reply, read it -- because BotFather is a chat partner
// and not an endpoint, and pretending otherwise hides where it can surprise us.
type Conversation struct {
c *Client
peer tg.InputPeerClass
last int // highest message id already seen, so a reply is never confused with an echo
}
func (c *Client) BotFather(ctx context.Context) (*Conversation, error) {
resolved, err := c.api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: botFatherUsername,
})
if err != nil {
return nil, fmt.Errorf("resolve @%s: %w", botFatherUsername, err)
}
if len(resolved.Users) == 0 {
return nil, fmt.Errorf("@%s did not resolve to a user", botFatherUsername)
}
u, ok := resolved.Users[0].(*tg.User)
if !ok {
return nil, fmt.Errorf("@%s resolved to %T", botFatherUsername, resolved.Users[0])
}
conv := &Conversation{
c: c,
peer: &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash},
}
// Anchor on the current end of the conversation so that a reply is always
// newer than the request that caused it.
if id, err := conv.latestID(ctx); err == nil {
conv.last = id
}
return conv, nil
}
// Ask sends text and returns BotFather's next reply.
func (conv *Conversation) Ask(ctx context.Context, text string) (string, error) {
pause()
randID, err := conv.c.client.RandInt64()
if err != nil {
return "", err
}
if err := conv.c.client.SendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: conv.peer,
Message: text,
RandomID: randID,
}); err != nil {
return "", fmt.Errorf("send %q to @%s: %w", firstLine(text), botFatherUsername, err)
}
return conv.awaitReply(ctx)
}
// awaitReply polls for the next incoming message. Polling rather than an update
// handler keeps provisioning a straight line: the tool is doing one thing, and a
// missed update would strand it rather than fail it.
func (conv *Conversation) awaitReply(ctx context.Context) (string, error) {
deadline := time.Now().Add(45 * time.Second)
for time.Now().Before(deadline) {
msgs, err := conv.history(ctx)
if err != nil {
return "", err
}
for _, m := range msgs {
msg, ok := m.(*tg.Message)
if !ok || msg.Out || msg.ID <= conv.last {
continue // our own message, or one we have already read
}
conv.last = msg.ID
return msg.Message, nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1500 * time.Millisecond):
}
}
return "", fmt.Errorf("@%s did not reply within 45s; it may be rate-limiting this "+
"account, or the conversation may be in a state this tool does not expect -- "+
"open the chat and look", botFatherUsername)
}
func (conv *Conversation) history(ctx context.Context) ([]tg.MessageClass, error) {
res, err := conv.c.api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: conv.peer,
Limit: 5,
})
if err != nil {
return nil, fmt.Errorf("read @%s history: %w", botFatherUsername, err)
}
switch v := res.(type) {
case *tg.MessagesMessages:
return v.Messages, nil
case *tg.MessagesMessagesSlice:
return v.Messages, nil
case *tg.MessagesChannelMessages:
return v.Messages, nil
}
return nil, fmt.Errorf("unexpected history type %T", res)
}
func (conv *Conversation) latestID(ctx context.Context) (int, error) {
msgs, err := conv.history(ctx)
if err != nil {
return 0, err
}
best := 0
for _, m := range msgs {
if msg, ok := m.(*tg.Message); ok && msg.ID > best {
best = msg.ID
}
}
return best, nil
}
// tokenRe matches a bot token in BotFather's confirmation. The token is
// extracted, written straight to OpenBao, and never logged -- so this is the one
// place it exists in memory, and callers must not print what it returns.
var tokenRe = regexp.MustCompile(`\b(\d{6,}:[A-Za-z0-9_-]{30,})\b`)
// ErrUsernameTaken means the caller should try its next candidate.
var ErrUsernameTaken = fmt.Errorf("username is taken")
// RegisterBot runs /newbot and returns the issued username and token.
func (conv *Conversation) RegisterBot(ctx context.Context, displayName, username string) (string, string, error) {
reply, err := conv.Ask(ctx, "/newbot")
if err != nil {
return "", "", err
}
if !strings.Contains(strings.ToLower(reply), "name") {
return "", "", fmt.Errorf("unexpected reply to /newbot: %q", firstLine(reply))
}
if _, err := conv.Ask(ctx, displayName); err != nil {
return "", "", err
}
reply, err = conv.Ask(ctx, username)
if err != nil {
return "", "", err
}
low := strings.ToLower(reply)
if strings.Contains(low, "already taken") || strings.Contains(low, "invalid") ||
strings.Contains(low, "sorry") {
return "", "", fmt.Errorf("%w: @%s (%s)", ErrUsernameTaken, username, firstLine(reply))
}
m := tokenRe.FindStringSubmatch(reply)
if m == nil {
return "", "", fmt.Errorf("@%s accepted @%s but no token was found in its reply; "+
"open the chat and check before retrying, or a second bot will be created",
botFatherUsername, username)
}
return username, m[1], nil
}
// 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 {
steps := []struct{ cmd, arg, label string }{
{"/setabouttext", about, "about text"},
{"/setdescription", description, "description"},
}
for _, s := range steps {
if s.arg == "" {
continue
}
if _, err := conv.Ask(ctx, s.cmd); err != nil {
return err
}
if _, err := conv.Ask(ctx, "@"+username); err != nil {
return err
}
reply, err := conv.Ask(ctx, s.arg)
if err != nil {
return err
}
if !strings.Contains(strings.ToLower(reply), "success") {
return fmt.Errorf("setting %s did not succeed: %q", s.label, firstLine(reply))
}
}
return nil
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 90 {
s = s[:90] + "..."
}
return s
}

View file

@ -0,0 +1,92 @@
package tg
import (
"testing"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/spec"
)
// The token is the one secret that passes through this process's memory. If the
// pattern stops matching, RegisterBot reports "no token found" after a bot has
// already been created -- which is the expensive failure, since retrying makes a
// second bot.
func TestTokenPattern(t *testing.T) {
replies := []struct {
name string
text string
want string
}{
{"typical", "Done! Congratulations on your new bot.\n\nUse this token to access the HTTP API:\n123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw\n\nKeep your token secure.", "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"},
{"long id", "Token: 7123456789:AAF-abcDEFghiJKLmnoPQRstuVWXyz012345", "7123456789:AAF-abcDEFghiJKLmnoPQRstuVWXyz012345"},
{"underscores and dashes", "8000000001:AA_this-has_all-the_chars012345678", "8000000001:AA_this-has_all-the_chars012345678"},
}
for _, r := range replies {
t.Run(r.name, func(t *testing.T) {
m := tokenRe.FindStringSubmatch(r.text)
if m == nil {
t.Fatalf("no token matched in %q", r.text)
}
if m[1] != r.want {
t.Fatalf("got %q, want %q", m[1], r.want)
}
})
}
}
// It must not match things that merely look like tokens, or a failure reply
// could be mistaken for success and a bogus token written to OpenBao.
func TestTokenPatternRejects(t *testing.T) {
for _, s := range []string{
"Sorry, this username is already taken.",
"12:34",
"Please choose a name for your bot.",
} {
if tokenRe.MatchString(s) {
t.Errorf("matched a token in %q", s)
}
}
}
func TestFirstLineTruncates(t *testing.T) {
if got := firstLine("one\ntwo"); got != "one" {
t.Errorf("got %q", got)
}
long := make([]byte, 200)
for i := range long {
long[i] = 'x'
}
if got := firstLine(string(long)); len(got) > 95 {
t.Errorf("length %d, expected truncation", len(got))
}
}
// Rights reported back must use the spec's vocabulary, so a plan can compare
// them to the spec without translating between two naming schemes -- and must
// report a wider right rather than dropping it, since dropping one would make
// drift invisible exactly where it matters.
func TestRightsNames(t *testing.T) {
got := rightsNames(tg.ChatAdminRights{PostMessages: true})
if len(got) != 1 || got[0] != spec.PostMessages {
t.Fatalf("got %v, want [%s]", got, spec.PostMessages)
}
got = rightsNames(tg.ChatAdminRights{PostMessages: true, DeleteMessages: true})
if len(got) != 2 {
t.Fatalf("a wider right must be reported, got %v", got)
}
var sawDelete bool
for _, r := range got {
if r == "delete_messages" {
sawDelete = true
}
}
if !sawDelete {
t.Errorf("delete_messages was dropped: %v", got)
}
if got := rightsNames(tg.ChatAdminRights{}); len(got) != 0 {
t.Errorf("a bot with no rights should report none, got %v", got)
}
}

178
internal/tg/channels.go Normal file
View file

@ -0,0 +1,178 @@
package tg
import (
"context"
"fmt"
"strings"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/spec"
)
// Channel is what provisioning needs to remember about a created channel.
type Channel struct {
ChatID int64
AccessHash int64
Username string
}
// CreateChannel creates a broadcast channel. There is deliberately no
// counterpart that deletes one: deleting destroys its subscribers and post
// history irreversibly, and no specification is trusted with that.
func (c *Client) CreateChannel(ctx context.Context, title, about string) (Channel, error) {
pause()
upd, err := c.api.ChannelsCreateChannel(ctx, &tg.ChannelsCreateChannelRequest{
Broadcast: true, // a channel, not a supergroup
Title: title,
About: about,
})
if err != nil {
return Channel{}, fmt.Errorf("create channel %q: %w", title, err)
}
ch, err := firstChannel(upd)
if err != nil {
return Channel{}, fmt.Errorf("create channel %q: %w", title, err)
}
return Channel{ChatID: ch.ID, AccessHash: ch.AccessHash, Username: ch.Username}, nil
}
// SetUsername claims a public username, returning false if it is taken. A
// collision is an ordinary outcome the caller walks its candidates through, not
// an error: a plan promises the attempt, never the result.
func (c *Client) SetUsername(ctx context.Context, ch Channel, username string) (bool, error) {
pause()
ok, err := c.api.ChannelsUpdateUsername(ctx, &tg.ChannelsUpdateUsernameRequest{
Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash},
Username: username,
})
if err != nil {
if isUsernameUnavailable(err) {
return false, nil
}
return false, fmt.Errorf("claim username @%s: %w", username, err)
}
return ok, nil
}
// PromoteBot grants the bot post_messages on a channel, and nothing else.
//
// The rights are built here rather than taken from the spec. The spec may state
// them for a reader's benefit, but which rights this system may hold is not the
// spec's decision -- InterfaceEvolutionIntent.md 7 forbids a wider one, and a
// right that is never exercised is still a right that was granted.
func (c *Client) PromoteBot(ctx context.Context, ch Channel, bot tg.InputUserClass, botUsername string) error {
pause()
_, err := c.api.ChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash},
UserID: bot,
AdminRights: tg.ChatAdminRights{PostMessages: true},
Rank: "",
})
if err != nil {
return fmt.Errorf("grant post_messages to @%s: %w", botUsername, err)
}
return nil
}
// AdminRights reports the rights the given bot actually holds, in the
// vocabulary the spec uses, so drift can be compared without translating.
func (c *Client) AdminRights(ctx context.Context, ch Channel, botID int64) ([]string, error) {
parts, err := c.api.ChannelsGetParticipant(ctx, &tg.ChannelsGetParticipantRequest{
Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash},
Participant: &tg.InputPeerUser{UserID: botID},
})
if err != nil {
return nil, fmt.Errorf("read participant rights: %w", err)
}
admin, ok := parts.Participant.(*tg.ChannelParticipantAdmin)
if !ok {
// Not an administrator at all. An empty set, not an error: a demoted bot
// is exactly the drift the plan is looking for.
return nil, nil
}
return rightsNames(admin.AdminRights), nil
}
// rightsNames lists granted rights using the spec's names. Only post_messages
// has a spec name; anything else is reported raw so a plan can show what was
// actually found rather than silently dropping it.
func rightsNames(r tg.ChatAdminRights) []string {
var out []string
if r.PostMessages {
out = append(out, spec.PostMessages)
}
for name, granted := range map[string]bool{
"change_info": r.ChangeInfo, "edit_messages": r.EditMessages,
"delete_messages": r.DeleteMessages, "ban_users": r.BanUsers,
"invite_users": r.InviteUsers, "pin_messages": r.PinMessages,
"add_admins": r.AddAdmins, "manage_call": r.ManageCall,
"post_stories": r.PostStories, "edit_stories": r.EditStories,
"delete_stories": r.DeleteStories, "manage_topics": r.ManageTopics,
} {
if granted {
out = append(out, name)
}
}
return out
}
func firstChannel(upd tg.UpdatesClass) (*tg.Channel, error) {
u, ok := upd.(*tg.Updates)
if !ok {
return nil, fmt.Errorf("unexpected updates type %T", upd)
}
for _, chat := range u.Chats {
if ch, ok := chat.(*tg.Channel); ok {
return ch, nil
}
}
return nil, fmt.Errorf("telegram accepted the request but returned no channel")
}
func isUsernameUnavailable(err error) bool {
s := strings.ToUpper(err.Error())
return strings.Contains(s, "USERNAME_OCCUPIED") ||
strings.Contains(s, "USERNAME_INVALID") ||
strings.Contains(s, "USERNAME_PURCHASE_AVAILABLE")
}
// ResolveChannel recovers a usable channel handle from a chat id. Access hashes
// are per-account and not persisted, so they are re-resolved on each run rather
// than stored -- a stale hash fails in ways that look like a missing channel.
func (c *Client) ResolveChannel(ctx context.Context, chatID int64) (Channel, error) {
res, err := c.api.ChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: chatID},
})
if err != nil {
return Channel{}, fmt.Errorf("resolve channel %d: %w", chatID, err)
}
chats, ok := res.(*tg.MessagesChats)
if !ok {
return Channel{}, fmt.Errorf("resolve channel %d: unexpected type %T", chatID, res)
}
for _, ch := range chats.Chats {
if c2, ok := ch.(*tg.Channel); ok && c2.ID == chatID {
return Channel{ChatID: c2.ID, AccessHash: c2.AccessHash, Username: c2.Username}, nil
}
}
return Channel{}, fmt.Errorf("channel %d was not returned by telegram", chatID)
}
// ChannelUsername reports the username a channel currently carries.
func (c *Client) ChannelUsername(ctx context.Context, ch Channel) (string, error) {
fresh, err := c.ResolveChannel(ctx, ch.ChatID)
if err != nil {
return "", err
}
return fresh.Username, nil
}
func resolveReq(username string) *tg.ContactsResolveUsernameRequest {
return &tg.ContactsResolveUsernameRequest{Username: username}
}
func isNotFound(err error) bool {
s := strings.ToUpper(err.Error())
return strings.Contains(s, "USERNAME_NOT_OCCUPIED") || strings.Contains(s, "USERNAME_INVALID")
}

123
internal/tg/client.go Normal file
View file

@ -0,0 +1,123 @@
// Package tg wraps the MTProto operations the provisioner needs.
//
// The Bot API cannot create a bot or a channel: both are client capabilities,
// reachable only through MTProto with a user account (Canon INT-03). So this
// package acts as the designated operator account -- messaging BotFather the way
// a person would, and calling channels.* directly.
//
// It is used by the provisioning plane only. The adapter never imports it and
// never holds a session.
package tg
import (
"context"
"fmt"
"strconv"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/secrets"
)
type Client struct {
client *telegram.Client
api *tg.Client
store *secrets.Store
}
// Authenticator supplies what only a person can: the login code Telegram sends
// out of band, and the 2FA password. Interactive during bootstrap; on every
// later run the stored session means neither is asked for.
type Authenticator interface {
Phone(ctx context.Context) (string, error)
Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error)
Password(ctx context.Context) (string, error)
}
// New builds a client backed by the session in OpenBao.
func New(store *secrets.Store, creds secrets.AppCredentials) *Client {
c := telegram.NewClient(creds.AppID, creds.AppHash, telegram.Options{
SessionStorage: secrets.SessionStorage{Store: store},
})
return &Client{client: c, store: store}
}
// LoadCredentials reads api_id/api_hash. They are issued by a web form and
// cannot be provisioned, so a missing pair is a runbook step, not a bug.
func LoadCredentials(ctx context.Context, store *secrets.Store) (secrets.AppCredentials, error) {
fields, found, err := store.Get(ctx, secrets.KeyOperatorApp)
if err != nil {
return secrets.AppCredentials{}, err
}
if !found {
return secrets.AppCredentials{}, fmt.Errorf(
"no app credentials at %s -- complete step 2 of docs/seeding-runbook.md",
store.Ref(secrets.KeyOperatorApp))
}
id, err := strconv.Atoi(fields["api_id"])
if err != nil {
return secrets.AppCredentials{}, fmt.Errorf("api_id at %s is not a number",
store.Ref(secrets.KeyOperatorApp))
}
hash := fields["api_hash"]
if hash == "" {
return secrets.AppCredentials{}, fmt.Errorf("api_hash is missing at %s",
store.Ref(secrets.KeyOperatorApp))
}
return secrets.AppCredentials{AppID: id, AppHash: hash}, nil
}
// Run connects and executes f. Authentication happens only if the stored session
// is absent or no longer valid.
func (c *Client) Run(ctx context.Context, a Authenticator, f func(context.Context, *Client) error) error {
return c.client.Run(ctx, func(ctx context.Context) error {
c.api = c.client.API()
if a != nil {
if err := c.client.Auth().IfNecessary(ctx, auth.NewFlow(
authAdapter{a}, auth.SendCodeOptions{},
)); err != nil {
return fmt.Errorf("authenticate operator account: %w", err)
}
} else if _, err := c.client.Self(ctx); err != nil {
return fmt.Errorf("the stored operator session is not usable; re-run "+
"`provision session bootstrap` (%w)", err)
}
return f(ctx, c)
})
}
// Self returns the account the session belongs to, for `session check`.
func (c *Client) Self(ctx context.Context) (*tg.User, error) { return c.client.Self(ctx) }
// API exposes the raw client for operations this package does not wrap.
func (c *Client) API() *tg.Client { return c.api }
// authAdapter bridges our Authenticator to gotd's flow. SignUp is refused:
// the operator account is registered by a person on a device, and a tool that
// can create accounts is a tool that can create them by accident.
type authAdapter struct{ a Authenticator }
func (x authAdapter) Phone(ctx context.Context) (string, error) { return x.a.Phone(ctx) }
func (x authAdapter) Password(ctx context.Context) (string, error) {
return x.a.Password(ctx)
}
func (x authAdapter) Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) {
return x.a.Code(ctx, sentCode)
}
func (x authAdapter) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error {
return fmt.Errorf("this account has not accepted Telegram's terms of service; " +
"sign in on a device once and accept them there")
}
func (x authAdapter) SignUp(ctx context.Context) (auth.UserInfo, error) {
return auth.UserInfo{}, fmt.Errorf(
"this phone number has no Telegram account; register it on a device first " +
"(docs/seeding-runbook.md step 1). This tool does not create accounts")
}
// pause keeps BotFather conversations at human pace. Automating a user account
// is not what Telegram's terms are written around, and a burst of requests is
// what draws a limit.
func pause() { time.Sleep(1200 * time.Millisecond) }

50
internal/tg/live.go Normal file
View file

@ -0,0 +1,50 @@
package tg
import (
"context"
"github.com/tegwick/fluid-telegram/internal/state"
)
// Live implements plan.Live over a connected client, so that a plan is computed
// against what is actually there rather than against the state file's memory of
// it. Everything it reports is observed; nothing is assumed.
type Live struct {
Ctx context.Context
Client *Client
Resolved *state.Resolved
}
func (l Live) BotExists(username string) (bool, error) {
if username == "" {
return false, nil
}
_, err := l.Client.API().ContactsResolveUsername(l.Ctx, resolveReq(username))
if err != nil {
if isNotFound(err) {
return false, nil
}
return false, err
}
return true, nil
}
func (l Live) ChannelAdminRights(chatID int64) ([]string, error) {
ch, err := l.channel(chatID)
if err != nil {
return nil, err
}
return l.Client.AdminRights(l.Ctx, ch, l.Resolved.Bot.ID)
}
func (l Live) ChannelUsername(chatID int64) (string, error) {
ch, err := l.channel(chatID)
if err != nil {
return "", err
}
return l.Client.ChannelUsername(l.Ctx, ch)
}
func (l Live) channel(chatID int64) (Channel, error) {
return l.Client.ResolveChannel(l.Ctx, chatID)
}