208 lines
6.2 KiB
Go
208 lines
6.2 KiB
Go
|
|
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
|
||
|
|
}
|