fluid-telegram/internal/tg/client.go

124 lines
4.7 KiB
Go
Raw Normal View History

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