From 3af33bd72843017f88c6d23bdd63c212916f9a24 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Sun, 19 Jul 2026 22:28:41 +0300 Subject: [PATCH] feat: add notifier Sender interface and digest formatter --- docs/plans/2026-07-19-notifier-webui-sync.md | 8 +- internal/notifier/digest.go | 53 ++++++++ internal/notifier/notifier_test.go | 129 +++++++++++++++++++ internal/notifier/sender.go | 78 +++++++++++ 4 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 internal/notifier/digest.go create mode 100644 internal/notifier/notifier_test.go create mode 100644 internal/notifier/sender.go diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index e67d029..22663ba 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -107,10 +107,10 @@ name collisions.) - [x] run tests - must pass before task 5 ### Task 5: Notifier — Telegram sender + digest -- [ ] define `Sender` interface (`Send(ctx, message string) error`) and a `telegramSender` using `TelegramConfig` (bot API `sendMessage`) -- [ ] add `FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string` (artist names + counts + Web UI link) -- [ ] write tests: digest formatting, sender failure handling (stub sender) -- [ ] run tests - must pass before task 6 +- [x] define `Sender` interface (`Send(ctx, message string) error`) and a `telegramSender` using `TelegramConfig` (bot API `sendMessage`) +- [x] add `FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string` (artist names + counts + Web UI link) +- [x] write tests: digest formatting, sender failure handling (stub sender) +- [x] run tests - must pass before task 6 ### Task 6: Notifier — scheduler + sent-tracking - [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid diff --git a/internal/notifier/digest.go b/internal/notifier/digest.go new file mode 100644 index 0000000..8caaf11 --- /dev/null +++ b/internal/notifier/digest.go @@ -0,0 +1,53 @@ +package notifier + +import ( + "fmt" + "sort" + "strings" + + "naviwatcher/internal/scanner" +) + +// FormatDigest renders newly-found missing releases into a human-readable +// Telegram message grouped by artist, with per-artist counts and a link to +// the Web UI dashboard. It is deterministic: artists are sorted by name and +// releases within an artist are sorted by title. +func FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string { + if len(missing) == 0 { + return "NaviWatcher: no new missing releases found." + } + + type entry struct { + title string + } + byArtist := make(map[string][]entry) + order := make([]string, 0) + for _, r := range missing { + if _, ok := byArtist[r.ArtistID]; !ok { + order = append(order, r.ArtistID) + } + byArtist[r.ArtistID] = append(byArtist[r.ArtistID], entry{title: r.Title}) + } + // Stable ordering by ArtistID. + sort.Strings(order) + + var b strings.Builder + fmt.Fprintf(&b, "NaviWatcher: %d new missing release(s) found:\n\n", len(missing)) + for _, artistID := range order { + entries := byArtist[artistID] + titles := make([]string, 0, len(entries)) + for _, e := range entries { + titles = append(titles, e.title) + } + sort.Strings(titles) + fmt.Fprintf(&b, "%s (%d):\n", artistID, len(titles)) + for _, t := range titles { + fmt.Fprintf(&b, " - %s\n", t) + } + b.WriteString("\n") + } + if uiBaseURL != "" { + fmt.Fprintf(&b, "View details: %s\n", strings.TrimRight(uiBaseURL, "/")) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/notifier/notifier_test.go b/internal/notifier/notifier_test.go new file mode 100644 index 0000000..7380320 --- /dev/null +++ b/internal/notifier/notifier_test.go @@ -0,0 +1,129 @@ +package notifier + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "naviwatcher/internal/scanner" +) + +type stubSender struct { + sent []string + failErr error +} + +func (s *stubSender) Send(ctx context.Context, message string) error { + if s.failErr != nil { + return s.failErr + } + s.sent = append(s.sent, message) + return nil +} + +func TestFormatDigest_Empty(t *testing.T) { + got := FormatDigest(nil, "http://ui") + if got != "NaviWatcher: no new missing releases found." { + t.Fatalf("unexpected empty digest: %q", got) + } +} + +func TestFormatDigest_GroupsByArtistAndCounts(t *testing.T) { + missing := []scanner.MissingRelease{ + {ArtistID: "art-b", Title: "Zebra", RGID: "r3"}, + {ArtistID: "art-a", Title: "Alpha", RGID: "r1"}, + {ArtistID: "art-a", Title: "Beta", RGID: "r2"}, + } + got := FormatDigest(missing, "http://localhost:8080/") + if !strings.Contains(got, "art-a (2):") { + t.Errorf("expected art-a with count 2, got:\n%s", got) + } + if !strings.Contains(got, "art-b (1):") { + t.Errorf("expected art-b with count 1, got:\n%s", got) + } + // art-a should be alphabetically before art-b. + if strings.Index(got, "art-a") > strings.Index(got, "art-b") { + t.Errorf("artists not sorted: got:\n%s", got) + } + // Releases within artist sorted: Alpha before Beta. + aIdx := strings.Index(got, "Alpha") + bIdx := strings.Index(got, "Beta") + if aIdx > bIdx { + t.Errorf("titles not sorted: got:\n%s", got) + } + if !strings.Contains(got, "View details: http://localhost:8080") { + t.Errorf("expected UI link, got:\n%s", got) + } +} + +func TestFormatDigest_EmptyUIBaseURLOmitsLink(t *testing.T) { + missing := []scanner.MissingRelease{{ArtistID: "a", Title: "x", RGID: "r1"}} + got := FormatDigest(missing, "") + if strings.Contains(got, "View details:") { + t.Errorf("did not expect UI link when base URL empty: got:\n%s", got) + } +} + +func TestTelegramSender_Success(t *testing.T) { + var gotReq sendMessageRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/botTOKEN/sendMessage" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode: %v", err) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + s := &telegramSender{ + httpClient: srv.Client(), + token: "TOKEN", + chatID: "CHAT", + baseURL: srv.URL, + } + if err := s.Send(context.Background(), "hello"); err != nil { + t.Fatalf("Send returned error: %v", err) + } + if gotReq.ChatID != "CHAT" || gotReq.Text != "hello" || !gotReq.DisableWebPagePreview { + t.Errorf("unexpected payload: %+v", gotReq) + } +} + +func TestTelegramSender_Non2xxError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"description":"Unauthorized"}`)) + })) + defer srv.Close() + + s := &telegramSender{ + httpClient: srv.Client(), + token: "TOKEN", + chatID: "CHAT", + baseURL: srv.URL, + } + err := s.Send(context.Background(), "hi") + if err == nil { + t.Fatal("expected error on non-2xx") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected status in error, got: %v", err) + } +} + +func TestSenderInterfaceFailurePropagated(t *testing.T) { + // Verifies the Sender interface can be used by callers and failures surface. + want := errors.New("boom") + s := &stubSender{failErr: want} + err := s.Send(context.Background(), "msg") + if !errors.Is(err, want) { + t.Fatalf("expected wrapped error %v, got %v", want, err) + } +} diff --git a/internal/notifier/sender.go b/internal/notifier/sender.go new file mode 100644 index 0000000..7f59107 --- /dev/null +++ b/internal/notifier/sender.go @@ -0,0 +1,78 @@ +package notifier + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "naviwatcher/internal/config" +) + +// Sender delivers a notification message to a destination (e.g. Telegram). +// It is an interface so the real HTTP bot can be swapped for a stub in tests. +type Sender interface { + Send(ctx context.Context, message string) error +} + +// telegramSender sends messages via the Telegram Bot API sendMessage method. +type telegramSender struct { + httpClient *http.Client + token string + chatID string + baseURL string +} + +// NewTelegramSender constructs a Sender that posts to the Telegram Bot API +// using the token and chat ID from the given TelegramConfig. +func NewTelegramSender(cfg config.TelegramConfig) *telegramSender { + return &telegramSender{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + token: cfg.Token, + chatID: cfg.ChatID, + baseURL: "https://api.telegram.org", + } +} + +// sendMessageRequest is the JSON payload for the Telegram sendMessage endpoint. +type sendMessageRequest struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + DisableWebPagePreview bool `json:"disable_web_page_preview"` +} + +// Send posts the message to the configured Telegram chat. It returns an error +// if the request cannot be built/sent or the API responds with a non-2xx code. +func (s *telegramSender) Send(ctx context.Context, message string) error { + payload := sendMessageRequest{ + ChatID: s.chatID, + Text: message, + DisableWebPagePreview: true, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal telegram payload: %w", err) + } + + url := fmt.Sprintf("%s/bot%s/sendMessage", s.baseURL, s.token) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build telegram request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send telegram message: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(respBody)) + } + return nil +}