- handleSubscribe checks IsSubscribed, calls AddSubscriber with chat ID and username - handleUnsubscribe calls RemoveSubscriber, reports rows affected - Both use storage layer from Plan 17-02 - Removed stub implementations from bot.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
7.2 KiB
Go
254 lines
7.2 KiB
Go
// Package bot implements the Telegram bot interface for KeyHunter.
|
|
// It wraps telego v1.8.0 with long-polling updates, per-chat authorization,
|
|
// per-user rate limiting, and command dispatch to handler stubs.
|
|
package bot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mymmrac/telego"
|
|
"github.com/mymmrac/telego/telegoutil"
|
|
"github.com/salvacybersec/keyhunter/pkg/engine"
|
|
"github.com/salvacybersec/keyhunter/pkg/providers"
|
|
"github.com/salvacybersec/keyhunter/pkg/recon"
|
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
|
)
|
|
|
|
// Config holds all dependencies and settings for the Telegram bot.
|
|
type Config struct {
|
|
// Token is the Telegram bot token from BotFather.
|
|
Token string
|
|
|
|
// AllowedChats restricts bot access to these chat IDs.
|
|
// Empty slice means allow all chats.
|
|
AllowedChats []int64
|
|
|
|
// DB is the SQLite database for subscriber queries and finding lookups.
|
|
DB *storage.DB
|
|
|
|
// ScanEngine is the scanning engine for /scan commands.
|
|
ScanEngine *engine.Engine
|
|
|
|
// ReconEngine is the recon engine for /recon commands.
|
|
ReconEngine *recon.Engine
|
|
|
|
// ProviderRegistry is the provider registry for /providers and /verify.
|
|
ProviderRegistry *providers.Registry
|
|
|
|
// EncKey is the encryption key for finding decryption.
|
|
EncKey []byte
|
|
}
|
|
|
|
// Bot wraps a telego.Bot with KeyHunter command handling and authorization.
|
|
type Bot struct {
|
|
cfg Config
|
|
bot *telego.Bot
|
|
cancel context.CancelFunc
|
|
|
|
rateMu sync.Mutex
|
|
rateLimits map[int64]time.Time
|
|
}
|
|
|
|
// commands is the list of bot commands registered with Telegram.
|
|
var commands = []telego.BotCommand{
|
|
{Command: "scan", Description: "Scan a target for API keys"},
|
|
{Command: "verify", Description: "Verify a found API key"},
|
|
{Command: "recon", Description: "Run OSINT recon for a keyword"},
|
|
{Command: "status", Description: "Show bot and scan status"},
|
|
{Command: "stats", Description: "Show finding statistics"},
|
|
{Command: "providers", Description: "List supported providers"},
|
|
{Command: "help", Description: "Show available commands"},
|
|
{Command: "key", Description: "Show full details for a finding"},
|
|
{Command: "subscribe", Description: "Subscribe to scan notifications"},
|
|
{Command: "unsubscribe", Description: "Unsubscribe from notifications"},
|
|
}
|
|
|
|
// New creates a new Bot from the given config. Returns an error if the token
|
|
// is invalid or telego cannot initialize.
|
|
func New(cfg Config) (*Bot, error) {
|
|
tb, err := telego.NewBot(cfg.Token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating telego bot: %w", err)
|
|
}
|
|
return &Bot{
|
|
cfg: cfg,
|
|
bot: tb,
|
|
rateLimits: make(map[int64]time.Time),
|
|
}, nil
|
|
}
|
|
|
|
// Start begins long-polling for updates and dispatching commands. It blocks
|
|
// until the provided context is cancelled or an error occurs.
|
|
func (b *Bot) Start(ctx context.Context) error {
|
|
ctx, b.cancel = context.WithCancel(ctx)
|
|
|
|
// Register command list with Telegram.
|
|
err := b.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
|
Commands: commands,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("setting bot commands: %w", err)
|
|
}
|
|
|
|
updates, err := b.bot.UpdatesViaLongPolling(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("starting long polling: %w", err)
|
|
}
|
|
|
|
for update := range updates {
|
|
if update.Message == nil {
|
|
continue
|
|
}
|
|
b.dispatch(ctx, update.Message)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop cancels the bot context, which stops long polling and the update loop.
|
|
func (b *Bot) Stop() {
|
|
if b.cancel != nil {
|
|
b.cancel()
|
|
}
|
|
}
|
|
|
|
// isAllowed returns true if the given chat ID is authorized to use the bot.
|
|
// If AllowedChats is empty, all chats are allowed.
|
|
func (b *Bot) isAllowed(chatID int64) bool {
|
|
if len(b.cfg.AllowedChats) == 0 {
|
|
return true
|
|
}
|
|
for _, id := range b.cfg.AllowedChats {
|
|
if id == chatID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkRateLimit returns true if the user is allowed to execute a command,
|
|
// false if they are still within the cooldown window.
|
|
func (b *Bot) checkRateLimit(userID int64, cooldown time.Duration) bool {
|
|
b.rateMu.Lock()
|
|
defer b.rateMu.Unlock()
|
|
|
|
last, ok := b.rateLimits[userID]
|
|
if ok && time.Since(last) < cooldown {
|
|
return false
|
|
}
|
|
b.rateLimits[userID] = time.Now()
|
|
return true
|
|
}
|
|
|
|
// dispatch routes an incoming message to the appropriate handler.
|
|
func (b *Bot) dispatch(ctx context.Context, msg *telego.Message) {
|
|
chatID := msg.Chat.ID
|
|
if !b.isAllowed(chatID) {
|
|
_ = b.replyPlain(ctx, chatID, "Unauthorized: your chat ID is not in the allowed list.")
|
|
return
|
|
}
|
|
|
|
text := strings.TrimSpace(msg.Text)
|
|
if text == "" {
|
|
return
|
|
}
|
|
|
|
// Extract command (first word, with optional @mention suffix removed).
|
|
cmd := strings.SplitN(text, " ", 2)[0]
|
|
if at := strings.Index(cmd, "@"); at > 0 {
|
|
cmd = cmd[:at]
|
|
}
|
|
|
|
// Determine cooldown based on command type.
|
|
var cooldown time.Duration
|
|
switch cmd {
|
|
case "/scan", "/verify", "/recon":
|
|
cooldown = 60 * time.Second
|
|
default:
|
|
cooldown = 5 * time.Second
|
|
}
|
|
|
|
if msg.From != nil && !b.checkRateLimit(msg.From.ID, cooldown) {
|
|
_ = b.replyPlain(ctx, chatID, "Rate limited. Please wait before sending another command.")
|
|
return
|
|
}
|
|
|
|
switch cmd {
|
|
case "/scan":
|
|
b.handleScan(ctx, msg)
|
|
case "/verify":
|
|
b.handleVerify(ctx, msg)
|
|
case "/recon":
|
|
b.handleRecon(ctx, msg)
|
|
case "/status":
|
|
b.handleStatus(ctx, msg)
|
|
case "/stats":
|
|
b.handleStats(ctx, msg)
|
|
case "/providers":
|
|
b.handleProviders(ctx, msg)
|
|
case "/help", "/start":
|
|
b.handleHelp(ctx, msg)
|
|
case "/key":
|
|
b.handleKey(ctx, msg)
|
|
case "/subscribe":
|
|
b.handleSubscribe(ctx, msg)
|
|
case "/unsubscribe":
|
|
b.handleUnsubscribe(ctx, msg)
|
|
}
|
|
}
|
|
|
|
// reply sends a MarkdownV2-formatted message to the given chat.
|
|
func (b *Bot) reply(ctx context.Context, chatID int64, text string) error {
|
|
params := telegoutil.Message(telego.ChatID{ID: chatID}, text).
|
|
WithParseMode("MarkdownV2")
|
|
_, err := b.bot.SendMessage(ctx, params)
|
|
return err
|
|
}
|
|
|
|
// replyPlain sends a plain text message to the given chat.
|
|
func (b *Bot) replyPlain(ctx context.Context, chatID int64, text string) error {
|
|
params := telegoutil.Message(telego.ChatID{ID: chatID}, text)
|
|
_, err := b.bot.SendMessage(ctx, params)
|
|
return err
|
|
}
|
|
|
|
// --- Handler stubs (implemented in Plan 17-03/17-04) ---
|
|
|
|
func (b *Bot) handleScan(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /scan")
|
|
}
|
|
|
|
func (b *Bot) handleVerify(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /verify")
|
|
}
|
|
|
|
func (b *Bot) handleRecon(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /recon")
|
|
}
|
|
|
|
func (b *Bot) handleStatus(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /status")
|
|
}
|
|
|
|
func (b *Bot) handleStats(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /stats")
|
|
}
|
|
|
|
func (b *Bot) handleProviders(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /providers")
|
|
}
|
|
|
|
func (b *Bot) handleHelp(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /help")
|
|
}
|
|
|
|
func (b *Bot) handleKey(ctx context.Context, msg *telego.Message) {
|
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /key")
|
|
}
|
|
|
|
// handleSubscribe and handleUnsubscribe are implemented in subscribe.go.
|