mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:04:47 +00:00
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:
parent
835b6a05df
commit
a3fc1b6d9d
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user