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-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
|
||||||
|
|
||||||
|
|||||||
@@ -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 | - |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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/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=
|
||||||
|
|||||||
319
pkg/bot/bot.go
319
pkg/bot/bot.go
@@ -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.
|
||||||
|
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 {
|
type Bot struct {
|
||||||
api *telego.Bot
|
cfg Config
|
||||||
handler *th.BotHandler
|
bot *telego.Bot
|
||||||
engine *engine.Engine
|
cancel context.CancelFunc
|
||||||
verifier *verify.HTTPVerifier
|
|
||||||
recon *recon.Engine
|
|
||||||
db *storage.DB
|
|
||||||
registry *providers.Registry
|
|
||||||
encKey []byte
|
|
||||||
|
|
||||||
mu sync.Mutex
|
rateMu sync.Mutex
|
||||||
startedAt time.Time
|
rateLimits map[int64]time.Time
|
||||||
lastScan time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deps bundles the dependencies required to construct a Bot.
|
// commands is the list of bot commands registered with Telegram.
|
||||||
type Deps struct {
|
var commands = []telego.BotCommand{
|
||||||
Engine *engine.Engine
|
{Command: "scan", Description: "Scan a target for API keys"},
|
||||||
Verifier *verify.HTTPVerifier
|
{Command: "verify", Description: "Verify a found API key"},
|
||||||
Recon *recon.Engine
|
{Command: "recon", Description: "Run OSINT recon for a keyword"},
|
||||||
DB *storage.DB
|
{Command: "status", Description: "Show bot and scan status"},
|
||||||
Registry *providers.Registry
|
{Command: "stats", Description: "Show finding statistics"},
|
||||||
EncKey []byte
|
{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.
|
// New creates a new Bot from the given config. Returns an error if the token
|
||||||
// Call RegisterHandlers to wire up command handlers before starting the update loop.
|
// is invalid or telego cannot initialize.
|
||||||
func New(api *telego.Bot, deps Deps) *Bot {
|
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
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}
|
}
|
||||||
_, _ = b.db.SaveFinding(sf, b.encKey)
|
b.dispatch(ctx, update.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.mu.Lock()
|
return nil
|
||||||
b.lastScan = time.Now()
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
return findings, 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