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