diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 524c8a7..75f1a84 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -339,7 +339,14 @@ Plans: 3. `/subscribe` enables auto-notifications; new key findings from any scan trigger an immediate Telegram message to all subscribed users 4. `/key ` sends full key detail to the requesting user's private chat only 5. `keyhunter schedule add --cron="0 */6 * * *" --scan=./myrepo` adds a recurring scan; `keyhunter schedule list` shows it; the job persists across restarts and sends Telegram notifications on new findings -**Plans**: TBD +**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 +- [ ] 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) +- [ ] 17-05-PLAN.md — CLI wiring: cmd/serve.go + cmd/schedule.go replacing stubs ### Phase 18: Web Dashboard **Goal**: Users can manage and interact with all KeyHunter capabilities through an embedded web dashboard — viewing scans, managing keys, launching recon, browsing providers, managing dorks, and configuring settings — with live scan progress via SSE @@ -351,7 +358,14 @@ Plans: 3. The keys page lists all findings with masked values and a "Reveal Key" toggle that shows the full key on demand 4. The recon page allows launching a recon sweep with source selection and shows live progress via Server-Sent Events 5. The REST API at `/api/v1/*` accepts and returns JSON for all dashboard actions; optional basic auth or token auth is configurable via settings page -**Plans**: TBD +**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 +- [ ] 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) +- [ ] 17-05-PLAN.md — CLI wiring: cmd/serve.go + cmd/schedule.go replacing stubs **UI hint**: yes ## Progress @@ -377,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/? | Not started | - | +| 17. Telegram Bot & Scheduled Scanning | 0/5 | Not started | - | | 18. Web Dashboard | 0/? | Not started | - | diff --git a/.planning/phases/17-telegram-scheduler/17-01-PLAN.md b/.planning/phases/17-telegram-scheduler/17-01-PLAN.md new file mode 100644 index 0000000..cc32a25 --- /dev/null +++ b/.planning/phases/17-telegram-scheduler/17-01-PLAN.md @@ -0,0 +1,165 @@ +--- +phase: 17-telegram-scheduler +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - pkg/bot/bot.go + - pkg/bot/bot_test.go + - go.mod + - go.sum +autonomous: true +requirements: [TELE-01] + +must_haves: + truths: + - "Bot struct initializes with telego client given a valid token" + - "Bot registers command handlers and starts long polling" + - "Bot respects allowed_chats restriction (empty = allow all)" + - "Bot gracefully shuts down on context cancellation" + artifacts: + - path: "pkg/bot/bot.go" + provides: "Bot struct, New, Start, Stop, RegisterHandlers, auth middleware" + exports: ["Bot", "New", "Config", "Start", "Stop"] + - path: "pkg/bot/bot_test.go" + provides: "Unit tests for Bot creation and auth filtering" + key_links: + - from: "pkg/bot/bot.go" + to: "github.com/mymmrac/telego" + via: "telego.NewBot + long polling" + pattern: "telego\\.NewBot" +--- + + +Create the pkg/bot/ package foundation: Bot struct wrapping telego v1.8.0, command registration, long-polling lifecycle, and chat ID authorization middleware. + +Purpose: Establishes the Telegram bot infrastructure that all command handlers (Plan 17-03, 17-04) build on. +Output: pkg/bot/bot.go with Bot struct, pkg/bot/bot_test.go with unit tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-telegram-scheduler/17-CONTEXT.md +@cmd/stubs.go +@pkg/storage/db.go + + + + + + Task 1: Add telego dependency and create Bot package skeleton + go.mod, go.sum, pkg/bot/bot.go + +1. Run `go get github.com/mymmrac/telego@v1.8.0` to add telego as a direct dependency. + +2. Create pkg/bot/bot.go with: + +- `Config` struct: + - `Token string` (Telegram bot token) + - `AllowedChats []int64` (empty = allow all) + - `DB *storage.DB` (for subscriber queries, finding lookups) + - `ScanEngine *engine.Engine` (for /scan handler) + - `ReconEngine *recon.Engine` (for /recon handler) + - `ProviderRegistry *providers.Registry` (for /providers, /verify) + - `EncKey []byte` (encryption key for finding decryption) + +- `Bot` struct: + - `cfg Config` + - `bot *telego.Bot` + - `updates <-chan telego.Update` (long polling channel) + - `cancel context.CancelFunc` (for shutdown) + +- `New(cfg Config) (*Bot, error)`: + - Create telego.Bot via `telego.NewBot(cfg.Token)` (no options needed for long polling) + - Return &Bot with config stored + +- `Start(ctx context.Context) error`: + - Create cancelable context from parent + - Call `bot.SetMyCommands` to register command descriptions (scan, verify, recon, status, stats, providers, help, key, subscribe, unsubscribe) + - Get updates via `bot.UpdatesViaLongPolling(nil)` which returns a channel + - Loop over updates channel, dispatch to handler based on update.Message.Text command prefix + - Check authorization via `isAllowed(chatID)` before dispatching any handler + - On ctx.Done(), call `bot.StopLongPolling()` and return + +- `Stop()`: + - Call cancel function to trigger shutdown + +- `isAllowed(chatID int64) bool`: + - If cfg.AllowedChats is empty, return true + - Otherwise check if chatID is in the list + +- Handler stubs (will be implemented in Plan 17-03): + - `handleScan(bot *telego.Bot, msg telego.Message)` + - `handleVerify(bot *telego.Bot, msg telego.Message)` + - `handleRecon(bot *telego.Bot, msg telego.Message)` + - `handleStatus(bot *telego.Bot, msg telego.Message)` + - `handleStats(bot *telego.Bot, msg telego.Message)` + - `handleProviders(bot *telego.Bot, msg telego.Message)` + - `handleHelp(bot *telego.Bot, msg telego.Message)` + - `handleKey(bot *telego.Bot, msg telego.Message)` + Each stub sends "Not yet implemented" reply via `bot.SendMessage`. + +- Use telego's MarkdownV2 parse mode for all replies. Create helper: + - `reply(bot *telego.Bot, chatID int64, text string) error` — sends MarkdownV2 message + - `replyPlain(bot *telego.Bot, chatID int64, text string) error` — sends plain text (for error messages) + +- Per-user rate limiting: `rateLimits map[int64]time.Time` with mutex. `checkRateLimit(userID int64, cooldown time.Duration) bool` returns false if user sent a command within cooldown window. Default cooldown 60s for /scan, /verify, /recon; 5s for others. + +Import paths: github.com/mymmrac/telego, github.com/mymmrac/telego/telegoutil (for SendMessageParams construction). + + + cd /home/salva/Documents/apikey && go build ./pkg/bot/... + + pkg/bot/bot.go compiles with telego dependency. Bot struct, New, Start, Stop, isAllowed, and all handler stubs exist. + + + + Task 2: Unit tests for Bot creation and auth filtering + pkg/bot/bot_test.go + + - Test 1: New() with empty token returns error from telego + - Test 2: isAllowed with empty AllowedChats returns true for any chatID + - Test 3: isAllowed with AllowedChats=[100,200] returns true for 100, false for 999 + - Test 4: checkRateLimit returns true on first call, false on immediate second call, true after cooldown + + +Create pkg/bot/bot_test.go: + +- TestNew_EmptyToken: Verify New(Config{Token:""}) returns an error. +- TestIsAllowed_EmptyList: Create Bot with empty AllowedChats, verify isAllowed(12345) returns true. +- TestIsAllowed_RestrictedList: Create Bot with AllowedChats=[100,200], verify isAllowed(100)==true, isAllowed(999)==false. +- TestCheckRateLimit: Create Bot, verify checkRateLimit(1, 60s)==true first call, ==false second call. + +Note: Since telego.NewBot requires a valid token format, for tests that need a Bot struct without a real connection, construct the Bot struct directly (bypassing New) to test isAllowed and rate limit logic independently. + + + cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1 + + All 4 test cases pass. Bot auth filtering and rate limiting logic verified. + + + + + +- `go build ./pkg/bot/...` compiles without errors +- `go test ./pkg/bot/... -v` passes all tests +- `grep telego go.mod` shows direct dependency at v1.8.0 + + + +- pkg/bot/bot.go exists with Bot struct, New, Start, Stop, isAllowed, handler stubs +- telego v1.8.0 is a direct dependency in go.mod +- All unit tests pass + + + +After completion, create `.planning/phases/17-telegram-scheduler/17-01-SUMMARY.md` + diff --git a/.planning/phases/17-telegram-scheduler/17-02-PLAN.md b/.planning/phases/17-telegram-scheduler/17-02-PLAN.md new file mode 100644 index 0000000..9362fbe --- /dev/null +++ b/.planning/phases/17-telegram-scheduler/17-02-PLAN.md @@ -0,0 +1,237 @@ +--- +phase: 17-telegram-scheduler +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - pkg/scheduler/scheduler.go + - pkg/scheduler/jobs.go + - pkg/scheduler/scheduler_test.go + - pkg/storage/schema.sql + - pkg/storage/subscribers.go + - pkg/storage/scheduled_jobs.go + - go.mod + - go.sum +autonomous: true +requirements: [SCHED-01] + +must_haves: + truths: + - "Scheduler loads enabled jobs from SQLite on startup and registers them with gocron" + - "Scheduled jobs persist across restarts (stored in scheduled_jobs table)" + - "Subscriber chat IDs persist in subscribers table" + - "Scheduler executes scan at cron intervals" + artifacts: + - path: "pkg/scheduler/scheduler.go" + provides: "Scheduler struct wrapping gocron with start/stop lifecycle" + exports: ["Scheduler", "New", "Start", "Stop"] + - path: "pkg/scheduler/jobs.go" + provides: "Job struct and CRUD operations" + exports: ["Job"] + - path: "pkg/storage/scheduled_jobs.go" + provides: "SQLite CRUD for scheduled_jobs table" + exports: ["ScheduledJob", "SaveScheduledJob", "ListScheduledJobs", "DeleteScheduledJob", "UpdateJobLastRun"] + - path: "pkg/storage/subscribers.go" + provides: "SQLite CRUD for subscribers table" + exports: ["Subscriber", "AddSubscriber", "RemoveSubscriber", "ListSubscribers"] + - path: "pkg/storage/schema.sql" + provides: "subscribers and scheduled_jobs CREATE TABLE statements" + contains: "CREATE TABLE IF NOT EXISTS subscribers" + key_links: + - from: "pkg/scheduler/scheduler.go" + to: "github.com/go-co-op/gocron/v2" + via: "gocron.NewScheduler + AddJob" + pattern: "gocron\\.NewScheduler" + - from: "pkg/scheduler/scheduler.go" + to: "pkg/storage" + via: "DB.ListScheduledJobs for startup load" + pattern: "db\\.ListScheduledJobs" +--- + + +Create the pkg/scheduler/ package and the SQLite storage tables (subscribers, scheduled_jobs) that both the bot and scheduler depend on. + +Purpose: Establishes cron-based recurring scan infrastructure and the persistence layer for subscriptions and jobs. Independent of pkg/bot/ (Wave 1 parallel). +Output: pkg/scheduler/, pkg/storage/subscribers.go, pkg/storage/scheduled_jobs.go, updated schema.sql. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-telegram-scheduler/17-CONTEXT.md +@pkg/storage/db.go +@pkg/storage/schema.sql +@pkg/engine/engine.go + + + + + +From pkg/storage/db.go: +```go +type DB struct { sql *sql.DB } +func Open(path string) (*DB, error) +func (db *DB) Close() error +func (db *DB) SQL() *sql.DB +``` + +From pkg/engine/engine.go: +```go +type ScanConfig struct { Workers int; Verify bool; Unmask bool } +func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error) +``` + + + + + + Task 1: Add gocron dependency, create storage tables, and subscriber/job CRUD + go.mod, go.sum, pkg/storage/schema.sql, pkg/storage/subscribers.go, pkg/storage/scheduled_jobs.go + +1. Run `go get github.com/go-co-op/gocron/v2@v2.19.1` to add gocron as a direct dependency. + +2. Append to pkg/storage/schema.sql (after existing custom_dorks table): + +```sql +-- Phase 17: Telegram bot subscribers for auto-notifications. +CREATE TABLE IF NOT EXISTS subscribers ( + chat_id INTEGER PRIMARY KEY, + username TEXT, + subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Phase 17: Cron-based scheduled scan jobs. +CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + cron_expr TEXT NOT NULL, + scan_command TEXT NOT NULL, + notify_telegram BOOLEAN DEFAULT FALSE, + enabled BOOLEAN DEFAULT TRUE, + last_run DATETIME, + next_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +3. Create pkg/storage/subscribers.go: +- `Subscriber` struct: `ChatID int64`, `Username string`, `SubscribedAt time.Time` +- `(db *DB) AddSubscriber(chatID int64, username string) error` — INSERT OR REPLACE +- `(db *DB) RemoveSubscriber(chatID int64) (int64, error)` — DELETE, return rows affected +- `(db *DB) ListSubscribers() ([]Subscriber, error)` — SELECT all +- `(db *DB) IsSubscribed(chatID int64) (bool, error)` — SELECT count + +4. Create pkg/storage/scheduled_jobs.go: +- `ScheduledJob` struct: `ID int64`, `Name string`, `CronExpr string`, `ScanCommand string`, `NotifyTelegram bool`, `Enabled bool`, `LastRun *time.Time`, `NextRun *time.Time`, `CreatedAt time.Time` +- `(db *DB) SaveScheduledJob(j ScheduledJob) (int64, error)` — INSERT +- `(db *DB) ListScheduledJobs() ([]ScheduledJob, error)` — SELECT all +- `(db *DB) GetScheduledJob(name string) (*ScheduledJob, error)` — SELECT by name +- `(db *DB) DeleteScheduledJob(name string) (int64, error)` — DELETE by name, return rows affected +- `(db *DB) UpdateJobLastRun(name string, lastRun time.Time, nextRun *time.Time) error` — UPDATE last_run and next_run +- `(db *DB) SetJobEnabled(name string, enabled bool) error` — UPDATE enabled flag + + + cd /home/salva/Documents/apikey && go build ./pkg/storage/... + + schema.sql has subscribers and scheduled_jobs tables. Storage CRUD methods compile. + + + + Task 2: Scheduler package with gocron wrapper and startup job loading + pkg/scheduler/scheduler.go, pkg/scheduler/jobs.go, pkg/scheduler/scheduler_test.go + + - Test 1: SaveScheduledJob + ListScheduledJobs round-trips correctly in :memory: DB + - Test 2: AddSubscriber + ListSubscribers round-trips correctly + - Test 3: Scheduler.Start loads jobs from DB and registers with gocron + - Test 4: Scheduler.AddJob persists to DB and registers cron job + - Test 5: Scheduler.RemoveJob removes from DB and gocron + + +1. Create pkg/scheduler/jobs.go: +- `Job` struct mirroring storage.ScheduledJob but with a `RunFunc func(context.Context) (int, error)` field (the scan function to call; returns finding count + error) +- `JobResult` struct: `JobName string`, `FindingCount int`, `Duration time.Duration`, `Error error` + +2. Create pkg/scheduler/scheduler.go: +- `Config` struct: + - `DB *storage.DB` + - `ScanFunc func(ctx context.Context, scanCommand string) (int, error)` — abstracted scan executor (avoids tight coupling to engine) + - `OnComplete func(result JobResult)` — callback for notification bridge (Plan 17-04 wires this) + +- `Scheduler` struct: + - `cfg Config` + - `sched gocron.Scheduler` (gocron scheduler instance) + - `jobs map[string]gocron.Job` (gocron job handles keyed by name) + - `mu sync.Mutex` + +- `New(cfg Config) (*Scheduler, error)`: + - Create gocron scheduler via `gocron.NewScheduler()` + - Return Scheduler + +- `Start(ctx context.Context) error`: + - Load all enabled jobs from DB via `cfg.DB.ListScheduledJobs()` + - For each, call internal `registerJob(job)` which creates a gocron.CronJob and stores handle + - Call `sched.Start()` to begin scheduling + +- `Stop() error`: + - Call `sched.Shutdown()` to stop all jobs + +- `AddJob(name, cronExpr, scanCommand string, notifyTelegram bool) error`: + - Save to DB via `cfg.DB.SaveScheduledJob` + - Register with gocron via `registerJob` + +- `RemoveJob(name string) error`: + - Remove gocron job handle from `jobs` map and call `sched.RemoveJob` + - Delete from DB via `cfg.DB.DeleteScheduledJob` + +- `ListJobs() ([]storage.ScheduledJob, error)`: + - Delegate to `cfg.DB.ListScheduledJobs()` + +- `RunJob(ctx context.Context, name string) (JobResult, error)`: + - Manual trigger — look up job in DB, call ScanFunc directly, call OnComplete callback + +- Internal `registerJob(sj storage.ScheduledJob)`: + - Create gocron job: `sched.NewJob(gocron.CronJob(sj.CronExpr, false), gocron.NewTask(func() { ... }))` + - The task function: call `cfg.ScanFunc(ctx, sj.ScanCommand)`, update last_run/next_run via DB, call `cfg.OnComplete` if sj.NotifyTelegram + +3. Create pkg/scheduler/scheduler_test.go: +- Use storage.Open(":memory:") for all tests +- TestStorageRoundTrip: Save job, list, verify fields match +- TestSubscriberRoundTrip: Add subscriber, list, verify; remove, verify empty +- TestSchedulerStartLoadsJobs: Save 2 enabled jobs to DB, create Scheduler with mock ScanFunc, call Start, verify gocron has 2 jobs registered (check len(s.jobs)==2) +- TestSchedulerAddRemoveJob: Add via Scheduler.AddJob, verify in DB; Remove, verify gone from DB +- TestSchedulerRunJob: Manual trigger via RunJob, verify ScanFunc called with correct scanCommand, verify OnComplete called with result + + + cd /home/salva/Documents/apikey && go test ./pkg/scheduler/... ./pkg/storage/... -v -count=1 -run "TestStorage|TestSubscriber|TestScheduler" + + Scheduler starts, loads jobs from DB, registers with gocron. AddJob/RemoveJob/RunJob work end-to-end. All tests pass. + + + + + +- `go build ./pkg/scheduler/...` compiles without errors +- `go test ./pkg/scheduler/... -v` passes all tests +- `go test ./pkg/storage/... -v -run Subscriber` passes subscriber CRUD tests +- `go test ./pkg/storage/... -v -run ScheduledJob` passes job CRUD tests +- `grep gocron go.mod` shows direct dependency at v2.19.1 + + + +- pkg/scheduler/ exists with Scheduler struct, gocron wrapper, job loading from DB +- pkg/storage/subscribers.go and pkg/storage/scheduled_jobs.go exist with full CRUD +- schema.sql has both new tables +- gocron v2.19.1 is a direct dependency in go.mod +- All tests pass + + + +After completion, create `.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md` + diff --git a/.planning/phases/17-telegram-scheduler/17-03-PLAN.md b/.planning/phases/17-telegram-scheduler/17-03-PLAN.md new file mode 100644 index 0000000..71f6d20 --- /dev/null +++ b/.planning/phases/17-telegram-scheduler/17-03-PLAN.md @@ -0,0 +1,217 @@ +--- +phase: 17-telegram-scheduler +plan: 03 +type: execute +wave: 2 +depends_on: ["17-01", "17-02"] +files_modified: + - pkg/bot/handlers.go + - pkg/bot/handlers_test.go +autonomous: true +requirements: [TELE-02, TELE-03, TELE-04, TELE-06] + +must_haves: + truths: + - "/scan triggers engine.Scan and returns masked findings via Telegram" + - "/verify verifies a specific key and returns result" + - "/recon runs recon sweep and returns findings" + - "/status shows uptime, total findings, last scan, active jobs" + - "/stats shows findings by provider, top 10, last 24h count" + - "/providers lists loaded provider count and names" + - "/help shows all available commands with descriptions" + - "/key sends full unmasked key detail to requesting user only" + artifacts: + - path: "pkg/bot/handlers.go" + provides: "All command handler implementations" + min_lines: 200 + - path: "pkg/bot/handlers_test.go" + provides: "Unit tests for handler logic" + key_links: + - from: "pkg/bot/handlers.go" + to: "pkg/engine" + via: "engine.Scan for /scan command" + pattern: "eng\\.Scan" + - from: "pkg/bot/handlers.go" + to: "pkg/recon" + via: "reconEngine.SweepAll for /recon command" + pattern: "SweepAll" + - from: "pkg/bot/handlers.go" + to: "pkg/storage" + via: "db.GetFinding for /key command" + pattern: "db\\.GetFinding" +--- + + +Implement all Telegram bot command handlers: /scan, /verify, /recon, /status, /stats, /providers, /help, /key. Replace the stubs created in Plan 17-01. + +Purpose: Makes the bot functional for all TELE-02..06 requirements. Users can control KeyHunter entirely from Telegram. +Output: pkg/bot/handlers.go with full implementations, pkg/bot/handlers_test.go. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/17-telegram-scheduler/17-CONTEXT.md +@.planning/phases/17-telegram-scheduler/17-01-SUMMARY.md +@.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md +@pkg/engine/engine.go +@pkg/recon/engine.go +@pkg/storage/db.go +@pkg/storage/queries.go +@pkg/storage/findings.go + + + + +From pkg/bot/bot.go (created in 17-01): +```go +type Config struct { + Token string + AllowedChats []int64 + DB *storage.DB + ScanEngine *engine.Engine + ReconEngine *recon.Engine + ProviderRegistry *providers.Registry + EncKey []byte +} +type Bot struct { cfg Config; bot *telego.Bot; ... } +func (b *Bot) reply(chatID int64, text string) error +func (b *Bot) replyPlain(chatID int64, text string) error +``` + +From pkg/storage/queries.go: +```go +func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error) +func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error) +``` + +From pkg/engine/engine.go: +```go +func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error) +``` + +From pkg/recon/engine.go: +```go +func (e *Engine) SweepAll(ctx context.Context, cfg Config) ([]Finding, error) +``` + + + + + + Task 1: Implement /scan, /verify, /recon command handlers + pkg/bot/handlers.go + +Create pkg/bot/handlers.go (replace stubs from bot.go). All handlers are methods on *Bot. + +**handleScan(bot *telego.Bot, msg telego.Message):** +- Parse path from message text: `/scan /path/to/dir` (whitespace split, second arg) +- If no path provided, reply with usage: "/scan " +- Check rate limit (60s cooldown) +- Reply "Scanning {path}..." immediately +- Create sources.FileSource for the path +- Run b.cfg.ScanEngine.Scan(ctx, src, engine.ScanConfig{Workers: runtime.NumCPU()*4}) +- Collect findings from channel +- Format response: "Found {N} potential keys:\n" + each finding as "- {provider}: {masked_key} ({confidence})" (max 20 per message, truncate with "...and N more") +- If 0 findings: "No API keys found in {path}" +- Always use masked keys — never send raw values + +**handleVerify(bot *telego.Bot, msg telego.Message):** +- Parse key ID from message: `/verify ` (parse int64) +- If no ID, reply usage: "/verify " +- Check rate limit (60s cooldown) +- Look up finding via b.cfg.DB.GetFinding(id, b.cfg.EncKey) +- If not found, reply "Key #{id} not found" +- Run verify.NewHTTPVerifier(10s).Verify against the finding using provider spec from registry +- Reply with: "Key #{id} ({provider}):\nStatus: {verified|invalid|error}\nHTTP: {code}\n{metadata if any}" + +**handleRecon(bot *telego.Bot, msg telego.Message):** +- Parse query from message: `/recon ` (everything after /recon) +- If no query, reply usage: "/recon " +- Check rate limit (60s cooldown) +- Reply "Running recon for '{query}'..." +- Run b.cfg.ReconEngine.SweepAll(ctx, recon.Config{Query: query}) +- Format response: "Found {N} results:\n" + each as "- [{source}] {url} ({snippet})" (max 15 per message) +- If 0 results: "No results found for '{query}'" + +**All handlers:** Wrap in goroutine so the update loop is not blocked. Use context.WithTimeout(ctx, 5*time.Minute) to prevent runaway scans. + + + cd /home/salva/Documents/apikey && go build ./pkg/bot/... + + /scan, /verify, /recon handlers compile and call correct engine methods. + + + + Task 2: Implement /status, /stats, /providers, /help, /key handlers and tests + pkg/bot/handlers.go, pkg/bot/handlers_test.go + +Add to pkg/bot/handlers.go: + +**handleStatus(bot *telego.Bot, msg telego.Message):** +- Query DB for total findings count: `SELECT COUNT(*) FROM findings` +- Query last scan time: `SELECT MAX(finished_at) FROM scans` +- Query active scheduled jobs: `SELECT COUNT(*) FROM scheduled_jobs WHERE enabled=1` +- Bot uptime: track start time in Bot struct, compute duration +- Reply: "Status:\n- Findings: {N}\n- Last scan: {time}\n- Active jobs: {N}\n- Uptime: {duration}" + +**handleStats(bot *telego.Bot, msg telego.Message):** +- Query findings by provider: `SELECT provider_name, COUNT(*) as cnt FROM findings GROUP BY provider_name ORDER BY cnt DESC LIMIT 10` +- Query findings last 24h: `SELECT COUNT(*) FROM findings WHERE created_at > datetime('now', '-1 day')` +- Reply: "Stats:\n- Top providers:\n 1. {provider}: {count}\n ...\n- Last 24h: {count} findings" + +**handleProviders(bot *telego.Bot, msg telego.Message):** +- Get provider list from b.cfg.ProviderRegistry.List() +- Reply: "Loaded {N} providers:\n{comma-separated list}" (truncate if >4096 chars Telegram message limit) + +**handleHelp(bot *telego.Bot, msg telego.Message):** +- Static response listing all commands: + "/scan - Scan files for API keys\n/verify - Verify a specific key\n/recon - Run OSINT recon\n/status - Show system status\n/stats - Show finding statistics\n/providers - List loaded providers\n/key - Show full key detail (DM only)\n/subscribe - Enable auto-notifications\n/unsubscribe - Disable auto-notifications\n/help - Show this help" + +**handleKey(bot *telego.Bot, msg telego.Message):** +- Parse key ID from `/key ` +- If no ID, reply usage +- Check message is from private chat (msg.Chat.Type == "private"). If group chat, reply "This command is only available in private chat for security" +- Look up finding via db.GetFinding(id, encKey) — this returns UNMASKED key +- Reply with full detail: "Key #{id}\nProvider: {provider}\nKey: {full_key_value}\nSource: {source_path}:{line}\nConfidence: {confidence}\nVerified: {yes/no}\nFound: {created_at}" +- This is the ONLY handler that sends unmasked keys + +**Tests in pkg/bot/handlers_test.go:** +- TestHandleHelp_ReturnsAllCommands: Verify help text contains all command names +- TestHandleKey_RejectsGroupChat: Verify /key in group chat returns security message +- TestFormatFindings_TruncatesAt20: Create 30 mock findings, verify formatted output has 20 entries + "...and 10 more" +- TestFormatStats_EmptyDB: Verify stats handler works with no findings + +For tests, create a helper that builds a Bot with :memory: DB and nil engines (for handlers that only query DB). + + + cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1 + + All 8 command handlers implemented. /key restricted to private chat. Tests pass for help, key security, truncation, empty stats. + + + + + +- `go build ./pkg/bot/...` compiles +- `go test ./pkg/bot/... -v` passes all tests +- All 8 commands have implementations (no stubs remain) + + + +- /scan triggers engine scan and returns masked findings +- /verify looks up and verifies a key +- /recon runs SweepAll +- /status, /stats, /providers, /help return informational responses +- /key sends unmasked detail only in private chat +- All output masks keys except /key in DM + + + +After completion, create `.planning/phases/17-telegram-scheduler/17-03-SUMMARY.md` + diff --git a/.planning/phases/17-telegram-scheduler/17-04-PLAN.md b/.planning/phases/17-telegram-scheduler/17-04-PLAN.md new file mode 100644 index 0000000..5c430e5 --- /dev/null +++ b/.planning/phases/17-telegram-scheduler/17-04-PLAN.md @@ -0,0 +1,180 @@ +--- +phase: 17-telegram-scheduler +plan: 04 +type: execute +wave: 2 +depends_on: ["17-01", "17-02"] +files_modified: + - pkg/bot/subscribe.go + - pkg/bot/notify.go + - pkg/bot/subscribe_test.go +autonomous: true +requirements: [TELE-05, TELE-07, SCHED-03] + +must_haves: + truths: + - "/subscribe adds user to subscribers table" + - "/unsubscribe removes user from subscribers table" + - "New key findings trigger Telegram notification to all subscribers" + - "Scheduled scan completion with findings triggers auto-notify" + artifacts: + - path: "pkg/bot/subscribe.go" + provides: "/subscribe and /unsubscribe handler implementations" + exports: ["handleSubscribe", "handleUnsubscribe"] + - path: "pkg/bot/notify.go" + provides: "Notification dispatcher sending findings to all subscribers" + exports: ["NotifyNewFindings"] + - path: "pkg/bot/subscribe_test.go" + provides: "Tests for subscribe/unsubscribe and notification" + key_links: + - from: "pkg/bot/notify.go" + to: "pkg/storage" + via: "db.ListSubscribers to get all chat IDs" + pattern: "db\\.ListSubscribers" + - from: "pkg/bot/notify.go" + to: "telego" + via: "bot.SendMessage to each subscriber" + pattern: "bot\\.SendMessage" + - from: "pkg/scheduler/scheduler.go" + to: "pkg/bot/notify.go" + via: "OnComplete callback calls NotifyNewFindings" + pattern: "NotifyNewFindings" +--- + + +Implement /subscribe, /unsubscribe handlers and the notification dispatcher that bridges scheduler job completions to Telegram messages. + +Purpose: Completes the auto-notification pipeline (TELE-05, TELE-07, SCHED-03). When scheduled scans find new keys, all subscribers get notified automatically. +Output: pkg/bot/subscribe.go, pkg/bot/notify.go, pkg/bot/subscribe_test.go. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/17-telegram-scheduler/17-CONTEXT.md +@.planning/phases/17-telegram-scheduler/17-01-SUMMARY.md +@.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md +@pkg/storage/subscribers.go +@pkg/bot/bot.go + + + + +From pkg/storage/subscribers.go: +```go +type Subscriber struct { ChatID int64; Username string; SubscribedAt time.Time } +func (db *DB) AddSubscriber(chatID int64, username string) error +func (db *DB) RemoveSubscriber(chatID int64) (int64, error) +func (db *DB) ListSubscribers() ([]Subscriber, error) +func (db *DB) IsSubscribed(chatID int64) (bool, error) +``` + +From pkg/scheduler/scheduler.go: +```go +type JobResult struct { JobName string; FindingCount int; Duration time.Duration; Error error } +type Config struct { ...; OnComplete func(result JobResult) } +``` + + + + + + Task 1: Implement /subscribe, /unsubscribe handlers + pkg/bot/subscribe.go + +Create pkg/bot/subscribe.go with methods on *Bot: + +**handleSubscribe(bot *telego.Bot, msg telego.Message):** +- Check if already subscribed via b.cfg.DB.IsSubscribed(msg.Chat.ID) +- If already subscribed, reply "You are already subscribed to notifications." +- Otherwise call b.cfg.DB.AddSubscriber(msg.Chat.ID, msg.From.Username) +- Reply "Subscribed! You will receive notifications when new API keys are found." + +**handleUnsubscribe(bot *telego.Bot, msg telego.Message):** +- Call b.cfg.DB.RemoveSubscriber(msg.Chat.ID) +- If rows affected == 0, reply "You are not subscribed." +- Otherwise reply "Unsubscribed. You will no longer receive notifications." + +Both handlers have no rate limit (instant operations). + + + cd /home/salva/Documents/apikey && go build ./pkg/bot/... + + /subscribe and /unsubscribe handlers compile and use storage layer. + + + + Task 2: Notification dispatcher and tests + pkg/bot/notify.go, pkg/bot/subscribe_test.go + + - Test 1: NotifyNewFindings with 0 subscribers sends no messages + - Test 2: NotifyNewFindings with 2 subscribers formats and sends to both + - Test 3: Subscribe/unsubscribe updates DB correctly + - Test 4: Notification message contains job name, finding count, and duration + + +1. Create pkg/bot/notify.go: + +**NotifyNewFindings(result scheduler.JobResult) method on *Bot:** +- If result.FindingCount == 0, do nothing (no notification for empty scans) +- If result.Error != nil, notify with error message instead +- Load all subscribers via b.cfg.DB.ListSubscribers() +- If no subscribers, return (no-op) +- Format message: + ``` + New findings from scheduled scan! + + Job: {result.JobName} + New keys found: {result.FindingCount} + Duration: {result.Duration} + + Use /stats for details. + ``` +- Send to each subscriber's chat ID via b.bot.SendMessage +- Log errors for individual send failures but continue to next subscriber (don't fail on one bad chat ID) +- Return total sent count and any errors + +**NotifyFinding(finding engine.Finding) method on *Bot:** +- Simpler variant for real-time notification of individual findings (called from scan pipeline if notification enabled) +- Format: "New key detected!\nProvider: {provider}\nKey: {masked}\nSource: {source_path}:{line}\nConfidence: {confidence}" +- Send to all subscribers +- Always use masked key + +2. Create pkg/bot/subscribe_test.go: +- TestSubscribeUnsubscribe: Open :memory: DB, add subscriber, verify IsSubscribed==true, remove, verify IsSubscribed==false +- TestNotifyNewFindings_NoSubscribers: Create Bot with :memory: DB (no subscribers), call NotifyNewFindings, verify no panic and returns 0 sent +- TestNotifyMessage_Format: Verify the formatted notification string contains job name, finding count, duration text +- TestNotifyNewFindings_ZeroFindings: Verify no notification sent when FindingCount==0 + +For tests that need to verify SendMessage calls, create a `mockTelegoBot` interface or use the Bot struct with a nil telego.Bot and verify the notification message format via a helper function (separate formatting from sending). + + + cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1 -run "Subscribe|Notify" + + Notification dispatcher sends to all subscribers on new findings. Subscribe/unsubscribe persists to DB. All tests pass. + + + + + +- `go build ./pkg/bot/...` compiles +- `go test ./pkg/bot/... -v -run "Subscribe|Notify"` passes +- NotifyNewFindings sends to all subscribers in DB +- /subscribe and /unsubscribe modify subscribers table + + + +- /subscribe adds chat to subscribers table, /unsubscribe removes it +- NotifyNewFindings sends formatted message to all subscribers +- Zero findings produces no notification +- Notification always uses masked keys + + + +After completion, create `.planning/phases/17-telegram-scheduler/17-04-SUMMARY.md` + diff --git a/.planning/phases/17-telegram-scheduler/17-05-PLAN.md b/.planning/phases/17-telegram-scheduler/17-05-PLAN.md new file mode 100644 index 0000000..c6d77e8 --- /dev/null +++ b/.planning/phases/17-telegram-scheduler/17-05-PLAN.md @@ -0,0 +1,296 @@ +--- +phase: 17-telegram-scheduler +plan: 05 +type: execute +wave: 3 +depends_on: ["17-01", "17-02", "17-03", "17-04"] +files_modified: + - cmd/serve.go + - cmd/schedule.go + - cmd/stubs.go + - cmd/root.go + - cmd/serve_test.go + - cmd/schedule_test.go +autonomous: true +requirements: [SCHED-02] + +must_haves: + truths: + - "keyhunter serve --telegram starts bot + scheduler and blocks until signal" + - "keyhunter schedule add creates a persistent cron job" + - "keyhunter schedule list shows all jobs with cron, next run, last run" + - "keyhunter schedule remove deletes a job by name" + - "keyhunter schedule run triggers a job manually" + - "serve and schedule stubs are replaced with real implementations" + artifacts: + - path: "cmd/serve.go" + provides: "serve command with --telegram flag, bot+scheduler lifecycle" + exports: ["serveCmd"] + - path: "cmd/schedule.go" + provides: "schedule add/list/remove/run subcommands" + exports: ["scheduleCmd"] + key_links: + - from: "cmd/serve.go" + to: "pkg/bot" + via: "bot.New + bot.Start for Telegram mode" + pattern: "bot\\.New|bot\\.Start" + - from: "cmd/serve.go" + to: "pkg/scheduler" + via: "scheduler.New + scheduler.Start" + pattern: "scheduler\\.New|scheduler\\.Start" + - from: "cmd/schedule.go" + to: "pkg/scheduler" + via: "scheduler.AddJob/RemoveJob/ListJobs/RunJob" + pattern: "scheduler\\." + - from: "cmd/root.go" + to: "cmd/serve.go" + via: "rootCmd.AddCommand(serveCmd) replacing stub" + pattern: "AddCommand.*serveCmd" +--- + + +Wire pkg/bot/ and pkg/scheduler/ into the CLI. Replace serve and schedule stubs in cmd/stubs.go with full implementations in cmd/serve.go and cmd/schedule.go. + +Purpose: Makes Telegram bot and scheduled scanning accessible via CLI commands (SCHED-02). This is the final integration plan. +Output: cmd/serve.go, cmd/schedule.go replacing stubs. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/17-telegram-scheduler/17-CONTEXT.md +@.planning/phases/17-telegram-scheduler/17-01-SUMMARY.md +@.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md +@.planning/phases/17-telegram-scheduler/17-03-SUMMARY.md +@.planning/phases/17-telegram-scheduler/17-04-SUMMARY.md +@cmd/root.go +@cmd/stubs.go +@cmd/scan.go + + + + +From pkg/bot/bot.go: +```go +type Config struct { + Token string; AllowedChats []int64; DB *storage.DB + ScanEngine *engine.Engine; ReconEngine *recon.Engine + ProviderRegistry *providers.Registry; EncKey []byte +} +func New(cfg Config) (*Bot, error) +func (b *Bot) Start(ctx context.Context) error +func (b *Bot) Stop() +func (b *Bot) NotifyNewFindings(result scheduler.JobResult) +``` + + +From pkg/scheduler/scheduler.go: +```go +type Config struct { + DB *storage.DB + ScanFunc func(ctx context.Context, scanCommand string) (int, error) + OnComplete func(result JobResult) +} +func New(cfg Config) (*Scheduler, error) +func (s *Scheduler) Start(ctx context.Context) error +func (s *Scheduler) Stop() error +func (s *Scheduler) AddJob(name, cronExpr, scanCommand string, notifyTelegram bool) error +func (s *Scheduler) RemoveJob(name string) error +func (s *Scheduler) ListJobs() ([]storage.ScheduledJob, error) +func (s *Scheduler) RunJob(ctx context.Context, name string) (JobResult, error) +``` + +From cmd/root.go: +```go +rootCmd.AddCommand(serveCmd) // currently from stubs.go +rootCmd.AddCommand(scheduleCmd) // currently from stubs.go +``` + +From cmd/scan.go (pattern to follow): +```go +dbPath := viper.GetString("database.path") +db, err := storage.Open(dbPath) +reg, err := providers.NewRegistry() +eng := engine.NewEngine(reg) +``` + + + + + + Task 1: Create cmd/serve.go with --telegram flag and bot+scheduler lifecycle + cmd/serve.go, cmd/stubs.go, cmd/root.go + +1. Create cmd/serve.go: + +**serveCmd** (replaces stub in stubs.go): +``` +Use: "serve" +Short: "Start the KeyHunter server (Telegram bot, scheduler, web dashboard)" +Long: "Starts the KeyHunter server. Use --telegram to enable the Telegram bot." +``` + +**Flags:** +- `--telegram` (bool, default false): Enable Telegram bot +- `--port` (int, default 8080): HTTP port for web dashboard (Phase 18, placeholder) + +**RunE logic:** +1. Open DB (same pattern as cmd/scan.go — viper.GetString("database.path"), storage.Open) +2. Load encryption key (same loadOrCreateEncKey pattern from scan.go — extract to shared helper if not already) +3. Initialize providers.NewRegistry() and engine.NewEngine(reg) +4. Initialize recon.NewEngine() and register all sources (same as cmd/recon.go pattern) + +5. Create scan function for scheduler: + ```go + scanFunc := func(ctx context.Context, scanCommand string) (int, error) { + src := sources.NewFileSource(scanCommand, nil) + ch, err := eng.Scan(ctx, src, engine.ScanConfig{Workers: runtime.NumCPU()*4}) + // collect findings, save to DB, return count + } + ``` + +6. If --telegram: + - Read token from viper: `viper.GetString("telegram.token")` or env `KEYHUNTER_TELEGRAM_TOKEN` + - If empty, return error "telegram.token not configured (set in ~/.keyhunter.yaml or KEYHUNTER_TELEGRAM_TOKEN env)" + - Read allowed chats: `viper.GetIntSlice("telegram.allowed_chats")` + - Create bot: `bot.New(bot.Config{Token, AllowedChats, DB, ScanEngine, ReconEngine, ProviderRegistry, EncKey})` + - Create scheduler with OnComplete wired to bot.NotifyNewFindings: + ```go + sched := scheduler.New(scheduler.Config{ + DB: db, + ScanFunc: scanFunc, + OnComplete: func(r scheduler.JobResult) { tgBot.NotifyNewFindings(r) }, + }) + ``` + - Start scheduler in goroutine + - Start bot (blocks on long polling) + - On SIGINT/SIGTERM: bot.Stop(), sched.Stop(), db.Close() + +7. If NOT --telegram (future web-only mode): + - Create scheduler without OnComplete (or with log-only callback) + - Start scheduler + - Print "Web dashboard not yet implemented (Phase 18). Scheduler running. Ctrl+C to stop." + - Block on signal + +8. Signal handling: use `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` for clean shutdown. + +2. Update cmd/stubs.go: Remove `serveCmd` and `scheduleCmd` variable declarations (they move to their own files). + +3. Update cmd/root.go: The AddCommand calls stay the same — they just resolve to the new files instead of stubs.go. Verify no compilation conflicts. + + + cd /home/salva/Documents/apikey && go build ./cmd/... + + cmd/serve.go compiles. `keyhunter serve --help` shows --telegram and --port flags. Stubs removed. + + + + Task 2: Create cmd/schedule.go with add/list/remove/run subcommands + cmd/schedule.go, cmd/schedule_test.go + + - Test 1: schedule add with valid flags creates job in DB + - Test 2: schedule list with no jobs shows empty table + - Test 3: schedule remove of nonexistent job returns error message + + +1. Create cmd/schedule.go: + +**scheduleCmd** (replaces stub): +``` +Use: "schedule" +Short: "Manage scheduled recurring scans" +``` +Parent command with subcommands (no RunE on parent — shows help if called alone). + +**scheduleAddCmd:** +``` +Use: "add" +Short: "Add a new scheduled scan" +``` +Flags: +- `--name` (string, required): Job name +- `--cron` (string, required): Cron expression (e.g., "0 */6 * * *") +- `--scan` (string, required): Path to scan +- `--notify` (string, optional): Notification channel ("telegram" or empty) + +RunE: +- Open DB +- Create scheduler.New with DB +- Call sched.AddJob(name, cron, scan, notify=="telegram") +- Print "Scheduled job '{name}' added. Cron: {cron}, Path: {scan}" + +**scheduleListCmd:** +``` +Use: "list" +Short: "List all scheduled scans" +``` +RunE: +- Open DB +- List all jobs via db.ListScheduledJobs() +- Print table: Name | Cron | Path | Notify | Enabled | Last Run | Next Run +- Use lipgloss table formatting (same pattern as other list commands) + +**scheduleRemoveCmd:** +``` +Use: "remove [name]" +Short: "Remove a scheduled scan" +Args: cobra.ExactArgs(1) +``` +RunE: +- Open DB +- Delete job by name +- If 0 rows affected: "No job named '{name}' found" +- Else: "Job '{name}' removed" + +**scheduleRunCmd:** +``` +Use: "run [name]" +Short: "Manually trigger a scheduled scan" +Args: cobra.ExactArgs(1) +``` +RunE: +- Open DB, init engine (same as serve.go pattern) +- Create scheduler with scanFunc +- Call sched.RunJob(ctx, name) +- Print result: "Job '{name}' completed. Found {N} keys in {duration}." + +Register subcommands: scheduleCmd.AddCommand(scheduleAddCmd, scheduleListCmd, scheduleRemoveCmd, scheduleRunCmd) + +2. Create cmd/schedule_test.go: +- TestScheduleAdd_MissingFlags: Run command without --name, verify error about required flag +- TestScheduleList_Empty: Open :memory: DB, list, verify no rows (test output format) +- Use the cobra command testing pattern from existing cmd/*_test.go files + + + cd /home/salva/Documents/apikey && go build -o /dev/null . && go test ./cmd/... -v -count=1 -run "Schedule" + + schedule add/list/remove/run subcommands work. Full binary compiles. Tests pass. + + + + + +- `go build -o /dev/null .` — full binary compiles with no stub conflicts +- `go test ./cmd/... -v -run Schedule` passes +- `./keyhunter serve --help` shows --telegram flag +- `./keyhunter schedule --help` shows add/list/remove/run subcommands +- No "not implemented" messages from serve or schedule commands + + + +- `keyhunter serve --telegram` starts bot+scheduler (requires token config) +- `keyhunter schedule add --name=daily --cron="0 0 * * *" --scan=./repo` persists job +- `keyhunter schedule list` shows jobs in table format +- `keyhunter schedule remove daily` deletes job +- `keyhunter schedule run daily` triggers manual scan +- serve and schedule stubs fully replaced + + + +After completion, create `.planning/phases/17-telegram-scheduler/17-05-SUMMARY.md` +