axe/internal/telegram/telegram.go
2026-10-18 22:07:25 +03:00

308 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<bot>?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
}