merge: phase 17 wave 2

This commit is contained in:
salvacybersec
2026-04-06 17:36:54 +03:00
9 changed files with 638 additions and 119 deletions

View File

@@ -236,15 +236,15 @@ Requirements for initial release. Each maps to roadmap phases.
- [ ] **TELE-02**: /verify command — key verification - [ ] **TELE-02**: /verify command — key verification
- [ ] **TELE-03**: /recon command — dork execution - [ ] **TELE-03**: /recon command — dork execution
- [ ] **TELE-04**: /status, /stats, /providers, /help commands - [ ] **TELE-04**: /status, /stats, /providers, /help commands
- [ ] **TELE-05**: /subscribe and /unsubscribe for auto-notifications - [x] **TELE-05**: /subscribe and /unsubscribe for auto-notifications
- [ ] **TELE-06**: /key <id> command — full key detail in private chat - [ ] **TELE-06**: /key <id> command — full key detail in private chat
- [ ] **TELE-07**: Auto-notification on new key findings - [x] **TELE-07**: Auto-notification on new key findings
### Scheduled Scanning ### Scheduled Scanning
- [x] **SCHED-01**: Cron-based recurring scan scheduling - [x] **SCHED-01**: Cron-based recurring scan scheduling
- [ ] **SCHED-02**: keyhunter schedule add/list/remove commands - [ ] **SCHED-02**: keyhunter schedule add/list/remove commands
- [ ] **SCHED-03**: Auto-notify on scheduled scan completion - [x] **SCHED-03**: Auto-notify on scheduled scan completion
## v2 Requirements ## v2 Requirements

View File

@@ -342,10 +342,10 @@ Plans:
**Plans**: 5 plans **Plans**: 5 plans
Plans: Plans:
- [ ] 17-01-PLAN.md — Bot package skeleton: telego dependency, Bot struct, long polling, auth middleware - [x] 17-01-PLAN.md — Bot package skeleton: telego dependency, Bot struct, long polling, auth middleware
- [ ] 17-02-PLAN.md — Scheduler package + storage tables: gocron wrapper, subscribers/scheduled_jobs CRUD - [x] 17-02-PLAN.md — Scheduler package + storage tables: gocron wrapper, subscribers/scheduled_jobs CRUD
- [ ] 17-03-PLAN.md — Bot command handlers: /scan, /verify, /recon, /status, /stats, /providers, /help, /key - [ ] 17-03-PLAN.md — Bot command handlers: /scan, /verify, /recon, /status, /stats, /providers, /help, /key
- [ ] 17-04-PLAN.md — Subscribe/unsubscribe handlers + notification dispatcher (scheduler→bot bridge) - [x] 17-04-PLAN.md — Subscribe/unsubscribe handlers + notification dispatcher (scheduler→bot bridge)
- [ ] 17-05-PLAN.md — CLI wiring: cmd/serve.go + cmd/schedule.go replacing stubs - [ ] 17-05-PLAN.md — CLI wiring: cmd/serve.go + cmd/schedule.go replacing stubs
### Phase 18: Web Dashboard ### Phase 18: Web Dashboard
@@ -391,5 +391,5 @@ Phases execute in numeric order: 1 → 2 → 3 → ... → 18
| 14. OSINT CI/CD Logs, Web Archives & Frontend Leaks | 1/1 | Complete | 2026-04-06 | | 14. OSINT CI/CD Logs, Web Archives & Frontend Leaks | 1/1 | Complete | 2026-04-06 |
| 15. OSINT Forums, Collaboration & Log Aggregators | 2/4 | Complete | 2026-04-06 | | 15. OSINT Forums, Collaboration & Log Aggregators | 2/4 | Complete | 2026-04-06 |
| 16. OSINT Threat Intel, Mobile, DNS & API Marketplaces | 0/? | Complete | 2026-04-06 | | 16. OSINT Threat Intel, Mobile, DNS & API Marketplaces | 0/? | Complete | 2026-04-06 |
| 17. Telegram Bot & Scheduled Scanning | 0/5 | Not started | - | | 17. Telegram Bot & Scheduled Scanning | 3/5 | In Progress| |
| 18. Web Dashboard | 0/? | Not started | - | | 18. Web Dashboard | 0/? | Not started | - |

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Completed 17-01-PLAN.md stopped_at: Completed 17-04-PLAN.md
last_updated: "2026-04-06T14:28:54.411Z" last_updated: "2026-04-06T14:34:18.714Z"
last_activity: 2026-04-06 last_activity: 2026-04-06
progress: progress:
total_phases: 18 total_phases: 18
completed_phases: 14 completed_phases: 14
total_plans: 85 total_plans: 90
completed_plans: 84 completed_plans: 86
percent: 20 percent: 20
--- ---
@@ -101,6 +101,7 @@ Progress: [██░░░░░░░░] 20%
| Phase 15 P03 | 4min | 2 tasks | 11 files | | Phase 15 P03 | 4min | 2 tasks | 11 files |
| Phase 16 P01 | 4min | 2 tasks | 6 files | | Phase 16 P01 | 4min | 2 tasks | 6 files |
| Phase 17 P01 | 3min | 2 tasks | 4 files | | Phase 17 P01 | 3min | 2 tasks | 4 files |
| Phase 17 P04 | 3min | 2 tasks | 4 files |
## Accumulated Context ## Accumulated Context
@@ -154,6 +155,7 @@ Recent decisions affecting current work:
- [Phase 16]: IX uses three-step flow: POST search, GET results, GET file content - [Phase 16]: IX uses three-step flow: POST search, GET results, GET file content
- [Phase 16]: URLhaus tag lookup with payload endpoint fallback - [Phase 16]: URLhaus tag lookup with payload endpoint fallback
- [Phase 17]: telego v1.8.0 promoted from indirect to direct; context cancellation for graceful shutdown; rate limit 60s scan/verify/recon, 5s others - [Phase 17]: telego v1.8.0 promoted from indirect to direct; context cancellation for graceful shutdown; rate limit 60s scan/verify/recon, 5s others
- [Phase 17]: Separated format from send for testable notifications without telego mock
### Pending Todos ### Pending Todos
@@ -168,6 +170,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-04-06T14:28:54.406Z Last session: 2026-04-06T14:34:18.710Z
Stopped at: Completed 17-01-PLAN.md Stopped at: Completed 17-04-PLAN.md
Resume file: None Resume file: None

View File

@@ -0,0 +1,103 @@
---
phase: 17-telegram-scheduler
plan: 04
subsystem: telegram
tags: [telego, telegram, notifications, subscribers, scheduler]
requires:
- phase: 17-01
provides: Bot struct, Config, command dispatch, Start/Stop lifecycle
- phase: 17-02
provides: subscribers table CRUD (AddSubscriber, RemoveSubscriber, ListSubscribers, IsSubscribed), scheduler JobResult
provides:
- /subscribe and /unsubscribe command handlers
- NotifyNewFindings dispatcher (scheduler to bot bridge)
- NotifyFinding real-time individual finding notification
- formatNotification/formatErrorNotification/formatFindingNotification helpers
affects: [17-05, serve-command, scheduled-scanning]
tech-stack:
added: []
patterns: [separate-format-from-send for testable notification logic, per-subscriber error resilience]
key-files:
created:
- pkg/bot/subscribe.go
- pkg/bot/notify.go
- pkg/bot/subscribe_test.go
modified:
- pkg/bot/bot.go
key-decisions:
- "Separated formatting from sending for testability without mocking telego"
- "Nil bot field used as test-mode indicator to skip actual SendMessage calls"
- "Zero-finding results produce no notification (silent success)"
patterns-established:
- "Format+Send separation: formatNotification returns string, NotifyNewFindings iterates subscribers"
- "Per-subscriber resilience: log error and continue to next subscriber on send failure"
requirements-completed: [TELE-05, TELE-07, SCHED-03]
duration: 3min
completed: 2026-04-06
---
# Phase 17 Plan 04: Subscribe/Unsubscribe + Notification Dispatcher Summary
**/subscribe and /unsubscribe handlers with NotifyNewFindings dispatcher bridging scheduler job completions to Telegram messages for all subscribers**
## Performance
- **Duration:** 3 min
- **Started:** 2026-04-06T14:30:33Z
- **Completed:** 2026-04-06T14:33:36Z
- **Tasks:** 2
- **Files modified:** 4
## Accomplishments
- /subscribe checks IsSubscribed before adding, /unsubscribe reports rows affected
- NotifyNewFindings sends formatted message to all subscribers when scheduled scans find keys
- NotifyFinding provides real-time per-finding notification with always-masked keys
- 6 tests covering subscribe DB round-trip, no-subscriber no-op, zero-finding skip, message format validation
## Task Commits
Each task was committed atomically:
1. **Task 1: Implement /subscribe, /unsubscribe handlers** - `d671695` (feat)
2. **Task 2: Notification dispatcher and tests (RED)** - `f7162aa` (test)
3. **Task 2: Notification dispatcher and tests (GREEN)** - `2643927` (feat)
## Files Created/Modified
- `pkg/bot/subscribe.go` - /subscribe and /unsubscribe command handlers using storage layer
- `pkg/bot/notify.go` - NotifyNewFindings, NotifyFinding dispatchers with format helpers
- `pkg/bot/subscribe_test.go` - 6 tests for subscribe/unsubscribe and notification formatting
- `pkg/bot/bot.go` - Removed stub implementations replaced by subscribe.go
## Decisions Made
- Separated formatting from sending: formatNotification/formatErrorNotification/formatFindingNotification return strings, tested independently without telego mock
- Nil telego.Bot field used as test-mode indicator to skip actual SendMessage calls while still exercising all logic paths
- Zero-finding scan completions produce no notification (avoids subscriber fatigue)
- Error results get a separate error notification format
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
- go.sum had merge conflict markers from worktree merge; resolved by removing conflict markers and running go mod tidy
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Notification pipeline complete: scheduler OnComplete -> NotifyNewFindings -> all subscribers
- Ready for Plan 17-05 (serve command integration wiring bot + scheduler together)
---
*Phase: 17-telegram-scheduler*
*Completed: 2026-04-06*

3
go.sum
View File

@@ -187,15 +187,12 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
<<<<<<< HEAD
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
=======
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
>>>>>>> worktree-agent-a282d1fe
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=

View File

@@ -1,140 +1,253 @@
// Package bot implements the Telegram bot interface for KeyHunter. // Package bot implements the Telegram bot interface for KeyHunter.
// It wraps existing scan, verify, recon, and storage functionality, // It wraps telego v1.8.0 with long-polling updates, per-chat authorization,
// exposing them through Telegram command handlers via the telego library. // per-user rate limiting, and command dispatch to handler stubs.
package bot package bot
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler" "github.com/mymmrac/telego/telegoutil"
"github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/recon" "github.com/salvacybersec/keyhunter/pkg/recon"
"github.com/salvacybersec/keyhunter/pkg/storage" "github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/salvacybersec/keyhunter/pkg/verify"
) )
// Bot holds the Telegram bot instance and all dependencies needed // Config holds all dependencies and settings for the Telegram bot.
// to process commands. It delegates to the existing KeyHunter engine, type Config struct {
// verifier, recon engine, and storage layer. // Token is the Telegram bot token from BotFather.
type Bot struct { Token string
api *telego.Bot
handler *th.BotHandler
engine *engine.Engine
verifier *verify.HTTPVerifier
recon *recon.Engine
db *storage.DB
registry *providers.Registry
encKey []byte
mu sync.Mutex // AllowedChats restricts bot access to these chat IDs.
startedAt time.Time // Empty slice means allow all chats.
lastScan time.Time AllowedChats []int64
}
// Deps bundles the dependencies required to construct a Bot. // DB is the SQLite database for subscriber queries and finding lookups.
type Deps struct {
Engine *engine.Engine
Verifier *verify.HTTPVerifier
Recon *recon.Engine
DB *storage.DB DB *storage.DB
Registry *providers.Registry
// ScanEngine is the scanning engine for /scan commands.
ScanEngine *engine.Engine
// ReconEngine is the recon engine for /recon commands.
ReconEngine *recon.Engine
// ProviderRegistry is the provider registry for /providers and /verify.
ProviderRegistry *providers.Registry
// EncKey is the encryption key for finding decryption.
EncKey []byte EncKey []byte
} }
// New creates a Bot backed by the given telego API client and dependencies. // Bot wraps a telego.Bot with KeyHunter command handling and authorization.
// Call RegisterHandlers to wire up command handlers before starting the update loop. type Bot struct {
func New(api *telego.Bot, deps Deps) *Bot { cfg Config
bot *telego.Bot
cancel context.CancelFunc
rateMu sync.Mutex
rateLimits map[int64]time.Time
}
// commands is the list of bot commands registered with Telegram.
var commands = []telego.BotCommand{
{Command: "scan", Description: "Scan a target for API keys"},
{Command: "verify", Description: "Verify a found API key"},
{Command: "recon", Description: "Run OSINT recon for a keyword"},
{Command: "status", Description: "Show bot and scan status"},
{Command: "stats", Description: "Show finding statistics"},
{Command: "providers", Description: "List supported providers"},
{Command: "help", Description: "Show available commands"},
{Command: "key", Description: "Show full details for a finding"},
{Command: "subscribe", Description: "Subscribe to scan notifications"},
{Command: "unsubscribe", Description: "Unsubscribe from notifications"},
}
// New creates a new Bot from the given config. Returns an error if the token
// is invalid or telego cannot initialize.
func New(cfg Config) (*Bot, error) {
tb, err := telego.NewBot(cfg.Token)
if err != nil {
return nil, fmt.Errorf("creating telego bot: %w", err)
}
return &Bot{ return &Bot{
api: api, cfg: cfg,
engine: deps.Engine, bot: tb,
verifier: deps.Verifier, rateLimits: make(map[int64]time.Time),
recon: deps.Recon, }, nil
db: deps.DB,
registry: deps.Registry,
encKey: deps.EncKey,
startedAt: time.Now(),
}
} }
// RegisterHandlers wires all command handlers into a BotHandler that processes // Start begins long-polling for updates and dispatching commands. It blocks
// updates from the Telegram API. The caller must call Start() on the returned // until the provided context is cancelled or an error occurs.
// BotHandler to begin processing. func (b *Bot) Start(ctx context.Context) error {
func (b *Bot) RegisterHandlers(updates <-chan telego.Update) *th.BotHandler { ctx, b.cancel = context.WithCancel(ctx)
bh, _ := th.NewBotHandler(b.api, updates)
bh.HandleMessage(b.handleHelp, th.CommandEqual("help")) // Register command list with Telegram.
bh.HandleMessage(b.handleScan, th.CommandEqual("scan")) err := b.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
bh.HandleMessage(b.handleVerify, th.CommandEqual("verify")) Commands: commands,
bh.HandleMessage(b.handleRecon, th.CommandEqual("recon"))
bh.HandleMessage(b.handleStatus, th.CommandEqual("status"))
bh.HandleMessage(b.handleStats, th.CommandEqual("stats"))
bh.HandleMessage(b.handleProviders, th.CommandEqual("providers"))
bh.HandleMessage(b.handleKey, th.CommandEqual("key"))
b.handler = bh
return bh
}
// reply sends a text message back to the chat that originated msg.
func (b *Bot) reply(ctx context.Context, msg *telego.Message, text string) {
_, _ = b.api.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: msg.Chat.ID},
Text: text,
}) })
}
// isPrivateChat returns true if the message was sent in a private (1:1) chat.
func isPrivateChat(msg *telego.Message) bool {
return msg.Chat.Type == "private"
}
// runScan executes a scan against the given path and returns findings.
// Findings are collected synchronously; the caller formats the output.
func (b *Bot) runScan(ctx context.Context, path string) ([]engine.Finding, error) {
src, err := selectBotSource(path)
if err != nil { if err != nil {
return nil, err return fmt.Errorf("setting bot commands: %w", err)
} }
cfg := engine.ScanConfig{ updates, err := b.bot.UpdatesViaLongPolling(ctx, nil)
Workers: 0, // auto
Verify: false,
Unmask: false,
}
ch, err := b.engine.Scan(ctx, src, cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("starting scan: %w", err) return fmt.Errorf("starting long polling: %w", err)
} }
var findings []engine.Finding for update := range updates {
for f := range ch { if update.Message == nil {
findings = append(findings, f) continue
}
b.dispatch(ctx, update.Message)
} }
// Persist findings return nil
for _, f := range findings {
sf := storage.Finding{
ProviderName: f.ProviderName,
KeyValue: f.KeyValue,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
SourcePath: f.Source,
SourceType: f.SourceType,
LineNumber: f.LineNumber,
}
_, _ = b.db.SaveFinding(sf, b.encKey)
} }
b.mu.Lock() // Stop cancels the bot context, which stops long polling and the update loop.
b.lastScan = time.Now() func (b *Bot) Stop() {
b.mu.Unlock() if b.cancel != nil {
b.cancel()
return findings, nil
} }
}
// isAllowed returns true if the given chat ID is authorized to use the bot.
// If AllowedChats is empty, all chats are allowed.
func (b *Bot) isAllowed(chatID int64) bool {
if len(b.cfg.AllowedChats) == 0 {
return true
}
for _, id := range b.cfg.AllowedChats {
if id == chatID {
return true
}
}
return false
}
// checkRateLimit returns true if the user is allowed to execute a command,
// false if they are still within the cooldown window.
func (b *Bot) checkRateLimit(userID int64, cooldown time.Duration) bool {
b.rateMu.Lock()
defer b.rateMu.Unlock()
last, ok := b.rateLimits[userID]
if ok && time.Since(last) < cooldown {
return false
}
b.rateLimits[userID] = time.Now()
return true
}
// dispatch routes an incoming message to the appropriate handler.
func (b *Bot) dispatch(ctx context.Context, msg *telego.Message) {
chatID := msg.Chat.ID
if !b.isAllowed(chatID) {
_ = b.replyPlain(ctx, chatID, "Unauthorized: your chat ID is not in the allowed list.")
return
}
text := strings.TrimSpace(msg.Text)
if text == "" {
return
}
// Extract command (first word, with optional @mention suffix removed).
cmd := strings.SplitN(text, " ", 2)[0]
if at := strings.Index(cmd, "@"); at > 0 {
cmd = cmd[:at]
}
// Determine cooldown based on command type.
var cooldown time.Duration
switch cmd {
case "/scan", "/verify", "/recon":
cooldown = 60 * time.Second
default:
cooldown = 5 * time.Second
}
if msg.From != nil && !b.checkRateLimit(msg.From.ID, cooldown) {
_ = b.replyPlain(ctx, chatID, "Rate limited. Please wait before sending another command.")
return
}
switch cmd {
case "/scan":
b.handleScan(ctx, msg)
case "/verify":
b.handleVerify(ctx, msg)
case "/recon":
b.handleRecon(ctx, msg)
case "/status":
b.handleStatus(ctx, msg)
case "/stats":
b.handleStats(ctx, msg)
case "/providers":
b.handleProviders(ctx, msg)
case "/help", "/start":
b.handleHelp(ctx, msg)
case "/key":
b.handleKey(ctx, msg)
case "/subscribe":
b.handleSubscribe(ctx, msg)
case "/unsubscribe":
b.handleUnsubscribe(ctx, msg)
}
}
// reply sends a MarkdownV2-formatted message to the given chat.
func (b *Bot) reply(ctx context.Context, chatID int64, text string) error {
params := telegoutil.Message(telego.ChatID{ID: chatID}, text).
WithParseMode("MarkdownV2")
_, err := b.bot.SendMessage(ctx, params)
return err
}
// replyPlain sends a plain text message to the given chat.
func (b *Bot) replyPlain(ctx context.Context, chatID int64, text string) error {
params := telegoutil.Message(telego.ChatID{ID: chatID}, text)
_, err := b.bot.SendMessage(ctx, params)
return err
}
// --- Handler stubs (implemented in Plan 17-03/17-04) ---
func (b *Bot) handleScan(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /scan")
}
func (b *Bot) handleVerify(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /verify")
}
func (b *Bot) handleRecon(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /recon")
}
func (b *Bot) handleStatus(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /status")
}
func (b *Bot) handleStats(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /stats")
}
func (b *Bot) handleProviders(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /providers")
}
func (b *Bot) handleHelp(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /help")
}
func (b *Bot) handleKey(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /key")
}
// handleSubscribe and handleUnsubscribe are implemented in subscribe.go.

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

59
pkg/bot/subscribe.go Normal file
View File

@@ -0,0 +1,59 @@
package bot
import (
"context"
"fmt"
"log"
"github.com/mymmrac/telego"
)
// handleSubscribe adds the requesting chat to the subscribers table.
// If the chat is already subscribed, it informs the user without error.
func (b *Bot) handleSubscribe(ctx context.Context, msg *telego.Message) {
chatID := msg.Chat.ID
var username string
if msg.From != nil {
username = msg.From.Username
}
subscribed, err := b.cfg.DB.IsSubscribed(chatID)
if err != nil {
log.Printf("subscribe: checking subscription for chat %d: %v", chatID, err)
_ = b.replyPlain(ctx, chatID, "Error checking subscription status. Please try again.")
return
}
if subscribed {
_ = b.replyPlain(ctx, chatID, "You are already subscribed to notifications.")
return
}
if err := b.cfg.DB.AddSubscriber(chatID, username); err != nil {
log.Printf("subscribe: adding subscriber chat %d: %v", chatID, err)
_ = b.replyPlain(ctx, chatID, fmt.Sprintf("Error subscribing: %v", err))
return
}
_ = b.replyPlain(ctx, chatID, "Subscribed! You will receive notifications when new API keys are found.")
}
// handleUnsubscribe removes the requesting chat from the subscribers table.
// If the chat was not subscribed, it informs the user without error.
func (b *Bot) handleUnsubscribe(ctx context.Context, msg *telego.Message) {
chatID := msg.Chat.ID
rows, err := b.cfg.DB.RemoveSubscriber(chatID)
if err != nil {
log.Printf("unsubscribe: removing subscriber chat %d: %v", chatID, err)
_ = b.replyPlain(ctx, chatID, fmt.Sprintf("Error unsubscribing: %v", err))
return
}
if rows == 0 {
_ = b.replyPlain(ctx, chatID, "You are not subscribed.")
return
}
_ = b.replyPlain(ctx, chatID, "Unsubscribed. You will no longer receive notifications.")
}

121
pkg/bot/subscribe_test.go Normal file
View File

@@ -0,0 +1,121 @@
package bot
import (
"testing"
"time"
"github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/scheduler"
"github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
db, err := storage.Open(":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestSubscribeUnsubscribe(t *testing.T) {
db := openTestDB(t)
// Initially not subscribed.
ok, err := db.IsSubscribed(12345)
require.NoError(t, err)
assert.False(t, ok, "should not be subscribed initially")
// Subscribe.
err = db.AddSubscriber(12345, "testuser")
require.NoError(t, err)
ok, err = db.IsSubscribed(12345)
require.NoError(t, err)
assert.True(t, ok, "should be subscribed after AddSubscriber")
// Unsubscribe.
rows, err := db.RemoveSubscriber(12345)
require.NoError(t, err)
assert.Equal(t, int64(1), rows, "should have removed 1 row")
ok, err = db.IsSubscribed(12345)
require.NoError(t, err)
assert.False(t, ok, "should not be subscribed after RemoveSubscriber")
// Unsubscribe again returns 0 rows.
rows, err = db.RemoveSubscriber(12345)
require.NoError(t, err)
assert.Equal(t, int64(0), rows, "should have removed 0 rows when not subscribed")
}
func TestNotifyNewFindings_NoSubscribers(t *testing.T) {
db := openTestDB(t)
b := &Bot{cfg: Config{DB: db}}
sent, errs := b.NotifyNewFindings(scheduler.JobResult{
JobName: "nightly-scan",
FindingCount: 5,
Duration: 10 * time.Second,
})
assert.Equal(t, 0, sent, "should send 0 messages with no subscribers")
assert.Empty(t, errs, "should have no errors with no subscribers")
}
func TestNotifyNewFindings_ZeroFindings(t *testing.T) {
db := openTestDB(t)
_ = db.AddSubscriber(12345, "user1")
b := &Bot{cfg: Config{DB: db}}
sent, errs := b.NotifyNewFindings(scheduler.JobResult{
JobName: "nightly-scan",
FindingCount: 0,
Duration: 3 * time.Second,
})
assert.Equal(t, 0, sent, "should not notify for zero findings")
assert.Empty(t, errs, "should have no errors for zero findings")
}
func TestFormatNotification(t *testing.T) {
result := scheduler.JobResult{
JobName: "nightly-scan",
FindingCount: 7,
Duration: 2*time.Minute + 30*time.Second,
}
msg := formatNotification(result)
assert.Contains(t, msg, "nightly-scan", "message should contain job name")
assert.Contains(t, msg, "7", "message should contain finding count")
assert.Contains(t, msg, "2m30s", "message should contain duration")
assert.Contains(t, msg, "/stats", "message should reference /stats command")
}
func TestFormatNotification_Error(t *testing.T) {
result := scheduler.JobResult{
JobName: "daily-scan",
FindingCount: 0,
Duration: 5 * time.Second,
Error: assert.AnError,
}
msg := formatErrorNotification(result)
assert.Contains(t, msg, "daily-scan", "error message should contain job name")
assert.Contains(t, msg, "error", "error message should indicate error")
}
func TestFormatFindingNotification(t *testing.T) {
finding := engine.Finding{
ProviderName: "OpenAI",
KeyValue: "sk-proj-1234567890abcdef",
KeyMasked: "sk-proj-...cdef",
Confidence: "high",
Source: "/tmp/test.py",
LineNumber: 42,
}
msg := formatFindingNotification(finding)
assert.Contains(t, msg, "OpenAI", "should contain provider name")
assert.Contains(t, msg, "sk-proj-...cdef", "should contain masked key")
assert.NotContains(t, msg, "sk-proj-1234567890abcdef", "should NOT contain full key")
assert.Contains(t, msg, "/tmp/test.py", "should contain source path")
assert.Contains(t, msg, "42", "should contain line number")
assert.Contains(t, msg, "high", "should contain confidence")
}