feat: add notifier Sender interface and digest formatter
This commit is contained in:
@@ -107,10 +107,10 @@ name collisions.)
|
|||||||
- [x] run tests - must pass before task 5
|
- [x] run tests - must pass before task 5
|
||||||
|
|
||||||
### Task 5: Notifier — Telegram sender + digest
|
### Task 5: Notifier — Telegram sender + digest
|
||||||
- [ ] define `Sender` interface (`Send(ctx, message string) error`) and a `telegramSender` using `TelegramConfig` (bot API `sendMessage`)
|
- [x] 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)
|
- [x] add `FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string` (artist names + counts + Web UI link)
|
||||||
- [ ] write tests: digest formatting, sender failure handling (stub sender)
|
- [x] write tests: digest formatting, sender failure handling (stub sender)
|
||||||
- [ ] run tests - must pass before task 6
|
- [x] run tests - must pass before task 6
|
||||||
|
|
||||||
### Task 6: Notifier — scheduler + sent-tracking
|
### Task 6: Notifier — scheduler + sent-tracking
|
||||||
- [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid
|
- [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid
|
||||||
|
|||||||
53
internal/notifier/digest.go
Normal file
53
internal/notifier/digest.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
129
internal/notifier/notifier_test.go
Normal file
129
internal/notifier/notifier_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/notifier/sender.go
Normal file
78
internal/notifier/sender.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user