package main // bot_flows.go — customer + reseller conversation flows and admin commands. import ( "encoding/base64" "fmt" "log" "strconv" "strings" "time" ) // ---------- Main menu ---------- func (b *Bot) showMainMenu(chatID int64, from *tgUser, msgID int64) { bu := b.botUser(from.ID) if bu.Role == "blocked" { b.sendOrEdit(chatID, msgID, "🚫 Seu acesso foi bloqueado.", nil) return } welcome := b.store.GetSetting(b.ctx, "welcome_text", "") var text string if welcome != "" { text = strings.ReplaceAll(welcome, "{name}", htmlEscape(from.FirstName)) } else { text = fmt.Sprintf("😉 Olá %s, seja bem-vindo!\n\n🚀 Aqui você encontra os melhores planos SSH e Xray Premium.\nSelecione uma das opções abaixo:", htmlEscape(from.FirstName)) } var rows [][]tgInlineButton if b.cfg.TrialEnabled { rows = append(rows, []tgInlineButton{btn("⏳ Teste Grátis", "trial"), btn("🛍️ Minhas Compras", "purchases")}) } else { rows = append(rows, []tgInlineButton{btn("🛍️ Minhas Compras", "purchases")}) } rows = append(rows, []tgInlineButton{btn("💎 Comprar Premium", "buy")}) rows = append(rows, []tgInlineButton{btn("🔄 Renovar", "renew")}) appURL := b.store.GetSetting(b.ctx, "app_url", "") appRow := []tgInlineButton{} if appURL != "" { appRow = append(appRow, urlBtn("📥 Baixar APP", appURL)) } else { appRow = append(appRow, btn("📥 Baixar APP", "app")) } appRow = append(appRow, btn("👤 Contato", "contact")) rows = append(rows, appRow) if bu.Role == "reseller" { rows = append(rows, []tgInlineButton{btn("👑 Área do Revendedor", "res:menu")}) } b.sendOrEdit(chatID, msgID, text, kb(rows...)) } // ---------- Plan list (buy / reseller create) ---------- func (b *Bot) showPlanList(chatID, msgID int64, _ string, action string) { plans, err := b.store.ListPlans(b.ctx, true) if err != nil || len(plans) == 0 { b.sendOrEdit(chatID, msgID, "Nenhum plano disponível no momento.", backKeyboard()) return } isReseller := action == "res:create" var rows [][]tgInlineButton for _, p := range plans { icon := "🔒" if p.Kind == "xray" { icon = "⚡" } var label string if isReseller { label = fmt.Sprintf("%s %s — %d créd.", icon, p.Name, p.CreditCost) } else { label = fmt.Sprintf("%s %s — %s", icon, p.Name, centsToBRL(p.PriceCents)) } rows = append(rows, []tgInlineButton{btn(label, action+":"+botItoa(p.ID))}) } rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", backTarget(action))}) title := "💎 Escolha um plano:" if isReseller { title = "➕ Escolha um plano para criar (custo em créditos):" } b.sendOrEdit(chatID, msgID, title, kb(rows...)) } func backTarget(action string) string { if strings.HasPrefix(action, "res:") { return "res:menu" } return "menu:main" } // ---------- Buy ---------- func (b *Bot) startPlanPurchase(chatID int64, from *tgUser, planIDStr string, _ bool) { id, _ := strconv.Atoi(planIDStr) p, err := b.store.GetPlan(b.ctx, id) if err != nil { b.send(chatID, "Plano não encontrado.", backKeyboard()) return } pid := p.ID b.createAndSendPix(chatID, from, "plan_purchase", "Compra: "+p.Name, p.PriceCents, &pid, nil, 0, "") } // ---------- Renew ---------- func (b *Bot) showRenewList(chatID int64, from *tgUser, msgID int64) { txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 100) seen := map[string]bool{} var rows [][]tgInlineButton for _, t := range txns { if t.Status != "approved" || t.TargetUsername == "" || t.PlanID == nil { continue } p, err := b.store.GetPlan(b.ctx, *t.PlanID) if err != nil { continue } key := p.Kind + ":" + t.TargetUsername if seen[key] { continue } seen[key] = true short := "s" if p.Kind == "xray" { short = "x" } disp := t.TargetUsername if len(disp) > 16 { disp = disp[:8] + "…" } rows = append(rows, []tgInlineButton{btn("🔄 "+disp+" ("+p.Kind+")", "renew:"+short+":"+t.TargetUsername)}) } if len(rows) == 0 { b.sendOrEdit(chatID, msgID, "Você não tem contas para renovar.", backKeyboard()) return } rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "menu:main")}) b.sendOrEdit(chatID, msgID, "🔄 Selecione a conta para renovar:", kb(rows...)) } func (b *Bot) startRenew(chatID int64, from *tgUser, msgID int64, target string) { kind := "ssh" if strings.HasPrefix(target, "x:") { kind = "xray" } plans, _ := b.store.ListPlans(b.ctx, true) var rows [][]tgInlineButton for _, p := range plans { if p.Kind != kind { continue } label := fmt.Sprintf("%s — %s", p.Name, centsToBRL(p.PriceCents)) rows = append(rows, []tgInlineButton{btn(label, "rnw:"+botItoa(p.ID)+":"+target)}) } if len(rows) == 0 { b.sendOrEdit(chatID, msgID, "Nenhum plano de renovação disponível.", backKeyboard()) return } rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "renew")}) b.sendOrEdit(chatID, msgID, "🔄 Escolha a duração da renovação:", kb(rows...)) } func (b *Bot) startRenewPayment(chatID int64, from *tgUser, payload string) { parts := strings.SplitN(payload, ":", 3) if len(parts) < 3 { b.send(chatID, "Renovação inválida.", backKeyboard()) return } id, _ := strconv.Atoi(parts[0]) renewTarget := parts[1] + ":" + parts[2] // "s:username" or "x:uuid" p, err := b.store.GetPlan(b.ctx, id) if err != nil { b.send(chatID, "Plano não encontrado.", backKeyboard()) return } pid := p.ID b.createAndSendPix(chatID, from, "plan_renewal", "Renovação: "+p.Name, p.PriceCents, &pid, nil, 0, renewTarget) } // ---------- Trial ---------- func (b *Bot) handleTrial(chatID int64, from *tgUser) { if !b.cfg.TrialEnabled { b.send(chatID, "Teste grátis indisponível.", backKeyboard()) return } bu := b.botUser(from.ID) if bu.TrialUsed { b.send(chatID, "⚠️ Você já utilizou seu teste grátis.", backKeyboard()) return } exp := time.Now().Add(time.Duration(b.cfg.TrialHours) * time.Hour) if b.cfg.TrialKind == "xray" { uuid, link, err := createXrayClient(b.ctx, b.store, b.cfg.TrialInboundTag, "", exp, b.cfg.TrialMaxConnections, "", b.cfg.XrayPublicHost) if err != nil { log.Printf("[bot] trial xray: %v", err) b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard()) return } _ = b.store.SetBotUserTrialUsed(b.ctx, from.ID) b.send(chatID, b.formatXrayDelivery("Teste Grátis", uuid, link, exp), backKeyboard()) return } user := genUsername("test") pass := genPassword() if err := createSSHUser(b.ctx, b.store, user, pass, exp, b.cfg.TrialMaxConnections, 0, 0, ""); err != nil { log.Printf("[bot] trial ssh: %v", err) b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard()) return } _ = b.store.SetBotUserTrialUsed(b.ctx, from.ID) b.send(chatID, b.formatSSHDelivery("Teste Grátis", user, pass, exp), backKeyboard()) } // ---------- Purchases ---------- func (b *Bot) showPurchases(chatID int64, from *tgUser, msgID int64) { txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 20) var sb strings.Builder sb.WriteString("🛍️ Suas Compras\n\n") count := 0 for _, t := range txns { if t.Status == "pending" { continue } count++ sb.WriteString(fmt.Sprintf("• #%d %s — %s — %s\n", t.ID, txnTypeLabel(t.Type), centsToBRL(t.AmountCents), statusLabel(t.Status))) if t.TargetUsername != "" && t.Status == "approved" { sb.WriteString(" conta: " + htmlEscape(t.TargetUsername) + "\n") } } if count == 0 { sb.WriteString("Nenhuma compra ainda.") } b.sendOrEdit(chatID, msgID, sb.String(), backKeyboard()) } // ---------- Reseller ---------- func (b *Bot) showResellerMenu(chatID int64, from *tgUser, msgID int64) { bu := b.botUser(from.ID) if bu.Role != "reseller" { b.sendOrEdit(chatID, msgID, "Você não é um revendedor.", backKeyboard()) return } text := fmt.Sprintf("👑 Área do Revendedor\n\n💳 Saldo: %d créditos", bu.CreditBalance) rows := [][]tgInlineButton{ {btn("💳 Recarregar Créditos", "res:topup")}, {btn("➕ Criar Conta", "res:create")}, {btn("👥 Meus Clientes", "res:clients")}, {btn("⬅️ Voltar", "menu:main")}, } b.sendOrEdit(chatID, msgID, text, kb(rows...)) } func (b *Bot) showCreditPackages(chatID, msgID int64) { pkgs, _ := b.store.ListCreditPackages(b.ctx, true) if len(pkgs) == 0 { b.sendOrEdit(chatID, msgID, "Nenhum pacote de créditos disponível.", kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")})) return } var rows [][]tgInlineButton for _, p := range pkgs { rows = append(rows, []tgInlineButton{btn(fmt.Sprintf("%s — %d créd. — %s", p.Name, p.Credits, centsToBRL(p.PriceCents)), "res:topup:"+botItoa(p.ID))}) } rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "res:menu")}) b.sendOrEdit(chatID, msgID, "💳 Escolha um pacote de créditos:", kb(rows...)) } func (b *Bot) startTopup(chatID int64, from *tgUser, pkgIDStr string) { id, _ := strconv.Atoi(pkgIDStr) p, err := b.store.GetCreditPackage(b.ctx, id) if err != nil { b.send(chatID, "Pacote não encontrado.", backKeyboard()) return } pid := p.ID b.createAndSendPix(chatID, from, "credit_topup", "Recarga: "+p.Name, p.PriceCents, nil, &pid, p.Credits, "") } func (b *Bot) resellerCreateAccount(chatID int64, from *tgUser, planIDStr string) { bu := b.botUser(from.ID) if bu.Role != "reseller" || bu.LinkedAdminUsername == "" { b.send(chatID, "Conta de revendedor não configurada.", backKeyboard()) return } id, _ := strconv.Atoi(planIDStr) p, err := b.store.GetPlan(b.ctx, id) if err != nil { b.send(chatID, "Plano não encontrado.", backKeyboard()) return } if bu.CreditBalance < p.CreditCost { b.send(chatID, fmt.Sprintf("❌ Saldo insuficiente. Necessário %d créditos, você tem %d.", p.CreditCost, bu.CreditBalance), kb([]tgInlineButton{btn("💳 Recarregar", "res:topup"), btn("⬅️ Voltar", "res:menu")})) return } if owner, ok := adminUsers.get(bu.LinkedAdminUsername); ok && owner.MaxUsers > 0 && countOwnedQuota(b.ctx, b.store, bu.LinkedAdminUsername) >= owner.MaxUsers { b.send(chatID, fmt.Sprintf("❌ Limite de contas atingido (%d).", owner.MaxUsers), backKeyboard()) return } // Debit first; refund if provisioning fails. if _, err := b.store.AdjustCredits(b.ctx, from.ID, -p.CreditCost, "account_create", nil); err != nil { b.send(chatID, "❌ Não foi possível debitar créditos.", backKeyboard()) return } exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour) var deliver string if p.Kind == "xray" { uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, bu.LinkedAdminUsername, b.cfg.XrayPublicHost) if err != nil { _, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil) b.send(chatID, "❌ Falha ao criar conta Xray. Créditos devolvidos.", backKeyboard()) return } deliver = b.formatXrayDelivery(p.Name, uuid, link, exp) } else { user := genUsername("r") pass := genPassword() if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, bu.LinkedAdminUsername); err != nil { _, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil) b.send(chatID, "❌ Falha ao criar conta SSH. Créditos devolvidos.", backKeyboard()) return } deliver = b.formatSSHDelivery(p.Name, user, pass, exp) } b.send(chatID, deliver+fmt.Sprintf("\n\n💳 Saldo restante: %d créditos", bu.CreditBalance-p.CreditCost), kb([]tgInlineButton{btn("➕ Criar outra", "res:create"), btn("⬅️ Voltar", "res:menu")})) } func (b *Bot) showResellerClients(chatID int64, from *tgUser, msgID int64) { bu := b.botUser(from.ID) if bu.Role != "reseller" || bu.LinkedAdminUsername == "" { b.sendOrEdit(chatID, msgID, "Conta de revendedor não configurada.", backKeyboard()) return } var sb strings.Builder sb.WriteString("👥 Seus Clientes\n\n") n := 0 for _, u := range userMgr.List() { if u.Cfg.OwnerUsername == bu.LinkedAdminUsername { n++ exp := "sem validade" if u.ExpiresAt != nil { exp = u.ExpiresAt.Format("02/01/2006") } sb.WriteString(fmt.Sprintf("• SSH %s — %s\n", htmlEscape(u.Cfg.Username), exp)) if n >= 40 { break } } } xs, _ := b.store.ListXrayClientsByOwner(b.ctx, bu.LinkedAdminUsername) for _, x := range xs { n++ exp := "sem validade" if x.ExpiresAt != nil { exp = x.ExpiresAt.Format("02/01/2006") } sb.WriteString(fmt.Sprintf("• Xray %s — %s\n", htmlEscape(x.UUID), exp)) if n >= 80 { break } } if n == 0 { sb.WriteString("Nenhum cliente ainda.") } b.sendOrEdit(chatID, msgID, sb.String(), kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")})) } // ---------- PIX charge creation + delivery formatting ---------- func (b *Bot) createAndSendPix(chatID int64, from *tgUser, ttype, description string, amountCents int, planID, pkgID *int, credits int, renewTarget string) { if amountCents <= 0 { b.send(chatID, "❌ Este item não tem preço configurado. Fale com o suporte.", backKeyboard()) return } if b.mp == nil { b.send(chatID, "❌ Pagamento não configurado no momento. Fale com o suporte.", backKeyboard()) return } exp := time.Now().Add(time.Duration(b.cfg.PixExpirationMinutes) * time.Minute) pix, err := b.mp.CreatePixPayment(b.ctx, amountCents, description, "", strconv.FormatInt(from.ID, 10), exp, uuidV4()) if err != nil { log.Printf("[bot] create pix: %v", err) b.send(chatID, "❌ Falha ao gerar o pagamento PIX. Tente novamente em instantes.", backKeyboard()) return } txn := &BotTransaction{ TelegramID: from.ID, Type: ttype, PlanID: planID, PackageID: pkgID, Credits: credits, AmountCents: amountCents, MPPaymentID: pix.PaymentID, MPQRCode: pix.QRCode, MPQRBase64: pix.QRBase64, Status: "pending", RenewTarget: renewTarget, ExpiresAt: &exp, } if err := b.store.CreateTransaction(b.ctx, txn); err != nil { log.Printf("[bot] create txn: %v", err) b.send(chatID, "❌ Erro interno ao registrar o pagamento.", backKeyboard()) return } b.sendPixMessage(chatID, txn, description) } func (b *Bot) sendPixMessage(chatID int64, txn *BotTransaction, description string) { caption := fmt.Sprintf("💳 Pagamento PIX\n%s\nValor: %s\n⏱ Validade: %d min\n\nEscaneie o QR acima ou use o código copia-e-cola abaixo. A liberação é automática após o pagamento.", htmlEscape(description), centsToBRL(txn.AmountCents), b.cfg.PixExpirationMinutes) kbd := kb( []tgInlineButton{btn("✅ Já paguei / Verificar", "pay:check:"+botItoa(txn.ID))}, []tgInlineButton{btn("⬅️ Voltar", "menu:main")}, ) sent := false if txn.MPQRBase64 != "" { if raw, err := base64.StdEncoding.DecodeString(txn.MPQRBase64); err == nil { if _, err := b.tg.sendPhotoBytes(b.ctx, chatID, raw, "pix.png", caption, kbd); err == nil { sent = true } } } if !sent { b.send(chatID, caption, kbd) } if txn.MPQRCode != "" { b.send(chatID, "📋 PIX Copia e Cola:\n"+htmlEscape(txn.MPQRCode)+"", nil) } } func (b *Bot) formatSSHDelivery(planName, user, pass string, exp time.Time) string { host := b.cfg.PublicHost if host == "" { host = "(configure o host no painel)" } return fmt.Sprintf("✅ %s\n\n🔒 Conta SSH\nHost: %s\nUsuário: %s\nSenha: %s\nValidade: %s", htmlEscape(planName), htmlEscape(host), htmlEscape(user), htmlEscape(pass), exp.Format("02/01/2006 15:04")) } func (b *Bot) formatXrayDelivery(planName, uuid, link string, exp time.Time) string { return fmt.Sprintf("✅ %s\n\n⚡ Conta Xray\nUUID: %s\nValidade: %s\n\n🔗 Link de conexão:\n%s", htmlEscape(planName), htmlEscape(uuid), exp.Format("02/01/2006 15:04"), htmlEscape(link)) } // ---------- Admin commands (in-chat convenience) ---------- func (b *Bot) cmdAdminStats(chatID int64) { users, _ := b.store.ListBotUsers(b.ctx) pend, _ := b.store.ListPendingTransactions(b.ctx) b.send(chatID, fmt.Sprintf("📊 Estatísticas\nUsuários do bot: %d\nPagamentos pendentes: %d\nContas SSH ativas: %d", len(users), len(pend), len(userMgr.List())), nil) } func (b *Bot) cmdAdminAddCredit(chatID int64, text string) { f := strings.Fields(text) if len(f) < 3 { b.send(chatID, "Uso: /addcredit <telegram_id> <quantidade>", nil) return } tid, _ := strconv.ParseInt(f[1], 10, 64) amt, _ := strconv.Atoi(f[2]) bal, err := b.store.AdjustCredits(b.ctx, tid, amt, "admin_adjust", nil) if err != nil { b.send(chatID, "Erro: "+err.Error(), nil) return } b.send(chatID, fmt.Sprintf("✅ Ajuste aplicado. Novo saldo de %d: %d créditos", tid, bal), nil) b.notify(tid, fmt.Sprintf("💳 Seu saldo foi ajustado em %+d créditos. Saldo atual: %d", amt, bal)) } func (b *Bot) notify(telegramID int64, text string) { if _, err := b.tg.sendMessage(b.ctx, telegramID, text, nil); err != nil { log.Printf("[bot] notify %d: %v", telegramID, err) } } // ---------- labels ---------- func txnTypeLabel(t string) string { switch t { case "plan_purchase": return "Compra" case "plan_renewal": return "Renovação" case "credit_topup": return "Recarga" } return t } func statusLabel(s string) string { switch s { case "approved": return "✅ pago" case "pending": return "⏳ pendente" case "expired": return "⌛ expirado" case "refunded": return "↩️ estornado" case "error": return "❌ erro" } return s }