plan --check treated BotFather profile reassertion as drift, so a scheduled check could never go quiet. Reassertion is now its own kind; a changed spec digest is the drift signal. apply reasserts the profile, updates channel copy when the spec changes, and is tested against a fake Telegram surface. The adapter reads its chat id from resolved state and its token from OpenBao, never the operator session. IEI-2 records that an approved provisioning plan is not autonomous Daimon action. Assistant: grok Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
255 lines
7.6 KiB
Go
255 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, name, about, description string) error {
|
|
steps := []struct{ cmd, arg, label string }{
|
|
{"/setname", name, "name"},
|
|
{"/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
|
|
}
|