From 897ad48a2dc06f6c5a1620106573909e9790feb8 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:29:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - второй канал рядом с ntfy, оба необязательные: токен бота, несколько chat id, тема форума, свой адрес Bot API - успешные бэкапы приходят без звука, в сообщениях ссылка на карточку клиента - тестовая отправка синхронная: в настройках виден результат по каждому каналу - токен бота не попадает в тексты ошибок и логи Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++- internal/server/api.go | 2 +- internal/server/notify.go | 202 +++++++++++++++++++++--- internal/server/notify_test.go | 76 +++++++++ internal/server/server.go | 4 +- internal/server/static/style.css | 2 + internal/server/templates/settings.html | 30 +++- internal/server/web.go | 58 +++++-- 8 files changed, 354 insertions(+), 38 deletions(-) create mode 100644 internal/server/notify_test.go diff --git a/README.md b/README.md index 3c9c29b..17be980 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,24 @@ sudo -u backup-master BM_DATA=/var/lib/backup-master backupctl admin-passwd но **Обновление сервера:** снова выполните блок «бинарники», затем `sudo systemctl restart backup-master`. Агенты обновляются командой `curl -fsSL https://backup.ВАШ-ДОМЕН/agent.sh | sudo bash` без `--token`, конфиг при этом сохраняется. -В «Настройках» укажите топик ntfy. Уведомления приходят: +### Уведомления + +В «Настройках» включаются каналы: **ntfy** и/или **Telegram**. Оба необязательные, уведомления уходят во все настроенные. + +Уведомления приходят: - об ошибках бэкапа; - когда от клиента нет бэкапа дольше заданного срока (по умолчанию 26 ч); -- если включено — и об успешных бэкапах. +- если включено — и об успешных бэкапах. В Telegram они приходят без звука. + +Каждое уведомление содержит ссылку на карточку клиента. + +Как подключить Telegram: +1. Создайте бота у [@BotFather](https://t.me/BotFather) и скопируйте токен. +2. Напишите боту что-нибудь (или добавьте его в группу). Затем откройте `https://api.telegram.org/bot<токен>/getUpdates` и возьмите `chat.id`. У групп он вида `-100…`. +3. В «Настройках» укажите токен и chat id. Чатов можно несколько, через запятую. Для группы с темами дополнительно укажите ID темы. +4. Отметьте «Отправить тестовое уведомление» и сохраните — на странице будет видно, дошло ли сообщение в каждый канал. + +Если `api.telegram.org` недоступен с сервера бэкапов, укажите в поле «Адрес Bot API» свой [Bot API сервер](https://github.com/tdlib/telegram-bot-api) или прокси. ## Добавление клиента diff --git a/internal/server/api.go b/internal/server/api.go index 8a72aac..05fe5d4 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -296,7 +296,7 @@ func (s *Server) apiComplete(w http.ResponseWriter, r *http.Request) { if len(m.Warnings) > 0 { msg += fmt.Sprintf(", предупреждений %d", len(m.Warnings)) } - s.notify("Бэкап готов: "+c.Domain, msg, "low", "white_check_mark") + s.notify(notification{Title: "Бэкап готов: " + c.Domain, Msg: msg, Priority: "low", Tags: "white_check_mark", Link: s.clientLink(c.ID)}) } if c2, err := s.st.GetClient(c.ID); err == nil { if err := s.applyRetention(c2); err != nil { diff --git a/internal/server/notify.go b/internal/server/notify.go index 87a8e72..efbeb6c 100644 --- a/internal/server/notify.go +++ b/internal/server/notify.go @@ -1,11 +1,17 @@ package server import ( + "bytes" "context" + "encoding/json" + "errors" "fmt" + "html" + "io" "log" "mime" "net/http" + "strconv" "strings" "time" ) @@ -16,36 +22,100 @@ const ( settingNtfyURL = "ntfy_url" settingNtfyAuth = "ntfy_auth" // токен tk_... или "user:password" settingNotifySuccess = "notify_success" + + settingTGToken = "tg_token" + settingTGChats = "tg_chats" // chat_id через запятую + settingTGThread = "tg_thread" // id темы в группе-форуме, необязательно + settingTGAPIURL = "tg_api_url" // свой Bot API сервер или прокси, по умолчанию api.telegram.org ) -// notify отправляет уведомление в ntfy (асинхронно, ошибки только в лог). -func (s *Server) notify(title, msg, priority, tags string) { - url := s.st.Setting(settingNtfyURL) - if url == "" { - return - } - auth := s.st.Setting(settingNtfyAuth) - go func() { - if err := sendNtfy(url, auth, title, msg, priority, tags); err != nil { - log.Printf("ntfy: %v", err) - } - }() +const defaultTGAPI = "https://api.telegram.org" + +type notification struct { + Title string + Msg string + Priority string // low, default, high — как в ntfy + Tags string // теги ntfy, они же выбирают эмодзи в Telegram + Link string // ссылка на страницу в морде } -func sendNtfy(url, auth, title, msg, priority, tags string) error { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg)) +// channel — канал доставки уведомлений. +type channel struct { + name string + send func(context.Context, notification) error +} + +// channels возвращает настроенные каналы (ntfy, Telegram). Пустой список — уведомления выключены. +func (s *Server) channels() []channel { + var out []channel + if url := s.st.Setting(settingNtfyURL); url != "" { + auth := s.st.Setting(settingNtfyAuth) + out = append(out, channel{"ntfy", func(ctx context.Context, n notification) error { + return sendNtfy(ctx, url, auth, n) + }}) + } + token, chats := s.st.Setting(settingTGToken), s.st.Setting(settingTGChats) + if token != "" && chats != "" { + tg := telegram{ + api: strings.TrimRight(s.st.Setting(settingTGAPIURL), "/"), + token: token, + chats: splitList(chats), + thread: s.st.Setting(settingTGThread), + } + if tg.api == "" { + tg.api = defaultTGAPI + } + out = append(out, channel{"telegram", tg.send}) + } + return out +} + +// notify рассылает уведомление во все каналы асинхронно, ошибки — в лог. +func (s *Server) notify(n notification) { + for _, ch := range s.channels() { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := ch.send(ctx, n); err != nil { + log.Printf("уведомление %s: %v", ch.name, err) + } + }() + } +} + +// notifySync рассылает уведомление и возвращает результат по каждому каналу (для теста в настройках). +func (s *Server) notifySync(ctx context.Context, n notification) map[string]error { + res := map[string]error{} + for _, ch := range s.channels() { + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + res[ch.name] = ch.send(cctx, n) + cancel() + } + return res +} + +func (s *Server) clientLink(id int64) string { + if s.cfg.PublicURL == "" { + return "" + } + return fmt.Sprintf("%s/clients/%d", s.cfg.PublicURL, id) +} + +func sendNtfy(ctx context.Context, url, auth string, n notification) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(n.Msg)) if err != nil { return err } // заголовки должны быть ASCII — кириллицу кодируем по RFC 2047, ntfy это понимает - req.Header.Set("Title", mime.BEncoding.Encode("UTF-8", title)) - if priority != "" { - req.Header.Set("Priority", priority) + req.Header.Set("Title", mime.BEncoding.Encode("UTF-8", n.Title)) + if n.Priority != "" { + req.Header.Set("Priority", n.Priority) } - if tags != "" { - req.Header.Set("Tags", tags) + if n.Tags != "" { + req.Header.Set("Tags", n.Tags) + } + if n.Link != "" { + req.Header.Set("Click", n.Link) } switch { case auth == "": @@ -66,6 +136,96 @@ func sendNtfy(url, auth, title, msg, priority, tags string) error { return nil } +type telegram struct { + api string + token string + chats []string + thread string +} + +var tgEmoji = map[string]string{ + "white_check_mark": "✅", + "x": "❌", + "warning": "⚠️", + "test_tube": "🧪", +} + +func (t telegram) text(n notification) string { + var b strings.Builder + if e := tgEmoji[n.Tags]; e != "" { + b.WriteString(e + " ") + } + b.WriteString("" + html.EscapeString(n.Title) + "") + if n.Msg != "" { + b.WriteString("\n" + html.EscapeString(n.Msg)) + } + if n.Link != "" { + b.WriteString("\nОткрыть в backup-master") + } + return b.String() +} + +func (t telegram) send(ctx context.Context, n notification) error { + var errs []error + for _, chat := range t.chats { + if err := t.sendOne(ctx, chat, n); err != nil { + errs = append(errs, fmt.Errorf("чат %s: %w", chat, err)) + } + } + return errors.Join(errs...) +} + +func (t telegram) sendOne(ctx context.Context, chat string, n notification) error { + body := map[string]any{ + "chat_id": chat, + "text": t.text(n), + "parse_mode": "HTML", + "disable_web_page_preview": true, + // успешные бэкапы — без звука, ошибки — со звуком + "disable_notification": n.Priority == "low", + } + if t.thread != "" { + if id, err := strconv.ParseInt(t.thread, 10, 64); err == nil { + body["message_thread_id"] = id + } + } + data, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.api+"/bot"+t.token+"/sendMessage", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + // в тексте ошибки net/http есть URL с токеном — не выносим его в лог + return errors.New(strings.ReplaceAll(err.Error(), t.token, "***")) + } + defer resp.Body.Close() + var r struct { + OK bool `json:"ok"` + Description string `json:"description"` + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + _ = json.Unmarshal(raw, &r) + if !r.OK { + if r.Description == "" { + r.Description = resp.Status + } + return errors.New(r.Description) + } + return nil +} + +func splitList(s string) []string { + var out []string + for _, p := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == ';' }) { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func humanBytes(n int64) string { const unit = 1024 if n < unit { diff --git a/internal/server/notify_test.go b/internal/server/notify_test.go new file mode 100644 index 0000000..7de3703 --- /dev/null +++ b/internal/server/notify_test.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestTelegramSend(t *testing.T) { + var mu sync.Mutex + var got []map[string]any + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/botTOKEN/sendMessage" { + http.NotFound(w, r) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + got = append(got, body) + mu.Unlock() + if body["chat_id"] == "bad" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"ok":false,"description":"Bad Request: chat not found"}`)) + return + } + w.Write([]byte(`{"ok":true}`)) + })) + defer api.Close() + + tg := telegram{api: api.URL, token: "TOKEN", chats: []string{"1", "-1002"}, thread: "7"} + n := notification{Title: "Бэкап готов: a.example", Msg: "1 ГБ", Priority: "low", Tags: "white_check_mark", Link: "https://bm.test/clients/1"} + if err := tg.send(context.Background(), n); err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("отправлено %d сообщений, ожидалось 2", len(got)) + } + m := got[0] + text, _ := m["text"].(string) + if !strings.HasPrefix(text, "✅ Бэкап готов: a<b>.example") || !strings.Contains(text, `href="https://bm.test/clients/1"`) { + t.Errorf("текст: %q", text) + } + if m["parse_mode"] != "HTML" || m["disable_notification"] != true || m["message_thread_id"] != float64(7) { + t.Errorf("параметры: %v", m) + } + + tg.chats = []string{"bad"} + err := tg.send(context.Background(), notification{Title: "x", Priority: "high"}) + if err == nil || !strings.Contains(err.Error(), "chat not found") { + t.Fatalf("ошибка: %v", err) + } +} + +func TestChannelsFromSettings(t *testing.T) { + s, _ := newTestServer(t) + if len(s.channels()) != 0 { + t.Fatal("без настроек каналов быть не должно") + } + _ = s.st.SetSetting(settingTGToken, "T") + if len(s.channels()) != 0 { + t.Fatal("Telegram без chat id включаться не должен") + } + _ = s.st.SetSetting(settingTGChats, "1") + _ = s.st.SetSetting(settingNtfyURL, "https://ntfy.test/x") + if n := len(s.channels()); n != 2 { + t.Fatalf("каналов %d, ожидалось 2", n) + } + if got := splitList("1, -100;2\n 3"); strings.Join(got, ",") != "1,-100,2,3" { + t.Fatalf("splitList: %v", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 6e8fdf9..6e5cf59 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -181,7 +181,7 @@ func (s *Server) tick() { if c.LastBackupAt == 0 { msg = fmt.Sprintf("%s: от клиента ещё не пришло ни одного бэкапа", c.Domain) } - s.notify("Бэкап просрочен: "+c.Domain, msg, "high", "warning") + s.notify(notification{Title: "Бэкап просрочен: " + c.Domain, Msg: msg, Priority: "high", Tags: "warning", Link: s.clientLink(c.ID)}) _ = s.st.SetStaleNotified(c.ID) } } @@ -200,7 +200,7 @@ func (s *Server) failBackup(b *Backup, msg string) { domain = c.Domain } log.Printf("бэкап #%d (%s) провален: %s", b.ID, domain, msg) - s.notify("Бэкап не удался: "+domain, msg, "high", "x") + s.notify(notification{Title: "Бэкап не удался: " + domain, Msg: msg, Priority: "high", Tags: "x", Link: s.clientLink(b.ClientID)}) } // --- токены --- diff --git a/internal/server/static/style.css b/internal/server/static/style.css index f7a8b01..7875d7f 100644 --- a/internal/server/static/style.css +++ b/internal/server/static/style.css @@ -76,6 +76,8 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-sp .badge.fail { background: var(--fail-bg); color: var(--fail); } .badge.off { background: var(--code-bg); color: var(--muted); } .ok-text { color: var(--ok); } +.fail-text { color: var(--fail); } +ul.results { list-style: none; padding: 0; margin: 0 0 16px; } .facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; } .facts dt { font-size: 12px; color: var(--muted); } diff --git a/internal/server/templates/settings.html b/internal/server/templates/settings.html index 3e92060..330e3e4 100644 --- a/internal/server/templates/settings.html +++ b/internal/server/templates/settings.html @@ -2,15 +2,39 @@ {{define "content"}}

Настройки

{{if .Saved}}

Сохранено.

{{end}} +{{if .Tested}} + {{if .TestResults}} + + {{else}} +

Ни один канал уведомлений не настроен.

+ {{end}} +{{end}}
-

Уведомления ntfy

+ +

ntfy

{{if .NtfyAuthSet}}{{end}} - +

Пустой URL — ntfy выключен.

+ +

Telegram

+ + {{if .TGTokenSet}}{{end}} + +

Можно несколько через запятую. Личный чат — ваш числовой id (напишите боту и откройте https://api.telegram.org/bot<токен>/getUpdates), группа — id вида -100…, бот должен быть её участником.

+ + +

Свой Bot API сервер или прокси, если api.telegram.org недоступен с сервера бэкапов.

+ +

Что отправлять

+ +

Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда, во все настроенные каналы.

-

Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда.

Логин и пароль администратора меняются на сервере: backupctl admin-passwd <логин>

diff --git a/internal/server/web.go b/internal/server/web.go index e5a6683..76f2422 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -376,29 +376,69 @@ func (s *Server) webBackupDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther) } -func (s *Server) webSettings(w http.ResponseWriter, r *http.Request) { - s.render(w, r, "settings", map[string]any{ +func (s *Server) settingsData(r *http.Request) map[string]any { + return map[string]any{ "NtfyURL": s.st.Setting(settingNtfyURL), "NtfyAuthSet": s.st.Setting(settingNtfyAuth) != "", + "TGTokenSet": s.st.Setting(settingTGToken) != "", + "TGChats": s.st.Setting(settingTGChats), + "TGThread": s.st.Setting(settingTGThread), + "TGAPIURL": s.st.Setting(settingTGAPIURL), "NotifySuccess": s.st.Setting(settingNotifySuccess) == "1", "Saved": r.URL.Query().Get("saved") != "", - }) + } +} + +func (s *Server) webSettings(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "settings", s.settingsData(r)) +} + +// setSecret обновляет секретную настройку: пустое поле — не менять, галочка clear — удалить. +func (s *Server) setSecret(r *http.Request, key, field string) { + if r.FormValue(field+"_clear") == "1" { + _ = s.st.SetSetting(key, "") + } else if v := strings.TrimSpace(r.FormValue(field)); v != "" { + _ = s.st.SetSetting(key, v) + } +} + +type testResult struct { + Channel string + Err string } func (s *Server) webSettingsSave(w http.ResponseWriter, r *http.Request) { _ = s.st.SetSetting(settingNtfyURL, strings.TrimSpace(r.FormValue("ntfy_url"))) - if r.FormValue("ntfy_auth_clear") == "1" { - _ = s.st.SetSetting(settingNtfyAuth, "") - } else if v := strings.TrimSpace(r.FormValue("ntfy_auth")); v != "" { - _ = s.st.SetSetting(settingNtfyAuth, v) - } + s.setSecret(r, settingNtfyAuth, "ntfy_auth") + s.setSecret(r, settingTGToken, "tg_token") + _ = s.st.SetSetting(settingTGChats, strings.Join(splitList(r.FormValue("tg_chats")), ",")) + _ = s.st.SetSetting(settingTGThread, strings.TrimSpace(r.FormValue("tg_thread"))) + _ = s.st.SetSetting(settingTGAPIURL, strings.TrimSpace(r.FormValue("tg_api_url"))) ns := "0" if r.FormValue("notify_success") == "1" { ns = "1" } _ = s.st.SetSetting(settingNotifySuccess, ns) if r.FormValue("test") == "1" { - s.notify("backup-master: тест", "Тестовое уведомление", "default", "test_tube") + res := s.notifySync(r.Context(), notification{ + Title: "backup-master: тест", Msg: "Тестовое уведомление", Priority: "default", Tags: "test_tube", Link: s.cfg.PublicURL, + }) + var results []testResult + for _, name := range []string{"ntfy", "telegram"} { + if err, ok := res[name]; ok { + tr := testResult{Channel: name} + if err != nil { + tr.Err = err.Error() + } + results = append(results, tr) + } + } + data := s.settingsData(r) + data["Saved"] = true + data["Tested"] = true + data["TestResults"] = results + s.render(w, r, "settings", data) + return } http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) }