This commit is contained in:
2026-07-13 00:06:51 -03:00
parent a4798f8db6
commit 09c1f57a34
17 changed files with 3799 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
/shell2.exe /shell2.exe
/BOT_PLAN.md
+33
View File
@@ -1250,3 +1250,36 @@ Reads/writes the panel's `config.json` and hot-applies changes.
#### `GET /check` — none #### `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. 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.
+2
View File
@@ -9,6 +9,7 @@ const tabTitles = {
stats: ["Server", "Monitoring"], stats: ["Server", "Monitoring"],
vnstat: ["Traffic", "VnStat"], vnstat: ["Traffic", "VnStat"],
logs: ["System", "Logs"], logs: ["System", "Logs"],
bot: ["Vendas", "Bot / Telegram"],
server: ["System", "Settings"], server: ["System", "Settings"],
}; };
function updatePageHeading() { function updatePageHeading() {
@@ -39,6 +40,7 @@ function selectTab(tab) {
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
if (tab === "resellers" && currentRole === "superadmin") loadResellers(); if (tab === "resellers" && currentRole === "superadmin") loadResellers();
if (tab === "servers" && currentRole === "superadmin") loadServers(); 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))); document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
+323
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
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 => `<option value="${botEsc(ib.tag)}">${botEsc(ib.protocol)}</option>`).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 => `
<tr>
<td>${botEsc(p.Name)}</td><td>${botEsc(p.Kind)}</td><td>${p.Days}</td>
<td>${brl(p.PriceCents)}</td><td>${p.CreditCost}</td>
<td>${p.IsActive ? "✅" : "—"}</td>
<td>
<button class="btn btn-ghost btn-sm" onclick='botEditPlan(${JSON.stringify(p)})'>Editar</button>
<button class="btn btn-ghost btn-sm" onclick='botDeletePlan(${p.ID})'>Excluir</button>
</td>
</tr>`).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 => `
<tr>
<td>${botEsc(p.Name)}</td><td>${p.Credits}</td><td>${brl(p.PriceCents)}</td>
<td>${p.IsActive ? "✅" : "—"}</td>
<td>
<button class="btn btn-ghost btn-sm" onclick='botEditPkg(${JSON.stringify(p)})'>Editar</button>
<button class="btn btn-ghost btn-sm" onclick='botDeletePkg(${p.ID})'>Excluir</button>
</td>
</tr>`).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 => `
<tr>
<td>${u.TelegramID}</td><td>${botEsc(u.FirstName)}</td><td>${botEsc(u.Username)}</td>
<td>${botEsc(u.Role)}</td><td>${botEsc(u.LinkedAdminUsername)}</td><td>${u.CreditBalance}</td>
<td>
<button class="btn btn-ghost btn-sm" onclick='botPromote(${u.TelegramID})'>Papel</button>
<button class="btn btn-ghost btn-sm" onclick='botCredit(${u.TelegramID})'>Créditos</button>
<button class="btn btn-ghost btn-sm" onclick='botBlock(${u.TelegramID}, ${u.Role === "blocked"})'>${u.Role === "blocked" ? "Desbloq." : "Bloquear"}</button>
</td>
</tr>`).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 => `
<tr>
<td>${t.ID}</td><td>${t.TelegramID}</td><td>${botEsc(t.Type)}</td><td>${brl(t.AmountCents)}</td>
<td>${botEsc(t.Status)}</td><td>${botEsc(t.TargetUsername)}</td>
<td>${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))}</td>
<td>
${t.Status === "pending" || t.Status === "approved" ? `<button class="btn btn-ghost btn-sm" onclick='botReprocess(${t.ID})'>Reprocessar</button>` : ""}
${t.Status !== "refunded" ? `<button class="btn btn-ghost btn-sm" onclick='botRefund(${t.ID})'>Estornar</button>` : ""}
</td>
</tr>`).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);
+173
View File
@@ -57,6 +57,7 @@
<button class="tab-btn superadmin-only hidden" data-tab="stats"><span class="nav-icon">📊</span><span>Servidor</span></button> <button class="tab-btn superadmin-only hidden" data-tab="stats"><span class="nav-icon">📊</span><span>Servidor</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="vnstat"><span class="nav-icon"></span><span>Tráfego</span></button> <button class="tab-btn superadmin-only hidden" data-tab="vnstat"><span class="nav-icon"></span><span>Tráfego</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon"></span><span>Logs</span></button> <button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon"></span><span>Logs</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="server"><span class="nav-icon"></span><span>Configurações</span></button> <button class="tab-btn superadmin-only hidden" data-tab="server"><span class="nav-icon"></span><span>Configurações</span></button>
</nav> </nav>
</aside> </aside>
@@ -948,6 +949,177 @@
</div> </div>
</div><!-- /tab-logs --> </div><!-- /tab-logs -->
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-bot">
<!-- Config -->
<div class="card">
<div class="card-hdr">
<div class="card-title">🤖 Configuração do Bot</div>
<div style="display:flex;gap:5px;">
<button class="btn btn-ghost btn-sm" id="botTestBtn">Testar conexão</button>
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn">Recarregar</button>
</div>
</div>
<div class="form-grid">
<div class="field"><label>Bot ativo</label><input id="botEnabled" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
<div class="field"><label>Token do Telegram <span class="hint" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<div class="field"><label>Modo do Telegram</label><select id="botTelegramMode"><option value="polling">polling (recomendado)</option><option value="webhook">webhook</option></select></div>
<div class="field"><label>Telegram Webhook URL</label><input id="botTelegramWebhookURL" placeholder="https://seu.dominio/api/telegram/webhook"/></div>
<div class="field"><label>Telegram Webhook Secret <span class="hint" id="botHasTgSecret"></span></label><input id="botTelegramWebhookSecret" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<div class="field"><label>Mercado Pago Access Token <span class="hint" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<div class="field"><label>Confirmação do pagamento</label><select id="botMPConfirmMode"><option value="polling">polling (recomendado)</option><option value="webhook">webhook</option></select></div>
<div class="field"><label>MP Webhook Secret <span class="hint" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<div class="field"><label>Intervalo de verificação (polling)</label><input id="botMPPollInterval" placeholder="20s"/></div>
<div class="field"><label>Expiração do PIX (min)</label><input id="botPixExp" type="number" min="1" placeholder="30"/></div>
<div class="field"><label>Teste grátis ativo</label><input id="botTrialEnabled" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
<div class="field"><label>Teste: horas</label><input id="botTrialHours" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Teste: conexões</label><input id="botTrialMaxConns" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Teste: tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
<div class="field"><label>Teste: inbound Xray</label><input id="botTrialInbound" placeholder="tag do inbound (se Xray)"/></div>
<div class="field"><label>IDs admin no Telegram <span class="hint">(vírgula)</span></label><input id="botAdminIDs" placeholder="111111111,222222222"/></div>
<div class="field"><label>Host público SSH</label><input id="botPublicHost" placeholder="seu.dominio ou IP"/></div>
<div class="field"><label>Host público Xray</label><input id="botXrayPublicHost" placeholder="seu.dominio (para links vless/vmess)"/></div>
</div>
<div class="form-actions">
<button class="btn" id="botConfigSaveBtn">Salvar configuração</button>
</div>
<div class="statusbar"><span id="botConfigStatus">Ready.</span></div>
</div>
<div class="grid2">
<!-- Plans -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💎 Planos <span class="chip" id="botPlanCount">0</span></div>
<div style="display:flex;gap:5px;">
<button class="btn btn-ghost btn-sm" id="botNewPlanBtn">+ Novo</button>
<button class="btn btn-ghost btn-sm" id="botReloadPlansBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Nome</th><th>Tipo</th><th>Dias</th><th>Preço</th><th>Créd.</th><th>Ativo</th><th>Ações</th></tr></thead>
<tbody id="botPlansBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botPlansStatus">Ready.</span></div>
</div>
<!-- Plan form -->
<div class="card">
<div class="card-hdr">
<div class="card-title" id="botPlanFormTitle">Novo Plano</div>
<button class="btn btn-ghost btn-sm" id="botCancelPlanBtn">Limpar</button>
</div>
<form id="botPlanForm">
<input type="hidden" id="planId"/>
<div class="form-grid">
<div class="field"><label>Nome</label><input id="planName" required/></div>
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
<div class="field"><label>Dias de validade</label><input id="planDays" type="number" min="1" value="30"/></div>
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" value="1"/></div>
<div class="field"><label>Limite Mbps ↑ (SSH)</label><input id="planUpMbps" type="number" min="0" value="0"/></div>
<div class="field"><label>Limite Mbps ↓ (SSH)</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag (se Xray)"/><datalist id="botInboundList"></datalist></div>
<div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">-</option><option value="vless">vless</option><option value="vmess">vmess</option><option value="trojan">trojan</option></select></div>
<div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div>
<div class="field"><label>Server ID <span class="hint">(vazio=local)</span></label><input id="planServerId"/></div>
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div>
<div class="field"><label>Ativo</label><input id="planActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
</div>
<div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div>
</form>
</div>
</div>
<div class="grid2">
<!-- Credit packages -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💳 Pacotes de Crédito <span class="chip" id="botPkgCount">0</span></div>
<div style="display:flex;gap:5px;">
<button class="btn btn-ghost btn-sm" id="botNewPkgBtn">+ Novo</button>
<button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Nome</th><th>Créditos</th><th>Preço</th><th>Ativo</th><th>Ações</th></tr></thead>
<tbody id="botPkgsBody"></tbody>
</table>
</div>
<form id="botPkgForm" style="margin-top:10px;">
<input type="hidden" id="pkgId"/>
<div class="form-grid">
<div class="field"><label>Nome</label><input id="pkgName" required/></div>
<div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div>
<div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div>
<div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div>
<div class="field"><label>Ativo</label><input id="pkgActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
</div>
<div class="form-actions"><button class="btn" type="submit">Salvar pacote</button></div>
</form>
<div class="statusbar"><span id="botPkgStatus">Ready.</span></div>
</div>
<!-- Messages -->
<div class="card">
<div class="card-hdr">
<div class="card-title">✉️ Mensagens do Bot</div>
<button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn">Recarregar</button>
</div>
<div class="field"><label>Boas-vindas <span class="hint">({name} = nome)</span></label><textarea id="setWelcome" rows="4"></textarea></div>
<div class="field"><label>Texto de Contato</label><textarea id="setContact" rows="3"></textarea></div>
<div class="field"><label>Texto Baixar APP</label><textarea id="setAppText" rows="2"></textarea></div>
<div class="field"><label>URL do APP <span class="hint">(botão vira link)</span></label><input id="setAppUrl" placeholder="https://..."/></div>
<div class="form-actions"><button class="btn" id="botSaveSettingsBtn">Salvar mensagens</button></div>
<div class="statusbar"><span id="botSettingsStatus">Ready.</span></div>
</div>
</div>
<!-- Bot users -->
<div class="card">
<div class="card-hdr">
<div class="card-title">👥 Clientes do Bot <span class="chip" id="botUserCount">0</span></div>
<button class="btn btn-ghost btn-sm" id="botReloadUsersBtn">Recarregar</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Telegram ID</th><th>Nome</th><th>@user</th><th>Papel</th><th>Revenda</th><th>Créditos</th><th>Ações</th></tr></thead>
<tbody id="botUsersBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botUsersStatus">Ready.</span></div>
</div>
<!-- Transactions -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💰 Pagamentos <span class="chip" id="botTxnCount">0</span></div>
<div style="display:flex;gap:5px;align-items:center;">
<select id="botTxnFilter" class="btn-sm">
<option value="">Todos</option>
<option value="pending">Pendentes</option>
<option value="approved">Aprovados</option>
<option value="expired">Expirados</option>
<option value="refunded">Estornados</option>
</select>
<button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>Telegram</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Conta</th><th>Data</th><th>Ações</th></tr></thead>
<tbody id="botTxnsBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botTxnStatus">Ready.</span></div>
</div>
</div><!-- /tab-bot -->
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ --> <!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-server"> <div class="tab-pane" id="tab-server">
@@ -1271,6 +1443,7 @@
<script defer src="assets/js/08-server-config.js?v=20260711updatecheck1"></script> <script defer src="assets/js/08-server-config.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260711updatecheck1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/11-update-status.js?v=20260711updatecheck1"></script> <script defer src="assets/js/11-update-status.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/12-bot.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/10-boot.js?v=20260711updatecheck1"></script> <script defer src="assets/js/10-boot.js?v=20260711updatecheck1"></script>
</body> </body>
</html> </html>
+445
View File
@@ -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)
}
+167
View File
@@ -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)
}
+272
View File
@@ -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")})
}
+151
View File
@@ -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:]
}
+511
View File
@@ -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á <b>%s</b>, seja bem-vindo!\n\n🚀 Aqui você encontra os melhores planos <b>SSH</b> e <b>Xray</b> 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("🛍️ <b>Suas Compras</b>\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: <code>" + htmlEscape(t.TargetUsername) + "</code>\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("👑 <b>Área do Revendedor</b>\n\n💳 Saldo: <b>%d créditos</b>", 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("👥 <b>Seus Clientes</b>\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 <code>%s</code> — %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 <code>%s</code> — %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("💳 <b>Pagamento PIX</b>\n%s\nValor: <b>%s</b>\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, "📋 <b>PIX Copia e Cola:</b>\n<code>"+htmlEscape(txn.MPQRCode)+"</code>", 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("✅ <b>%s</b>\n\n🔒 <b>Conta SSH</b>\nHost: <code>%s</code>\nUsuário: <code>%s</code>\nSenha: <code>%s</code>\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("✅ <b>%s</b>\n\n⚡ <b>Conta Xray</b>\nUUID: <code>%s</code>\nValidade: %s\n\n🔗 Link de conexão:\n<code>%s</code>",
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("📊 <b>Estatísticas</b>\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 &lt;telegram_id&gt; &lt;quantidade&gt;", 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
}
+205
View File
@@ -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:<dataID>;request-id:<x-request-id>;ts:<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)
}
+237
View File
@@ -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("✅ <b>%s</b> renovado!\n⚡ Xray <code>%s</code>\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("✅ <b>%s</b> renovado!\n🔒 SSH <code>%s</code>\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")}))
}
+272
View File
@@ -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))
}
}
+672
View File
@@ -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
}
+253
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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
}
+64
View File
@@ -400,11 +400,75 @@ CREATE TABLE IF NOT EXISTS xray_clients (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 SCHEMA public OWNER TO ${DB_USER};
ALTER TABLE IF EXISTS ssh_users 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 ssh_iface_totals OWNER TO ${DB_USER};
ALTER TABLE IF EXISTS admin_users 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 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}; 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 DATABASE ${DB_NAME} TO ${DB_USER};
GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER}; GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};
+18
View File
@@ -1322,6 +1322,9 @@ func NewStore(dsn string) (*Store, error) {
if err := store.EnsureManagedServersSchema(ctx); err != nil { if err := store.EnsureManagedServersSchema(ctx); err != nil {
return nil, err return nil, err
} }
if err := store.EnsureBotSchema(ctx); err != nil {
return nil, err
}
return store, nil 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) // Superadmin-only: server config (read/write config.json + live banner apply)
mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig))) 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 *. // Public: user/UUID check — no auth, CORS *.
mux.Handle("/check", http.HandlerFunc(handleCheck)) mux.Handle("/check", http.HandlerFunc(handleCheck))
@@ -2871,6 +2887,8 @@ func main() {
log.Printf("failed to load admin users: %v", err) log.Printf("failed to load admin users: %v", err)
} }
startResellerExpiryChecker(store) startResellerExpiryChecker(store)
// Start the Telegram sales bot if enabled in the DB config.
startBotService(store)
} }
// Optional: initialize interface totals persistence (best-effort). // Optional: initialize interface totals persistence (best-effort).