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
120 lines
3.1 KiB
Go
120 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/gotd/td/tg"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/secrets"
|
|
tgc "github.com/tegwick/fluid-telegram/internal/tg"
|
|
)
|
|
|
|
// prompt is the only interactive path in this tool. It exists so the
|
|
// interactive part is bounded and named rather than spread through the process:
|
|
// Telegram sends the login code out of band by design, and no amount of
|
|
// automation removes that.
|
|
type prompt struct{ in *bufio.Reader }
|
|
|
|
func newPrompt() *prompt { return &prompt{in: bufio.NewReader(os.Stdin)} }
|
|
|
|
func (p *prompt) ask(label string) (string, error) {
|
|
fmt.Fprintf(os.Stderr, "%s: ", label)
|
|
line, err := p.in.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
|
|
func (p *prompt) askSecret(label string) (string, error) {
|
|
fmt.Fprintf(os.Stderr, "%s: ", label)
|
|
b, err := term.ReadPassword(int(syscall.Stdin))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(b)), nil
|
|
}
|
|
|
|
func (p *prompt) Phone(context.Context) (string, error) {
|
|
return p.ask("operator phone number (international format)")
|
|
}
|
|
|
|
func (p *prompt) Code(_ context.Context, _ *tg.AuthSentCode) (string, error) {
|
|
return p.ask("login code Telegram just sent")
|
|
}
|
|
|
|
func (p *prompt) Password(context.Context) (string, error) {
|
|
return p.askSecret("two-factor password")
|
|
}
|
|
|
|
func cmdSession(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("session needs a subcommand: bootstrap or check")
|
|
}
|
|
fs := flag.NewFlagSet("session", flag.ExitOnError)
|
|
campaign := fs.String("campaign", "", "campaign slug (names the OpenBao subtree)")
|
|
fs.Parse(args[1:])
|
|
if *campaign == "" {
|
|
return fmt.Errorf("--campaign is required")
|
|
}
|
|
|
|
store, err := secrets.NewFromEnv(*campaign)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
creds, err := tgc.LoadCredentials(ctx, store)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := tgc.New(store, creds)
|
|
|
|
switch args[0] {
|
|
case "bootstrap":
|
|
fmt.Fprintf(os.Stderr,
|
|
"Minting an operator session for %q.\n"+
|
|
"The session is a full-account credential: it goes straight to %s\n"+
|
|
"and is never written to disk.\n\n", *campaign,
|
|
store.Ref(secrets.KeyOperatorSession))
|
|
return client.Run(ctx, newPrompt(), func(ctx context.Context, c *tgc.Client) error {
|
|
self, err := c.Self(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("session stored for %s (id %d)\n", displayName(self), self.ID)
|
|
return nil
|
|
})
|
|
|
|
case "check":
|
|
// No authenticator: a check must fail if the stored session is unusable,
|
|
// not quietly prompt for a new one and call that success.
|
|
return client.Run(ctx, nil, func(ctx context.Context, c *tgc.Client) error {
|
|
self, err := c.Self(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("session valid: %s (id %d)\n", displayName(self), self.ID)
|
|
return nil
|
|
})
|
|
}
|
|
return fmt.Errorf("unknown session subcommand %q", args[0])
|
|
}
|
|
|
|
func displayName(u *tg.User) string {
|
|
if u.Username != "" {
|
|
return "@" + u.Username
|
|
}
|
|
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
|
if name == "" {
|
|
return "(unnamed account)"
|
|
}
|
|
return name
|
|
}
|