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 }