bot TG
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
/shell2.exe
|
/shell2.exe
|
||||||
|
/BOT_PLAN.md
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// ─── Bot / Vendas (Telegram + Mercado Pago) ─────────────────────────────────
|
||||||
|
// Superadmin-only tab. Uses the shared api() helper from 01-core.js.
|
||||||
|
|
||||||
|
function botEsc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
function botStatus(id, msg, ok) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; }
|
||||||
|
}
|
||||||
|
function brl(cents) {
|
||||||
|
return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBotTab() {
|
||||||
|
loadBotConfig();
|
||||||
|
loadBotInbounds();
|
||||||
|
loadBotPlans();
|
||||||
|
loadBotPkgs();
|
||||||
|
loadBotUsers();
|
||||||
|
loadBotTxns();
|
||||||
|
loadBotSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config ───
|
||||||
|
async function loadBotConfig() {
|
||||||
|
try {
|
||||||
|
const res = await api("/api/bot/config");
|
||||||
|
const c = await res.json();
|
||||||
|
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
|
||||||
|
const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
|
||||||
|
chk("botEnabled", c.enabled);
|
||||||
|
set("botTelegramMode", c.telegram_mode);
|
||||||
|
set("botTelegramWebhookURL", c.telegram_webhook_url);
|
||||||
|
set("botMPConfirmMode", c.mp_confirm_mode);
|
||||||
|
set("botMPPollInterval", c.mp_poll_interval);
|
||||||
|
set("botPixExp", c.pix_expiration_minutes);
|
||||||
|
chk("botTrialEnabled", c.trial_enabled);
|
||||||
|
set("botTrialHours", c.trial_hours);
|
||||||
|
set("botTrialMaxConns", c.trial_max_connections);
|
||||||
|
set("botTrialKind", c.trial_kind);
|
||||||
|
set("botTrialInbound", c.trial_inbound_tag);
|
||||||
|
set("botAdminIDs", (c.admin_telegram_ids || []).join(","));
|
||||||
|
set("botPublicHost", c.public_host);
|
||||||
|
set("botXrayPublicHost", c.xray_public_host);
|
||||||
|
document.getElementById("botHasTgToken").textContent = c.has_telegram_token ? "✓ configurado" : "não definido";
|
||||||
|
document.getElementById("botHasTgSecret").textContent = c.has_telegram_webhook_secret ? "✓ configurado" : "não definido";
|
||||||
|
document.getElementById("botHasMpToken").textContent = c.has_mp_access_token ? "✓ configurado" : "não definido";
|
||||||
|
document.getElementById("botHasMpSecret").textContent = c.has_mp_webhook_secret ? "✓ configurado" : "não definido";
|
||||||
|
botStatus("botConfigStatus", "Carregado.");
|
||||||
|
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBotConfig() {
|
||||||
|
const val = id => (document.getElementById(id)?.value || "").trim();
|
||||||
|
const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||||
|
const chk = id => !!document.getElementById(id)?.checked;
|
||||||
|
const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||||||
|
const body = {
|
||||||
|
enabled: chk("botEnabled"),
|
||||||
|
telegram_token: val("botTelegramToken"),
|
||||||
|
telegram_mode: val("botTelegramMode"),
|
||||||
|
telegram_webhook_url: val("botTelegramWebhookURL"),
|
||||||
|
telegram_webhook_secret: val("botTelegramWebhookSecret"),
|
||||||
|
mp_access_token: val("botMPToken"),
|
||||||
|
mp_confirm_mode: val("botMPConfirmMode"),
|
||||||
|
mp_webhook_secret: val("botMPWebhookSecret"),
|
||||||
|
mp_poll_interval: val("botMPPollInterval"),
|
||||||
|
pix_expiration_minutes: num("botPixExp"),
|
||||||
|
trial_enabled: chk("botTrialEnabled"),
|
||||||
|
trial_hours: num("botTrialHours"),
|
||||||
|
trial_max_connections: num("botTrialMaxConns"),
|
||||||
|
trial_kind: val("botTrialKind"),
|
||||||
|
trial_inbound_tag: val("botTrialInbound"),
|
||||||
|
admin_telegram_ids: ids,
|
||||||
|
public_host: val("botPublicHost"),
|
||||||
|
xray_public_host: val("botXrayPublicHost"),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
["botTelegramToken", "botTelegramWebhookSecret", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; });
|
||||||
|
botStatus("botConfigStatus", "Configuração salva e bot reiniciado.");
|
||||||
|
loadBotConfig();
|
||||||
|
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testBot() {
|
||||||
|
botStatus("botConfigStatus", "Testando...");
|
||||||
|
const body = {
|
||||||
|
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
|
||||||
|
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
const r = await res.json();
|
||||||
|
const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`;
|
||||||
|
const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`;
|
||||||
|
botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok);
|
||||||
|
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inbounds datalist ───
|
||||||
|
async function loadBotInbounds() {
|
||||||
|
try {
|
||||||
|
const res = await api("/api/xray/inbounds");
|
||||||
|
const list = await res.json();
|
||||||
|
const dl = document.getElementById("botInboundList");
|
||||||
|
if (dl) dl.innerHTML = (list || []).map(ib => `<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);
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <telegram_id> <quantidade>", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid, _ := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
amt, _ := strconv.Atoi(f[2])
|
||||||
|
bal, err := b.store.AdjustCredits(b.ctx, tid, amt, "admin_adjust", nil)
|
||||||
|
if err != nil {
|
||||||
|
b.send(chatID, "Erro: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.send(chatID, fmt.Sprintf("✅ Ajuste aplicado. Novo saldo de %d: %d créditos", tid, bal), nil)
|
||||||
|
b.notify(tid, fmt.Sprintf("💳 Seu saldo foi ajustado em %+d créditos. Saldo atual: %d", amt, bal))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) notify(telegramID int64, text string) {
|
||||||
|
if _, err := b.tg.sendMessage(b.ctx, telegramID, text, nil); err != nil {
|
||||||
|
log.Printf("[bot] notify %d: %v", telegramID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- labels ----------
|
||||||
|
|
||||||
|
func txnTypeLabel(t string) string {
|
||||||
|
switch t {
|
||||||
|
case "plan_purchase":
|
||||||
|
return "Compra"
|
||||||
|
case "plan_renewal":
|
||||||
|
return "Renovação"
|
||||||
|
case "credit_topup":
|
||||||
|
return "Recarga"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "approved":
|
||||||
|
return "✅ pago"
|
||||||
|
case "pending":
|
||||||
|
return "⏳ pendente"
|
||||||
|
case "expired":
|
||||||
|
return "⌛ expirado"
|
||||||
|
case "refunded":
|
||||||
|
return "↩️ estornado"
|
||||||
|
case "error":
|
||||||
|
return "❌ erro"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -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
@@ -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")}))
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,253 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// bot_telegram.go — minimal Telegram Bot API client built on net/http.
|
||||||
|
// No third-party dependency.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tgAPIBase = "https://api.telegram.org/bot"
|
||||||
|
|
||||||
|
// ---------- Wire types (subset) ----------
|
||||||
|
|
||||||
|
type tgUpdate struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
Message *tgMessage `json:"message"`
|
||||||
|
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgMessage struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
From *tgUser `json:"from"`
|
||||||
|
Chat tgChat `json:"chat"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgCallbackQuery struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
From tgUser `json:"from"`
|
||||||
|
Message *tgMessage `json:"message"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgUser struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgChat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgInlineKeyboard struct {
|
||||||
|
InlineKeyboard [][]tgInlineButton `json:"inline_keyboard"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgInlineButton struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
CallbackData string `json:"callback_data,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Client ----------
|
||||||
|
|
||||||
|
type tgClient struct {
|
||||||
|
token string
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTGClient(token string) *tgClient {
|
||||||
|
return &tgClient{token: token, hc: &http.Client{Timeout: 65 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) call(ctx context.Context, method string, payload interface{}) (json.RawMessage, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/"+method, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
var tr tgResponse
|
||||||
|
if err := json.Unmarshal(data, &tr); err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram %s: bad response: %s", method, string(data))
|
||||||
|
}
|
||||||
|
if !tr.OK {
|
||||||
|
return nil, fmt.Errorf("telegram %s: %s", method, tr.Description)
|
||||||
|
}
|
||||||
|
return tr.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUpdates long-polls. offset is the next update_id to fetch.
|
||||||
|
func (c *tgClient) getUpdates(ctx context.Context, offset int64, timeoutSec int) ([]tgUpdate, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"offset": offset,
|
||||||
|
"timeout": timeoutSec,
|
||||||
|
"allowed_updates": []string{"message", "callback_query"},
|
||||||
|
}
|
||||||
|
raw, err := c.call(ctx, "getUpdates", payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ups []tgUpdate
|
||||||
|
if err := json.Unmarshal(raw, &ups); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) sendMessage(ctx context.Context, chatID int64, text string, kb *tgInlineKeyboard) (int64, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": true,
|
||||||
|
}
|
||||||
|
if kb != nil {
|
||||||
|
payload["reply_markup"] = kb
|
||||||
|
}
|
||||||
|
raw, err := c.call(ctx, "sendMessage", payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var m tgMessage
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
return m.MessageID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) editMessageText(ctx context.Context, chatID, messageID int64, text string, kb *tgInlineKeyboard) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": true,
|
||||||
|
}
|
||||||
|
if kb != nil {
|
||||||
|
payload["reply_markup"] = kb
|
||||||
|
}
|
||||||
|
_, err := c.call(ctx, "editMessageText", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) answerCallback(ctx context.Context, callbackID, text string) error {
|
||||||
|
payload := map[string]interface{}{"callback_query_id": callbackID}
|
||||||
|
if text != "" {
|
||||||
|
payload["text"] = text
|
||||||
|
}
|
||||||
|
_, err := c.call(ctx, "answerCallbackQuery", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPhotoBytes uploads a photo (e.g. a PIX QR PNG) via multipart.
|
||||||
|
func (c *tgClient) sendPhotoBytes(ctx context.Context, chatID int64, photo []byte, filename, caption string, kb *tgInlineKeyboard) (int64, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
|
||||||
|
if caption != "" {
|
||||||
|
_ = w.WriteField("caption", caption)
|
||||||
|
_ = w.WriteField("parse_mode", "HTML")
|
||||||
|
}
|
||||||
|
if kb != nil {
|
||||||
|
kbJSON, _ := json.Marshal(kb)
|
||||||
|
_ = w.WriteField("reply_markup", string(kbJSON))
|
||||||
|
}
|
||||||
|
fw, err := w.CreateFormFile("photo", filename)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(photo); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/sendPhoto", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
var tr tgResponse
|
||||||
|
if err := json.Unmarshal(data, &tr); err != nil || !tr.OK {
|
||||||
|
return 0, fmt.Errorf("telegram sendPhoto: %s", string(data))
|
||||||
|
}
|
||||||
|
var m tgMessage
|
||||||
|
_ = json.Unmarshal(tr.Result, &m)
|
||||||
|
return m.MessageID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) setWebhook(ctx context.Context, webhookURL, secret string) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"url": webhookURL,
|
||||||
|
"allowed_updates": []string{"message", "callback_query"},
|
||||||
|
}
|
||||||
|
if secret != "" {
|
||||||
|
payload["secret_token"] = secret
|
||||||
|
}
|
||||||
|
_, err := c.call(ctx, "setWebhook", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tgClient) deleteWebhook(ctx context.Context) error {
|
||||||
|
_, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMe validates the token and returns the bot username.
|
||||||
|
func (c *tgClient) getMe(ctx context.Context) (string, error) {
|
||||||
|
raw, err := c.call(ctx, "getMe", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var me tgUser
|
||||||
|
_ = json.Unmarshal(raw, &me)
|
||||||
|
return me.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWebhookUpdate decodes a Telegram webhook POST body.
|
||||||
|
func parseWebhookUpdate(r io.Reader) (*tgUpdate, error) {
|
||||||
|
var u tgUpdate
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r, 4<<20)).Decode(&u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
+64
@@ -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};
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user