feat: операторская авторизация (Telegram initData + пароль) + MiniApp + каркас Sber PAY

- backend: /api/operator/auth (initData HMAC | пароль), gate /api/projects,
  бот /myid + кнопка «Открыть CRM» для оператора (ADMIN_TG_IDS)
- crm.html: Telegram SDK + гейт (вход через TG на телефоне, пароль на десктопе),
  X-Operator-Token на /api/projects
- каркас Sber PAY: mTLS из .p12, OAuth-токен, /api/payment/sber-invoice,
  /api/payment/sber-status — активируется по .env (SBER_PAY_ENABLED + пароль + secret)
This commit is contained in:
wasrusgen 2026-06-02 07:01:13 +03:00
parent 835b6a05df
commit a3fc1b6d9d
2 changed files with 227 additions and 6 deletions

View File

@ -6,7 +6,8 @@ ELENA CONSULTING — Backend API
Реальный чат-интервью + построение бизнес-модели через Opus 4.8.
Крутится на Finnish VPS (RU IP блокируется Anthropic).
"""
import os, re, json, sqlite3, secrets, time, base64
import os, re, json, sqlite3, secrets, time, base64, hmac, hashlib
from urllib.parse import parse_qsl
from datetime import datetime, timezone
from flask import Flask, request, jsonify, g
from flask_cors import CORS
@ -64,6 +65,46 @@ def _yookassa_creds():
client = anthropic.Anthropic(api_key=_key())
SYSTEM_PROMPT = open(PROMPT_PATH, encoding="utf-8").read()
# ── Операторская авторизация (Telegram initData + пароль на десктопе) ──
def _env(key, default=None):
try:
env = open(os.path.join(BASE, ".env")).read()
m = re.search(rf'^{re.escape(key)}=(.*)$', env, re.M)
if m:
return m.group(1).strip()
except Exception:
pass
return default
TG_BOT_TOKEN = "8767209545:AAEVgfL-bAhg6j0fHUyKWUze4SLTfJbLklM"
OPERATOR_PASSWORD = _env("OPERATOR_PASSWORD", "")
ADMIN_TG_IDS = set(x for x in (_env("ADMIN_TG_IDS", "") or "").replace(" ", "").split(",") if x)
def _op_token():
"""Серверный операторский токен (зависит от пароля — смена пароля инвалидирует сессии)."""
secret = (OPERATOR_PASSWORD or "elena-op-fallback").encode()
return hmac.new(secret, b"operator-session-v1", hashlib.sha256).hexdigest()
def verify_tg_initdata(init_data):
"""Проверка подписи Telegram WebApp initData. Возвращает dict пользователя или None."""
try:
data = dict(parse_qsl(init_data, keep_blank_values=True))
recv = data.pop("hash", None)
if not recv:
return None
check = "\n".join(f"{k}={data[k]}" for k in sorted(data))
secret = hmac.new(b"WebAppData", TG_BOT_TOKEN.encode(), hashlib.sha256).digest()
calc = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(calc, recv):
return None
return json.loads(data.get("user", "{}"))
except Exception:
return None
def is_operator():
"""True если запрос несёт валидный операторский токен."""
return bool(OPERATOR_PASSWORD) and request.headers.get("X-Operator-Token") == _op_token()
app = Flask(__name__)
CORS(app)
@ -258,6 +299,31 @@ def ai_status():
low = ("credit balance" in msg.lower()) or ("too low" in msg.lower()) or ("billing" in msg.lower())
return jsonify({"ok": False, "reason": "low_balance" if low else "error", "detail": msg[:200]})
@app.route("/api/operator/auth", methods=["POST"])
def operator_auth():
"""Вход оператора: либо Telegram initData (tg-id в ADMIN_TG_IDS), либо пароль (десктоп)."""
data = request.get_json(force=True) or {}
init_data = data.get("init_data")
password = data.get("password")
name = "Оператор"
if init_data:
user = verify_tg_initdata(init_data)
if not user:
return jsonify({"ok": False, "error": "bad_initdata"}), 401
if str(user.get("id")) not in ADMIN_TG_IDS:
return jsonify({"ok": False, "error": "not_admin"}), 403
name = user.get("first_name") or "Оператор"
elif password is not None:
if not OPERATOR_PASSWORD or not hmac.compare_digest(str(password), OPERATOR_PASSWORD):
return jsonify({"ok": False, "error": "bad_password"}), 401
else:
return jsonify({"ok": False, "error": "no_creds"}), 400
return jsonify({"ok": True, "op_token": _op_token(), "name": name})
@app.route("/api/operator/check")
def operator_check():
return jsonify({"ok": is_operator()})
@app.route("/api/project/new", methods=["POST"])
def new_project():
data = request.get_json(force=True) or {}
@ -944,6 +1010,102 @@ def update_crm():
save_artifact(proj["id"], "crm", crm)
return jsonify({"ok": True, "crm": crm})
# ── Sber API «Платежи» (PAY): счета для юрлиц через OAuth2 + mTLS (.p12) ──
# КАРКАС: активируется когда в .env появятся SBER_PAY_ENABLED=1 + пароль .p12 + client_secret.
SBER_PAY_ENABLED = _env("SBER_PAY_ENABLED", "0") == "1"
SBER_CLIENT_ID = _env("SBER_CLIENT_ID", "")
SBER_CLIENT_SECRET = _env("SBER_CLIENT_SECRET", "")
SBER_P12_PATH = _env("SBER_P12_PATH", os.path.join(BASE, "certs", "sber.p12"))
SBER_P12_PASSWORD = _env("SBER_P12_PASSWORD", "")
SBER_API_BASE = _env("SBER_API_BASE", "https://api.sberbank.ru") # уточнить по докам СберБизнес
SBER_SCOPE = _env("SBER_SCOPE", "")
_SBER_TOKEN_CACHE = {"access": None, "exp": 0}
def _sber_session():
"""requests-сессия с mTLS из .p12. (session, paths) или (None, reason)."""
if not SBER_PAY_ENABLED:
return None, "SBER_PAY_ENABLED!=1"
try:
import requests, tempfile
from cryptography.hazmat.primitives.serialization import (
pkcs12, Encoding, PrivateFormat, NoEncryption)
raw = open(SBER_P12_PATH, "rb").read()
pwd = SBER_P12_PASSWORD.encode() if SBER_P12_PASSWORD else None
key, cert, _ = pkcs12.load_key_and_certificates(raw, pwd)
cf = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
cf.write(cert.public_bytes(Encoding.PEM)); cf.close()
kf = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
kf.write(key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())); kf.close()
s = requests.Session(); s.cert = (cf.name, kf.name)
return s, (cf.name, kf.name)
except Exception as e:
return None, f"mTLS error: {e}"
def _sber_token():
"""OAuth2 access token с кэшем/рефрешем. None если не настроено."""
if not SBER_PAY_ENABLED:
return None
if _SBER_TOKEN_CACHE["access"] and _SBER_TOKEN_CACHE["exp"] > time.time() + 30:
return _SBER_TOKEN_CACHE["access"]
sess, _info = _sber_session()
if not sess:
return None
try:
r = sess.post(SBER_API_BASE.rstrip("/") + "/ic/sso/api/v2/oauth",
data={"grant_type": "client_credentials", "scope": SBER_SCOPE},
auth=(SBER_CLIENT_ID, SBER_CLIENT_SECRET), timeout=20)
j = r.json()
tok = j.get("access_token")
if tok:
_SBER_TOKEN_CACHE["access"] = tok
_SBER_TOKEN_CACHE["exp"] = time.time() + int(j.get("expires_in", 3600))
return tok
except Exception:
return None
@app.route("/api/payment/sber-status")
def sber_status():
"""Диагностика готовности Sber PAY."""
if not SBER_PAY_ENABLED:
return jsonify({"enabled": False, "reason": "каркас готов — ждёт SBER_PAY_ENABLED=1 + пароль .p12 + client_secret в .env"})
sess, info = _sber_session()
if not sess:
return jsonify({"enabled": True, "mtls": False, "reason": info})
return jsonify({"enabled": True, "mtls": True, "client_id": bool(SBER_CLIENT_ID), "secret": bool(SBER_CLIENT_SECRET), "token": bool(_sber_token())})
@app.route("/api/payment/sber-invoice", methods=["POST"])
def sber_invoice():
"""Выставление счёта юрлицу (Sber PAY). endpoint/поля уточнить по докам Сбера."""
data = request.get_json(force=True) or {}
proj = get_project(data.get("token"))
if not proj:
return jsonify({"error": "project not found"}), 404
amount = float(data.get("amount") or 0)
if amount <= 0:
return jsonify({"error": "bad amount"}), 400
if not SBER_PAY_ENABLED:
return jsonify({"demo": True, "reason": "Sber PAY не активирован (нет пароля .p12 / client_secret). Каркас готов."})
tok = _sber_token()
if not tok:
return jsonify({"error": "sber auth failed"}), 502
sess, _info = _sber_session()
order = "INV-" + secrets.token_hex(5)
crm = latest_artifact(proj["id"], "crm") or {}
crm.setdefault("sber_invoices", {})[order] = {"amount": amount, "status": "issued", "at": now()}
save_artifact(proj["id"], "crm", crm)
try:
payload = {
"amount": int(round(amount * 100)), "currency": "RUB",
"orderNumber": order,
"description": (data.get("description") or "Оплата консалтинга")[:255],
"callbackUrl": "https://wasrusgen1.ru/consulting/api/payment/sber-webhook",
}
r = sess.post(SBER_API_BASE.rstrip("/") + "/v1/invoices", # путь уточнить по докам
json=payload, headers={"Authorization": "Bearer " + tok}, timeout=25)
return jsonify({"ok": True, "order": order, "status": r.status_code, "sber": r.json()})
except Exception as e:
return jsonify({"error": str(e), "order": order}), 502
# ── СБП (Сбер) — динамический QR на оплату этапа ─────────────────
SBER_SBP_ENABLED = os.getenv("SBER_SBP_ENABLED", "0") == "1"
SBER_SBP_URL = os.getenv("SBER_SBP_URL", "") # базовый URL API СБП Сбера
@ -1332,6 +1494,8 @@ def documents_context(project_id):
@app.route("/api/projects")
def list_projects():
if not is_operator():
return jsonify({"error": "unauthorized"}), 401
rows = db().execute(
"SELECT p.token, p.client_name, p.niche, p.status, p.created_at, "
"(SELECT COUNT(*) FROM messages m WHERE m.project_id=p.id) as msg_count "
@ -1525,11 +1689,22 @@ def tg_webhook():
if not msg:
return jsonify({"ok": True})
chat_id = msg["chat"]["id"]
uid = str((msg.get("from") or {}).get("id", chat_id))
text = (msg.get("text") or "").strip()
cmd = text.split()[0].split("@")[0].lower() if text.startswith("/") else ""
CRM_URL = "https://wasrusgen1.ru/consulting/crm.html"
# /start [token] — открыть кабинет или прислать ссылку
# /myid — узнать свой Telegram ID (для регистрации оператора и алертов)
if cmd == "/myid":
tg_send(chat_id, f"Ваш Telegram ID: <code>{uid}</code>\nПередайте его консультанту для доступа к операторской CRM.")
return jsonify({"ok": True})
# /start [token] — оператору CRM, клиенту кабинет
if cmd == "/start":
if uid in ADMIN_TG_IDS:
tg_send(chat_id, "👋 Операторская CRM @wasrusgen1 | КОНСАЛТИНГ",
reply_markup={"inline_keyboard": [[{"text": "🖥 Открыть CRM", "web_app": {"url": CRM_URL}}]]})
return jsonify({"ok": True})
parts = text.split()
token = parts[1] if len(parts) > 1 else None
if token:

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CRM консультанта · @wasrusgen1 | КОНСАЛТИНГ</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
<style>
@ -211,8 +212,43 @@ function secHead(k,title,right){
}
function chips(cur,opts,fn){return opts.map(([k,n])=>`<button class="fchip ${cur===k?'on':''}" onclick="${fn}('${k}')">${n}</button>`).join('');}
// ── Операторская авторизация ──
let OP_TOKEN=localStorage.getItem('op_token')||'';
function opHeaders(extra){return Object.assign({'X-Operator-Token':OP_TOKEN||''},extra||{});}
async function loadProjects(){
const r=await fetch(`${API}/api/projects`);const d=await r.json();projects=d.projects;renderClientList();
const r=await fetch(`${API}/api/projects`,{headers:opHeaders()});
if(r.status===401){return false;}
const d=await r.json();projects=d.projects;renderClientList();return true;
}
async function opAuth(body){
try{const r=await fetch(`${API}/api/operator/auth`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await r.json();
if(d.ok){OP_TOKEN=d.op_token;localStorage.setItem('op_token',OP_TOKEN);return {ok:true,name:d.name};}
return {ok:false,error:d.error};
}catch(e){return {ok:false,error:e.message};}
}
function showDenied(msg){
document.body.innerHTML=`<div style="height:100vh;display:flex;align-items:center;justify-content:center;background:#0F0F1A;color:#fff;font-family:Inter;text-align:center;padding:24px"><div><div style="font-size:40px;margin-bottom:12px">🔒</div><div style="font-size:18px;font-weight:800;font-family:Montserrat">Доступ только для оператора</div><div style="font-size:13px;color:#8A94A6;margin-top:8px;max-width:340px">${msg}</div></div></div>`;
}
function showOpLogin(){
document.body.innerHTML=`<div id="opLogin" style="height:100vh;display:flex;align-items:center;justify-content:center;background:#0F0F1A;font-family:Inter;padding:24px">
<div style="background:#fff;border-radius:16px;padding:28px 26px;width:100%;max-width:340px;box-shadow:0 12px 40px rgba(0,0,0,.4)">
<div style="font-size:13px;font-weight:700;color:#047857;letter-spacing:.05em">@wasrusgen1 · КОНСАЛТИНГ</div>
<div style="font-size:20px;font-weight:800;font-family:Montserrat;margin:6px 0 2px">Вход оператора</div>
<div style="font-size:12px;color:#6B7280;margin-bottom:16px">CRM закрыта. Введите пароль или откройте через Telegram-бота.</div>
<input id="opPass" type="password" placeholder="Пароль оператора" style="width:100%;border:1.5px solid #E5E7EB;border-radius:10px;padding:11px 13px;font-size:14px;font-family:Inter;outline:none;box-sizing:border-box" onkeydown="if(event.key==='Enter')doOpLogin()">
<div id="opErr" style="color:#DC2626;font-size:12px;margin-top:7px;min-height:14px"></div>
<button onclick="doOpLogin()" style="width:100%;margin-top:6px;padding:12px;background:#047857;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:700;font-family:Inter;cursor:pointer">Войти →</button>
<div style="font-size:11px;color:#9CA3AF;margin-top:14px;text-align:center">Или в Telegram: <b>@wasrusgen1_consulting_bot</b> → /start</div>
</div></div>`;
}
async function doOpLogin(){
const p=document.getElementById('opPass').value;const err=document.getElementById('opErr');
if(!p){err.textContent='Введите пароль';return;}
err.textContent='Проверка...';
const a=await opAuth({password:p});
if(!a.ok){err.textContent=a.error==='bad_password'?'Неверный пароль':('Ошибка: '+a.error);return;}
location.reload();
}
function renderClientList(){
document.getElementById("clientList").innerHTML=projects.map(p=>{
@ -1119,9 +1155,19 @@ async function checkAiStatus(){
b.style.display='flex';
}catch(e){/* сетевой сбой — не показываем ложную тревогу */}
}
loadProjects().then(render);
checkAiStatus();
setInterval(checkAiStatus,3600000); // раз в час
async function startApp(){
const tg=window.Telegram&&window.Telegram.WebApp;
if(tg&&tg.initData){
tg.ready();tg.expand();try{tg.setHeaderColor&&tg.setHeaderColor('#0F0F1A');}catch(e){}
const a=await opAuth({init_data:tg.initData});
if(!a.ok){showDenied(a.error==='not_admin'
?'Ваш Telegram не в списке операторов. Отправьте боту команду /myid и передайте ID консультанту.'
:'Не удалось авторизоваться через Telegram.');return;}
}
if(await loadProjects()){render();checkAiStatus();setInterval(checkAiStatus,3600000);return;}
showOpLogin(); // десктоп без валидного токена
}
startApp();
</script>
</body>
</html>