This commit is contained in:
2026-07-13 00:17:55 -03:00
parent 09c1f57a34
commit 2776eba034
9 changed files with 192 additions and 225 deletions
+6 -6
View File
@@ -1260,9 +1260,10 @@ Public status lookup for SSH users and Xray/V2Ray UUIDs (CORS `*`). See the **Pu
### Como configurar ### Como configurar
1. Abra o painel → aba **Bot / Vendas**. 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**. 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): 3. **Confirmação do pagamento — 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`). - **Polling** (padrão): o bot consulta o status do PIX a cada intervalo. Não precisa de domínio/HTTPS.
- **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`). - **Webhook**: configure no painel do Mercado Pago a URL `https://SEU_DOMINIO/api/mp/webhook`. O painel mostra a URL exata quando você seleciona esse modo.
- O **Telegram** usa long-polling automático — não requer domínio, webhook nem configuração extra.
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*. 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. 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. 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.
@@ -1279,7 +1280,6 @@ Os tokens (Telegram, Mercado Pago e segredos de webhook) são gravados **criptog
- `GET/POST /api/bot/transactions` — lista pagamentos; POST com `action` = `refund` \| `reprocess`. - `GET/POST /api/bot/transactions` — lista pagamentos; POST com `action` = `refund` \| `reprocess`.
- `GET/POST /api/bot/settings` — textos do bot (chave/valor). - `GET/POST /api/bot/settings` — textos do bot (chave/valor).
- `POST /api/bot/test` — testa token do Telegram e do Mercado Pago. - `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/mp/webhook`**público**, chamado pelo Mercado Pago quando o modo de confirmação é *webhook* (valida `x-signature` se houver segredo; sempre reconfirma o pagamento na API antes de liberar). Ignorado em modo *polling*.
- `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. **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). Telegram runs on long-polling (no domain needed); only **Mercado Pago confirmation** is toggleable between **polling** (default) and **webhook** in the panel.
+15 -10
View File
@@ -32,8 +32,6 @@ async function loadBotConfig() {
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; 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; }; const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
chk("botEnabled", c.enabled); chk("botEnabled", c.enabled);
set("botTelegramMode", c.telegram_mode);
set("botTelegramWebhookURL", c.telegram_webhook_url);
set("botMPConfirmMode", c.mp_confirm_mode); set("botMPConfirmMode", c.mp_confirm_mode);
set("botMPPollInterval", c.mp_poll_interval); set("botMPPollInterval", c.mp_poll_interval);
set("botPixExp", c.pix_expiration_minutes); set("botPixExp", c.pix_expiration_minutes);
@@ -45,14 +43,23 @@ async function loadBotConfig() {
set("botAdminIDs", (c.admin_telegram_ids || []).join(",")); set("botAdminIDs", (c.admin_telegram_ids || []).join(","));
set("botPublicHost", c.public_host); set("botPublicHost", c.public_host);
set("botXrayPublicHost", c.xray_public_host); set("botXrayPublicHost", c.xray_public_host);
document.getElementById("botHasTgToken").textContent = c.has_telegram_token ? "✓ configurado" : "não definido"; const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; };
document.getElementById("botHasTgSecret").textContent = c.has_telegram_webhook_secret ? "✓ configurado" : "não definido"; hint("botHasTgToken", c.has_telegram_token);
document.getElementById("botHasMpToken").textContent = c.has_mp_access_token ? "✓ configurado" : "não definido"; hint("botHasMpToken", c.has_mp_access_token);
document.getElementById("botHasMpSecret").textContent = c.has_mp_webhook_secret ? "✓ configurado" : "não definido"; hint("botHasMpSecret", c.has_mp_webhook_secret);
botToggleMPWebhookBox();
botStatus("botConfigStatus", "Carregado."); botStatus("botConfigStatus", "Carregado.");
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); } } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); }
} }
function botToggleMPWebhookBox() {
const mode = document.getElementById("botMPConfirmMode")?.value;
const box = document.getElementById("botMPWebhookBox");
if (box) box.style.display = mode === "webhook" ? "" : "none";
const url = document.getElementById("botMPWebhookURL");
if (url) url.textContent = location.origin + "/api/mp/webhook";
}
async function saveBotConfig() { async function saveBotConfig() {
const val = id => (document.getElementById(id)?.value || "").trim(); const val = id => (document.getElementById(id)?.value || "").trim();
const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0; const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0;
@@ -61,9 +68,6 @@ async function saveBotConfig() {
const body = { const body = {
enabled: chk("botEnabled"), enabled: chk("botEnabled"),
telegram_token: val("botTelegramToken"), telegram_token: val("botTelegramToken"),
telegram_mode: val("botTelegramMode"),
telegram_webhook_url: val("botTelegramWebhookURL"),
telegram_webhook_secret: val("botTelegramWebhookSecret"),
mp_access_token: val("botMPToken"), mp_access_token: val("botMPToken"),
mp_confirm_mode: val("botMPConfirmMode"), mp_confirm_mode: val("botMPConfirmMode"),
mp_webhook_secret: val("botMPWebhookSecret"), mp_webhook_secret: val("botMPWebhookSecret"),
@@ -80,7 +84,7 @@ async function saveBotConfig() {
}; };
try { try {
await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) }); 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 = ""; }); ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; });
botStatus("botConfigStatus", "Configuração salva e bot reiniciado."); botStatus("botConfigStatus", "Configuração salva e bot reiniciado.");
loadBotConfig(); loadBotConfig();
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); } } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); }
@@ -309,6 +313,7 @@ async function saveBotSettings() {
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig); document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig); document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig);
document.getElementById("botTestBtn")?.addEventListener("click", testBot); document.getElementById("botTestBtn")?.addEventListener("click", testBot);
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans); document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
+67 -37
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260711updatecheck1"/> <link rel="stylesheet" href="assets/app.css?v=20260713bot2"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -952,39 +952,69 @@
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ --> <!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-bot"> <div class="tab-pane" id="tab-bot">
<!-- Config --> <!-- Config: sticky action bar -->
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">🤖 Configuração do Bot</div> <div class="card-title">🤖 Configuração do Bot</div>
<div style="display:flex;gap:5px;"> <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span id="botConfigStatus" class="hint">Pronto.</span>
<button class="btn btn-ghost btn-sm" id="botTestBtn">Testar conexão</button> <button class="btn btn-ghost btn-sm" id="botTestBtn">Testar conexão</button>
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn">Recarregar</button> <button class="btn btn-ghost btn-sm" id="botConfigReloadBtn">Recarregar</button>
<button class="btn btn-sm" id="botConfigSaveBtn">Salvar configuração</button>
</div> </div>
</div> </div>
<div class="form-grid"> <p class="hint" style="margin:2px 0 0;">1) Cole os tokens abaixo e clique <b>Testar</b> · 2) Crie os <b>Planos</b> · 3) Promova revendedores em <b>Clientes</b>. O Telegram funciona por long-polling — não precisa de domínio.</p>
<div class="field"><label>Bot ativo</label><input id="botEnabled" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div> </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="grid2">
<div class="field"><label>Telegram Webhook URL</label><input id="botTelegramWebhookURL" placeholder="https://seu.dominio/api/telegram/webhook"/></div> <!-- Telegram -->
<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="card">
<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="card-hdr"><div class="card-title">✈️ Telegram</div></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> <label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
<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> <input id="botEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Bot ativo</span>
<div class="field"><label>Intervalo de verificação (polling)</label><input id="botMPPollInterval" placeholder="20s"/></div> </label>
<div class="field"><label>Expiração do PIX (min)</label><input id="botPixExp" type="number" min="1" placeholder="30"/></div> <div class="field"><label>Token do bot <span class="hint" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="cole o token do @BotFather (em branco = manter)"/></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>IDs de admin <span class="hint">separados por vírgula</span></label><input id="botAdminIDs" placeholder="111111111,222222222"/></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>
<div class="form-actions">
<button class="btn" id="botConfigSaveBtn">Salvar configuração</button> <!-- Mercado Pago -->
<div class="card">
<div class="card-hdr"><div class="card-title">💠 Mercado Pago (PIX)</div></div>
<div class="field"><label>Access Token <span class="hint" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-... (em branco = manter)"/></div>
<div class="form-grid">
<div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling (sem domínio)</option><option value="webhook">Webhook</option></select></div>
<div class="field"><label>Intervalo do 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>
<div id="botMPWebhookBox" style="display:none;margin-top:6px;">
<div class="field"><label>Webhook Secret <span class="hint" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<p class="hint" style="margin:4px 0 0;">No Mercado Pago, cadastre a URL de notificação:<br><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code></p>
</div>
</div>
</div>
<div class="grid2">
<!-- Trial -->
<div class="card">
<div class="card-hdr"><div class="card-title">⏳ Teste Grátis</div></div>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
<input id="botTrialEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Oferecer teste grátis</span>
</label>
<div class="form-grid">
<div class="field"><label>Duração (horas)</label><input id="botTrialHours" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
<div class="field"><label>Inbound (se Xray)</label><input id="botTrialInbound" list="botInboundList" placeholder="tag"/></div>
</div>
</div>
<!-- Delivery host -->
<div class="card">
<div class="card-hdr"><div class="card-title">🌐 Host de entrega</div></div>
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="seu.dominio ou IP público"/></div>
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="seu.dominio (vless/vmess)"/></div>
<p class="hint" style="margin:4px 0 0;">Enviado ao cliente nas credenciais após o pagamento.</p>
</div> </div>
<div class="statusbar"><span id="botConfigStatus">Ready.</span></div>
</div> </div>
<div class="grid2"> <div class="grid2">
@@ -1433,17 +1463,17 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260711updatecheck1"></script> <script defer src="assets/js/01-core.js?v=20260713bot2"></script>
<script defer src="assets/js/02-shell.js?v=20260711updatecheck1"></script> <script defer src="assets/js/02-shell.js?v=20260713bot2"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260711updatecheck1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260713bot2"></script>
<script defer src="assets/js/04-xray.js?v=20260711updatecheck1"></script> <script defer src="assets/js/04-xray.js?v=20260713bot2"></script>
<script defer src="assets/js/05-resellers.js?v=20260711updatecheck1"></script> <script defer src="assets/js/05-resellers.js?v=20260713bot2"></script>
<script defer src="assets/js/06-servers.js?v=20260711updatecheck1"></script> <script defer src="assets/js/06-servers.js?v=20260713bot2"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260711updatecheck1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260713bot2"></script>
<script defer src="assets/js/08-server-config.js?v=20260711updatecheck1"></script> <script defer src="assets/js/08-server-config.js?v=20260713bot2"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260711updatecheck1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260713bot2"></script>
<script defer src="assets/js/11-update-status.js?v=20260711updatecheck1"></script> <script defer src="assets/js/11-update-status.js?v=20260713bot2"></script>
<script defer src="assets/js/12-bot.js?v=20260711updatecheck1"></script> <script defer src="assets/js/12-bot.js?v=20260713bot2"></script>
<script defer src="assets/js/10-boot.js?v=20260711updatecheck1"></script> <script defer src="assets/js/10-boot.js?v=20260713bot2"></script>
</body> </body>
</html> </html>
+52 -84
View File
@@ -1,6 +1,6 @@
package main package main
// bot_api.go — /api/bot/* admin endpoints (superadmin) + Telegram webhook route. // bot_api.go — /api/bot/* admin endpoints (superadmin).
import ( import (
"encoding/json" "encoding/json"
@@ -25,30 +25,26 @@ func botStoreReady(w http.ResponseWriter, store *Store) bool {
// ---------- Config ---------- // ---------- Config ----------
type botConfigDTO struct { type botConfigDTO struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TelegramMode string `json:"telegram_mode"` MPConfirmMode string `json:"mp_confirm_mode"`
TelegramWebhookURL string `json:"telegram_webhook_url"` MPPollInterval string `json:"mp_poll_interval"`
MPConfirmMode string `json:"mp_confirm_mode"` PixExpirationMinutes int `json:"pix_expiration_minutes"`
MPPollInterval string `json:"mp_poll_interval"` TrialEnabled bool `json:"trial_enabled"`
PixExpirationMinutes int `json:"pix_expiration_minutes"` TrialHours int `json:"trial_hours"`
TrialEnabled bool `json:"trial_enabled"` TrialMaxConnections int `json:"trial_max_connections"`
TrialHours int `json:"trial_hours"` TrialKind string `json:"trial_kind"`
TrialMaxConnections int `json:"trial_max_connections"` TrialInboundTag string `json:"trial_inbound_tag"`
TrialKind string `json:"trial_kind"` AdminTelegramIDs []int64 `json:"admin_telegram_ids"`
TrialInboundTag string `json:"trial_inbound_tag"` Currency string `json:"currency"`
AdminTelegramIDs []int64 `json:"admin_telegram_ids"` PublicHost string `json:"public_host"`
Currency string `json:"currency"` XrayPublicHost string `json:"xray_public_host"`
PublicHost string `json:"public_host"` HasTelegramToken bool `json:"has_telegram_token"`
XrayPublicHost string `json:"xray_public_host"` HasMPAccessToken bool `json:"has_mp_access_token"`
HasTelegramToken bool `json:"has_telegram_token"` HasMPWebhookSecret bool `json:"has_mp_webhook_secret"`
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). // Write-only secret fields (empty on GET; empty on POST = keep existing).
TelegramToken string `json:"telegram_token"` TelegramToken string `json:"telegram_token"`
MPAccessToken string `json:"mp_access_token"` MPAccessToken string `json:"mp_access_token"`
TelegramWebhookSecret string `json:"telegram_webhook_secret"` MPWebhookSecret string `json:"mp_webhook_secret"`
MPWebhookSecret string `json:"mp_webhook_secret"`
} }
func handleBotConfig(store *Store) http.HandlerFunc { func handleBotConfig(store *Store) http.HandlerFunc {
@@ -65,25 +61,22 @@ func handleBotConfig(store *Store) http.HandlerFunc {
return return
} }
botWriteJSON(w, botConfigDTO{ botWriteJSON(w, botConfigDTO{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
TelegramMode: cfg.TelegramMode, MPConfirmMode: cfg.MPConfirmMode,
TelegramWebhookURL: cfg.TelegramWebhookURL, MPPollInterval: cfg.MPPollInterval,
MPConfirmMode: cfg.MPConfirmMode, PixExpirationMinutes: cfg.PixExpirationMinutes,
MPPollInterval: cfg.MPPollInterval, TrialEnabled: cfg.TrialEnabled,
PixExpirationMinutes: cfg.PixExpirationMinutes, TrialHours: cfg.TrialHours,
TrialEnabled: cfg.TrialEnabled, TrialMaxConnections: cfg.TrialMaxConnections,
TrialHours: cfg.TrialHours, TrialKind: cfg.TrialKind,
TrialMaxConnections: cfg.TrialMaxConnections, TrialInboundTag: cfg.TrialInboundTag,
TrialKind: cfg.TrialKind, AdminTelegramIDs: cfg.AdminTelegramIDs,
TrialInboundTag: cfg.TrialInboundTag, Currency: cfg.Currency,
AdminTelegramIDs: cfg.AdminTelegramIDs, PublicHost: cfg.PublicHost,
Currency: cfg.Currency, XrayPublicHost: cfg.XrayPublicHost,
PublicHost: cfg.PublicHost, HasTelegramToken: cfg.TelegramToken != "",
XrayPublicHost: cfg.XrayPublicHost, HasMPAccessToken: cfg.MPAccessToken != "",
HasTelegramToken: cfg.TelegramToken != "", HasMPWebhookSecret: cfg.MPWebhookSecret != "",
HasMPAccessToken: cfg.MPAccessToken != "",
HasTelegramWebhookSecret: cfg.TelegramWebhookSecret != "",
HasMPWebhookSecret: cfg.MPWebhookSecret != "",
}) })
case http.MethodPost: case http.MethodPost:
var dto botConfigDTO var dto botConfigDTO
@@ -92,25 +85,22 @@ func handleBotConfig(store *Store) http.HandlerFunc {
return return
} }
cfg := &BotConfig{ cfg := &BotConfig{
Enabled: dto.Enabled, Enabled: dto.Enabled,
TelegramToken: strings.TrimSpace(dto.TelegramToken), TelegramToken: strings.TrimSpace(dto.TelegramToken),
TelegramMode: dto.TelegramMode, MPAccessToken: strings.TrimSpace(dto.MPAccessToken),
TelegramWebhookURL: strings.TrimSpace(dto.TelegramWebhookURL), MPConfirmMode: dto.MPConfirmMode,
TelegramWebhookSecret: strings.TrimSpace(dto.TelegramWebhookSecret), MPWebhookSecret: strings.TrimSpace(dto.MPWebhookSecret),
MPAccessToken: strings.TrimSpace(dto.MPAccessToken), MPPollInterval: dto.MPPollInterval,
MPConfirmMode: dto.MPConfirmMode, PixExpirationMinutes: dto.PixExpirationMinutes,
MPWebhookSecret: strings.TrimSpace(dto.MPWebhookSecret), TrialEnabled: dto.TrialEnabled,
MPPollInterval: dto.MPPollInterval, TrialHours: dto.TrialHours,
PixExpirationMinutes: dto.PixExpirationMinutes, TrialMaxConnections: dto.TrialMaxConnections,
TrialEnabled: dto.TrialEnabled, TrialKind: dto.TrialKind,
TrialHours: dto.TrialHours, TrialInboundTag: strings.TrimSpace(dto.TrialInboundTag),
TrialMaxConnections: dto.TrialMaxConnections, AdminTelegramIDs: dto.AdminTelegramIDs,
TrialKind: dto.TrialKind, Currency: dto.Currency,
TrialInboundTag: strings.TrimSpace(dto.TrialInboundTag), PublicHost: strings.TrimSpace(dto.PublicHost),
AdminTelegramIDs: dto.AdminTelegramIDs, XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
Currency: dto.Currency,
PublicHost: strings.TrimSpace(dto.PublicHost),
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
} }
if err := SaveBotConfig(ctx, store, cfg); err != nil { if err := SaveBotConfig(ctx, store, cfg); err != nil {
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError) http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
@@ -421,25 +411,3 @@ func handleBotTest(store *Store) http.HandlerFunc {
botWriteJSON(w, out) 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)
}
+39 -54
View File
@@ -14,26 +14,25 @@ var errInsufficientCredits = errors.New("insufficient credits")
func botItoa(n int) string { return strconv.Itoa(n) } func botItoa(n int) string { return strconv.Itoa(n) }
// BotConfig is the decrypted, ready-to-use bot configuration. // BotConfig is the decrypted, ready-to-use bot configuration.
// Telegram always uses long-polling (no webhook). The webhook/polling toggle
// applies only to Mercado Pago payment confirmation (MPConfirmMode).
type BotConfig struct { type BotConfig struct {
Enabled bool Enabled bool
TelegramToken string TelegramToken string
TelegramMode string // polling | webhook MPAccessToken string
TelegramWebhookURL string MPConfirmMode string // webhook | polling
TelegramWebhookSecret string MPWebhookSecret string
MPAccessToken string MPPollInterval string
MPConfirmMode string // webhook | polling PixExpirationMinutes int
MPWebhookSecret string TrialEnabled bool
MPPollInterval string TrialHours int
PixExpirationMinutes int TrialMaxConnections int
TrialEnabled bool TrialKind string // ssh | xray
TrialHours int TrialInboundTag string
TrialMaxConnections int AdminTelegramIDs []int64
TrialKind string // ssh | xray Currency string
TrialInboundTag string PublicHost string // SSH connection host shown to buyers
AdminTelegramIDs []int64 XrayPublicHost string // host used to build vless/vmess links
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. // LoadBotConfig reads the config row and decrypts secrets into a BotConfig.
@@ -46,10 +45,6 @@ func LoadBotConfig(ctx context.Context, store *Store) (*BotConfig, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
tgSec, err := decryptSecret(r.TelegramWebhookSecretEnc)
if err != nil {
return nil, err
}
mp, err := decryptSecret(r.MPAccessTokenEnc) mp, err := decryptSecret(r.MPAccessTokenEnc)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -60,34 +55,28 @@ func LoadBotConfig(ctx context.Context, store *Store) (*BotConfig, error) {
} }
cfg := &BotConfig{ cfg := &BotConfig{
Enabled: r.Enabled, Enabled: r.Enabled,
TelegramToken: tok, TelegramToken: tok,
TelegramMode: r.TelegramMode, MPAccessToken: mp,
TelegramWebhookURL: r.TelegramWebhookURL, MPConfirmMode: r.MPConfirmMode,
TelegramWebhookSecret: tgSec, MPWebhookSecret: mpSec,
MPAccessToken: mp, MPPollInterval: r.MPPollInterval,
MPConfirmMode: r.MPConfirmMode, PixExpirationMinutes: r.PixExpirationMinutes,
MPWebhookSecret: mpSec, TrialEnabled: r.TrialEnabled,
MPPollInterval: r.MPPollInterval, TrialHours: r.TrialHours,
PixExpirationMinutes: r.PixExpirationMinutes, TrialMaxConnections: r.TrialMaxConnections,
TrialEnabled: r.TrialEnabled, TrialKind: r.TrialKind,
TrialHours: r.TrialHours, TrialInboundTag: r.TrialInboundTag,
TrialMaxConnections: r.TrialMaxConnections, AdminTelegramIDs: r.AdminTelegramIDs,
TrialKind: r.TrialKind, Currency: r.Currency,
TrialInboundTag: r.TrialInboundTag, PublicHost: r.PublicHost,
AdminTelegramIDs: r.AdminTelegramIDs, XrayPublicHost: r.XrayPublicHost,
Currency: r.Currency,
PublicHost: r.PublicHost,
XrayPublicHost: r.XrayPublicHost,
} }
cfg.applyDefaults() cfg.applyDefaults()
return cfg, nil return cfg, nil
} }
func (c *BotConfig) applyDefaults() { func (c *BotConfig) applyDefaults() {
if c.TelegramMode == "" {
c.TelegramMode = "polling"
}
if c.MPConfirmMode == "" { if c.MPConfirmMode == "" {
c.MPConfirmMode = "polling" c.MPConfirmMode = "polling"
} }
@@ -126,8 +115,8 @@ func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error {
cfg.applyDefaults() cfg.applyDefaults()
row := &botConfigRow{ row := &botConfigRow{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
TelegramMode: cfg.TelegramMode, TelegramMode: "polling", // Telegram is always long-polling
TelegramWebhookURL: cfg.TelegramWebhookURL, TelegramWebhookURL: "",
MPConfirmMode: cfg.MPConfirmMode, MPConfirmMode: cfg.MPConfirmMode,
MPPollInterval: cfg.MPPollInterval, MPPollInterval: cfg.MPPollInterval,
PixExpirationMinutes: cfg.PixExpirationMinutes, PixExpirationMinutes: cfg.PixExpirationMinutes,
@@ -141,18 +130,13 @@ func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error {
PublicHost: cfg.PublicHost, PublicHost: cfg.PublicHost,
XrayPublicHost: cfg.XrayPublicHost, XrayPublicHost: cfg.XrayPublicHost,
} }
var tokEnc, tgSecEnc, mpEnc, mpSecEnc []byte var tokEnc, mpEnc, mpSecEnc []byte
var err error var err error
if cfg.TelegramToken != "" { if cfg.TelegramToken != "" {
if tokEnc, err = encryptSecret(cfg.TelegramToken); err != nil { if tokEnc, err = encryptSecret(cfg.TelegramToken); err != nil {
return err return err
} }
} }
if cfg.TelegramWebhookSecret != "" {
if tgSecEnc, err = encryptSecret(cfg.TelegramWebhookSecret); err != nil {
return err
}
}
if cfg.MPAccessToken != "" { if cfg.MPAccessToken != "" {
if mpEnc, err = encryptSecret(cfg.MPAccessToken); err != nil { if mpEnc, err = encryptSecret(cfg.MPAccessToken); err != nil {
return err return err
@@ -163,5 +147,6 @@ func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error {
return err return err
} }
} }
return store.saveBotConfigRow(ctx, row, tokEnc, tgSecEnc, mpEnc, mpSecEnc) // tgSecEnc is always nil now (no Telegram webhook secret).
return store.saveBotConfigRow(ctx, row, tokEnc, nil, mpEnc, mpSecEnc)
} }
+6 -11
View File
@@ -81,18 +81,13 @@ func startBotService(store *Store) {
func reloadBotService(store *Store) { startBotService(store) } func reloadBotService(store *Store) { startBotService(store) }
func (b *Bot) start() { func (b *Bot) start() {
log.Printf("[bot] starting (telegram_mode=%s mp_confirm=%s)", b.cfg.TelegramMode, b.cfg.MPConfirmMode) log.Printf("[bot] starting (telegram=long-polling, mp_confirm=%s)", b.cfg.MPConfirmMode)
if b.cfg.TelegramMode == "webhook" && b.cfg.TelegramWebhookURL != "" { // Telegram uses long-polling only. Clearing any stale webhook + polling both
if err := b.tg.setWebhook(b.ctx, b.cfg.TelegramWebhookURL, b.cfg.TelegramWebhookSecret); err != nil { // hit the network, so run off the goroutine that holds botMgrMu.
log.Printf("[bot] setWebhook failed, falling back to polling: %v", err) go func() {
go b.runPolling()
} else {
log.Printf("[bot] webhook registered at %s", b.cfg.TelegramWebhookURL)
}
} else {
_ = b.tg.deleteWebhook(b.ctx) _ = b.tg.deleteWebhook(b.ctx)
go b.runPolling() b.runPolling()
} }()
if b.mp != nil && b.cfg.MPConfirmMode == "polling" { if b.mp != nil && b.cfg.MPConfirmMode == "polling" {
go b.runPaymentPoller() go b.runPaymentPoller()
} }
+6
View File
@@ -162,6 +162,12 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
return return
} }
// Only honor webhooks when confirmation mode is "webhook". In polling mode
// the poller drives fulfillment; ignore unsolicited posts to this endpoint.
if b.cfg.MPConfirmMode != "webhook" {
w.WriteHeader(http.StatusOK)
return
}
// Extract the payment id from body or query. // Extract the payment id from body or query.
dataID := r.URL.Query().Get("data.id") dataID := r.URL.Query().Get("data.id")
if dataID == "" { if dataID == "" {
-21
View File
@@ -206,18 +206,6 @@ func (c *tgClient) sendPhotoBytes(ctx context.Context, chatID int64, photo []byt
return m.MessageID, nil 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 { func (c *tgClient) deleteWebhook(ctx context.Context) error {
_, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false}) _, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false})
return err return err
@@ -242,12 +230,3 @@ func htmlEscape(s string) string {
s = strings.ReplaceAll(s, ">", "&gt;") s = strings.ReplaceAll(s, ">", "&gt;")
return 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
}
+1 -2
View File
@@ -1620,9 +1620,8 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/bot/settings", saSession(http.HandlerFunc(handleBotSettings(store)))) mux.Handle("/api/bot/settings", saSession(http.HandlerFunc(handleBotSettings(store))))
mux.Handle("/api/bot/test", saSession(http.HandlerFunc(handleBotTest(store)))) mux.Handle("/api/bot/test", saSession(http.HandlerFunc(handleBotTest(store))))
// Public: payment + Telegram webhooks (secured by signature/secret inside). // Public: Mercado Pago payment webhook (validated by signature inside).
mux.Handle("/api/mp/webhook", http.HandlerFunc(handleMPWebhook)) 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))