mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:44:45 +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.
|
Реальный чат-интервью + построение бизнес-модели через Opus 4.8.
|
||||||
Крутится на Finnish VPS (RU IP блокируется Anthropic).
|
Крутится на 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 datetime import datetime, timezone
|
||||||
from flask import Flask, request, jsonify, g
|
from flask import Flask, request, jsonify, g
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
@ -64,6 +65,46 @@ def _yookassa_creds():
|
|||||||
client = anthropic.Anthropic(api_key=_key())
|
client = anthropic.Anthropic(api_key=_key())
|
||||||
SYSTEM_PROMPT = open(PROMPT_PATH, encoding="utf-8").read()
|
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__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
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())
|
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]})
|
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"])
|
@app.route("/api/project/new", methods=["POST"])
|
||||||
def new_project():
|
def new_project():
|
||||||
data = request.get_json(force=True) or {}
|
data = request.get_json(force=True) or {}
|
||||||
@ -944,6 +1010,102 @@ def update_crm():
|
|||||||
save_artifact(proj["id"], "crm", crm)
|
save_artifact(proj["id"], "crm", crm)
|
||||||
return jsonify({"ok": True, "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 на оплату этапа ─────────────────
|
# ── СБП (Сбер) — динамический QR на оплату этапа ─────────────────
|
||||||
SBER_SBP_ENABLED = os.getenv("SBER_SBP_ENABLED", "0") == "1"
|
SBER_SBP_ENABLED = os.getenv("SBER_SBP_ENABLED", "0") == "1"
|
||||||
SBER_SBP_URL = os.getenv("SBER_SBP_URL", "") # базовый URL API СБП Сбера
|
SBER_SBP_URL = os.getenv("SBER_SBP_URL", "") # базовый URL API СБП Сбера
|
||||||
@ -1332,6 +1494,8 @@ def documents_context(project_id):
|
|||||||
|
|
||||||
@app.route("/api/projects")
|
@app.route("/api/projects")
|
||||||
def list_projects():
|
def list_projects():
|
||||||
|
if not is_operator():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
rows = db().execute(
|
rows = db().execute(
|
||||||
"SELECT p.token, p.client_name, p.niche, p.status, p.created_at, "
|
"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 "
|
"(SELECT COUNT(*) FROM messages m WHERE m.project_id=p.id) as msg_count "
|
||||||
@ -1525,11 +1689,22 @@ def tg_webhook():
|
|||||||
if not msg:
|
if not msg:
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
chat_id = msg["chat"]["id"]
|
chat_id = msg["chat"]["id"]
|
||||||
|
uid = str((msg.get("from") or {}).get("id", chat_id))
|
||||||
text = (msg.get("text") or "").strip()
|
text = (msg.get("text") or "").strip()
|
||||||
cmd = text.split()[0].split("@")[0].lower() if text.startswith("/") else ""
|
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 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()
|
parts = text.split()
|
||||||
token = parts[1] if len(parts) > 1 else None
|
token = parts[1] if len(parts) > 1 else None
|
||||||
if token:
|
if token:
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CRM консультанта · @wasrusgen1 | КОНСАЛТИНГ</title>
|
<title>CRM консультанта · @wasrusgen1 | КОНСАЛТИНГ</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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">
|
<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>
|
<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('');}
|
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(){
|
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(){
|
function renderClientList(){
|
||||||
document.getElementById("clientList").innerHTML=projects.map(p=>{
|
document.getElementById("clientList").innerHTML=projects.map(p=>{
|
||||||
@ -1119,9 +1155,19 @@ async function checkAiStatus(){
|
|||||||
b.style.display='flex';
|
b.style.display='flex';
|
||||||
}catch(e){/* сетевой сбой — не показываем ложную тревогу */}
|
}catch(e){/* сетевой сбой — не показываем ложную тревогу */}
|
||||||
}
|
}
|
||||||
loadProjects().then(render);
|
async function startApp(){
|
||||||
checkAiStatus();
|
const tg=window.Telegram&&window.Telegram.WebApp;
|
||||||
setInterval(checkAiStatus,3600000); // раз в час
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user