Add Telegram approval flow: inline buttons, user creation, email notifications
This commit is contained in:
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user