308 lines
12 KiB
Go
308 lines
12 KiB
Go
// 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
|
||
}
|