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:
124
pkg/bot/notify.go
Normal file
124
pkg/bot/notify.go
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user