From a3fc1b6d9db6db0955f9fbc8a850cd36c5b46a31 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 2 Jun 2026 07:01:13 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=81=D0=BA=D0=B0=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20(Telegram=20initDa?= =?UTF-8?q?ta=20+=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C)=20+=20MiniApp=20?= =?UTF-8?q?+=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20Sber=20PAY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/elena_app.py | 179 ++++++++++++++++++++++++++++++++++++++++++- docs/crm.html | 54 ++++++++++++- 2 files changed, 227 insertions(+), 6 deletions(-) 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 @@ CRM консультанта · @wasrusgen1 | КОНСАЛТИНГ +