273 lines
6.9 KiB
Go
273 lines
6.9 KiB
Go
package main
|
|
|
|
// bot_core.go — Bot lifecycle, Telegram update dispatch, and shared helpers.
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Global bot instance (nil when disabled). Guarded by botMgrMu.
|
|
var (
|
|
botMgr *Bot
|
|
botMgrMu sync.Mutex
|
|
)
|
|
|
|
func currentBot() *Bot {
|
|
botMgrMu.Lock()
|
|
defer botMgrMu.Unlock()
|
|
return botMgr
|
|
}
|
|
|
|
type Bot struct {
|
|
store *Store
|
|
cfg *BotConfig
|
|
tg *tgClient
|
|
mp *mpClient
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func newBot(store *Store, cfg *BotConfig) *Bot {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
b := &Bot{
|
|
store: store,
|
|
cfg: cfg,
|
|
tg: newTGClient(cfg.TelegramToken),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
if cfg.MPAccessToken != "" {
|
|
b.mp = newMPClient(cfg.MPAccessToken)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ---------- Lifecycle (called from main.go boot and bot_api.go on save) ----------
|
|
|
|
// startBotService loads config from the DB and starts the bot if enabled.
|
|
func startBotService(store *Store) {
|
|
if store == nil {
|
|
return
|
|
}
|
|
cfg, err := LoadBotConfig(context.Background(), store)
|
|
if err != nil {
|
|
log.Printf("[bot] load config: %v", err)
|
|
return
|
|
}
|
|
botMgrMu.Lock()
|
|
defer botMgrMu.Unlock()
|
|
if botMgr != nil {
|
|
botMgr.cancel()
|
|
botMgr = nil
|
|
}
|
|
if !cfg.Enabled {
|
|
log.Printf("[bot] disabled")
|
|
return
|
|
}
|
|
if cfg.TelegramToken == "" {
|
|
log.Printf("[bot] enabled but no telegram token configured; not starting")
|
|
return
|
|
}
|
|
b := newBot(store, cfg)
|
|
botMgr = b
|
|
b.start()
|
|
}
|
|
|
|
// reloadBotService restarts the bot after a config change.
|
|
func reloadBotService(store *Store) { startBotService(store) }
|
|
|
|
func (b *Bot) start() {
|
|
log.Printf("[bot] starting (telegram_mode=%s mp_confirm=%s)", b.cfg.TelegramMode, b.cfg.MPConfirmMode)
|
|
if b.cfg.TelegramMode == "webhook" && b.cfg.TelegramWebhookURL != "" {
|
|
if err := b.tg.setWebhook(b.ctx, b.cfg.TelegramWebhookURL, b.cfg.TelegramWebhookSecret); err != nil {
|
|
log.Printf("[bot] setWebhook failed, falling back to polling: %v", err)
|
|
go b.runPolling()
|
|
} else {
|
|
log.Printf("[bot] webhook registered at %s", b.cfg.TelegramWebhookURL)
|
|
}
|
|
} else {
|
|
_ = b.tg.deleteWebhook(b.ctx)
|
|
go b.runPolling()
|
|
}
|
|
if b.mp != nil && b.cfg.MPConfirmMode == "polling" {
|
|
go b.runPaymentPoller()
|
|
}
|
|
}
|
|
|
|
func (b *Bot) stop() { b.cancel() }
|
|
|
|
// ---------- Update polling ----------
|
|
|
|
func (b *Bot) runPolling() {
|
|
var offset int64
|
|
log.Printf("[bot] long-polling started")
|
|
for {
|
|
select {
|
|
case <-b.ctx.Done():
|
|
log.Printf("[bot] polling stopped")
|
|
return
|
|
default:
|
|
}
|
|
ups, err := b.tg.getUpdates(b.ctx, offset, 50)
|
|
if err != nil {
|
|
if b.ctx.Err() != nil {
|
|
return
|
|
}
|
|
log.Printf("[bot] getUpdates: %v", err)
|
|
time.Sleep(3 * time.Second)
|
|
continue
|
|
}
|
|
for i := range ups {
|
|
u := ups[i]
|
|
if u.UpdateID >= offset {
|
|
offset = u.UpdateID + 1
|
|
}
|
|
b.handleUpdate(&u)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Dispatch ----------
|
|
|
|
func (b *Bot) handleUpdate(u *tgUpdate) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[bot] panic handling update: %v", r)
|
|
}
|
|
}()
|
|
switch {
|
|
case u.CallbackQuery != nil:
|
|
b.handleCallback(u.CallbackQuery)
|
|
case u.Message != nil && u.Message.From != nil:
|
|
b.handleMessage(u.Message)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) handleMessage(m *tgMessage) {
|
|
b.touchUser(m.From)
|
|
text := strings.TrimSpace(m.Text)
|
|
switch {
|
|
case text == "/start" || text == "/menu" || text == "start":
|
|
b.showMainMenu(m.Chat.ID, m.From, 0)
|
|
case strings.HasPrefix(text, "/stats") && b.cfg.isAdmin(m.From.ID):
|
|
b.cmdAdminStats(m.Chat.ID)
|
|
case strings.HasPrefix(text, "/addcredit") && b.cfg.isAdmin(m.From.ID):
|
|
b.cmdAdminAddCredit(m.Chat.ID, text)
|
|
default:
|
|
b.showMainMenu(m.Chat.ID, m.From, 0)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) handleCallback(cb *tgCallbackQuery) {
|
|
b.touchUser(&cb.From)
|
|
_ = b.tg.answerCallback(b.ctx, cb.ID, "")
|
|
if cb.Message == nil {
|
|
return
|
|
}
|
|
chatID := cb.Message.Chat.ID
|
|
msgID := cb.Message.MessageID
|
|
data := cb.Data
|
|
|
|
switch {
|
|
case data == "menu:main":
|
|
b.showMainMenu(chatID, &cb.From, msgID)
|
|
case data == "buy":
|
|
b.showPlanList(chatID, msgID, "ssh_or_xray", "buy")
|
|
case strings.HasPrefix(data, "buy:"):
|
|
b.startPlanPurchase(chatID, &cb.From, data[len("buy:"):], false)
|
|
case data == "renew":
|
|
b.showRenewList(chatID, &cb.From, msgID)
|
|
case strings.HasPrefix(data, "renew:"):
|
|
b.startRenew(chatID, &cb.From, msgID, data[len("renew:"):])
|
|
case strings.HasPrefix(data, "rnw:"):
|
|
b.startRenewPayment(chatID, &cb.From, data[len("rnw:"):])
|
|
case data == "trial":
|
|
b.handleTrial(chatID, &cb.From)
|
|
case data == "purchases":
|
|
b.showPurchases(chatID, &cb.From, msgID)
|
|
case data == "app":
|
|
b.showText(chatID, msgID, "app_text", "📥 App: (configure em bot_settings)")
|
|
case data == "contact":
|
|
b.showText(chatID, msgID, "contact_text", "👤 Contato: (configure em bot_settings)")
|
|
case data == "res:menu":
|
|
b.showResellerMenu(chatID, &cb.From, msgID)
|
|
case data == "res:topup":
|
|
b.showCreditPackages(chatID, msgID)
|
|
case strings.HasPrefix(data, "res:topup:"):
|
|
b.startTopup(chatID, &cb.From, data[len("res:topup:"):])
|
|
case data == "res:create":
|
|
b.showPlanList(chatID, msgID, "ssh_or_xray", "res:create")
|
|
case strings.HasPrefix(data, "res:create:"):
|
|
b.resellerCreateAccount(chatID, &cb.From, data[len("res:create:"):])
|
|
case data == "res:clients":
|
|
b.showResellerClients(chatID, &cb.From, msgID)
|
|
case strings.HasPrefix(data, "pay:check:"):
|
|
b.checkPaymentButton(chatID, &cb.From, data[len("pay:check:"):])
|
|
default:
|
|
// unknown — refresh menu
|
|
b.showMainMenu(chatID, &cb.From, msgID)
|
|
}
|
|
}
|
|
|
|
// ---------- User helpers ----------
|
|
|
|
func (b *Bot) touchUser(u *tgUser) {
|
|
if u == nil {
|
|
return
|
|
}
|
|
_ = b.store.UpsertBotUser(b.ctx, &BotUser{
|
|
TelegramID: u.ID,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
})
|
|
}
|
|
|
|
func (b *Bot) botUser(telegramID int64) *BotUser {
|
|
u, err := b.store.GetBotUser(b.ctx, telegramID)
|
|
if err != nil {
|
|
return &BotUser{TelegramID: telegramID, Role: "customer"}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// ---------- Message helpers ----------
|
|
|
|
func (b *Bot) send(chatID int64, text string, kb *tgInlineKeyboard) {
|
|
if _, err := b.tg.sendMessage(b.ctx, chatID, text, kb); err != nil {
|
|
log.Printf("[bot] sendMessage: %v", err)
|
|
}
|
|
}
|
|
|
|
// sendOrEdit edits an existing message if msgID>0, else sends a new one.
|
|
func (b *Bot) sendOrEdit(chatID, msgID int64, text string, kb *tgInlineKeyboard) {
|
|
if msgID > 0 {
|
|
if err := b.tg.editMessageText(b.ctx, chatID, msgID, text, kb); err == nil {
|
|
return
|
|
}
|
|
}
|
|
b.send(chatID, text, kb)
|
|
}
|
|
|
|
func (b *Bot) showText(chatID, msgID int64, key, def string) {
|
|
txt := b.store.GetSetting(b.ctx, key, def)
|
|
b.sendOrEdit(chatID, msgID, txt, backKeyboard())
|
|
}
|
|
|
|
// ---------- Keyboard builders ----------
|
|
|
|
func kb(rows ...[]tgInlineButton) *tgInlineKeyboard {
|
|
return &tgInlineKeyboard{InlineKeyboard: rows}
|
|
}
|
|
|
|
func btn(text, data string) tgInlineButton { return tgInlineButton{Text: text, CallbackData: data} }
|
|
|
|
func urlBtn(text, u string) tgInlineButton { return tgInlineButton{Text: text, URL: u} }
|
|
|
|
func backKeyboard() *tgInlineKeyboard {
|
|
return kb([]tgInlineButton{btn("⬅️ Voltar", "menu:main")})
|
|
}
|