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:
parent
10018a99b6
commit
5ddfee8250
14 changed files with 1544 additions and 57 deletions
207
internal/tg/botfather.go
Normal file
207
internal/tg/botfather.go
Normal 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
|
||||
}
|
||||
92
internal/tg/botfather_test.go
Normal file
92
internal/tg/botfather_test.go
Normal 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
178
internal/tg/channels.go
Normal 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
123
internal/tg/client.go
Normal 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
50
internal/tg/live.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue