package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "html" "io" "log" "mime" "net/http" "strconv" "strings" "time" ) const ( settingAdminUser = "admin_user" settingAdminHash = "admin_hash" 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 ) 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 // ссылка на страницу в морде } // 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", n.Title)) if n.Priority != "" { req.Header.Set("Priority", n.Priority) } if n.Tags != "" { req.Header.Set("Tags", n.Tags) } if n.Link != "" { req.Header.Set("Click", n.Link) } switch { case auth == "": case strings.Contains(auth, ":"): u, p, _ := strings.Cut(auth, ":") req.SetBasicAuth(u, p) default: req.Header.Set("Authorization", "Bearer "+auth) } resp, err := http.DefaultClient.Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("ответ %s", resp.Status) } 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 { return fmt.Sprintf("%d Б", n) } div, exp := int64(unit), 0 for m := n / unit; m >= unit; m /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp]) }