Add Telegram approval flow: inline buttons, user creation, email notifications

This commit is contained in:
2026-05-29 14:41:42 +00:00
parent ad1e781040
commit e5ea23487e
4 changed files with 307 additions and 8 deletions
+8
View File
@@ -37,3 +37,11 @@ SERVICE_ICONS_DIR = Path("static/service-icons")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org/bot")
SMTP_HOST = os.getenv("SMTP_HOST", "mail.hosting.reg.ru")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
+271 -8
View File
@@ -27,11 +27,13 @@ from config import (
MAX_ACTIVE_SERVICES_PER_USER, PUBLIC_HOST, SESSION_IDLE_SECONDS,
WEB_POOL_BUFFER, WEB_POOL_SIZE,
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_API_URL,
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD,
SMTP_FROM_EMAIL, SMTP_FROM_NAME, PORTAL_URL,
)
from database import get_db
from models import (
AuditLog, Category, RdpSlot, Service, ServiceCategory, ServiceType,
SessionModel, SessionStatus, User, UserServiceAccess,
PendingAccessRequest, SessionModel, SessionStatus, User, UserServiceAccess,
)
from utils import (
audit, ensure_icons_dir, format_service_comment, log_event, normalize_web_target,
@@ -90,6 +92,56 @@ def _get_geo(ip: str) -> str:
pass
return ""
import secrets as _secrets
import string as _string
import smtplib as _smtplib
import ssl as _ssl
import json as _json2
from email.mime.multipart import MIMEMultipart as _MIMEMultipart
from email.mime.text import MIMEText as _MIMEText
def _generate_password(length: int = 10) -> str:
alphabet = _string.ascii_letters + _string.digits
while True:
pwd = ''.join(_secrets.choice(alphabet) for _ in range(length))
if (any(c.isupper() for c in pwd) and any(c.islower() for c in pwd)
and any(c.isdigit() for c in pwd)):
return pwd
def _send_email(to: str, subject: str, html_body: str) -> None:
msg = _MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
msg["To"] = to
msg.attach(_MIMEText(html_body, "html", "utf-8"))
ctx = _ssl.create_default_context()
with _smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx) as srv:
srv.login(SMTP_USERNAME, SMTP_PASSWORD)
srv.sendmail(SMTP_FROM_EMAIL, to, msg.as_string())
def _tg_api(method: str, payload: dict) -> dict:
import urllib.request as _ur
import json as _j
url = f"{TELEGRAM_API_URL}{TELEGRAM_BOT_TOKEN}/{method}"
data = _j.dumps(payload).encode()
req = _ur.Request(url, data=data, headers={"Content-Type": "application/json"})
with _ur.urlopen(req, timeout=10) as r:
return _j.loads(r.read())
def _make_approval_keyboard(req_id: str) -> dict:
return {
"inline_keyboard": [
[
{"text": "7 дней", "callback_data": f"a7_{req_id}"},
{"text": "14 дней", "callback_data": f"a14_{req_id}"},
{"text": "30 дней", "callback_data": f"a30_{req_id}"},
{"text": "90 дней", "callback_data": f"a90_{req_id}"},
],
[{"text": "Отказать", "callback_data": f"r_{req_id}"}],
]
}
app = FastAPI(title="MONT - инфрастуктурный полигон")
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -443,6 +495,210 @@ def privacy_page():
return RedirectResponse(url="https://www.mont.ru/ru-ru/privacy", status_code=301)
@app.post("/api/telegram-webhook", include_in_schema=False)
async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
import json as _jw
import datetime as _dt2
try:
data = await request.json()
except Exception:
return {"ok": True}
cq = data.get("callback_query")
if not cq:
return {"ok": True}
cq_id = cq["id"]
cb_data = cq.get("data", "")
chat_id = cq["message"]["chat"]["id"]
msg_id = cq["message"]["message_id"]
try:
_tg_api("answerCallbackQuery", {"callback_query_id": cq_id})
except Exception:
pass
# parse callback_data: a7_ID, a14_ID, a30_ID, a90_ID, r_ID
import re as _rew
approve_match = _rew.match(r'^a(\d+)_(.+)$', cb_data)
reject_match = _rew.match(r'^r_(.+)$', cb_data)
if not approve_match and not reject_match:
return {"ok": True}
req_id = approve_match.group(2) if approve_match else reject_match.group(1)
pending = db.get(PendingAccessRequest, req_id)
if not pending:
try:
_tg_api("editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": "Запрос не найден (возможно уже обработан).",
})
except Exception:
pass
return {"ok": True}
if pending.status != "pending":
try:
_tg_api("editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": f"Запрос уже обработан: {pending.status}.",
})
except Exception:
pass
return {"ok": True}
products = _jw.loads(pending.products_json or "[]")
portal_url = PORTAL_URL
if approve_match:
days = int(approve_match.group(1))
password = _generate_password()
username = pending.email
# ensure username unique
if db.scalar(select(User).where(User.username == username)):
username = pending.email.split("@")[0] + "_" + _secrets.token_hex(3)
expires = _dt2.datetime.now(_dt2.timezone.utc) + _dt2.timedelta(days=days)
parts = pending.name.strip().split(None, 1)
new_user = User(
username=username,
password_hash=hash_password(password),
expires_at=expires,
active=True,
is_admin=False,
first_name=parts[0] if parts else "",
last_name=parts[1] if len(parts) > 1 else "",
)
db.add(new_user)
db.flush()
# assign requested services
if products:
from sqlalchemy import func as _func
matched = db.scalars(
select(Service).where(
func.lower(Service.name).in_([p.lower() for p in products]),
Service.active == True,
)
).all()
for svc in matched:
db.add(UserServiceAccess(user_id=new_user.id, service_id=svc.id))
db.commit()
# send approval email
products_html = ""
if products:
items = "".join(f"<li>{p}</li>" for p in products)
products_html = f"<p style='margin:16px 0 6px'><b>Предоставлен доступ к продуктам:</b></p><ul style='margin:0;padding-left:20px;color:#c8d8ea'>{items}</ul>"
html_email = f"""<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"/></head>
<body style="margin:0;padding:0;background:#0a1929;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
<table width="560" cellpadding="0" cellspacing="0" style="background:linear-gradient(150deg,#0b1a2e,#0d2040);border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
<tr><td style="padding:32px 36px 0">
<img src="{portal_url}/static/logo.png" alt="MONT" height="40" style="margin-bottom:24px"/><br>
<h1 style="margin:0 0 8px;font-size:22px;color:#e8f1fb">Доступ к Инфраструктурному полигону MONT</h1>
<p style="margin:0 0 24px;color:#7a9abd;font-size:14px">Ваш запрос одобрен</p>
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:0 0 24px"/>
</td></tr>
<tr><td style="padding:0 36px">
<p style="color:#c8d8ea;font-size:15px;line-height:1.6;margin:0 0 20px">Здравствуйте, <b>{pending.name}</b>!<br>
Вам предоставлен доступ к полигону на <b>{days} {'день' if days==1 else 'дня' if days<5 else 'дней'}</b>.</p>
<table width="100%" cellpadding="12" cellspacing="0" style="background:rgba(255,255,255,0.04);border-radius:10px;border:1px solid rgba(255,255,255,0.07)">
<tr><td style="color:#7a9abd;font-size:13px;width:40%">Адрес портала</td>
<td><a href="{portal_url}" style="color:#5b9bd5;font-size:14px">{portal_url}</a></td></tr>
<tr><td style="color:#7a9abd;font-size:13px;border-top:1px solid rgba(255,255,255,0.06)">Логин</td>
<td style="border-top:1px solid rgba(255,255,255,0.06);color:#e8f1fb;font-size:14px;font-family:monospace">{username}</td></tr>
<tr><td style="color:#7a9abd;font-size:13px;border-top:1px solid rgba(255,255,255,0.06)">Пароль</td>
<td style="border-top:1px solid rgba(255,255,255,0.06);color:#e8f1fb;font-size:14px;font-family:monospace">{password}</td></tr>
<tr><td style="color:#7a9abd;font-size:13px;border-top:1px solid rgba(255,255,255,0.06)">Доступ до</td>
<td style="border-top:1px solid rgba(255,255,255,0.06);color:#e8f1fb;font-size:14px">{expires.strftime('%d.%m.%Y')}</td></tr>
</table>
{products_html}
<div style="margin:28px 0">
<a href="{portal_url}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;text-decoration:none;border-radius:8px;font-size:15px;font-weight:600">Войти в полигон</a>
</div>
</td></tr>
<tr><td style="padding:20px 36px 28px;color:#4a6a8a;font-size:12px;border-top:1px solid rgba(255,255,255,0.06);line-height:1.6">
Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на <a href="mailto:mont@mont.ru" style="color:#5b9bd5">mont@mont.ru</a>
</td></tr>
</table></td></tr></table>
</body></html>"""
pending.status = "approved"
db.commit()
try:
_send_email(pending.email, "Доступ к Инфраструктурному полигону MONT", html_email)
email_status = "Email отправлен"
except Exception as ex:
log_event("email_send_error", error=str(ex))
email_status = f"Ошибка отправки email: {ex}"
try:
_tg_api("editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": (
f"Одобрено на {days} дней\n"
f"Логин: `{username}`\n"
f"Пароль: `{password}`\n"
f"{email_status}"
),
"parse_mode": "Markdown",
})
except Exception:
pass
elif reject_match:
manager_contact = pending.manager if pending.manager else "менеджера MONT"
html_email = f"""<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"/></head>
<body style="margin:0;padding:0;background:#0a1929;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
<table width="560" cellpadding="0" cellspacing="0" style="background:linear-gradient(150deg,#0b1a2e,#0d2040);border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
<tr><td style="padding:32px 36px 0">
<img src="{portal_url}/static/logo.png" alt="MONT" height="40" style="margin-bottom:24px"/><br>
<h1 style="margin:0 0 8px;font-size:22px;color:#e8f1fb">Запрос на доступ к полигону MONT</h1>
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:16px 0 24px"/>
</td></tr>
<tr><td style="padding:0 36px">
<p style="color:#c8d8ea;font-size:15px;line-height:1.7;margin:0 0 20px">Здравствуйте, <b>{pending.name}</b>!<br><br>
К сожалению, на данный момент мы не можем предоставить доступ к полигону.</p>
<p style="color:#c8d8ea;font-size:15px;line-height:1.7;margin:0 0 24px">
Для уточнения деталей, пожалуйста, свяжитесь с <b>{manager_contact}</b>.<br>
Если вы не знаете, кто ваш менеджер, напишите нам на <a href="mailto:mont@mont.ru" style="color:#5b9bd5">mont@mont.ru</a> — мы поможем.</p>
</td></tr>
<tr><td style="padding:20px 36px 28px;color:#4a6a8a;font-size:12px;border-top:1px solid rgba(255,255,255,0.06)">
С уважением, команда MONT
</td></tr>
</table></td></tr></table>
</body></html>"""
pending.status = "rejected"
db.commit()
try:
_send_email(pending.email, "Запрос на доступ к полигону MONT", html_email)
email_status = "Email отправлен"
except Exception as ex:
log_event("email_send_error", error=str(ex))
email_status = f"Ошибка отправки email: {ex}"
try:
_tg_api("editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": f"Отклонено. {email_status}",
})
except Exception:
pass
return {"ok": True}
@app.get("/robots.txt", include_in_schema=False)
def robots_txt():
from fastapi.responses import FileResponse
@@ -534,19 +790,26 @@ async def request_access(request: Request, db: Session = Depends(get_db)):
log_event("telegram_not_configured")
return {"ok": True}
# save pending request
import json as _j2
req_id = _secrets.token_urlsafe(8)[:12]
pending = PendingAccessRequest(
id=req_id, name=name, company=company, email=email,
phone=phone, manager=manager,
products_json=_j2.dumps(products, ensure_ascii=False),
)
db.add(pending)
db.commit()
try:
payload = _json.dumps({
_tg_api("sendMessage", {
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "Markdown",
}).encode()
url = f"{TELEGRAM_API_URL}{TELEGRAM_BOT_TOKEN}/sendMessage"
req = _urllib_request.Request(url, data=payload, headers={"Content-Type": "application/json"})
with _urllib_request.urlopen(req, timeout=10) as resp:
resp.read()
"reply_markup": _make_approval_keyboard(req_id),
})
except Exception as e:
log_event("telegram_send_error", error=str(e))
raise HTTPException(status_code=502, detail="Ошибка отправки запроса")
return {"ok": True}
+14
View File
@@ -115,3 +115,17 @@ class AuditLog(Base):
action: Mapped[str] = mapped_column(String(128), index=True)
details: Mapped[str] = mapped_column(Text)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
class PendingAccessRequest(Base):
__tablename__ = "pending_access_requests"
id: Mapped[str] = mapped_column(String(12), primary_key=True)
name: Mapped[str] = mapped_column(String(256))
company: Mapped[str] = mapped_column(String(256))
email: Mapped[str] = mapped_column(String(256))
phone: Mapped[str] = mapped_column(String(64))
manager: Mapped[str] = mapped_column(String(256), default="")
products_json: Mapped[str] = mapped_column(Text, default="[]")
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
+14
View File
@@ -60,6 +60,13 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on:
- db
volumes:
@@ -113,6 +120,13 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on:
- db
volumes: