docs(17): create phase plan — Telegram bot + scheduled scanning
This commit is contained in:
@@ -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
|
3. `/subscribe` enables auto-notifications; new key findings from any scan trigger an immediate Telegram message to all subscribed users
|
||||||
4. `/key <id>` sends full key detail to the requesting user's private chat only
|
4. `/key <id>` 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
|
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
|
### 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
|
**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
|
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
|
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
|
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
|
**UI hint**: yes
|
||||||
|
|
||||||
## Progress
|
## 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 |
|
| 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/? | Not started | - |
|
| 17. Telegram Bot & Scheduled Scanning | 0/5 | Not started | - |
|
||||||
| 18. Web Dashboard | 0/? | Not started | - |
|
| 18. Web Dashboard | 0/? | Not started | - |
|
||||||
|
|||||||
165
.planning/phases/17-telegram-scheduler/17-01-PLAN.md
Normal file
165
.planning/phases/17-telegram-scheduler/17-01-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/17-telegram-scheduler/17-CONTEXT.md
|
||||||
|
@cmd/stubs.go
|
||||||
|
@pkg/storage/db.go
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add telego dependency and create Bot package skeleton</name>
|
||||||
|
<files>go.mod, go.sum, pkg/bot/bot.go</files>
|
||||||
|
<action>
|
||||||
|
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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build ./pkg/bot/...</automated>
|
||||||
|
</verify>
|
||||||
|
<done>pkg/bot/bot.go compiles with telego dependency. Bot struct, New, Start, Stop, isAllowed, and all handler stubs exist.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Unit tests for Bot creation and auth filtering</name>
|
||||||
|
<files>pkg/bot/bot_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- 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
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 4 test cases pass. Bot auth filtering and rate limiting logic verified.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-telegram-scheduler/17-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
237
.planning/phases/17-telegram-scheduler/17-02-PLAN.md
Normal file
237
.planning/phases/17-telegram-scheduler/17-02-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types the executor needs from existing codebase -->
|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add gocron dependency, create storage tables, and subscriber/job CRUD</name>
|
||||||
|
<files>go.mod, go.sum, pkg/storage/schema.sql, pkg/storage/subscribers.go, pkg/storage/scheduled_jobs.go</files>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build ./pkg/storage/...</automated>
|
||||||
|
</verify>
|
||||||
|
<done>schema.sql has subscribers and scheduled_jobs tables. Storage CRUD methods compile.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Scheduler package with gocron wrapper and startup job loading</name>
|
||||||
|
<files>pkg/scheduler/scheduler.go, pkg/scheduler/jobs.go, pkg/scheduler/scheduler_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- 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
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go test ./pkg/scheduler/... ./pkg/storage/... -v -count=1 -run "TestStorage|TestSubscriber|TestScheduler"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Scheduler starts, loads jobs from DB, registers with gocron. AddJob/RemoveJob/RunJob work end-to-end. All tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
217
.planning/phases/17-telegram-scheduler/17-03-PLAN.md
Normal file
217
.planning/phases/17-telegram-scheduler/17-03-PLAN.md
Normal file
@@ -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 <id> 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 <id> 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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key interfaces from Plan 17-01 output -->
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Implement /scan, /verify, /recon command handlers</name>
|
||||||
|
<files>pkg/bot/handlers.go</files>
|
||||||
|
<action>
|
||||||
|
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 <path>"
|
||||||
|
- 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 <id>` (parse int64)
|
||||||
|
- If no ID, reply usage: "/verify <key-id>"
|
||||||
|
- 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 <query>` (everything after /recon)
|
||||||
|
- If no query, reply usage: "/recon <search-query>"
|
||||||
|
- 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build ./pkg/bot/...</automated>
|
||||||
|
</verify>
|
||||||
|
<done>/scan, /verify, /recon handlers compile and call correct engine methods.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Implement /status, /stats, /providers, /help, /key handlers and tests</name>
|
||||||
|
<files>pkg/bot/handlers.go, pkg/bot/handlers_test.go</files>
|
||||||
|
<action>
|
||||||
|
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 <path> - Scan files for API keys\n/verify <id> - Verify a specific key\n/recon <query> - Run OSINT recon\n/status - Show system status\n/stats - Show finding statistics\n/providers - List loaded providers\n/key <id> - 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 <id>`
|
||||||
|
- 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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 8 command handlers implemented. /key restricted to private chat. Tests pass for help, key security, truncation, empty stats.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go build ./pkg/bot/...` compiles
|
||||||
|
- `go test ./pkg/bot/... -v` passes all tests
|
||||||
|
- All 8 commands have implementations (no stubs remain)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-telegram-scheduler/17-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
180
.planning/phases/17-telegram-scheduler/17-04-PLAN.md
Normal file
180
.planning/phases/17-telegram-scheduler/17-04-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 17-02 storage layer -->
|
||||||
|
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) }
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Implement /subscribe, /unsubscribe handlers</name>
|
||||||
|
<files>pkg/bot/subscribe.go</files>
|
||||||
|
<action>
|
||||||
|
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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build ./pkg/bot/...</automated>
|
||||||
|
</verify>
|
||||||
|
<done>/subscribe and /unsubscribe handlers compile and use storage layer.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Notification dispatcher and tests</name>
|
||||||
|
<files>pkg/bot/notify.go, pkg/bot/subscribe_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- 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
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go test ./pkg/bot/... -v -count=1 -run "Subscribe|Notify"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Notification dispatcher sends to all subscribers on new findings. Subscribe/unsubscribe persists to DB. All tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-telegram-scheduler/17-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
296
.planning/phases/17-telegram-scheduler/17-05-PLAN.md
Normal file
296
.planning/phases/17-telegram-scheduler/17-05-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 17-01 -->
|
||||||
|
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 Plan 17-02 -->
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create cmd/serve.go with --telegram flag and bot+scheduler lifecycle</name>
|
||||||
|
<files>cmd/serve.go, cmd/stubs.go, cmd/root.go</files>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build ./cmd/...</automated>
|
||||||
|
</verify>
|
||||||
|
<done>cmd/serve.go compiles. `keyhunter serve --help` shows --telegram and --port flags. Stubs removed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Create cmd/schedule.go with add/list/remove/run subcommands</name>
|
||||||
|
<files>cmd/schedule.go, cmd/schedule_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- 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
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/salva/Documents/apikey && go build -o /dev/null . && go test ./cmd/... -v -count=1 -run "Schedule"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>schedule add/list/remove/run subcommands work. Full binary compiles. Tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-telegram-scheduler/17-05-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user