From 264392782143bb1a66e40db513e7e546fa361147 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 6 Apr 2026 17:33:32 +0300 Subject: [PATCH] feat(17-04): implement notification dispatcher with tests - NotifyNewFindings sends to all subscribers on scan completion with findings - NotifyFinding sends real-time individual finding notifications (always masked) - formatNotification/formatErrorNotification/formatFindingNotification helpers - Zero findings = no notification; errors get separate error format - Per-subscriber error handling: log and continue on individual send failures - 6 tests pass: subscribe DB round-trip, no-subscriber no-op, zero-finding skip, message format validation, error format, masked key enforcement Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/bot/notify.go | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 pkg/bot/notify.go diff --git a/pkg/bot/notify.go b/pkg/bot/notify.go new file mode 100644 index 0000000..4043a53 --- /dev/null +++ b/pkg/bot/notify.go @@ -0,0 +1,124 @@ +package bot + +import ( + "context" + "fmt" + "log" + + "github.com/mymmrac/telego" + "github.com/mymmrac/telego/telegoutil" + "github.com/salvacybersec/keyhunter/pkg/engine" + "github.com/salvacybersec/keyhunter/pkg/scheduler" +) + +// NotifyNewFindings sends a notification to all subscribers about scan results. +// It returns the number of messages successfully sent and any per-subscriber errors. +// If FindingCount is 0 and Error is nil, no notification is sent (silent success). +// If Error is non-nil, an error notification is sent instead. +func (b *Bot) NotifyNewFindings(result scheduler.JobResult) (int, []error) { + // No notification for zero-finding success. + if result.FindingCount == 0 && result.Error == nil { + return 0, nil + } + + subs, err := b.cfg.DB.ListSubscribers() + if err != nil { + log.Printf("notify: listing subscribers: %v", err) + return 0, []error{fmt.Errorf("listing subscribers: %w", err)} + } + if len(subs) == 0 { + return 0, nil + } + + var msg string + if result.Error != nil { + msg = formatErrorNotification(result) + } else { + msg = formatNotification(result) + } + + var sent int + var errs []error + for _, sub := range subs { + if b.bot == nil { + // No telego bot (test mode) -- count as would-send. + continue + } + params := telegoutil.Message(telego.ChatID{ID: sub.ChatID}, msg) + if _, sendErr := b.bot.SendMessage(context.Background(), params); sendErr != nil { + log.Printf("notify: sending to chat %d: %v", sub.ChatID, sendErr) + errs = append(errs, fmt.Errorf("chat %d: %w", sub.ChatID, sendErr)) + continue + } + sent++ + } + + return sent, errs +} + +// NotifyFinding sends a real-time notification about an individual finding +// to all subscribers. The key is always masked. +func (b *Bot) NotifyFinding(finding engine.Finding) (int, []error) { + subs, err := b.cfg.DB.ListSubscribers() + if err != nil { + log.Printf("notify: listing subscribers: %v", err) + return 0, []error{fmt.Errorf("listing subscribers: %w", err)} + } + if len(subs) == 0 { + return 0, nil + } + + msg := formatFindingNotification(finding) + + var sent int + var errs []error + for _, sub := range subs { + if b.bot == nil { + continue + } + params := telegoutil.Message(telego.ChatID{ID: sub.ChatID}, msg) + if _, sendErr := b.bot.SendMessage(context.Background(), params); sendErr != nil { + log.Printf("notify: sending finding to chat %d: %v", sub.ChatID, sendErr) + errs = append(errs, fmt.Errorf("chat %d: %w", sub.ChatID, sendErr)) + continue + } + sent++ + } + + return sent, errs +} + +// formatNotification builds the notification message for a successful scan +// with findings. +func formatNotification(result scheduler.JobResult) string { + return fmt.Sprintf( + "New findings from scheduled scan!\n\nJob: %s\nNew keys found: %d\nDuration: %s\n\nUse /stats for details.", + result.JobName, + result.FindingCount, + result.Duration, + ) +} + +// formatErrorNotification builds the notification message for a scan that +// encountered an error. +func formatErrorNotification(result scheduler.JobResult) string { + return fmt.Sprintf( + "Scheduled scan error\n\nJob: %s\nDuration: %s\nError: %v", + result.JobName, + result.Duration, + result.Error, + ) +} + +// formatFindingNotification builds the notification message for an individual +// finding. Always uses the masked key. +func formatFindingNotification(finding engine.Finding) string { + return fmt.Sprintf( + "New key detected!\nProvider: %s\nKey: %s\nSource: %s:%d\nConfidence: %s", + finding.ProviderName, + finding.KeyMasked, + finding.Source, + finding.LineNumber, + finding.Confidence, + ) +}