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}} +Ни один канал уведомлений не настроен.
+ {{end}} +{{end}}Логин и пароль администратора меняются на сервере: backupctl admin-passwd <логин>