fluid-telegram/internal/tg/client.go
tegwick 5ddfee8250 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
2026-09-04 21:39:30 +02:00

123 lines
4.7 KiB
Go

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