// Package telegram — клиент Telegram Bot API: отправка сообщений, // проверка чата и длинный поллинг апдейтов. Сторонних SDK нет — только // net/http и один общий разбор ответа (ok/result/error_code/description). // // Фича выключается пустым токеном: Bot создаётся «выключенным» // (Enabled() == false), методы возвращают ErrDisabled, а cmd/api не // запускает фоновые задачи. Локальная разработка и тесты работают без // сети и без бота. // // Настройки читает cmd/api из env: // // TELEGRAM_BOT_TOKEN — токен бота; пусто — уведомления выключены // TELEGRAM_API_URL — база API; пусто — https://api.telegram.org package telegram import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) const ( defaultAPIURL = "https://api.telegram.org" // defaultTimeout — таймаут HTTP-клиента: он должен перекрывать // длинный поллинг (getUpdates держит соединение до timeout секунд). defaultTimeout = 45 * time.Second // maxBody — защита от чужого API, отдающего гигабайты. maxBody = 1 << 20 ) var ( // ErrDisabled — бот выключен (нет TELEGRAM_BOT_TOKEN). ErrDisabled = errors.New("telegram disabled") // ErrChatNotFound — чат недоступен: бота не добавили в группу, // выгнали или человек ещё не писал боту. ErrChatNotFound = errors.New("chat not found") // ErrForbidden — бот заблокирован или не имеет прав писать в чат. ErrForbidden = errors.New("forbidden") ) // Permanent — ошибка, от которой повтор не зависит: прислать сообщение // в тот же чат в следующем тике не поможет. Уведомления на такой ошибке // помечают отправленным, чтобы не крутить бессмысленный цикл каждую минуту. func Permanent(err error) bool { return errors.Is(err, ErrChatNotFound) || errors.Is(err, ErrForbidden) || errors.Is(err, ErrDisabled) } // Config — настройки клиента из env. type Config struct { Token string // TELEGRAM_BOT_TOKEN; пусто — бот выключен APIURL string // TELEGRAM_API_URL; пусто — https://api.telegram.org HTTP *http.Client // свой клиент (тесты); пусто — с таймаутом defaultTimeout } // API — то, что приложению нужно от бота: *Bot реализует, в тестах // подставляется фейк с теми же сигнатурами. type API interface { Enabled() bool Username() string Init(ctx context.Context) error SendMessage(ctx context.Context, chat, text string) error GetChat(ctx context.Context, target string) error GetUpdates(ctx context.Context, offset int64, timeout time.Duration) ([]Update, error) } // Bot — клиент одного бота. Потокобезопасен: username, узнанный в Init, // читается из фоновых задач и из хендлера выдачи кодов. type Bot struct { token string apiURL string http *http.Client mu sync.RWMutex username string } // New создаёт клиента. Токен пустой — бот «выключенный»: Enabled false, // методы возвращают ErrDisabled, сетевых вызовов нет. func New(cfg Config) *Bot { apiURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") if apiURL == "" { apiURL = defaultAPIURL } hc := cfg.HTTP if hc == nil { hc = &http.Client{Timeout: defaultTimeout} } return &Bot{ token: strings.TrimSpace(cfg.Token), apiURL: apiURL, http: hc, } } // Enabled — настроен ли токен. func (b *Bot) Enabled() bool { return b.token != "" } // Username — @имя бота из getMe; пусто, пока Init не выполнен (или не удался). func (b *Bot) Username() string { b.mu.RLock() defer b.mu.RUnlock() return b.username } // Init поднимает имя бота через getMe: оно нужно для deep-link // t.me/?start=<код>. Ошибка не фатальна — уведомления работают, // а ссылка «открыть бота» станет доступна после повторной попытки. func (b *Bot) Init(ctx context.Context) error { if !b.Enabled() { return ErrDisabled } var me User if err := b.call(ctx, "getMe", url.Values{}, &me); err != nil { return err } if me.Username == "" { return errors.New("telegram getMe: username is empty") } b.mu.Lock() b.username = me.Username b.mu.Unlock() return nil } // CodeHash — sha256 кода привязки в hex: в базе хранится только он // (как и хеши паролей), по нему же ищет поллер при /start. func CodeHash(code string) string { sum := sha256.Sum256([]byte(code)) return hex.EncodeToString(sum[:]) } // Link — deep-link на бота с кодом привязки; пустое имя бота → "". func Link(username, code string) string { if username == "" || code == "" { return "" } // Код сам собирается из A-Z0-9 (см. handlers), но экранируем // на случай, если его передадут извне. return "https://t.me/" + url.PathEscape(username) + "?start=" + url.QueryEscape(code) } // SendMessage — текст в чат (HTML: ссылка на заявку). Пустой chat → ErrChatNotFound. func (b *Bot) SendMessage(ctx context.Context, chat, text string) error { if strings.TrimSpace(chat) == "" { return ErrChatNotFound } form := url.Values{ "chat_id": {chat}, "text": {text}, // Telegram парсит только свой кусок HTML (b/i/a/code), // поэтому весь пользовательский текст экранируется до отправки. "parse_mode": {"HTML"}, } return b.call(ctx, "sendMessage", form, nil) } // GetChat — существует ли чат и доступен ли он боту: так проверяется // вручную введённая группа/канал (личный @ник здесь не пройдёт — // бот не видит человека, пока тот ему сам не напишет). func (b *Bot) GetChat(ctx context.Context, target string) error { if strings.TrimSpace(target) == "" { return ErrChatNotFound } var chat Chat return b.call(ctx, "getChat", url.Values{"chat_id": {target}}, &chat) } // GetUpdates — длинный поллинг: соединение держится до timeout, дальше — // либо апдейты, либо пустой список. offset — первый необработанный // update_id; вызывающий сдвигает его всегда, иначе одно «плохое» // сообщение навечно встало бы в очередь. func (b *Bot) GetUpdates(ctx context.Context, offset int64, timeout time.Duration) ([]Update, error) { secs := int(timeout / time.Second) if secs < 0 { secs = 0 } form := url.Values{ "offset": {strconv.FormatInt(offset, 10)}, "timeout": {strconv.Itoa(secs)}, "allowed_updates": {`["message"]`}, } var updates []Update if err := b.call(ctx, "getUpdates", form, &updates); err != nil { return nil, err } return updates, nil } // Update — апдейт из очереди; боту приходят только сообщения // (allowed_updates отфильтрован), остальное — nil. type Update struct { UpdateID int64 `json:"update_id"` Message *Message `json:"message"` } // Message — входящее сообщение: для привязки важны текст и чат. type Message struct { Text string `json:"text"` Chat Chat `json:"chat"` From *User `json:"from"` } // Chat — куда писать: положительный id — личные сообщения, // отрицательный — группа/канал; у групп есть @username и заголовок. type Chat struct { ID int64 `json:"id"` Type string `json:"type"` Title string `json:"title"` Username string `json:"username"` } // User — автор сообщения / бот из getMe. type User struct { ID int64 `json:"id"` Username string `json:"username"` } // call — один метод API: POST form, разбор общей обёртки ответа. // Тексты ошибок короткие и англоязычные; наружу они не уходят — // хендлеры отдают свои сообщения, детали остаются в логе. func (b *Bot) call(ctx context.Context, method string, form url.Values, out any) error { if !b.Enabled() { return ErrDisabled } // Метод лежит в пути запроса (так устроен Bot API), тело — form: // текст уведомления не попадает в URL и логи прокси. endpoint := b.apiURL + "/bot" + b.token + "/" + method req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return fmt.Errorf("telegram %s: build request: %w", method, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := b.http.Do(req) if err != nil { return fmt.Errorf("telegram %s: %w", method, err) } // Тело читается ниже целиком; закрытие — best-effort (errcheck). defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(io.LimitReader(res.Body, maxBody)) if err != nil { return fmt.Errorf("telegram %s: read response: %w", method, err) } var ar struct { OK bool `json:"ok"` Result json.RawMessage `json:"result"` ErrorCode int `json:"error_code"` Description string `json:"description"` } if err := json.Unmarshal(body, &ar); err != nil { return fmt.Errorf("telegram %s: bad response (HTTP %d)", method, res.StatusCode) } if !ar.OK { return apiError(ar.ErrorCode, ar.Description) } if out != nil && len(ar.Result) > 0 { if err := json.Unmarshal(ar.Result, out); err != nil { return fmt.Errorf("telegram %s: bad result", method) } } return nil } // apiError — классификация ошибок Bot API. Нужна два раза: поллеру, // чтобы не отвечать «код не найден» при упавшем API, и уведомлениям, // чтобы отличить «чат недоступен» (повтор бессмыслен) от сетевого сбоя // (повторим на следующем тике). func apiError(code int, desc string) error { d := strings.ToLower(desc) switch { case code == http.StatusBadRequest && (strings.Contains(d, "chat not found") || strings.Contains(d, "chat_id is empty") || strings.Contains(d, "user not found")): return fmt.Errorf("%w: %s", ErrChatNotFound, desc) case code == http.StatusForbidden || strings.Contains(d, "bot was blocked") || strings.Contains(d, "not a member") || strings.Contains(d, "not enough rights") || strings.Contains(d, "have no rights"): return fmt.Errorf("%w: %s", ErrForbidden, desc) default: // Прочее (слишком часто, серверная ошибка Telegram): общий вид // короткий, детали только в логе вызывающего. return fmt.Errorf("telegram api (HTTP %d): %s", code, firstLine(desc)) } } // firstLine — описание ошибки Telegram бывает многострочным (там же // приходит подсказка): в лог берём одну строку. func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }