axe/internal/telegram/telegram.go

308 lines
12 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
// 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
}