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) <noreply@anthropic.com>
This commit is contained in:
salvacybersec
2026-04-06 17:33:32 +03:00
parent f7162aa34a
commit 2643927821

124
pkg/bot/notify.go Normal file
View File

@@ -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,
)
}