The avatar is now applied rather than deferred: BotFather's /setuserpic is a conversation in which you send a photo, so the file is uploaded and sent as a message. It is content addressed -- replacing the file is what triggers an update, and the digest is recorded only after BotFather confirms, so a failed upload retries rather than being remembered as done. The image is validated before the conversation starts, because an image rejected halfway leaves the bot registered without a picture. Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials, session presence, salt and the resulting plan, checked in one run that writes nothing and never contacts Telegram. Every failure it reports is one that would otherwise surface after a phone number had been spent. Two bugs it found immediately. The avatar path is documented as repo-relative but resolved against the spec's own directory, so the real campaign spec failed to find its own asset. And the OpenBao error named both variables when only one was missing, sending the reader to check the one already set. 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
254 lines
7.6 KiB
Go
254 lines
7.6 KiB
Go
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
|
|
}
|