merge: phase 17 wave 2
This commit is contained in:
@@ -236,15 +236,15 @@ Requirements for initial release. Each maps to roadmap phases.
|
||||
- [ ] **TELE-02**: /verify command — key verification
|
||||
- [ ] **TELE-03**: /recon command — dork execution
|
||||
- [ ] **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-07**: Auto-notification on new key findings
|
||||
- [x] **TELE-07**: Auto-notification on new key findings
|
||||
|
||||
### Scheduled Scanning
|
||||
|
||||
- [x] **SCHED-01**: Cron-based recurring scan scheduling
|
||||
- [ ] **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
|
||||
|
||||
|
||||
@@ -342,10 +342,10 @@ Plans:
|
||||
**Plans**: 5 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 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-01-PLAN.md — Bot package skeleton: telego dependency, Bot struct, long polling, auth middleware
|
||||
- [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-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
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| 17. Telegram Bot & Scheduled Scanning | 0/5 | Not started | - |
|
||||
| 17. Telegram Bot & Scheduled Scanning | 3/5 | In Progress| |
|
||||
| 18. Web Dashboard | 0/? | Not started | - |
|
||||
|
||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Completed 17-01-PLAN.md
|
||||
last_updated: "2026-04-06T14:28:54.411Z"
|
||||
stopped_at: Completed 17-04-PLAN.md
|
||||
last_updated: "2026-04-06T14:34:18.714Z"
|
||||
last_activity: 2026-04-06
|
||||
progress:
|
||||
total_phases: 18
|
||||
completed_phases: 14
|
||||
total_plans: 85
|
||||
completed_plans: 84
|
||||
total_plans: 90
|
||||
completed_plans: 86
|
||||
percent: 20
|
||||
---
|
||||
|
||||
@@ -101,6 +101,7 @@ Progress: [██░░░░░░░░] 20%
|
||||
| Phase 15 P03 | 4min | 2 tasks | 11 files |
|
||||
| Phase 16 P01 | 4min | 2 tasks | 6 files |
|
||||
| Phase 17 P01 | 3min | 2 tasks | 4 files |
|
||||
| Phase 17 P04 | 3min | 2 tasks | 4 files |
|
||||
|
||||
## 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]: 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]: Separated format from send for testable notifications without telego mock
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -168,6 +170,6 @@ None yet.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-06T14:28:54.406Z
|
||||
Stopped at: Completed 17-01-PLAN.md
|
||||
Last session: 2026-04-06T14:34:18.710Z
|
||||
Stopped at: Completed 17-04-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
103
.planning/phases/17-telegram-scheduler/17-04-SUMMARY.md
Normal file
103
.planning/phases/17-telegram-scheduler/17-04-SUMMARY.md
Normal 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
3
go.sum
@@ -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/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=
|
||||
<<<<<<< HEAD
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
=======
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
|
||||
319
pkg/bot/bot.go
319
pkg/bot/bot.go
@@ -1,140 +1,253 @@
|
||||
// Package bot implements the Telegram bot interface for KeyHunter.
|
||||
// It wraps existing scan, verify, recon, and storage functionality,
|
||||
// exposing them through Telegram command handlers via the telego library.
|
||||
// It wraps telego v1.8.0 with long-polling updates, per-chat authorization,
|
||||
// per-user rate limiting, and command dispatch to handler stubs.
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/providers"
|
||||
"github.com/salvacybersec/keyhunter/pkg/recon"
|
||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||
"github.com/salvacybersec/keyhunter/pkg/verify"
|
||||
)
|
||||
|
||||
// Bot holds the Telegram bot instance and all dependencies needed
|
||||
// to process commands. It delegates to the existing KeyHunter engine,
|
||||
// verifier, recon engine, and storage layer.
|
||||
// Config holds all dependencies and settings for the Telegram bot.
|
||||
type Config struct {
|
||||
// Token is the Telegram bot token from BotFather.
|
||||
Token string
|
||||
|
||||
// AllowedChats restricts bot access to these chat IDs.
|
||||
// Empty slice means allow all chats.
|
||||
AllowedChats []int64
|
||||
|
||||
// DB is the SQLite database for subscriber queries and finding lookups.
|
||||
DB *storage.DB
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Bot wraps a telego.Bot with KeyHunter command handling and authorization.
|
||||
type Bot struct {
|
||||
api *telego.Bot
|
||||
handler *th.BotHandler
|
||||
engine *engine.Engine
|
||||
verifier *verify.HTTPVerifier
|
||||
recon *recon.Engine
|
||||
db *storage.DB
|
||||
registry *providers.Registry
|
||||
encKey []byte
|
||||
cfg Config
|
||||
bot *telego.Bot
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
startedAt time.Time
|
||||
lastScan time.Time
|
||||
rateMu sync.Mutex
|
||||
rateLimits map[int64]time.Time
|
||||
}
|
||||
|
||||
// Deps bundles the dependencies required to construct a Bot.
|
||||
type Deps struct {
|
||||
Engine *engine.Engine
|
||||
Verifier *verify.HTTPVerifier
|
||||
Recon *recon.Engine
|
||||
DB *storage.DB
|
||||
Registry *providers.Registry
|
||||
EncKey []byte
|
||||
// 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 Bot backed by the given telego API client and dependencies.
|
||||
// Call RegisterHandlers to wire up command handlers before starting the update loop.
|
||||
func New(api *telego.Bot, deps Deps) *Bot {
|
||||
// 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{
|
||||
api: api,
|
||||
engine: deps.Engine,
|
||||
verifier: deps.Verifier,
|
||||
recon: deps.Recon,
|
||||
db: deps.DB,
|
||||
registry: deps.Registry,
|
||||
encKey: deps.EncKey,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
cfg: cfg,
|
||||
bot: tb,
|
||||
rateLimits: make(map[int64]time.Time),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterHandlers wires all command handlers into a BotHandler that processes
|
||||
// updates from the Telegram API. The caller must call Start() on the returned
|
||||
// BotHandler to begin processing.
|
||||
func (b *Bot) RegisterHandlers(updates <-chan telego.Update) *th.BotHandler {
|
||||
bh, _ := th.NewBotHandler(b.api, updates)
|
||||
// Start begins long-polling for updates and dispatching commands. It blocks
|
||||
// until the provided context is cancelled or an error occurs.
|
||||
func (b *Bot) Start(ctx context.Context) error {
|
||||
ctx, b.cancel = context.WithCancel(ctx)
|
||||
|
||||
bh.HandleMessage(b.handleHelp, th.CommandEqual("help"))
|
||||
bh.HandleMessage(b.handleScan, th.CommandEqual("scan"))
|
||||
bh.HandleMessage(b.handleVerify, th.CommandEqual("verify"))
|
||||
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,
|
||||
// Register command list with Telegram.
|
||||
err := b.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
||||
Commands: commands,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
return fmt.Errorf("setting bot commands: %w", err)
|
||||
}
|
||||
|
||||
cfg := engine.ScanConfig{
|
||||
Workers: 0, // auto
|
||||
Verify: false,
|
||||
Unmask: false,
|
||||
}
|
||||
|
||||
ch, err := b.engine.Scan(ctx, src, cfg)
|
||||
updates, err := b.bot.UpdatesViaLongPolling(ctx, 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 f := range ch {
|
||||
findings = append(findings, f)
|
||||
}
|
||||
|
||||
// Persist findings
|
||||
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,
|
||||
for update := range updates {
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
_, _ = b.db.SaveFinding(sf, b.encKey)
|
||||
b.dispatch(ctx, update.Message)
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.lastScan = time.Now()
|
||||
b.mu.Unlock()
|
||||
|
||||
return findings, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels the bot context, which stops long polling and the update loop.
|
||||
func (b *Bot) Stop() {
|
||||
if b.cancel != nil {
|
||||
b.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
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,
|
||||
)
|
||||
}
|
||||
59
pkg/bot/subscribe.go
Normal file
59
pkg/bot/subscribe.go
Normal 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
121
pkg/bot/subscribe_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user