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
|
|||
|
|
}
|