diff --git a/.gitignore b/.gitignore index 594cf03..e0aef9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /shell2.exe +/BOT_PLAN.md diff --git a/README.md b/README.md index 48a54bf..30b3850 100644 --- a/README.md +++ b/README.md @@ -1250,3 +1250,36 @@ Reads/writes the panel's `config.json` and hot-applies changes. #### `GET /check` — none Public status lookup for SSH users and Xray/V2Ray UUIDs (CORS `*`). See the **Public CheckUser API** section above for query params, response fields, and examples. + +--- + +## Bot de Vendas (Telegram + Mercado Pago) + +**PT-BR:** O painel inclui um bot de Telegram integrado ao próprio binário para vender contas **SSH e Xray** por **PIX** (Mercado Pago), com **teste grátis**, **renovação**, e um **sistema de revendedores por créditos**. Toda a gestão fica na aba **Bot / Vendas** do painel (somente superadmin). + +### Como configurar +1. Abra o painel → aba **Bot / Vendas**. +2. Em **Configuração**: marque *Bot ativo*, cole o **Token do Telegram** (via @BotFather) e o **Access Token do Mercado Pago**. Clique **Testar conexão** e depois **Salvar**. +3. **Webhook × Polling** (o painel deixa você escolher): + - **Telegram**: `polling` (padrão, não precisa de domínio/HTTPS) ou `webhook` (informe a URL pública `https://SEU_DOMINIO/api/telegram/webhook`). + - **Mercado Pago**: `polling` (o bot consulta o status a cada intervalo) ou `webhook` (configure no painel do Mercado Pago a URL `https://SEU_DOMINIO/api/mp/webhook`). +4. Em **Planos**: crie planos SSH e/ou Xray (dias, conexões, preço em R$, e — para Xray — o *inbound* e protocolo). Para revendedores, defina o *custo em créditos*. +5. Em **Pacotes de Crédito**: defina os valores de recarga dos revendedores. +6. Em **Clientes do Bot**: promova um usuário a **revendedor** (vinculando-o a uma conta de revendedor em *Revendedores*), ajuste créditos ou bloqueie. +7. Em **Mensagens**: edite os textos de boas-vindas, contato e link do app. + +### Segurança dos segredos +Os tokens (Telegram, Mercado Pago e segredos de webhook) são gravados **criptografados (AES-256-GCM)** na tabela `bot_config` do PostgreSQL. A **chave-mestra** fica **fora do banco**: variável de ambiente `BOT_MASTER_KEY` (64 caracteres hex) ou, se ausente, um arquivo `0600` em `/opt/sshpanel/bot_master.key` gerado automaticamente no primeiro uso. **Faça backup desse arquivo** junto com o banco — sem ele os segredos não podem ser decifrados. A API do painel nunca retorna os tokens em texto puro. + +### Endpoints (superadmin, exceto webhooks) +- `GET/POST /api/bot/config` — lê/grava a configuração (segredos só entram no POST; campo vazio mantém o atual; o GET retorna apenas `has_*`). +- `GET/POST/DELETE /api/bot/plans` — CRUD de planos (`?id=` no DELETE). +- `GET/POST/DELETE /api/bot/credit-packages` — CRUD de pacotes de crédito. +- `GET/POST /api/bot/users` — lista clientes; POST com `action` = `set_role` \| `block` \| `unblock` \| `adjust_credits`. +- `GET/POST /api/bot/transactions` — lista pagamentos; POST com `action` = `refund` \| `reprocess`. +- `GET/POST /api/bot/settings` — textos do bot (chave/valor). +- `POST /api/bot/test` — testa token do Telegram e do Mercado Pago. +- `POST /api/mp/webhook` — **público**, chamado pelo Mercado Pago (valida `x-signature` se houver segredo; sempre reconfirma o pagamento na API antes de liberar). +- `POST /api/telegram/webhook` — **público**, chamado pelo Telegram (valida o header `X-Telegram-Bot-Api-Secret-Token`). + +**EN-US:** The panel ships an in-process Telegram bot that sells **SSH and Xray** accounts via **PIX (Mercado Pago)**, with free trial, renewal, and a **credit-based reseller system** — all managed from the superadmin **Bot / Vendas** tab. Bot secrets are stored **AES-256-GCM encrypted** in PostgreSQL; the master key lives outside the DB (`BOT_MASTER_KEY` env or a `0600` `/opt/sshpanel/bot_master.key` auto-generated on first use — back it up). Both Telegram delivery and Mercado Pago confirmation can be toggled between **polling** (default, no public HTTPS needed) and **webhook** in the panel. diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js index 0bec7eb..f974a98 100644 --- a/admin/assets/js/02-shell.js +++ b/admin/assets/js/02-shell.js @@ -9,6 +9,7 @@ const tabTitles = { stats: ["Server", "Monitoring"], vnstat: ["Traffic", "VnStat"], logs: ["System", "Logs"], + bot: ["Vendas", "Bot / Telegram"], server: ["System", "Settings"], }; function updatePageHeading() { @@ -39,6 +40,7 @@ function selectTab(tab) { if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); if (tab === "resellers" && currentRole === "superadmin") loadResellers(); if (tab === "servers" && currentRole === "superadmin") loadServers(); + if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab(); } document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab))); diff --git a/admin/assets/js/12-bot.js b/admin/assets/js/12-bot.js new file mode 100644 index 0000000..926d887 --- /dev/null +++ b/admin/assets/js/12-bot.js @@ -0,0 +1,323 @@ +// ─── Bot / Vendas (Telegram + Mercado Pago) ───────────────────────────────── +// Superadmin-only tab. Uses the shared api() helper from 01-core.js. + +function botEsc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} +function botStatus(id, msg, ok) { + const el = document.getElementById(id); + if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; } +} +function brl(cents) { + return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ","); +} + +async function loadBotTab() { + loadBotConfig(); + loadBotInbounds(); + loadBotPlans(); + loadBotPkgs(); + loadBotUsers(); + loadBotTxns(); + loadBotSettings(); +} + +// ─── Config ─── +async function loadBotConfig() { + try { + const res = await api("/api/bot/config"); + const c = await res.json(); + const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; + const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; }; + chk("botEnabled", c.enabled); + set("botTelegramMode", c.telegram_mode); + set("botTelegramWebhookURL", c.telegram_webhook_url); + set("botMPConfirmMode", c.mp_confirm_mode); + set("botMPPollInterval", c.mp_poll_interval); + set("botPixExp", c.pix_expiration_minutes); + chk("botTrialEnabled", c.trial_enabled); + set("botTrialHours", c.trial_hours); + set("botTrialMaxConns", c.trial_max_connections); + set("botTrialKind", c.trial_kind); + set("botTrialInbound", c.trial_inbound_tag); + set("botAdminIDs", (c.admin_telegram_ids || []).join(",")); + set("botPublicHost", c.public_host); + set("botXrayPublicHost", c.xray_public_host); + document.getElementById("botHasTgToken").textContent = c.has_telegram_token ? "✓ configurado" : "não definido"; + document.getElementById("botHasTgSecret").textContent = c.has_telegram_webhook_secret ? "✓ configurado" : "não definido"; + document.getElementById("botHasMpToken").textContent = c.has_mp_access_token ? "✓ configurado" : "não definido"; + document.getElementById("botHasMpSecret").textContent = c.has_mp_webhook_secret ? "✓ configurado" : "não definido"; + botStatus("botConfigStatus", "Carregado."); + } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); } +} + +async function saveBotConfig() { + const val = id => (document.getElementById(id)?.value || "").trim(); + const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0; + const chk = id => !!document.getElementById(id)?.checked; + const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); + const body = { + enabled: chk("botEnabled"), + telegram_token: val("botTelegramToken"), + telegram_mode: val("botTelegramMode"), + telegram_webhook_url: val("botTelegramWebhookURL"), + telegram_webhook_secret: val("botTelegramWebhookSecret"), + mp_access_token: val("botMPToken"), + mp_confirm_mode: val("botMPConfirmMode"), + mp_webhook_secret: val("botMPWebhookSecret"), + mp_poll_interval: val("botMPPollInterval"), + pix_expiration_minutes: num("botPixExp"), + trial_enabled: chk("botTrialEnabled"), + trial_hours: num("botTrialHours"), + trial_max_connections: num("botTrialMaxConns"), + trial_kind: val("botTrialKind"), + trial_inbound_tag: val("botTrialInbound"), + admin_telegram_ids: ids, + public_host: val("botPublicHost"), + xray_public_host: val("botXrayPublicHost"), + }; + try { + await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) }); + ["botTelegramToken", "botTelegramWebhookSecret", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; }); + botStatus("botConfigStatus", "Configuração salva e bot reiniciado."); + loadBotConfig(); + } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); } +} + +async function testBot() { + botStatus("botConfigStatus", "Testando..."); + const body = { + telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(), + mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(), + }; + try { + const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) }); + const r = await res.json(); + const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`; + const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`; + botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok); + } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); } +} + +// ─── Inbounds datalist ─── +async function loadBotInbounds() { + try { + const res = await api("/api/xray/inbounds"); + const list = await res.json(); + const dl = document.getElementById("botInboundList"); + if (dl) dl.innerHTML = (list || []).map(ib => ``).join(""); + } catch (e) { /* xray may be off; ignore */ } +} + +// ─── Plans ─── +async function loadBotPlans() { + try { + const res = await api("/api/bot/plans"); + const plans = await res.json() || []; + document.getElementById("botPlanCount").textContent = plans.length; + document.getElementById("botPlansBody").innerHTML = plans.map(p => ` + + ${botEsc(p.Name)}${botEsc(p.Kind)}${p.Days} + ${brl(p.PriceCents)}${p.CreditCost} + ${p.IsActive ? "✅" : "—"} + + + + + `).join(""); + botStatus("botPlansStatus", "Ready."); + } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao carregar.", false); } +} +function botEditPlan(p) { + const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; + set("planId", p.ID); set("planName", p.Name); set("planKind", p.Kind); set("planDays", p.Days); + set("planMaxConns", p.MaxConnections); set("planUpMbps", p.LimitMbpsUp); set("planDownMbps", p.LimitMbpsDown); + set("planInbound", p.XrayInboundTag); set("planProtocol", p.XrayProtocol); + set("planPrice", (p.PriceCents / 100).toFixed(2)); set("planCreditCost", p.CreditCost); + set("planServerId", p.ServerID); set("planSort", p.SortOrder); + document.getElementById("planActive").checked = !!p.IsActive; + document.getElementById("botPlanFormTitle").textContent = "Editar Plano #" + p.ID; +} +function botClearPlanForm() { + document.getElementById("botPlanForm").reset(); + document.getElementById("planId").value = ""; + document.getElementById("botPlanFormTitle").textContent = "Novo Plano"; +} +async function botSavePlan(ev) { + ev.preventDefault(); + const val = id => document.getElementById(id).value; + const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0; + const body = { + id: num("planId"), name: val("planName"), kind: val("planKind"), days: num("planDays"), + max_connections: num("planMaxConns"), limit_mbps_up: num("planUpMbps"), limit_mbps_down: num("planDownMbps"), + xray_inbound_tag: val("planInbound"), xray_protocol: val("planProtocol"), + price_cents: Math.round(parseFloat(val("planPrice") || "0") * 100), + credit_cost: num("planCreditCost"), server_id: val("planServerId"), sort_order: num("planSort"), + is_active: document.getElementById("planActive").checked, + }; + // map to Go struct JSON tags (exported field names) + const payload = { + ID: body.id, Name: body.name, Kind: body.kind, Days: body.days, MaxConnections: body.max_connections, + LimitMbpsUp: body.limit_mbps_up, LimitMbpsDown: body.limit_mbps_down, XrayInboundTag: body.xray_inbound_tag, + XrayProtocol: body.xray_protocol, PriceCents: body.price_cents, CreditCost: body.credit_cost, + ServerID: body.server_id, IsActive: body.is_active, SortOrder: body.sort_order, + }; + try { + await api("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) }); + botClearPlanForm(); loadBotPlans(); botStatus("botPlansStatus", "Plano salvo."); + } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao salvar.", false); } +} +async function botDeletePlan(id) { + if (!confirm("Excluir este plano?")) return; + try { await api("/api/bot/plans?id=" + id, { method: "DELETE" }); loadBotPlans(); } + catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao excluir.", false); } +} + +// ─── Credit packages ─── +async function loadBotPkgs() { + try { + const res = await api("/api/bot/credit-packages"); + const pkgs = await res.json() || []; + document.getElementById("botPkgCount").textContent = pkgs.length; + document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => ` + + ${botEsc(p.Name)}${p.Credits}${brl(p.PriceCents)} + ${p.IsActive ? "✅" : "—"} + + + + + `).join(""); + botStatus("botPkgStatus", "Ready."); + } catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao carregar.", false); } +} +function botEditPkg(p) { + const set = (id, v) => { document.getElementById(id).value = v ?? ""; }; + set("pkgId", p.ID); set("pkgName", p.Name); set("pkgCredits", p.Credits); + set("pkgPrice", (p.PriceCents / 100).toFixed(2)); set("pkgSort", p.SortOrder); + document.getElementById("pkgActive").checked = !!p.IsActive; +} +function botClearPkgForm() { document.getElementById("botPkgForm").reset(); document.getElementById("pkgId").value = ""; } +async function botSavePkg(ev) { + ev.preventDefault(); + const val = id => document.getElementById(id).value; + const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0; + const payload = { + ID: num("pkgId"), Name: val("pkgName"), Credits: num("pkgCredits"), + PriceCents: Math.round(parseFloat(val("pkgPrice") || "0") * 100), + SortOrder: num("pkgSort"), IsActive: document.getElementById("pkgActive").checked, + }; + try { await api("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) }); botClearPkgForm(); loadBotPkgs(); botStatus("botPkgStatus", "Pacote salvo."); } + catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao salvar.", false); } +} +async function botDeletePkg(id) { + if (!confirm("Excluir este pacote?")) return; + try { await api("/api/bot/credit-packages?id=" + id, { method: "DELETE" }); loadBotPkgs(); } + catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao excluir.", false); } +} + +// ─── Bot users ─── +async function loadBotUsers() { + try { + const res = await api("/api/bot/users"); + const users = await res.json() || []; + document.getElementById("botUserCount").textContent = users.length; + document.getElementById("botUsersBody").innerHTML = users.map(u => ` + + ${u.TelegramID}${botEsc(u.FirstName)}${botEsc(u.Username)} + ${botEsc(u.Role)}${botEsc(u.LinkedAdminUsername)}${u.CreditBalance} + + + + + + `).join(""); + botStatus("botUsersStatus", "Ready."); + } catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro ao carregar.", false); } +} +async function botPromote(tid) { + const role = prompt("Papel (customer / reseller / blocked):", "reseller"); + if (!role) return; + let linked = ""; + if (role === "reseller") { linked = prompt("Username do revendedor (admin_users) vinculado:", "") || ""; } + try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "set_role", role, linked_admin_username: linked }) }); loadBotUsers(); } + catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); } +} +async function botCredit(tid) { + const v = prompt("Ajuste de créditos (use negativo para remover):", "10"); + if (v === null) return; + const n = parseInt(v, 10); if (isNaN(n)) return; + try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "adjust_credits", credits: n }) }); loadBotUsers(); } + catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro (saldo insuficiente?).", false); } +} +async function botBlock(tid, isBlocked) { + try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: isBlocked ? "unblock" : "block" }) }); loadBotUsers(); } + catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); } +} + +// ─── Transactions ─── +async function loadBotTxns() { + const filter = document.getElementById("botTxnFilter")?.value || ""; + try { + const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter)); + const txns = await res.json() || []; + document.getElementById("botTxnCount").textContent = txns.length; + document.getElementById("botTxnsBody").innerHTML = txns.map(t => ` + + ${t.ID}${t.TelegramID}${botEsc(t.Type)}${brl(t.AmountCents)} + ${botEsc(t.Status)}${botEsc(t.TargetUsername)} + ${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))} + + ${t.Status === "pending" || t.Status === "approved" ? `` : ""} + ${t.Status !== "refunded" ? `` : ""} + + `).join(""); + botStatus("botTxnStatus", "Ready."); + } catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro ao carregar.", false); } +} +async function botReprocess(id) { + try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) }); botStatus("botTxnStatus", "Reprocessando #" + id + "..."); setTimeout(loadBotTxns, 1500); } + catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro (bot ativo?).", false); } +} +async function botRefund(id) { + if (!confirm("Marcar pagamento #" + id + " como estornado?")) return; + try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); loadBotTxns(); } + catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro.", false); } +} + +// ─── Settings (messages) ─── +async function loadBotSettings() { + try { + const res = await api("/api/bot/settings"); + const s = await res.json() || {}; + const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; }; + set("setWelcome", s.welcome_text); set("setContact", s.contact_text); + set("setAppText", s.app_text); set("setAppUrl", s.app_url); + botStatus("botSettingsStatus", "Ready."); + } catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); } +} +async function saveBotSettings() { + const val = id => document.getElementById(id)?.value || ""; + const payload = { welcome_text: val("setWelcome"), contact_text: val("setContact"), app_text: val("setAppText"), app_url: val("setAppUrl") }; + try { await api("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); botStatus("botSettingsStatus", "Mensagens salvas."); } + catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao salvar.", false); } +} + +// ─── Wiring ─── +document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig); +document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig); +document.getElementById("botTestBtn")?.addEventListener("click", testBot); +document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans); +document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm); +document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm); +document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan); +document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs); +document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm); +document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg); +document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers); +document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns); +document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns); +document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings); +document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings); diff --git a/admin/index.html b/admin/index.html index e41de4c..88909ba 100644 --- a/admin/index.html +++ b/admin/index.html @@ -57,6 +57,7 @@ + @@ -948,6 +949,177 @@ + +
+ + +
+
+
🤖 Configuração do Bot
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Ready.
+
+ +
+ +
+
+
💎 Planos 0
+
+ + +
+
+
+ + + +
NomeTipoDiasPreçoCréd.AtivoAções
+
+
Ready.
+
+ + +
+
+
Novo Plano
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
💳 Pacotes de Crédito 0
+
+ + +
+
+
+ + + +
NomeCréditosPreçoAtivoAções
+
+
+ +
+
+
+
+
+
+
+
+
+
Ready.
+
+ + +
+
+
✉️ Mensagens do Bot
+ +
+
+
+
+
+
+
Ready.
+
+
+ + +
+
+
👥 Clientes do Bot 0
+ +
+
+ + + +
Telegram IDNome@userPapelRevendaCréditosAções
+
+
Ready.
+
+ + +
+
+
💰 Pagamentos 0
+
+ + +
+
+
+ + + +
#TelegramTipoValorStatusContaDataAções
+
+
Ready.
+
+ +
+
@@ -1271,6 +1443,7 @@ + diff --git a/bot_api.go b/bot_api.go new file mode 100644 index 0000000..a34a2cb --- /dev/null +++ b/bot_api.go @@ -0,0 +1,445 @@ +package main + +// bot_api.go — /api/bot/* admin endpoints (superadmin) + Telegram webhook route. + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) + +func botWriteJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func botStoreReady(w http.ResponseWriter, store *Store) bool { + if store == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return false + } + return true +} + +// ---------- Config ---------- + +type botConfigDTO struct { + Enabled bool `json:"enabled"` + TelegramMode string `json:"telegram_mode"` + TelegramWebhookURL string `json:"telegram_webhook_url"` + MPConfirmMode string `json:"mp_confirm_mode"` + MPPollInterval string `json:"mp_poll_interval"` + PixExpirationMinutes int `json:"pix_expiration_minutes"` + TrialEnabled bool `json:"trial_enabled"` + TrialHours int `json:"trial_hours"` + TrialMaxConnections int `json:"trial_max_connections"` + TrialKind string `json:"trial_kind"` + TrialInboundTag string `json:"trial_inbound_tag"` + AdminTelegramIDs []int64 `json:"admin_telegram_ids"` + Currency string `json:"currency"` + PublicHost string `json:"public_host"` + XrayPublicHost string `json:"xray_public_host"` + HasTelegramToken bool `json:"has_telegram_token"` + HasMPAccessToken bool `json:"has_mp_access_token"` + HasTelegramWebhookSecret bool `json:"has_telegram_webhook_secret"` + HasMPWebhookSecret bool `json:"has_mp_webhook_secret"` + // Write-only secret fields (empty on GET; empty on POST = keep existing). + TelegramToken string `json:"telegram_token"` + MPAccessToken string `json:"mp_access_token"` + TelegramWebhookSecret string `json:"telegram_webhook_secret"` + MPWebhookSecret string `json:"mp_webhook_secret"` +} + +func handleBotConfig(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + cfg, err := LoadBotConfig(ctx, store) + if err != nil { + http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError) + return + } + botWriteJSON(w, botConfigDTO{ + Enabled: cfg.Enabled, + TelegramMode: cfg.TelegramMode, + TelegramWebhookURL: cfg.TelegramWebhookURL, + MPConfirmMode: cfg.MPConfirmMode, + MPPollInterval: cfg.MPPollInterval, + PixExpirationMinutes: cfg.PixExpirationMinutes, + TrialEnabled: cfg.TrialEnabled, + TrialHours: cfg.TrialHours, + TrialMaxConnections: cfg.TrialMaxConnections, + TrialKind: cfg.TrialKind, + TrialInboundTag: cfg.TrialInboundTag, + AdminTelegramIDs: cfg.AdminTelegramIDs, + Currency: cfg.Currency, + PublicHost: cfg.PublicHost, + XrayPublicHost: cfg.XrayPublicHost, + HasTelegramToken: cfg.TelegramToken != "", + HasMPAccessToken: cfg.MPAccessToken != "", + HasTelegramWebhookSecret: cfg.TelegramWebhookSecret != "", + HasMPWebhookSecret: cfg.MPWebhookSecret != "", + }) + case http.MethodPost: + var dto botConfigDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + cfg := &BotConfig{ + Enabled: dto.Enabled, + TelegramToken: strings.TrimSpace(dto.TelegramToken), + TelegramMode: dto.TelegramMode, + TelegramWebhookURL: strings.TrimSpace(dto.TelegramWebhookURL), + TelegramWebhookSecret: strings.TrimSpace(dto.TelegramWebhookSecret), + MPAccessToken: strings.TrimSpace(dto.MPAccessToken), + MPConfirmMode: dto.MPConfirmMode, + MPWebhookSecret: strings.TrimSpace(dto.MPWebhookSecret), + MPPollInterval: dto.MPPollInterval, + PixExpirationMinutes: dto.PixExpirationMinutes, + TrialEnabled: dto.TrialEnabled, + TrialHours: dto.TrialHours, + TrialMaxConnections: dto.TrialMaxConnections, + TrialKind: dto.TrialKind, + TrialInboundTag: strings.TrimSpace(dto.TrialInboundTag), + AdminTelegramIDs: dto.AdminTelegramIDs, + Currency: dto.Currency, + PublicHost: strings.TrimSpace(dto.PublicHost), + XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost), + } + if err := SaveBotConfig(ctx, store, cfg); err != nil { + http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError) + return + } + reloadBotService(store) + botWriteJSON(w, map[string]bool{"ok": true}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Plans ---------- + +func handleBotPlans(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + plans, err := store.ListPlans(ctx, false) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, plans) + case http.MethodPost: + var p BotPlan + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if p.Kind == "" { + p.Kind = "ssh" + } + if err := store.UpsertPlan(ctx, &p); err != nil { + http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + botWriteJSON(w, p) + case http.MethodDelete: + id, _ := strconv.Atoi(r.URL.Query().Get("id")) + if id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := store.DeletePlan(ctx, id); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Credit packages ---------- + +func handleBotCreditPackages(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + pkgs, err := store.ListCreditPackages(ctx, false) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, pkgs) + case http.MethodPost: + var p BotCreditPackage + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := store.UpsertCreditPackage(ctx, &p); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, p) + case http.MethodDelete: + id, _ := strconv.Atoi(r.URL.Query().Get("id")) + if id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := store.DeleteCreditPackage(ctx, id); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Bot users ---------- + +func handleBotUsers(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + users, err := store.ListBotUsers(ctx) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, users) + case http.MethodPost: + var req struct { + TelegramID int64 `json:"telegram_id"` + Action string `json:"action"` + Role string `json:"role"` + LinkedAdminUsername string `json:"linked_admin_username"` + Credits int `json:"credits"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TelegramID == 0 { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + switch req.Action { + case "set_role": + if req.Role == "" { + req.Role = "customer" + } + if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + case "block": + if err := store.SetBotUserRole(ctx, req.TelegramID, "blocked", ""); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + case "unblock": + if err := store.SetBotUserRole(ctx, req.TelegramID, "customer", ""); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + case "adjust_credits": + if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil { + http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest) + return + } + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + botWriteJSON(w, map[string]bool{"ok": true}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Transactions ---------- + +func handleBotTransactions(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + status := r.URL.Query().Get("status") + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + txns, err := store.ListTransactions(ctx, status, limit) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, txns) + case http.MethodPost: + var req struct { + ID int `json:"id"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + switch req.Action { + case "refund": + if err := store.SetTransactionStatus(ctx, req.ID, "refunded"); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + case "reprocess": + if b := currentBot(); b != nil { + go b.tryFulfill(req.ID) + } else { + http.Error(w, "bot not running", http.StatusServiceUnavailable) + return + } + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + botWriteJSON(w, map[string]bool{"ok": true}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Settings (bot texts) ---------- + +func handleBotSettings(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + all, err := store.AllSettings(ctx) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + botWriteJSON(w, all) + case http.MethodPost: + var kv map[string]string + if err := json.NewDecoder(r.Body).Decode(&kv); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + for k, v := range kv { + if err := store.SetSetting(ctx, k, v); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + } + botWriteJSON(w, map[string]bool{"ok": true}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +// ---------- Connectivity test ---------- + +func handleBotTest(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !botStoreReady(w, store) { + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + var req struct { + TelegramToken string `json:"telegram_token"` + MPAccessToken string `json:"mp_access_token"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + cfg, _ := LoadBotConfig(ctx, store) + tgToken := strings.TrimSpace(req.TelegramToken) + mpToken := strings.TrimSpace(req.MPAccessToken) + if cfg != nil { + if tgToken == "" { + tgToken = cfg.TelegramToken + } + if mpToken == "" { + mpToken = cfg.MPAccessToken + } + } + out := map[string]interface{}{} + if tgToken != "" { + name, err := newTGClient(tgToken).getMe(ctx) + if err != nil { + out["telegram_ok"] = false + out["telegram_error"] = err.Error() + } else { + out["telegram_ok"] = true + out["telegram_bot"] = "@" + name + } + } else { + out["telegram_ok"] = false + out["telegram_error"] = "no token configured" + } + if mpToken != "" { + _, err := newMPClient(mpToken).do(ctx, http.MethodGet, "/v1/payment_methods", nil, "") + if err != nil { + out["mp_ok"] = false + out["mp_error"] = err.Error() + } else { + out["mp_ok"] = true + } + } else { + out["mp_ok"] = false + out["mp_error"] = "no token configured" + } + botWriteJSON(w, out) + } +} + +// ---------- Telegram webhook (public) ---------- + +func handleTelegramWebhook(w http.ResponseWriter, r *http.Request) { + b := currentBot() + if b == nil { + w.WriteHeader(http.StatusOK) + return + } + if b.cfg.TelegramWebhookSecret != "" && + r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != b.cfg.TelegramWebhookSecret { + w.WriteHeader(http.StatusUnauthorized) + return + } + up, err := parseWebhookUpdate(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + go b.handleUpdate(up) + w.WriteHeader(http.StatusOK) +} diff --git a/bot_config.go b/bot_config.go new file mode 100644 index 0000000..0440013 --- /dev/null +++ b/bot_config.go @@ -0,0 +1,167 @@ +package main + +// bot_config.go — in-memory bot configuration loaded from the bot_config table. +// Secrets are decrypted here and never persisted in plaintext. + +import ( + "context" + "errors" + "strconv" +) + +var errInsufficientCredits = errors.New("insufficient credits") + +func botItoa(n int) string { return strconv.Itoa(n) } + +// BotConfig is the decrypted, ready-to-use bot configuration. +type BotConfig struct { + Enabled bool + TelegramToken string + TelegramMode string // polling | webhook + TelegramWebhookURL string + TelegramWebhookSecret string + MPAccessToken string + MPConfirmMode string // webhook | polling + MPWebhookSecret string + MPPollInterval string + PixExpirationMinutes int + TrialEnabled bool + TrialHours int + TrialMaxConnections int + TrialKind string // ssh | xray + TrialInboundTag string + AdminTelegramIDs []int64 + Currency string + PublicHost string // SSH connection host shown to buyers + XrayPublicHost string // host used to build vless/vmess links +} + +// LoadBotConfig reads the config row and decrypts secrets into a BotConfig. +func LoadBotConfig(ctx context.Context, store *Store) (*BotConfig, error) { + r, err := store.getBotConfigRow(ctx) + if err != nil { + return nil, err + } + tok, err := decryptSecret(r.TelegramTokenEnc) + if err != nil { + return nil, err + } + tgSec, err := decryptSecret(r.TelegramWebhookSecretEnc) + if err != nil { + return nil, err + } + mp, err := decryptSecret(r.MPAccessTokenEnc) + if err != nil { + return nil, err + } + mpSec, err := decryptSecret(r.MPWebhookSecretEnc) + if err != nil { + return nil, err + } + + cfg := &BotConfig{ + Enabled: r.Enabled, + TelegramToken: tok, + TelegramMode: r.TelegramMode, + TelegramWebhookURL: r.TelegramWebhookURL, + TelegramWebhookSecret: tgSec, + MPAccessToken: mp, + MPConfirmMode: r.MPConfirmMode, + MPWebhookSecret: mpSec, + MPPollInterval: r.MPPollInterval, + PixExpirationMinutes: r.PixExpirationMinutes, + TrialEnabled: r.TrialEnabled, + TrialHours: r.TrialHours, + TrialMaxConnections: r.TrialMaxConnections, + TrialKind: r.TrialKind, + TrialInboundTag: r.TrialInboundTag, + AdminTelegramIDs: r.AdminTelegramIDs, + Currency: r.Currency, + PublicHost: r.PublicHost, + XrayPublicHost: r.XrayPublicHost, + } + cfg.applyDefaults() + return cfg, nil +} + +func (c *BotConfig) applyDefaults() { + if c.TelegramMode == "" { + c.TelegramMode = "polling" + } + if c.MPConfirmMode == "" { + c.MPConfirmMode = "polling" + } + if c.MPPollInterval == "" { + c.MPPollInterval = "20s" + } + if c.PixExpirationMinutes <= 0 { + c.PixExpirationMinutes = 30 + } + if c.TrialHours <= 0 { + c.TrialHours = 1 + } + if c.TrialMaxConnections <= 0 { + c.TrialMaxConnections = 1 + } + if c.TrialKind == "" { + c.TrialKind = "ssh" + } + if c.Currency == "" { + c.Currency = "BRL" + } +} + +func (c *BotConfig) isAdmin(telegramID int64) bool { + for _, id := range c.AdminTelegramIDs { + if id == telegramID { + return true + } + } + return false +} + +// SaveBotConfig persists a BotConfig. Empty secret fields preserve the stored +// value (nil blob → column left unchanged). +func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error { + cfg.applyDefaults() + row := &botConfigRow{ + Enabled: cfg.Enabled, + TelegramMode: cfg.TelegramMode, + TelegramWebhookURL: cfg.TelegramWebhookURL, + MPConfirmMode: cfg.MPConfirmMode, + MPPollInterval: cfg.MPPollInterval, + PixExpirationMinutes: cfg.PixExpirationMinutes, + TrialEnabled: cfg.TrialEnabled, + TrialHours: cfg.TrialHours, + TrialMaxConnections: cfg.TrialMaxConnections, + TrialKind: cfg.TrialKind, + TrialInboundTag: cfg.TrialInboundTag, + AdminTelegramIDs: cfg.AdminTelegramIDs, + Currency: cfg.Currency, + PublicHost: cfg.PublicHost, + XrayPublicHost: cfg.XrayPublicHost, + } + var tokEnc, tgSecEnc, mpEnc, mpSecEnc []byte + var err error + if cfg.TelegramToken != "" { + if tokEnc, err = encryptSecret(cfg.TelegramToken); err != nil { + return err + } + } + if cfg.TelegramWebhookSecret != "" { + if tgSecEnc, err = encryptSecret(cfg.TelegramWebhookSecret); err != nil { + return err + } + } + if cfg.MPAccessToken != "" { + if mpEnc, err = encryptSecret(cfg.MPAccessToken); err != nil { + return err + } + } + if cfg.MPWebhookSecret != "" { + if mpSecEnc, err = encryptSecret(cfg.MPWebhookSecret); err != nil { + return err + } + } + return store.saveBotConfigRow(ctx, row, tokEnc, tgSecEnc, mpEnc, mpSecEnc) +} diff --git a/bot_core.go b/bot_core.go new file mode 100644 index 0000000..c7c6f3c --- /dev/null +++ b/bot_core.go @@ -0,0 +1,272 @@ +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")}) +} diff --git a/bot_crypto.go b/bot_crypto.go new file mode 100644 index 0000000..c958807 --- /dev/null +++ b/bot_crypto.go @@ -0,0 +1,151 @@ +package main + +// bot_crypto.go — secret encryption for the Telegram/Mercado Pago bot. +// +// Bot tokens (Telegram bot token, Mercado Pago access token, webhook secrets) +// are stored in PostgreSQL encrypted with AES-256-GCM. The 32-byte master key +// lives OUTSIDE the database, so a DB dump alone never reveals the secrets: +// 1) env BOT_MASTER_KEY (64 hex chars), if set; otherwise +// 2) a 0600 key file next to config.json (bot_master.key); otherwise +// 3) generated with crypto/rand on first use and written to that key file. + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + botKeyOnce sync.Once + botKey []byte + botKeyErr error +) + +// botMasterKeyPath returns the on-disk location of the AES master key. +func botMasterKeyPath() string { + if globalCfgPath != "" { + return filepath.Join(filepath.Dir(globalCfgPath), "bot_master.key") + } + return "/opt/sshpanel/bot_master.key" +} + +// loadBotMasterKey resolves the 32-byte master key (env → file → generate). +func loadBotMasterKey() ([]byte, error) { + botKeyOnce.Do(func() { + if env := strings.TrimSpace(os.Getenv("BOT_MASTER_KEY")); env != "" { + k, err := hex.DecodeString(env) + if err != nil { + botKeyErr = fmt.Errorf("BOT_MASTER_KEY invalid hex: %w", err) + return + } + if len(k) != 32 { + botKeyErr = fmt.Errorf("BOT_MASTER_KEY must be 32 bytes (64 hex chars), got %d", len(k)) + return + } + botKey = k + return + } + + path := botMasterKeyPath() + data, err := os.ReadFile(path) + if err == nil { + k, derr := hex.DecodeString(strings.TrimSpace(string(data))) + if derr == nil && len(k) == 32 { + botKey = k + return + } + // Refuse to overwrite a bad key file — overwriting would make + // existing ciphertext undecryptable and silently lose secrets. + botKeyErr = fmt.Errorf("bot master key file %s is invalid; refusing to overwrite", path) + return + } + if !errors.Is(err, os.ErrNotExist) { + botKeyErr = fmt.Errorf("read bot master key: %w", err) + return + } + + k := make([]byte, 32) + if _, e := rand.Read(k); e != nil { + botKeyErr = fmt.Errorf("generate bot master key: %w", e) + return + } + if e := os.WriteFile(path, []byte(hex.EncodeToString(k)), 0o600); e != nil { + botKeyErr = fmt.Errorf("write bot master key %s: %w", path, e) + return + } + botKey = k + }) + return botKey, botKeyErr +} + +// encryptSecret encrypts a plaintext secret. Empty input returns nil (no blob). +func encryptSecret(plain string) ([]byte, error) { + if plain == "" { + return nil, nil + } + key, err := loadBotMasterKey() + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + // Output is nonce || ciphertext(+tag). + return gcm.Seal(nonce, nonce, []byte(plain), nil), nil +} + +// decryptSecret reverses encryptSecret. Empty/nil input returns "". +func decryptSecret(enc []byte) (string, error) { + if len(enc) == 0 { + return "", nil + } + key, err := loadBotMasterKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(enc) < gcm.NonceSize() { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ct := enc[:gcm.NonceSize()], enc[gcm.NonceSize():] + pt, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", fmt.Errorf("decrypt secret: %w", err) + } + return string(pt), nil +} + +// maskSecret returns a log-safe representation of a secret. +func maskSecret(s string) string { + if s == "" { + return "(empty)" + } + if len(s) <= 6 { + return "***" + } + return s[:3] + "***" + s[len(s)-2:] +} diff --git a/bot_flows.go b/bot_flows.go new file mode 100644 index 0000000..70b345c --- /dev/null +++ b/bot_flows.go @@ -0,0 +1,511 @@ +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 +} diff --git a/bot_mercadopago.go b/bot_mercadopago.go new file mode 100644 index 0000000..d58a36d --- /dev/null +++ b/bot_mercadopago.go @@ -0,0 +1,205 @@ +package main + +// bot_mercadopago.go — Mercado Pago PIX client + inbound webhook handler. +// Built on net/http; no third-party dependency. + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +const mpAPIBase = "https://api.mercadopago.com" + +type mpClient struct { + accessToken string + hc *http.Client +} + +func newMPClient(accessToken string) *mpClient { + return &mpClient{accessToken: accessToken, hc: &http.Client{Timeout: 25 * time.Second}} +} + +// mpPixResult holds what the bot needs to show the buyer. +type mpPixResult struct { + PaymentID string + QRCode string // copy-and-paste PIX string + QRBase64 string // PNG image, base64 (no data: prefix) + Status string +} + +// CreatePixPayment creates a PIX charge and returns the QR data. +// amountCents is BRL cents; expiresAt bounds the QR validity. +func (c *mpClient) CreatePixPayment(ctx context.Context, amountCents int, description, payerEmail, externalRef string, expiresAt time.Time, idempotencyKey string) (*mpPixResult, error) { + if payerEmail == "" { + payerEmail = "comprador@example.com" + } + body := map[string]interface{}{ + "transaction_amount": float64(amountCents) / 100.0, + "description": description, + "payment_method_id": "pix", + "payer": map[string]interface{}{"email": payerEmail}, + "date_of_expiration": expiresAt.Format("2006-01-02T15:04:05.000-07:00"), + "external_reference": externalRef, + } + raw, err := c.do(ctx, http.MethodPost, "/v1/payments", body, idempotencyKey) + if err != nil { + return nil, err + } + var resp struct { + ID json.Number `json:"id"` + Status string `json:"status"` + PointOfInteraction struct { + TransactionData struct { + QRCode string `json:"qr_code"` + QRCodeBase64 string `json:"qr_code_base64"` + } `json:"transaction_data"` + } `json:"point_of_interaction"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("mp create payment: parse: %w", err) + } + if resp.ID.String() == "" { + return nil, fmt.Errorf("mp create payment: no id in response: %s", string(raw)) + } + return &mpPixResult{ + PaymentID: resp.ID.String(), + QRCode: resp.PointOfInteraction.TransactionData.QRCode, + QRBase64: resp.PointOfInteraction.TransactionData.QRCodeBase64, + Status: resp.Status, + }, nil +} + +// GetPaymentStatus returns the current status of a payment (e.g. "approved"). +func (c *mpClient) GetPaymentStatus(ctx context.Context, paymentID string) (string, error) { + raw, err := c.do(ctx, http.MethodGet, "/v1/payments/"+paymentID, nil, "") + if err != nil { + return "", err + } + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return "", err + } + return resp.Status, nil +} + +func (c *mpClient) do(ctx context.Context, method, path string, body interface{}, idempotencyKey string) ([]byte, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, mpAPIBase+path, rdr) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Content-Type", "application/json") + if idempotencyKey != "" { + req.Header.Set("X-Idempotency-Key", idempotencyKey) + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("mercado pago %s %s: http %d: %s", method, path, resp.StatusCode, string(data)) + } + return data, nil +} + +// verifyMPSignature validates the x-signature header per Mercado Pago's spec. +// Manifest: "id:;request-id:;ts:;" HMAC-SHA256(secret). +func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool { + if secret == "" { + return true // validation disabled + } + var ts, v1 string + for _, part := range strings.Split(xSignature, ",") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + switch strings.TrimSpace(kv[0]) { + case "ts": + ts = strings.TrimSpace(kv[1]) + case "v1": + v1 = strings.TrimSpace(kv[1]) + } + } + if ts == "" || v1 == "" { + return false + } + manifest := fmt.Sprintf("id:%s;request-id:%s;ts:%s;", strings.ToLower(dataID), xRequestID, ts) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(manifest)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(v1)) +} + +// handleMPWebhook is the public endpoint Mercado Pago calls on payment events. +// It never trusts the body: it re-fetches the payment and fulfills idempotently. +func handleMPWebhook(w http.ResponseWriter, r *http.Request) { + b := currentBot() + if b == nil { + w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries + return + } + // Extract the payment id from body or query. + dataID := r.URL.Query().Get("data.id") + if dataID == "" { + dataID = r.URL.Query().Get("id") + } + var payload struct { + Type string `json:"type"` + Action string `json:"action"` + Data struct { + ID json.Number `json:"id"` + } `json:"data"` + } + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if len(body) > 0 { + _ = json.Unmarshal(body, &payload) + if dataID == "" { + dataID = payload.Data.ID.String() + } + } + if dataID == "" { + w.WriteHeader(http.StatusOK) + return + } + + if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), dataID, b.cfg.MPWebhookSecret) { + log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Acknowledge immediately; process in the background so MP doesn't time out. + go b.processPaymentByMPID(dataID) + w.WriteHeader(http.StatusOK) +} + +// centsToBRL formats cents as "R$ 12,34". +func centsToBRL(cents int) string { + reais := cents / 100 + cent := cents % 100 + return "R$ " + strconv.Itoa(reais) + "," + fmt.Sprintf("%02d", cent) +} diff --git a/bot_payments.go b/bot_payments.go new file mode 100644 index 0000000..706be7c --- /dev/null +++ b/bot_payments.go @@ -0,0 +1,237 @@ +package main + +// bot_payments.go — payment polling, webhook processing, and idempotent delivery. + +import ( + "fmt" + "log" + "strconv" + "strings" + "time" +) + +// ---------- Polling mode ---------- + +func (b *Bot) runPaymentPoller() { + d, err := time.ParseDuration(b.cfg.MPPollInterval) + if err != nil || d < 5*time.Second { + d = 20 * time.Second + } + t := time.NewTicker(d) + defer t.Stop() + log.Printf("[bot] payment poller started (interval=%s)", d) + for { + select { + case <-b.ctx.Done(): + log.Printf("[bot] payment poller stopped") + return + case <-t.C: + b.pollPending() + } + } +} + +func (b *Bot) pollPending() { + txns, err := b.store.ListPendingTransactions(b.ctx) + if err != nil { + log.Printf("[bot] poll list: %v", err) + return + } + now := time.Now() + for _, txn := range txns { + if txn.ExpiresAt != nil && now.After(*txn.ExpiresAt) { + _ = b.store.SetTransactionStatus(b.ctx, txn.ID, "expired") + b.notify(txn.TelegramID, fmt.Sprintf("⌛ O PIX do pedido #%d expirou. Gere um novo pagamento se ainda quiser.", txn.ID)) + continue + } + if b.mp == nil { + continue + } + status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID) + if err != nil { + continue + } + if status == "approved" { + b.tryFulfill(txn.ID) + } + } +} + +// ---------- Webhook mode ---------- + +// processPaymentByMPID is invoked from the Mercado Pago webhook handler. +func (b *Bot) processPaymentByMPID(mpID string) { + txn, err := b.store.GetTransactionByMPID(b.ctx, mpID) + if err != nil { + log.Printf("[bot] webhook: no txn for mp payment %s: %v", mpID, err) + return + } + if b.mp == nil { + return + } + status, err := b.mp.GetPaymentStatus(b.ctx, mpID) + if err != nil { + log.Printf("[bot] webhook: get status %s: %v", mpID, err) + return + } + if status == "approved" { + b.tryFulfill(txn.ID) + } +} + +// ---------- Idempotent fulfillment ---------- + +// tryFulfill flips the txn to approved exactly once, then delivers. +func (b *Bot) tryFulfill(txnID int) { + ok, err := b.store.MarkTransactionApproved(b.ctx, txnID) + if err != nil { + log.Printf("[bot] mark approved %d: %v", txnID, err) + return + } + if !ok { + return // already fulfilled by another path + } + txn, err := b.store.GetTransaction(b.ctx, txnID) + if err != nil { + log.Printf("[bot] fulfill get txn %d: %v", txnID, err) + return + } + b.fulfillTransaction(txn) +} + +func (b *Bot) fulfillTransaction(txn *BotTransaction) { + switch txn.Type { + case "credit_topup": + bal, err := b.store.AdjustCredits(b.ctx, txn.TelegramID, txn.Credits, "topup", &txn.ID) + if err != nil { + log.Printf("[bot] topup credit %d: %v", txn.ID, err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao creditar. Contate o suporte.") + return + } + b.notify(txn.TelegramID, fmt.Sprintf("✅ Recarga aprovada! +%d créditos.\n💳 Saldo atual: %d créditos.", txn.Credits, bal)) + case "plan_renewal": + b.fulfillRenewal(txn) + default: + b.fulfillPurchase(txn) + } +} + +func (b *Bot) fulfillPurchase(txn *BotTransaction) { + if txn.PlanID == nil { + return + } + p, err := b.store.GetPlan(b.ctx, *txn.PlanID) + if err != nil { + log.Printf("[bot] fulfill purchase: plan %v: %v", txn.PlanID, err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas o plano não foi encontrado. Contate o suporte.") + return + } + exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour) + if p.Kind == "xray" { + uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, "", b.cfg.XrayPublicHost) + if err != nil { + log.Printf("[bot] fulfill xray: %v", err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta Xray. Contate o suporte.") + return + } + _ = b.store.SetTransactionTarget(b.ctx, txn.ID, uuid) + b.notify(txn.TelegramID, b.formatXrayDelivery(p.Name, uuid, link, exp)) + return + } + user := genUsername("ssh") + pass := genPassword() + if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, ""); err != nil { + log.Printf("[bot] fulfill ssh: %v", err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta SSH. Contate o suporte.") + return + } + _ = b.store.SetTransactionTarget(b.ctx, txn.ID, user) + b.notify(txn.TelegramID, b.formatSSHDelivery(p.Name, user, pass, exp)) +} + +func (b *Bot) fulfillRenewal(txn *BotTransaction) { + if txn.PlanID == nil || txn.RenewTarget == "" { + return + } + p, err := b.store.GetPlan(b.ctx, *txn.PlanID) + if err != nil { + return + } + parts := strings.SplitN(txn.RenewTarget, ":", 2) + if len(parts) < 2 { + return + } + kind, id := parts[0], parts[1] + base := time.Now() + add := time.Duration(p.Days) * 24 * time.Hour + + if kind == "x" { + newExp := base.Add(add) + if meta, err := b.store.GetXrayClientMeta(b.ctx, id); err == nil && meta.ExpiresAt != nil && meta.ExpiresAt.After(base) { + newExp = meta.ExpiresAt.Add(add) + } + if err := renewXrayClient(b.ctx, b.store, id, newExp); err != nil { + log.Printf("[bot] renew xray %s: %v", id, err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.") + return + } + _ = b.store.SetTransactionTarget(b.ctx, txn.ID, id) + b.notify(txn.TelegramID, fmt.Sprintf("✅ %s renovado!\n⚡ Xray %s\nNova validade: %s", + htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04"))) + return + } + + newExp := base.Add(add) + if u, ok := userMgr.Get(id); ok && u.ExpiresAt != nil && u.ExpiresAt.After(base) { + newExp = u.ExpiresAt.Add(add) + } + if err := renewSSHUser(b.ctx, b.store, id, newExp); err != nil { + log.Printf("[bot] renew ssh %s: %v", id, err) + b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.") + return + } + _ = b.store.SetTransactionTarget(b.ctx, txn.ID, id) + b.notify(txn.TelegramID, fmt.Sprintf("✅ %s renovado!\n🔒 SSH %s\nNova validade: %s", + htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04"))) +} + +// ---------- "Verificar" button ---------- + +func (b *Bot) checkPaymentButton(chatID int64, from *tgUser, txnIDStr string) { + id, _ := strconv.Atoi(txnIDStr) + txn, err := b.store.GetTransaction(b.ctx, id) + if err != nil { + b.send(chatID, "Pagamento não encontrado.", backKeyboard()) + return + } + if txn.TelegramID != from.ID { + b.send(chatID, "Pagamento inválido.", backKeyboard()) + return + } + switch txn.Status { + case "approved": + b.send(chatID, "✅ Pagamento já confirmado! Veja em 🛍️ Minhas Compras.", backKeyboard()) + return + case "expired": + b.send(chatID, "⌛ Este PIX expirou. Gere um novo pagamento.", backKeyboard()) + return + case "refunded": + b.send(chatID, "Este pagamento foi estornado.", backKeyboard()) + return + } + if b.mp == nil { + b.send(chatID, "Pagamento não configurado.", backKeyboard()) + return + } + status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID) + if err != nil { + b.send(chatID, "Não foi possível verificar agora. Tente novamente em instantes.", backKeyboard()) + return + } + if status == "approved" { + b.tryFulfill(txn.ID) // delivers via notify + return + } + b.send(chatID, "⏱ Pagamento ainda não identificado. Assim que cair, a liberação é automática.", + kb([]tgInlineButton{btn("🔄 Verificar novamente", "pay:check:"+botItoa(txn.ID)), btn("⬅️ Voltar", "menu:main")})) +} diff --git a/bot_provision.go b/bot_provision.go new file mode 100644 index 0000000..3906825 --- /dev/null +++ b/bot_provision.go @@ -0,0 +1,272 @@ +package main + +// bot_provision.go — bridges the bot to the panel's account creation. +// SSH: Store.UpsertUser. Xray: xrayMgr.AddXrayClient + UpsertXrayClientMeta. + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/url" + "strconv" + "strings" + "time" +) + +// ---------- credential generation ---------- + +const credAlphabet = "abcdefghijkmnpqrstuvwxyz23456789" + +func randString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // extremely unlikely; fall back to a fixed-length timestamp-free filler + for i := range b { + b[i] = credAlphabet[0] + } + return string(b) + } + for i := range b { + b[i] = credAlphabet[int(b[i])%len(credAlphabet)] + } + return string(b) +} + +func genUsername(prefix string) string { + if prefix == "" { + prefix = "ssh" + } + return prefix + randString(6) +} + +func genPassword() string { return randString(10) } + +func uuidV4() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// ---------- SSH ---------- + +func createSSHUser(ctx context.Context, store *Store, username, password string, expiresAt time.Time, maxConns, upMbps, downMbps int, owner string) error { + cfg := UserConfig{ + Username: username, + Password: password, + MaxConnections: maxConns, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + LimitMbpsUp: upMbps, + LimitMbpsDown: downMbps, + OwnerUsername: owner, + } + if err := store.UpsertUser(ctx, cfg); err != nil { + return err + } + reloadUsersFromDB(ctx, store) + return nil +} + +// renewSSHUser extends an existing SSH account's expiry, preserving credentials. +func renewSSHUser(ctx context.Context, store *Store, username string, newExpiry time.Time) error { + u, ok := userMgr.Get(username) + if !ok { + return fmt.Errorf("conta SSH %q não encontrada", username) + } + cfg := u.Cfg + cfg.ExpiresAt = newExpiry.UTC().Format(time.RFC3339) + if err := store.UpsertUser(ctx, cfg); err != nil { + return err + } + reloadUsersFromDB(ctx, store) + return nil +} + +// ---------- Xray ---------- + +func createXrayClient(ctx context.Context, store *Store, inboundTag, protocol string, expiresAt time.Time, maxConns int, owner, publicHost string) (uuid, link string, err error) { + if inboundTag == "" { + return "", "", fmt.Errorf("plano Xray sem inbound configurado") + } + uuid = uuidV4() + email := "bot-" + uuid[:8] + if err = xrayMgr.AddXrayClient(inboundTag, uuid, email); err != nil { + return "", "", err + } + exp := expiresAt + meta := XrayClientMeta{ + UUID: uuid, + Name: email, + Email: email, + InboundTag: inboundTag, + OwnerUsername: owner, + MaxConns: maxConns, + ExpiresAt: &exp, + } + if e := store.UpsertXrayClientMeta(ctx, meta); e != nil { + // The client is already live in Xray; a metadata failure must not + // abort delivery. Log and continue. + log.Printf("[bot] xray meta save for %s: %v", uuid, e) + } + xrayMgr.restartIfExternalRunning() + link = buildXrayLink(inboundTag, protocol, uuid, publicHost, email) + return uuid, link, nil +} + +func renewXrayClient(ctx context.Context, store *Store, uuid string, newExpiry time.Time) error { + meta, err := store.GetXrayClientMeta(ctx, uuid) + if err != nil { + return fmt.Errorf("cliente Xray não encontrado: %w", err) + } + exp := newExpiry + meta.ExpiresAt = &exp + return store.UpsertXrayClientMeta(ctx, *meta) +} + +// ---------- Xray connection link ---------- + +type xrayInboundDetail struct { + Protocol string + Port int + Network string + Security string + Path string + Host string + SNI string + ServiceName string +} + +// inboundDetail reads streamSettings for an inbound from the raw Xray config. +func (m *XrayManager) inboundDetail(tag string) (*xrayInboundDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := m.readConfigLocked() + if err != nil { + return nil, err + } + var cfg struct { + Inbounds []json.RawMessage `json:"inbounds"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + for _, raw := range cfg.Inbounds { + var ib struct { + Tag string `json:"tag"` + Protocol string `json:"protocol"` + Port json.RawMessage `json:"port"` + StreamSettings map[string]interface{} `json:"streamSettings"` + } + if err := json.Unmarshal(raw, &ib); err != nil { + continue + } + if ib.Tag != tag { + continue + } + d := &xrayInboundDetail{Protocol: strings.ToLower(ib.Protocol)} + var pnum int + if json.Unmarshal(ib.Port, &pnum) == nil { + d.Port = pnum + } else { + var pstr string + if json.Unmarshal(ib.Port, &pstr) == nil { + d.Port, _ = strconv.Atoi(pstr) + } + } + if ss := ib.StreamSettings; ss != nil { + d.Network, _ = ss["network"].(string) + d.Security, _ = ss["security"].(string) + d.Path, d.Host, d.ServiceName = extractStreamParams(ss, d.Network) + if tls, ok := ss["tlsSettings"].(map[string]interface{}); ok { + d.SNI, _ = tls["serverName"].(string) + } + if rl, ok := ss["realitySettings"].(map[string]interface{}); ok { + if sn, _ := rl["serverNames"].([]interface{}); len(sn) > 0 { + d.SNI, _ = sn[0].(string) + } + } + } + return d, nil + } + return nil, fmt.Errorf("inbound %q não encontrado", tag) +} + +func extractStreamParams(ss map[string]interface{}, network string) (path, host, serviceName string) { + get := func(key, field string) string { + if sub, ok := ss[key].(map[string]interface{}); ok { + v, _ := sub[field].(string) + return v + } + return "" + } + switch network { + case "ws": + return get("wsSettings", "path"), get("wsSettings", "host"), "" + case "xhttp": + return get("xhttpSettings", "path"), get("xhttpSettings", "host"), "" + case "httpupgrade": + return get("httpupgradeSettings", "path"), get("httpupgradeSettings", "host"), "" + case "grpc": + return "", "", get("grpcSettings", "serviceName") + case "http", "h2": + return get("httpSettings", "path"), get("httpSettings", "host"), "" + } + return "", "", "" +} + +// buildXrayLink assembles a shareable connection URI. Best-effort: if the config +// can't be read it still returns a minimal link with host/port/uuid. +func buildXrayLink(inboundTag, protocol, uuid, publicHost, label string) string { + d, err := xrayMgr.inboundDetail(inboundTag) + if err != nil || d == nil { + if protocol == "" { + protocol = "vless" + } + return fmt.Sprintf("%s://%s@%s#%s", protocol, uuid, publicHost, url.QueryEscape(label)) + } + if protocol == "" { + protocol = d.Protocol + } + host := publicHost + if host == "" { + host = d.SNI + } + addr := fmt.Sprintf("%s:%d", host, d.Port) + + q := url.Values{} + if d.Network != "" { + q.Set("type", d.Network) + } + if d.Security != "" { + q.Set("security", d.Security) + } + if d.Path != "" { + q.Set("path", d.Path) + } + if d.Host != "" { + q.Set("host", d.Host) + } + if d.SNI != "" { + q.Set("sni", d.SNI) + } + if d.ServiceName != "" { + q.Set("serviceName", d.ServiceName) + } + + switch protocol { + case "vmess": + conf := map[string]interface{}{ + "v": "2", "ps": label, "add": host, "port": strconv.Itoa(d.Port), + "id": uuid, "aid": "0", "scy": "auto", "net": d.Network, + "type": "none", "host": d.Host, "path": d.Path, "tls": d.Security, "sni": d.SNI, + } + b, _ := json.Marshal(conf) + return "vmess://" + base64.StdEncoding.EncodeToString(b) + default: // vless, trojan + return fmt.Sprintf("%s://%s@%s?%s#%s", protocol, uuid, addr, q.Encode(), url.QueryEscape(label)) + } +} diff --git a/bot_store.go b/bot_store.go new file mode 100644 index 0000000..e99fa56 --- /dev/null +++ b/bot_store.go @@ -0,0 +1,672 @@ +package main + +// bot_store.go — PostgreSQL persistence for the Telegram sales bot. +// +// Tables (all created idempotently by EnsureBotSchema): +// bot_users — Telegram customers/resellers +// bot_plans — sellable SSH/Xray plans +// bot_credit_packages — reseller credit top-up packages +// bot_transactions — PIX payments (Mercado Pago) +// bot_credits_ledger — auditable credit movements +// bot_settings — editable bot texts (key/value) +// bot_config — single-row bot config with encrypted secrets + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" +) + +// ---------- Models ---------- + +type BotUser struct { + TelegramID int64 + Username string + FirstName string + Role string // customer | reseller | blocked + LinkedAdminUsername string + CreditBalance int + TrialUsed bool + CreatedAt time.Time + LastSeenAt time.Time +} + +type BotPlan struct { + ID int + Name string + Kind string // ssh | xray + Days int + MaxConnections int + LimitMbpsUp int + LimitMbpsDown int + XrayInboundTag string + XrayProtocol string + PriceCents int + CreditCost int + ServerID string + IsActive bool + SortOrder int +} + +type BotCreditPackage struct { + ID int + Name string + Credits int + PriceCents int + IsActive bool + SortOrder int +} + +type BotTransaction struct { + ID int + TelegramID int64 + Type string // plan_purchase | plan_renewal | credit_topup + PlanID *int + PackageID *int + Credits int + AmountCents int + MPPaymentID string + MPQRCode string + MPQRBase64 string + Status string // pending | approved | expired | refunded | error + TargetUsername string // account/uuid created or renewed + RenewTarget string // for renewals: existing account/uuid to extend + CreatedAt time.Time + PaidAt *time.Time + ExpiresAt *time.Time +} + +type BotLedgerEntry struct { + ID int + TelegramID int64 + Delta int + Reason string + RefTxnID *int + BalanceAfter int + CreatedAt time.Time +} + +// ---------- Schema ---------- + +func (s *Store) EnsureBotSchema(ctx context.Context) error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS bot_users ( + telegram_id BIGINT PRIMARY KEY, + username TEXT NOT NULL DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'customer', + linked_admin_username TEXT NOT NULL DEFAULT '', + credit_balance INT NOT NULL DEFAULT 0, + trial_used BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS bot_plans ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'ssh', + days INT NOT NULL DEFAULT 30, + max_connections INT NOT NULL DEFAULT 1, + limit_mbps_up INT NOT NULL DEFAULT 0, + limit_mbps_down INT NOT NULL DEFAULT 0, + xray_inbound_tag TEXT NOT NULL DEFAULT '', + xray_protocol TEXT NOT NULL DEFAULT '', + price_cents INT NOT NULL DEFAULT 0, + credit_cost INT NOT NULL DEFAULT 1, + server_id TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS bot_credit_packages ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + credits INT NOT NULL DEFAULT 0, + price_cents INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS bot_transactions ( + id SERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL, + type TEXT NOT NULL, + plan_id INT, + package_id INT, + credits INT NOT NULL DEFAULT 0, + amount_cents INT NOT NULL DEFAULT 0, + mp_payment_id TEXT NOT NULL DEFAULT '', + mp_qr_code TEXT NOT NULL DEFAULT '', + mp_qr_base64 TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + target_username TEXT NOT NULL DEFAULT '', + renew_target TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + paid_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx + ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> ''`, + `CREATE TABLE IF NOT EXISTS bot_credits_ledger ( + id SERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL, + delta INT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + ref_transaction_id INT, + balance_after INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS bot_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + )`, + `CREATE TABLE IF NOT EXISTS bot_config ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT false, + telegram_mode TEXT NOT NULL DEFAULT 'polling', + telegram_webhook_url TEXT NOT NULL DEFAULT '', + mp_confirm_mode TEXT NOT NULL DEFAULT 'polling', + mp_poll_interval TEXT NOT NULL DEFAULT '20s', + pix_expiration_minutes INT NOT NULL DEFAULT 30, + trial_enabled BOOLEAN NOT NULL DEFAULT true, + trial_hours INT NOT NULL DEFAULT 1, + trial_max_connections INT NOT NULL DEFAULT 1, + trial_kind TEXT NOT NULL DEFAULT 'ssh', + trial_inbound_tag TEXT NOT NULL DEFAULT '', + admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}', + currency TEXT NOT NULL DEFAULT 'BRL', + public_host TEXT NOT NULL DEFAULT '', + xray_public_host TEXT NOT NULL DEFAULT '', + telegram_token_enc BYTEA, + telegram_webhook_secret_enc BYTEA, + mp_access_token_enc BYTEA, + mp_webhook_secret_enc BYTEA, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING`, + } + for _, stmt := range stmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return err + } + } + return nil +} + +// ---------- bot_users ---------- + +// UpsertBotUser inserts a user or refreshes username/first_name/last_seen. +// Role, credits, linkage and trial_used are preserved on update. +func (s *Store) UpsertBotUser(ctx context.Context, u *BotUser) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO bot_users (telegram_id, username, first_name, last_seen_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (telegram_id) DO UPDATE + SET username = EXCLUDED.username, + first_name = EXCLUDED.first_name, + last_seen_at = NOW()`, + u.TelegramID, u.Username, u.FirstName) + return err +} + +func scanBotUser(row interface{ Scan(...interface{}) error }) (*BotUser, error) { + var u BotUser + err := row.Scan(&u.TelegramID, &u.Username, &u.FirstName, &u.Role, + &u.LinkedAdminUsername, &u.CreditBalance, &u.TrialUsed, &u.CreatedAt, &u.LastSeenAt) + if err != nil { + return nil, err + } + return &u, nil +} + +const botUserCols = `telegram_id, username, first_name, role, linked_admin_username, credit_balance, trial_used, created_at, last_seen_at` + +func (s *Store) GetBotUser(ctx context.Context, telegramID int64) (*BotUser, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+botUserCols+` FROM bot_users WHERE telegram_id=$1`, telegramID) + return scanBotUser(row) +} + +func (s *Store) ListBotUsers(ctx context.Context) ([]*BotUser, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+botUserCols+` FROM bot_users ORDER BY last_seen_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotUser + for rows.Next() { + u, err := scanBotUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// SetBotUserRole updates role and reseller linkage. +func (s *Store) SetBotUserRole(ctx context.Context, telegramID int64, role, linkedAdmin string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE bot_users SET role=$2, linked_admin_username=$3 WHERE telegram_id=$1`, + telegramID, role, linkedAdmin) + return err +} + +func (s *Store) SetBotUserTrialUsed(ctx context.Context, telegramID int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE bot_users SET trial_used=true WHERE telegram_id=$1`, telegramID) + return err +} + +// AdjustCredits changes a reseller's balance atomically and writes a ledger row. +// Returns the resulting balance. Fails (rolls back) if the balance would go negative. +func (s *Store) AdjustCredits(ctx context.Context, telegramID int64, delta int, reason string, refTxn *int) (int, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + var balance int + if err := tx.QueryRowContext(ctx, + `UPDATE bot_users SET credit_balance = credit_balance + $2 + WHERE telegram_id=$1 RETURNING credit_balance`, + telegramID, delta).Scan(&balance); err != nil { + return 0, err + } + if balance < 0 { + return 0, errInsufficientCredits + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO bot_credits_ledger (telegram_id, delta, reason, ref_transaction_id, balance_after) + VALUES ($1,$2,$3,$4,$5)`, + telegramID, delta, reason, refTxn, balance); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return balance, nil +} + +func (s *Store) ListLedger(ctx context.Context, telegramID int64, limit int) ([]*BotLedgerEntry, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, telegram_id, delta, reason, ref_transaction_id, balance_after, created_at + FROM bot_credits_ledger WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotLedgerEntry + for rows.Next() { + var e BotLedgerEntry + if err := rows.Scan(&e.ID, &e.TelegramID, &e.Delta, &e.Reason, &e.RefTxnID, &e.BalanceAfter, &e.CreatedAt); err != nil { + return nil, err + } + out = append(out, &e) + } + return out, rows.Err() +} + +// ---------- bot_plans ---------- + +const botPlanCols = `id, name, kind, days, max_connections, limit_mbps_up, limit_mbps_down, xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order` + +func scanBotPlan(row interface{ Scan(...interface{}) error }) (*BotPlan, error) { + var p BotPlan + err := row.Scan(&p.ID, &p.Name, &p.Kind, &p.Days, &p.MaxConnections, &p.LimitMbpsUp, &p.LimitMbpsDown, + &p.XrayInboundTag, &p.XrayProtocol, &p.PriceCents, &p.CreditCost, &p.ServerID, &p.IsActive, &p.SortOrder) + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *Store) ListPlans(ctx context.Context, onlyActive bool) ([]*BotPlan, error) { + q := `SELECT ` + botPlanCols + ` FROM bot_plans` + if onlyActive { + q += ` WHERE is_active=true` + } + q += ` ORDER BY sort_order, id` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotPlan + for rows.Next() { + p, err := scanBotPlan(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) GetPlan(ctx context.Context, id int) (*BotPlan, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+botPlanCols+` FROM bot_plans WHERE id=$1`, id) + return scanBotPlan(row) +} + +func (s *Store) UpsertPlan(ctx context.Context, p *BotPlan) error { + if p.ID == 0 { + return s.db.QueryRowContext(ctx, ` + INSERT INTO bot_plans (name, kind, days, max_connections, limit_mbps_up, limit_mbps_down, + xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`, + p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, + p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder, + ).Scan(&p.ID) + } + _, err := s.db.ExecContext(ctx, ` + UPDATE bot_plans SET name=$2, kind=$3, days=$4, max_connections=$5, limit_mbps_up=$6, limit_mbps_down=$7, + xray_inbound_tag=$8, xray_protocol=$9, price_cents=$10, credit_cost=$11, server_id=$12, is_active=$13, sort_order=$14 + WHERE id=$1`, + p.ID, p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, + p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder) + return err +} + +func (s *Store) DeletePlan(ctx context.Context, id int) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM bot_plans WHERE id=$1`, id) + return err +} + +// ---------- bot_credit_packages ---------- + +const botPkgCols = `id, name, credits, price_cents, is_active, sort_order` + +func scanBotPkg(row interface{ Scan(...interface{}) error }) (*BotCreditPackage, error) { + var p BotCreditPackage + if err := row.Scan(&p.ID, &p.Name, &p.Credits, &p.PriceCents, &p.IsActive, &p.SortOrder); err != nil { + return nil, err + } + return &p, nil +} + +func (s *Store) ListCreditPackages(ctx context.Context, onlyActive bool) ([]*BotCreditPackage, error) { + q := `SELECT ` + botPkgCols + ` FROM bot_credit_packages` + if onlyActive { + q += ` WHERE is_active=true` + } + q += ` ORDER BY sort_order, id` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotCreditPackage + for rows.Next() { + p, err := scanBotPkg(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) GetCreditPackage(ctx context.Context, id int) (*BotCreditPackage, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+botPkgCols+` FROM bot_credit_packages WHERE id=$1`, id) + return scanBotPkg(row) +} + +func (s *Store) UpsertCreditPackage(ctx context.Context, p *BotCreditPackage) error { + if p.ID == 0 { + return s.db.QueryRowContext(ctx, + `INSERT INTO bot_credit_packages (name, credits, price_cents, is_active, sort_order) + VALUES ($1,$2,$3,$4,$5) RETURNING id`, + p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder).Scan(&p.ID) + } + _, err := s.db.ExecContext(ctx, + `UPDATE bot_credit_packages SET name=$2, credits=$3, price_cents=$4, is_active=$5, sort_order=$6 WHERE id=$1`, + p.ID, p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder) + return err +} + +func (s *Store) DeleteCreditPackage(ctx context.Context, id int) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM bot_credit_packages WHERE id=$1`, id) + return err +} + +// ---------- bot_transactions ---------- + +const botTxnCols = `id, telegram_id, type, plan_id, package_id, credits, amount_cents, mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, created_at, paid_at, expires_at` + +func scanBotTxn(row interface{ Scan(...interface{}) error }) (*BotTransaction, error) { + var t BotTransaction + err := row.Scan(&t.ID, &t.TelegramID, &t.Type, &t.PlanID, &t.PackageID, &t.Credits, &t.AmountCents, + &t.MPPaymentID, &t.MPQRCode, &t.MPQRBase64, &t.Status, &t.TargetUsername, &t.RenewTarget, + &t.CreatedAt, &t.PaidAt, &t.ExpiresAt) + if err != nil { + return nil, err + } + return &t, nil +} + +func (s *Store) CreateTransaction(ctx context.Context, t *BotTransaction) error { + return s.db.QueryRowContext(ctx, ` + INSERT INTO bot_transactions (telegram_id, type, plan_id, package_id, credits, amount_cents, + mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, created_at`, + t.TelegramID, t.Type, t.PlanID, t.PackageID, t.Credits, t.AmountCents, + t.MPPaymentID, t.MPQRCode, t.MPQRBase64, t.Status, t.TargetUsername, t.RenewTarget, t.ExpiresAt, + ).Scan(&t.ID, &t.CreatedAt) +} + +func (s *Store) GetTransaction(ctx context.Context, id int) (*BotTransaction, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE id=$1`, id) + return scanBotTxn(row) +} + +func (s *Store) GetTransactionByMPID(ctx context.Context, mpID string) (*BotTransaction, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE mp_payment_id=$1`, mpID) + return scanBotTxn(row) +} + +func (s *Store) ListPendingTransactions(ctx context.Context) ([]*BotTransaction, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+botTxnCols+` FROM bot_transactions WHERE status='pending' AND mp_payment_id <> '' ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotTransaction + for rows.Next() { + t, err := scanBotTxn(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) ListUserTransactions(ctx context.Context, telegramID int64, limit int) ([]*BotTransaction, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+botTxnCols+` FROM bot_transactions WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotTransaction + for rows.Next() { + t, err := scanBotTxn(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) ListTransactions(ctx context.Context, status string, limit int) ([]*BotTransaction, error) { + if limit <= 0 { + limit = 200 + } + var rows *sql.Rows + var err error + if status != "" { + rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE status=$1 ORDER BY id DESC LIMIT $2`, status, limit) + } else { + rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions ORDER BY id DESC LIMIT $1`, limit) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []*BotTransaction + for rows.Next() { + t, err := scanBotTxn(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// MarkTransactionApproved atomically flips a pending txn to approved. It returns +// true only for the caller that actually performed the transition, giving +// idempotent delivery even if webhook and poller race. +func (s *Store) MarkTransactionApproved(ctx context.Context, id int) (bool, error) { + res, err := s.db.ExecContext(ctx, + `UPDATE bot_transactions SET status='approved', paid_at=NOW() WHERE id=$1 AND status='pending'`, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (s *Store) SetTransactionStatus(ctx context.Context, id int, status string) error { + _, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET status=$2 WHERE id=$1`, id, status) + return err +} + +func (s *Store) SetTransactionTarget(ctx context.Context, id int, target string) error { + _, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET target_username=$2 WHERE id=$1`, id, target) + return err +} + +// ---------- bot_settings ---------- + +func (s *Store) GetSetting(ctx context.Context, key, def string) string { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM bot_settings WHERE key=$1`, key).Scan(&v) + if err != nil { + return def + } + return v +} + +func (s *Store) SetSetting(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO bot_settings (key, value) VALUES ($1,$2) + ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`, key, value) + return err +} + +func (s *Store) AllSettings(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM bot_settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + out[k] = v + } + return out, rows.Err() +} + +// ---------- bot_config (row) ---------- + +// botConfigRow mirrors the DB row; secrets stay encrypted here. +type botConfigRow struct { + Enabled bool + TelegramMode string + TelegramWebhookURL string + MPConfirmMode string + MPPollInterval string + PixExpirationMinutes int + TrialEnabled bool + TrialHours int + TrialMaxConnections int + TrialKind string + TrialInboundTag string + AdminTelegramIDs []int64 + Currency string + PublicHost string + XrayPublicHost string + TelegramTokenEnc []byte + TelegramWebhookSecretEnc []byte + MPAccessTokenEnc []byte + MPWebhookSecretEnc []byte +} + +func (s *Store) getBotConfigRow(ctx context.Context) (*botConfigRow, error) { + var r botConfigRow + var ids pq.Int64Array + err := s.db.QueryRowContext(ctx, ` + SELECT enabled, telegram_mode, telegram_webhook_url, mp_confirm_mode, mp_poll_interval, + pix_expiration_minutes, trial_enabled, trial_hours, trial_max_connections, trial_kind, + trial_inbound_tag, admin_telegram_ids, currency, public_host, xray_public_host, + telegram_token_enc, telegram_webhook_secret_enc, mp_access_token_enc, mp_webhook_secret_enc + FROM bot_config WHERE id=1`).Scan( + &r.Enabled, &r.TelegramMode, &r.TelegramWebhookURL, &r.MPConfirmMode, &r.MPPollInterval, + &r.PixExpirationMinutes, &r.TrialEnabled, &r.TrialHours, &r.TrialMaxConnections, &r.TrialKind, + &r.TrialInboundTag, &ids, &r.Currency, &r.PublicHost, &r.XrayPublicHost, + &r.TelegramTokenEnc, &r.TelegramWebhookSecretEnc, &r.MPAccessTokenEnc, &r.MPWebhookSecretEnc) + if err != nil { + return nil, err + } + r.AdminTelegramIDs = []int64(ids) + return &r, nil +} + +// saveBotConfigRow writes the non-secret fields plus any encrypted blobs that +// are non-nil (nil blob = keep existing secret). +func (s *Store) saveBotConfigRow(ctx context.Context, r *botConfigRow, tokEnc, tgSecEnc, mpEnc, mpSecEnc []byte) error { + set := `enabled=$1, telegram_mode=$2, telegram_webhook_url=$3, mp_confirm_mode=$4, mp_poll_interval=$5, + pix_expiration_minutes=$6, trial_enabled=$7, trial_hours=$8, trial_max_connections=$9, trial_kind=$10, + trial_inbound_tag=$11, admin_telegram_ids=$12, currency=$13, public_host=$14, xray_public_host=$15, + updated_at=NOW()` + args := []interface{}{ + r.Enabled, r.TelegramMode, r.TelegramWebhookURL, r.MPConfirmMode, r.MPPollInterval, + r.PixExpirationMinutes, r.TrialEnabled, r.TrialHours, r.TrialMaxConnections, r.TrialKind, + r.TrialInboundTag, pq.Array(r.AdminTelegramIDs), r.Currency, r.PublicHost, r.XrayPublicHost, + } + n := len(args) + if tokEnc != nil { + n++ + set += `, telegram_token_enc=$` + botItoa(n) + args = append(args, tokEnc) + } + if tgSecEnc != nil { + n++ + set += `, telegram_webhook_secret_enc=$` + botItoa(n) + args = append(args, tgSecEnc) + } + if mpEnc != nil { + n++ + set += `, mp_access_token_enc=$` + botItoa(n) + args = append(args, mpEnc) + } + if mpSecEnc != nil { + n++ + set += `, mp_webhook_secret_enc=$` + botItoa(n) + args = append(args, mpSecEnc) + } + _, err := s.db.ExecContext(ctx, `UPDATE bot_config SET `+set+` WHERE id=1`, args...) + return err +} diff --git a/bot_telegram.go b/bot_telegram.go new file mode 100644 index 0000000..b8c03cd --- /dev/null +++ b/bot_telegram.go @@ -0,0 +1,253 @@ +package main + +// bot_telegram.go — minimal Telegram Bot API client built on net/http. +// No third-party dependency. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" +) + +const tgAPIBase = "https://api.telegram.org/bot" + +// ---------- Wire types (subset) ---------- + +type tgUpdate struct { + UpdateID int64 `json:"update_id"` + Message *tgMessage `json:"message"` + CallbackQuery *tgCallbackQuery `json:"callback_query"` +} + +type tgMessage struct { + MessageID int64 `json:"message_id"` + From *tgUser `json:"from"` + Chat tgChat `json:"chat"` + Text string `json:"text"` +} + +type tgCallbackQuery struct { + ID string `json:"id"` + From tgUser `json:"from"` + Message *tgMessage `json:"message"` + Data string `json:"data"` +} + +type tgUser struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + Username string `json:"username"` +} + +type tgChat struct { + ID int64 `json:"id"` +} + +type tgInlineKeyboard struct { + InlineKeyboard [][]tgInlineButton `json:"inline_keyboard"` +} + +type tgInlineButton struct { + Text string `json:"text"` + CallbackData string `json:"callback_data,omitempty"` + URL string `json:"url,omitempty"` +} + +// ---------- Client ---------- + +type tgClient struct { + token string + hc *http.Client +} + +func newTGClient(token string) *tgClient { + return &tgClient{token: token, hc: &http.Client{Timeout: 65 * time.Second}} +} + +type tgResponse struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result json.RawMessage `json:"result"` +} + +func (c *tgClient) call(ctx context.Context, method string, payload interface{}) (json.RawMessage, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/"+method, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + var tr tgResponse + if err := json.Unmarshal(data, &tr); err != nil { + return nil, fmt.Errorf("telegram %s: bad response: %s", method, string(data)) + } + if !tr.OK { + return nil, fmt.Errorf("telegram %s: %s", method, tr.Description) + } + return tr.Result, nil +} + +// getUpdates long-polls. offset is the next update_id to fetch. +func (c *tgClient) getUpdates(ctx context.Context, offset int64, timeoutSec int) ([]tgUpdate, error) { + payload := map[string]interface{}{ + "offset": offset, + "timeout": timeoutSec, + "allowed_updates": []string{"message", "callback_query"}, + } + raw, err := c.call(ctx, "getUpdates", payload) + if err != nil { + return nil, err + } + var ups []tgUpdate + if err := json.Unmarshal(raw, &ups); err != nil { + return nil, err + } + return ups, nil +} + +func (c *tgClient) sendMessage(ctx context.Context, chatID int64, text string, kb *tgInlineKeyboard) (int64, error) { + payload := map[string]interface{}{ + "chat_id": chatID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": true, + } + if kb != nil { + payload["reply_markup"] = kb + } + raw, err := c.call(ctx, "sendMessage", payload) + if err != nil { + return 0, err + } + var m tgMessage + _ = json.Unmarshal(raw, &m) + return m.MessageID, nil +} + +func (c *tgClient) editMessageText(ctx context.Context, chatID, messageID int64, text string, kb *tgInlineKeyboard) error { + payload := map[string]interface{}{ + "chat_id": chatID, + "message_id": messageID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": true, + } + if kb != nil { + payload["reply_markup"] = kb + } + _, err := c.call(ctx, "editMessageText", payload) + return err +} + +func (c *tgClient) answerCallback(ctx context.Context, callbackID, text string) error { + payload := map[string]interface{}{"callback_query_id": callbackID} + if text != "" { + payload["text"] = text + } + _, err := c.call(ctx, "answerCallbackQuery", payload) + return err +} + +// sendPhotoBytes uploads a photo (e.g. a PIX QR PNG) via multipart. +func (c *tgClient) sendPhotoBytes(ctx context.Context, chatID int64, photo []byte, filename, caption string, kb *tgInlineKeyboard) (int64, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10)) + if caption != "" { + _ = w.WriteField("caption", caption) + _ = w.WriteField("parse_mode", "HTML") + } + if kb != nil { + kbJSON, _ := json.Marshal(kb) + _ = w.WriteField("reply_markup", string(kbJSON)) + } + fw, err := w.CreateFormFile("photo", filename) + if err != nil { + return 0, err + } + if _, err := fw.Write(photo); err != nil { + return 0, err + } + _ = w.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/sendPhoto", &buf) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := c.hc.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + var tr tgResponse + if err := json.Unmarshal(data, &tr); err != nil || !tr.OK { + return 0, fmt.Errorf("telegram sendPhoto: %s", string(data)) + } + var m tgMessage + _ = json.Unmarshal(tr.Result, &m) + return m.MessageID, nil +} + +func (c *tgClient) setWebhook(ctx context.Context, webhookURL, secret string) error { + payload := map[string]interface{}{ + "url": webhookURL, + "allowed_updates": []string{"message", "callback_query"}, + } + if secret != "" { + payload["secret_token"] = secret + } + _, err := c.call(ctx, "setWebhook", payload) + return err +} + +func (c *tgClient) deleteWebhook(ctx context.Context) error { + _, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false}) + return err +} + +// getMe validates the token and returns the bot username. +func (c *tgClient) getMe(ctx context.Context) (string, error) { + raw, err := c.call(ctx, "getMe", map[string]interface{}{}) + if err != nil { + return "", err + } + var me tgUser + _ = json.Unmarshal(raw, &me) + return me.Username, nil +} + +// ---------- helpers ---------- + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// parseWebhookUpdate decodes a Telegram webhook POST body. +func parseWebhookUpdate(r io.Reader) (*tgUpdate, error) { + var u tgUpdate + if err := json.NewDecoder(io.LimitReader(r, 4<<20)).Decode(&u); err != nil { + return nil, err + } + return &u, nil +} diff --git a/install.sh b/install.sh index daae407..717ba4e 100644 --- a/install.sh +++ b/install.sh @@ -400,11 +400,75 @@ CREATE TABLE IF NOT EXISTS xray_clients ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Telegram sales bot (created idempotently by the app too; mirrored here for a clean install) +CREATE TABLE IF NOT EXISTS bot_users ( + telegram_id BIGINT PRIMARY KEY, + username TEXT NOT NULL DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'customer', + linked_admin_username TEXT NOT NULL DEFAULT '', + credit_balance INT NOT NULL DEFAULT 0, + trial_used BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_plans ( + id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT 'ssh', + days INT NOT NULL DEFAULT 30, max_connections INT NOT NULL DEFAULT 1, + limit_mbps_up INT NOT NULL DEFAULT 0, limit_mbps_down INT NOT NULL DEFAULT 0, + xray_inbound_tag TEXT NOT NULL DEFAULT '', xray_protocol TEXT NOT NULL DEFAULT '', + price_cents INT NOT NULL DEFAULT 0, credit_cost INT NOT NULL DEFAULT 1, + server_id TEXT NOT NULL DEFAULT '', is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_credit_packages ( + id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', credits INT NOT NULL DEFAULT 0, + price_cents INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_transactions ( + id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, type TEXT NOT NULL, + plan_id INT, package_id INT, credits INT NOT NULL DEFAULT 0, amount_cents INT NOT NULL DEFAULT 0, + mp_payment_id TEXT NOT NULL DEFAULT '', mp_qr_code TEXT NOT NULL DEFAULT '', mp_qr_base64 TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', target_username TEXT NOT NULL DEFAULT '', renew_target TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), paid_at TIMESTAMPTZ, expires_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> ''; + +CREATE TABLE IF NOT EXISTS bot_credits_ledger ( + id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, delta INT NOT NULL, reason TEXT NOT NULL DEFAULT '', + ref_transaction_id INT, balance_after INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT ''); + +CREATE TABLE IF NOT EXISTS bot_config ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false, + telegram_mode TEXT NOT NULL DEFAULT 'polling', telegram_webhook_url TEXT NOT NULL DEFAULT '', + mp_confirm_mode TEXT NOT NULL DEFAULT 'polling', mp_poll_interval TEXT NOT NULL DEFAULT '20s', + pix_expiration_minutes INT NOT NULL DEFAULT 30, trial_enabled BOOLEAN NOT NULL DEFAULT true, + trial_hours INT NOT NULL DEFAULT 1, trial_max_connections INT NOT NULL DEFAULT 1, + trial_kind TEXT NOT NULL DEFAULT 'ssh', trial_inbound_tag TEXT NOT NULL DEFAULT '', + admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}', currency TEXT NOT NULL DEFAULT 'BRL', + public_host TEXT NOT NULL DEFAULT '', xray_public_host TEXT NOT NULL DEFAULT '', + telegram_token_enc BYTEA, telegram_webhook_secret_enc BYTEA, mp_access_token_enc BYTEA, mp_webhook_secret_enc BYTEA, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; + ALTER SCHEMA public OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS ssh_users OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS ssh_iface_totals OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS admin_users OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS xray_clients OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_users OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_plans OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_credit_packages OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_transactions OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_credits_ledger OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_settings OWNER TO ${DB_USER}; +ALTER TABLE IF EXISTS bot_config OWNER TO ${DB_USER}; ALTER SEQUENCE IF EXISTS admin_users_id_seq OWNER TO ${DB_USER}; GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER}; GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER}; diff --git a/main.go b/main.go index 5d95997..57813f1 100644 --- a/main.go +++ b/main.go @@ -1322,6 +1322,9 @@ func NewStore(dsn string) (*Store, error) { if err := store.EnsureManagedServersSchema(ctx); err != nil { return nil, err } + if err := store.EnsureBotSchema(ctx); err != nil { + return nil, err + } return store, nil } @@ -1608,6 +1611,19 @@ func startAdminAPI(store *Store, addr string, adminDir string) { // Superadmin-only: server config (read/write config.json + live banner apply) mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig))) + // Superadmin-only: Telegram sales bot management + mux.Handle("/api/bot/config", saSession(http.HandlerFunc(handleBotConfig(store)))) + mux.Handle("/api/bot/plans", saSession(http.HandlerFunc(handleBotPlans(store)))) + mux.Handle("/api/bot/credit-packages", saSession(http.HandlerFunc(handleBotCreditPackages(store)))) + mux.Handle("/api/bot/users", saSession(http.HandlerFunc(handleBotUsers(store)))) + mux.Handle("/api/bot/transactions", saSession(http.HandlerFunc(handleBotTransactions(store)))) + mux.Handle("/api/bot/settings", saSession(http.HandlerFunc(handleBotSettings(store)))) + mux.Handle("/api/bot/test", saSession(http.HandlerFunc(handleBotTest(store)))) + + // Public: payment + Telegram webhooks (secured by signature/secret inside). + mux.Handle("/api/mp/webhook", http.HandlerFunc(handleMPWebhook)) + mux.Handle("/api/telegram/webhook", http.HandlerFunc(handleTelegramWebhook)) + // Public: user/UUID check — no auth, CORS *. mux.Handle("/check", http.HandlerFunc(handleCheck)) @@ -2871,6 +2887,8 @@ func main() { log.Printf("failed to load admin users: %v", err) } startResellerExpiryChecker(store) + // Start the Telegram sales bot if enabled in the DB config. + startBotService(store) } // Optional: initialize interface totals persistence (best-effort).