Netpulse_SasS/server/internal/alerting/telegram_bot.go
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

670 lines
29 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 alerting
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/store"
)
// Приймач натискань кнопок Telegram.
//
// ЧОМУ ДОВГЕ ОПИТУВАННЯ, А НЕ ВЕБХУК
//
// Bot API дає два способи отримувати оновлення, і вибір тут зробило
// саме розгортання, а не смак.
//
// Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний
// порт із переліку 443/80/88/8443 і TLS-сертифікат, якому довіряє
// їхній бік. Самопідписаний приймається лише як завантажений у
// setWebhook файл, і навіть тоді потрібне ім'я, на яке він виданий.
// Наш стенд — самопідписаний TLS на голій IP-адресі без домену. Це не
// «поки не налаштували», а стан, у якому продукт живе: self-hosted
// інсталяція в мережі оператора зазвичай узагалі не має входу ззовні.
// Вебхук там не запрацює ніколи, і код, написаний під нього, був би
// кодом, який не працює в жодній наявній інсталяції.
//
// Довге опитування не вимагає від нас ані вхідного порту, ані імені,
// ані сертифіката: з'єднання ініціює сервер, TLS перевіряється в бік
// api.telegram.org, тобто в той бік, де сертифікат справжній. Ціна —
// одне висяче HTTP-з'єднання на бота й курсор у базі (0061).
//
// Секретний токен у заголовку X-Telegram-Bot-Api-Secret-Token — це
// захист вебхука від сторонніх POST-ів на наш відкритий шлях. Тут
// відкритого шляху немає взагалі: приймати нема чого, ми самі ходимо
// по оновлення й показуємо в URL токен бота. Отвору, який той заголовок
// затуляє, у цій схемі не існує.
//
// Якщо колись з'явиться домен і справжній сертифікат, вебхук стане
// кращим (менше з'єднань, менша затримка) — і перевірка натискання
// (chatMatch → прив'язка → права → дія) переїде в нього без змін: вона
// навмисно не знає, звідки прийшло оновлення.
// Bot читає оновлення ботів усіх кабінетів і виконує натиснуте.
type Bot struct {
st *store.Store
ring *crypto.Keyring
log *slog.Logger
// hc — для довгого опитування. Таймаут свідомо більший за
// pollTimeout: getUpdates мовчить рівно стільки, скільки просили,
// і клієнт, який рветься раніше, перетворював би штатне очікування
// на потік помилок.
hc *http.Client
// action — для коротких викликів (відповідь на натискання,
// редагування повідомлення). Окремий клієнт, бо чекати на них 40
// секунд немає жодного сенсу.
action *http.Client
pollTimeout time.Duration
}
// NewBot створює приймач. ring обов'язковий: без ключів шифрування
// токени ботів не розшифрувати, а отже й опитувати нікого.
func NewBot(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Bot {
poll := 25 * time.Second
return &Bot{
st: st,
ring: ring,
log: log.With("component", "telegram"),
hc: &http.Client{Timeout: poll + 15*time.Second},
action: &http.Client{Timeout: 15 * time.Second},
pollTimeout: poll,
}
}
// telegramLockKey — довільна стала, аби її не займав ніхто інший у цій
// же базі. Сусідня з ключем движка алертів (див. engine.go).
const telegramLockKey = 0x6e70_7467 // "nptg"
// Run тримає опитування до скасування контексту.
//
// Advisory-блокування береться на ВЕСЬ час роботи, а не на такт, як у
// движка алертів. Причина в тому, що getUpdates ексклюзивний: вибране
// оновлення другому читачеві вже не дістанеться, і два процеси на
// одному боті ділили б натискання між собою навпіл. Блокування живе
// разом із з'єднанням, тож падіння процесу звільняє його само — сусід
// підхопить опитування за пів хвилини.
func (b *Bot) Run(ctx context.Context) {
if b.ring == nil {
b.log.Info("приймач Telegram вимкнено: немає ключів шифрування")
return
}
b.log.Info("приймач натискань Telegram запущено")
for ctx.Err() == nil {
conn, held := b.acquire(ctx)
if !held {
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
continue
}
}
b.serve(ctx, conn)
// context.WithoutCancel: під час зупинки контекст уже мертвий,
// а блокування зняти все одно треба — інакше сусідній процес
// чекатиме на нього до розриву з'єднання.
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, int64(telegramLockKey))
conn.Release()
}
}
func (b *Bot) acquire(ctx context.Context) (*pgxpool.Conn, bool) {
// WorkerPool, а не Pool: блокування має жити стільки ж, скільки
// саме опитування, а опитування за побудовою ходить поверх усіх
// кабінетів — це та сама роль, що й у решти фонових тактів.
conn, err := b.st.WorkerPool().Acquire(ctx)
if err != nil {
return nil, false
}
var got bool
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
int64(telegramLockKey)).Scan(&got); err != nil || !got {
conn.Release()
return nil, false
}
return conn, true
}
// serve крутить такти, доки тримається блокування.
func (b *Bot) serve(ctx context.Context, conn *pgxpool.Conn) {
for ctx.Err() == nil {
// Перелік ботів перечитується щотакту. Такт — це майже завжди
// очікування на getUpdates, тобто раз на ~25 секунд, і за цю
// ціну щойно доданий канал починає слухати кнопки сам, без
// перезапуску процесу.
groups, err := b.groups(ctx)
if err != nil {
b.log.Error("читання каналів Telegram", "помилка", err)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
continue
}
}
if len(groups) == 0 {
// Жодного telegram-каналу: спати довше, ніж такт опитування.
select {
case <-ctx.Done():
return
case <-time.After(60 * time.Second):
continue
}
}
var wg sync.WaitGroup
for _, g := range groups {
wg.Add(1)
go func(g botGroup) {
defer wg.Done()
b.pollOnce(ctx, g)
}(g)
}
wg.Wait()
// Живе з'єднання — доказ, що блокування ще наше. Мертве означає,
// що Postgres його вже зняв і опитувати далі не можна: сусідній
// процес міг узяти бота собі.
if err := conn.Ping(ctx); err != nil {
b.log.Warn("з'єднання з блокуванням втрачено", "помилка", err)
return
}
}
}
// botGroup — один бот і всі канали, які через нього шлють.
//
// Групування саме за токеном, а не за каналом: один бот цілком може
// обслуговувати кілька чатів і навіть кілька кабінетів, а getUpdates
// у нього одна черга на всіх.
type botGroup struct {
token string
hash []byte
chans []store.Channel
}
func (b *Bot) groups(ctx context.Context) ([]botGroup, error) {
channels, err := b.st.TelegramChannels(ctx, b.ring)
if err != nil {
return nil, err
}
byToken := map[string]*botGroup{}
var out []botGroup
for _, c := range channels {
if c.Secret == "" {
continue
}
g, ok := byToken[c.Secret]
if !ok {
sum := sha256.Sum256([]byte(c.Secret))
g = &botGroup{token: c.Secret, hash: sum[:]}
byToken[c.Secret] = g
}
g.chans = append(g.chans, c)
}
for _, g := range byToken {
out = append(out, *g)
}
return out, nil
}
// pollOnce робить один getUpdates і обробляє все, що прийшло.
func (b *Bot) pollOnce(ctx context.Context, g botGroup) {
offset, err := b.st.TelegramCursor(ctx, g.hash)
if err != nil {
b.log.Error("читання курсора", "помилка", err)
return
}
updates, err := b.getUpdates(ctx, g, offset)
if err != nil {
if ctx.Err() != nil {
return
}
b.log.Warn("getUpdates", "бот", g.chans[0].Name, "помилка", err)
// Пауза після помилки: без неї недоступний api.telegram.org
// перетворював би такт на щільний цикл запитів.
select {
case <-ctx.Done():
case <-time.After(10 * time.Second):
}
return
}
var next int64
for _, u := range updates {
if u.UpdateID >= next {
next = u.UpdateID + 1
}
b.handle(ctx, g, u)
}
if next == 0 {
return
}
// Курсор посувається НЕЗАЛЕЖНО від того, чи вдалася сама дія.
// Оновлення, на якому обробник спіткнувся, інакше приходило б знову
// й знову, і одна крива кнопка глушила б усі наступні назавжди.
// Людина при цьому не лишається без відповіді: невдача їй сказана
// текстом у answerCallbackQuery.
if err := b.st.SaveTelegramCursor(context.WithoutCancel(ctx), g.hash, next); err != nil {
b.log.Error("збереження курсора", "помилка", err)
}
}
func (b *Bot) getUpdates(ctx context.Context, g botGroup, offset int64) ([]tgUpdate, error) {
body := map[string]any{
"timeout": int(b.pollTimeout.Seconds()),
// Просимо рівно ті два типи, які вміємо: натискання кнопок і
// повідомлення з командою прив'язки. Решта (правки, реакції,
// вступи в чат) не має навіть потрапляти в чергу — вона займала
// б місце й змушувала б нас її вичитувати.
"allowed_updates": []string{"callback_query", "message"},
}
if offset > 0 {
body["offset"] = offset
}
var out struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result []tgUpdate `json:"result"`
}
if err := b.call(ctx, b.hc, g.token, "getUpdates", body, &out); err != nil {
return nil, err
}
if !out.OK {
return nil, fmt.Errorf("%s", out.Description)
}
return out.Result, nil
}
// handle розводить оновлення по обробниках.
func (b *Bot) handle(ctx context.Context, g botGroup, u tgUpdate) {
switch {
case u.CallbackQuery != nil:
b.handleCallback(ctx, g, u.CallbackQuery)
case u.Message != nil:
b.handleMessage(ctx, g, u.Message)
}
}
// ---------------------------------------------------------------------
// Натискання кнопки
// ---------------------------------------------------------------------
// handleCallback виконує натиснуте.
//
// Порядок перевірок навмисно такий: спершу «чий це чат» (звідси
// кабінет), потім «що просять» (розбір callback_data), потім «хто саме
// натиснув» (прив'язка), потім «чи можна йому» (права й доступ до
// хоста) — і лише тоді дія. Жоден крок не бере кабінет чи особу з
// вмісту кнопки: підробити її може будь-хто, хто бачив формат.
func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuery) {
answer := "Не вдалося обробити"
// Відповідь на натискання обов'язкова й безумовна. Доки її немає,
// Telegram крутить на кнопці годинник — і людина бачить не
// «відмовлено», а «зламалось». Тому вона в defer, а не в кінці
// щасливого шляху, і йде з власним контекстом: під час зупинки
// процесу натискання все одно має отримати відповідь.
defer func() {
ansCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if err := b.answerCallback(ansCtx, g.token, cq.ID, answer); err != nil {
b.log.Warn("answerCallbackQuery", "помилка", err)
}
}()
if cq.Message == nil {
answer = "Повідомлення застаріле — відкрийте алерт у NetPulse"
return
}
ch, ok := matchChannel(g.chans, cq.Message.Chat.ID, cq.Message.ThreadID)
if !ok {
// Бот стоїть у чаті, якого немає в жодному каналі. Кабінет із
// такого натискання не виводиться ніяк, і вгадувати його за
// вмістом кнопки — рівно те, чого робити не можна.
answer = "Цей чат не налаштовано в NetPulse"
b.log.Warn("натискання з невідомого чату", "chat", cq.Message.Chat.ID)
return
}
act, err := parseCallbackData(cq.Data)
if err != nil {
answer = "Кнопка застаріла або невідома"
b.log.Warn("розбір callback_data", "канал", ch.Name, "помилка", err)
return
}
acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID)
if errors.Is(err, store.ErrTelegramNotLinked) {
// Текст називає те, що людина побачить на екрані, а не назву
// сторінки в коді. Попередній варіант відсилав до «Профіль →
// Telegram», і саме так це вперше й не спрацювало: пункту
// «Профіль» у меню не було, посилання в шапці не малювалось
// (власник входить іменем, а не поштою, а /me імені не
// віддавало), і людина, яка сумлінно виконала інструкцію,
// доходила до висновку, що зламалось.
answer = "Ваш Telegram не прив'язано до NetPulse.\n" +
"У NetPulse: меню → Мій профіль → картка «Telegram» → " +
"«Отримати код», далі надішліть цьому боту: /link КОД"
return
}
if err != nil {
b.log.Error("пошук прив'язки", "канал", ch.Name, "помилка", err)
return
}
perms, err := b.st.UserPermissions(ctx, acc.UserID, ch.TenantID)
if err != nil {
b.log.Error("права користувача", "помилка", err)
return
}
if !hasPerm(perms, "alerts:ack") {
// Порожній набір прав означає ще й відкликане членство: людину
// прибрали з кабінету, а прив'язка лишилась. Відповідь однакова
// навмисно — з боку Telegram це та сама відмова.
answer = "Немає права підтверджувати алерти"
return
}
sc, err := b.st.LoadScope(ctx, ch.TenantID, acc.UserID)
if err != nil {
b.log.Error("доступ до хостів", "помилка", err)
return
}
var line string
switch act.Kind {
case "ack":
answer, line = b.doAck(ctx, ch, acc, sc, act.ID)
case "mute":
answer, line = b.doMute(ctx, ch, acc, sc, act.ID)
}
if line == "" {
return
}
_ = b.st.TouchTelegramAccount(ctx, ch.TenantID, acc.ID)
// Правка самого повідомлення — не прикраса. answerCallbackQuery
// показує спливаючий рядок на кілька секунд і тому, хто натиснув;
// у чат він не потрапляє, а чат читає вся зміна. Без правки
// повідомлення про аварію так і лишається з живими кнопками, і
// наступний черговий натискає їх ще раз.
if err := b.editMessage(ctx, g.token, cq.Message, line); err != nil {
b.log.Warn("правка повідомлення", "помилка", err)
}
}
// doAck підтверджує алерт від імені прив'язаного користувача.
//
// Викликає той самий store.AckAlert, що й POST /api/v1/alerts/{id}/ack:
// підтвердження з телефона й підтвердження з браузера мають лишати в
// базі однаковий слід, а власна копія логіки розійшлася б із оригіналом
// на першій же зміні — і розбіжність побачили б не тут, а в звіті.
func (b *Bot) doAck(ctx context.Context, ch store.Channel, acc store.TelegramAccount,
sc store.Scope, alertID string) (answer, line string) {
cur, err := b.st.AlertAckState(ctx, ch.TenantID, alertID)
if errors.Is(err, store.ErrAlertNotFound) {
// Кабінет узято з чату, тож «не знайдено» тут означає саме
// «немає в цьому кабінеті» — зокрема й тоді, коли алерт із
// таким id є в чужому.
return "Алерт не знайдено", ""
}
if err != nil {
b.log.Error("читання алерту", "помилка", err)
return "Не вдалося прочитати алерт", ""
}
if cur.DeviceID != "" && !sc.CanWrite(cur.DeviceID) {
return "Немає доступу до цього хоста", ""
}
// Ідемпотентність. Друге натискання не має ні падати помилкою, ні
// переписувати автора: перший, хто взявся, лишається першим.
if cur.State == "acknowledged" {
who := cur.AckedByEmail
if who == "" {
who = "невідомо ким"
}
at := time.Now()
if cur.AckedAt != nil {
at = *cur.AckedAt
}
return "Уже підтверджено: " + who, ackLine(who, at)
}
if cur.State == "resolved" || cur.State == "expired" {
return "Алерт уже закрито", ""
}
a, err := b.st.AckAlert(ctx, ch.TenantID, alertID, acc.UserID, "підтверджено з Telegram")
if errors.Is(err, store.ErrAlertNotFound) {
// Хтось встиг підтвердити між читанням і записом — для людини
// це той самий результат, що й гілка вище.
return "Уже підтверджено", ""
}
if err != nil {
b.log.Error("підтвердження алерту", "помилка", err)
return "Не вдалося підтвердити", ""
}
at := time.Now()
if a.AckedAt != nil {
at = *a.AckedAt
}
return "Підтверджено", ackLine(acc.Email, at)
}
// doMute глушить хост на годину — тією ж дією, що й POST /api/v1/mutes.
func (b *Bot) doMute(ctx context.Context, ch store.Channel, acc store.TelegramAccount,
sc store.Scope, deviceID string) (answer, line string) {
name, err := b.st.DeviceNameInTenant(ctx, ch.TenantID, deviceID)
if errors.Is(err, store.ErrNotFound) {
return "Хост не знайдено", ""
}
if err != nil {
b.log.Error("пошук хоста", "помилка", err)
return "Не вдалося знайти хост", ""
}
if !sc.CanWrite(deviceID) {
return "Немає доступу до хоста " + name, ""
}
// Ідемпотентність: уже заглушений хост не глушиться вдруге.
// Інакше подвійне натискання мовчки подвоювало б час тиші, і
// дізнались би про це аж тоді, коли алерт не прийшов.
if until, muted, err := b.st.ActiveMute(ctx, ch.TenantID, deviceID); err == nil && muted {
return "Уже заглушено до " + until.In(tgLocation).Format("15:04"),
muteLine(acc.Email, until)
}
until := time.Now().Add(time.Hour)
if max := time.Now().Add(store.MaxMute); until.After(max) {
until = max
}
if err := b.st.MuteDevice(ctx, ch.TenantID, deviceID, acc.UserID,
"заглушено з Telegram", until); err != nil {
b.log.Error("заглушення хоста", "помилка", err)
return "Не вдалося заглушити", ""
}
return "Заглушено до " + until.In(tgLocation).Format("15:04"), muteLine(acc.Email, until)
}
func hasPerm(perms []string, want string) bool {
for _, p := range perms {
if p == want || p == "*" {
return true
}
}
return false
}
// ---------------------------------------------------------------------
// Прив'язка акаунта
// ---------------------------------------------------------------------
// handleMessage відповідає лише на дві команди й мовчить на решту.
//
// Бот часто стоїть у робочому груповому чаті. Відповідь на кожне
// повідомлення зробила б його джерелом шуму — і першою реакцією
// команди стало б вимкнути сповіщення того чату, тобто рівно те, чому
// продукт має запобігати.
func (b *Bot) handleMessage(ctx context.Context, g botGroup, m *tgMessage) {
if m.From == nil || m.From.IsBot {
return
}
code, isLink := parseLinkCommand(m.Text)
if !isLink {
return
}
if code == "" {
b.reply(ctx, g.token, m, "Надішліть код із профілю NetPulse: /link КОД")
return
}
// Кабінети, яким належить цей бот. Без цього переліку код був би
// універсальним: чинний код кабінету А, надісланий боту кабінету Б,
// прив'язав би людину туди, де її бот навіть не стоїть.
seen := map[string]bool{}
var tenants []string
for _, c := range g.chans {
if !seen[c.TenantID] {
seen[c.TenantID] = true
tenants = append(tenants, c.TenantID)
}
}
acc, err := b.st.RedeemTelegramLinkCode(ctx, code, tenants,
m.From.ID, m.From.Username, tgDisplayName(*m.From))
if errors.Is(err, store.ErrTelegramLinkInvalid) {
b.reply(ctx, g.token, m, "Код недійсний, вже використаний або прострочений. "+
"Візьміть новий у профілі NetPulse.")
return
}
if err != nil {
b.log.Error("прив'язка telegram", "помилка", err)
b.reply(ctx, g.token, m, "Не вдалося прив'язати. Спробуйте пізніше.")
return
}
_ = b.st.WriteAudit(ctx, acc.TenantID, store.AuditEntry{
ActorUserID: acc.UserID,
Action: store.AuditActionTelegramLink,
ObjectType: store.AuditObjectTelegram,
ObjectID: acc.ID,
Meta: map[string]any{"tg_user_id": acc.TgUserID, "tg_username": acc.TgUsername},
})
b.reply(ctx, g.token, m, "Готово: кнопки під алертами тепер працюють від вашого імені. "+
"Повідомлення з кодом можна видалити — код уже зужито.")
}
// ---------------------------------------------------------------------
// Виклики Bot API
// ---------------------------------------------------------------------
func (b *Bot) answerCallback(ctx context.Context, token, queryID, text string) error {
// show_alert=false: спливаючий рядок замість вікна з кнопкою «ОК».
// Черговий тримає телефон однією рукою, і зайве підтвердження на
// кожне натискання коштувало б рівно стільки ж, скільки економить
// сама кнопка.
body := map[string]any{"callback_query_id": queryID, "text": text}
return b.call(ctx, b.action, token, "answerCallbackQuery", body, nil)
}
// editMessage дописує підсумок у повідомлення й прибирає кнопки.
func (b *Bot) editMessage(ctx context.Context, token string, m *tgMessage, line string) error {
text := withStatus(m.Text, line)
if m.Text == "" {
text = line
}
body := map[string]any{
"chat_id": m.Chat.ID,
"message_id": m.MessageID,
"text": text,
// parse_mode навмисно НЕ задається, хоч надсилали ми з HTML.
// Telegram віддає в message.text уже готовий текст без розмітки,
// і повторна відправка його як HTML або зламалася б на першому
// «<» у назві інтерфейсу, або перетворила б частину тексту
// алерту на теги.
//
// Порожній inline_keyboard замість пропуску поля: так кнопки
// зникають гарантовано, а не за замовчуванням, на яке довелось
// би покладатися.
"reply_markup": map[string]any{"inline_keyboard": [][]any{}},
}
return b.call(ctx, b.action, token, "editMessageText", body, nil)
}
func (b *Bot) reply(ctx context.Context, token string, m *tgMessage, text string) {
body := map[string]any{"chat_id": m.Chat.ID, "text": text}
if m.ThreadID != 0 {
body["message_thread_id"] = m.ThreadID
}
if err := b.call(ctx, b.action, token, "sendMessage", body, nil); err != nil {
b.log.Warn("відповідь боту", "помилка", err)
}
}
// call — один виклик Bot API.
//
// Токен іде в шляху URL (так вимагає Bot API), тому будь-яка помилка
// транспорту проходить через scrubToken: http.Client вкладає в її текст
// повний URL, а в журналі токен бота — це чинний доступ.
func (b *Bot) call(ctx context.Context, hc *http.Client, token, method string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(payload))
if err != nil {
return scrubToken(err, token)
}
req.Header.Set("Content-Type", "application/json")
res, err := hc.Do(req)
if err != nil {
return scrubToken(err, token)
}
defer res.Body.Close()
if out == nil {
// Тіло відповіді нікому не потрібне, але прочитати його треба:
// недочитане з'єднання не повертається в keep-alive, а на
// довгому опитуванні це нове TLS-рукостискання щохвилини.
var sink struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(res.Body).Decode(&sink); err != nil {
return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode)
}
if !sink.OK {
return fmt.Errorf("%s: %s", method, sink.Description)
}
return nil
}
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode)
}
return nil
}