124 lines
4.7 KiB
Go
124 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) }
|