diff --git a/backend/elena_app.py b/backend/elena_app.py
index 0ed8846..11c98ec 100644
--- a/backend/elena_app.py
+++ b/backend/elena_app.py
@@ -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: {uid}\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:
diff --git a/docs/crm.html b/docs/crm.html
index 57eb142..d9f0f6e 100644
--- a/docs/crm.html
+++ b/docs/crm.html
@@ -4,6 +4,7 @@