fluid-telegram/internal/tg/botfather.go

255 lines
7.6 KiB
Go
Raw Normal View History

package tg
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/gotd/td/telegram/uploader"
"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
}
// SetAvatar sends the bot's profile picture through BotFather.
//
// A bot's picture cannot be set through any API: /setuserpic is a conversation
// in which you send a photo, so the file is uploaded and then sent as a message
// like a person would send it.
func (conv *Conversation) SetAvatar(ctx context.Context, username, path string) error {
reply, err := conv.Ask(ctx, "/setuserpic")
if err != nil {
return err
}
if strings.Contains(strings.ToLower(reply), "choose a bot") ||
strings.Contains(reply, "/") {
if _, err := conv.Ask(ctx, "@"+username); err != nil {
return err
}
}
pause()
up := uploader.NewUploader(conv.c.api)
file, err := up.FromPath(ctx, path)
if err != nil {
return fmt.Errorf("upload avatar %s: %w", path, err)
}
randID, err := conv.c.client.RandInt64()
if err != nil {
return err
}
if _, err := conv.c.api.MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
Peer: conv.peer,
Media: &tg.InputMediaUploadedPhoto{File: file},
RandomID: randID,
}); err != nil {
return fmt.Errorf("send avatar to @%s: %w", botFatherUsername, err)
}
reply, err = conv.awaitReply(ctx)
if err != nil {
return err
}
if !strings.Contains(strings.ToLower(reply), "success") {
return fmt.Errorf("@%s did not confirm the picture: %q", botFatherUsername, firstLine(reply))
}
return nil
}