v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+81 -143
View File
@@ -1,159 +1,97 @@
import os
import re
import hmac
import hashlib
import logging
from datetime import datetime
import httpx
from typing import Optional
import aiohttp
logger = logging.getLogger(__name__)
MAX_API_URL = "https://platform-api.max.ru"
from app.config import settings
class MaxAPIClient:
def __init__(self, token: str, test_mode: bool = False):
self.token = token
self.test_mode = test_mode
self._session: Optional[aiohttp.ClientSession] = None
self.test_messages: list = []
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
if self._session and not self._session.closed:
await self._session.close()
class MaxAPI:
def __init__(self):
self.base_url = settings.max_api_base
self.token = settings.max_token
self.client = httpx.AsyncClient(timeout=30.0)
def _headers(self) -> dict:
return {"Authorization": self.token, "Content-Type": "application/json"}
async def _request(self, method: str, path: str, **kwargs) -> dict:
if self.test_mode:
logger.info(f"[TEST_MODE] {method} {path} kwargs={kwargs}")
return {"ok": True, "test_mode": True}
session = await self._get_session()
url = f"{MAX_API_URL}{path}"
async with session.request(method, url, headers=self._headers(), **kwargs) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error(f"Max API error {resp.status}: {body}")
resp.raise_for_status()
return await resp.json()
return {
"Authorization": self.token,
"Content-Type": "application/json",
}
async def get_me(self) -> dict:
return await self._request("GET", "/me")
r = await self.client.get(f"{self.base_url}/me", headers=self._headers())
r.raise_for_status()
return r.json()
async def send_message(self, user_id: int = None, chat_id: int = None, text: str = "",
attachments: list = None, format: str = None,
disable_link_preview: bool = False, notify: bool = True) -> dict:
body = {"text": text}
if attachments:
body["attachments"] = attachments
if format:
body["format"] = format
if not disable_link_preview:
body["disable_link_preview"] = False
if not notify:
body["notify"] = False
params = {}
if user_id:
params["user_id"] = user_id
if chat_id:
params["chat_id"] = chat_id
if self.test_mode:
msg = {"role": "bot", "text": text, "format": format}
if attachments:
msg["attachments"] = attachments
self.test_messages.append(msg)
return await self._request("POST", "/messages", json=body, params=params)
async def send_message_with_keyboard(self, user_id: int, text: str, buttons: list,
format: str = None) -> dict:
attachments = [{
"type": "inline_keyboard",
"payload": {"buttons": buttons}
}]
return await self.send_message(user_id=user_id, text=text, attachments=attachments, format=format)
async def edit_message(self, message_id: int, text: str, attachments: list = None) -> dict:
body = {"text": text}
if attachments:
body["attachments"] = attachments
return await self._request("PUT", f"/messages/{message_id}", json=body)
async def get_message(self, message_id: int) -> dict:
return await self._request("GET", f"/messages/{message_id}")
async def get_messages(self, user_id: int = None, chat_id: int = None, limit: int = 10) -> dict:
params = {"limit": limit}
if user_id:
params["user_id"] = user_id
if chat_id:
params["chat_id"] = chat_id
return await self._request("GET", "/messages", params=params)
async def answer_callback(self, callback_id: str, text: str = "", show_alert: bool = False) -> dict:
body = {"callback_id": callback_id}
if text:
body["text"] = text
if show_alert:
body["show_alert"] = True
return await self._request("POST", "/answers", json=body)
async def subscribe_webhook(self, url: str, update_types: list = None, secret: str = None) -> dict:
body = {"url": url}
if update_types:
body["update_types"] = update_types
if secret:
body["secret"] = secret
return await self._request("POST", "/subscriptions", json=body)
async def get_subscriptions(self) -> dict:
return await self._request("GET", "/subscriptions")
async def subscribe_webhook(self) -> dict:
payload = {
"url": settings.webhook_url,
"update_types": settings.update_types,
"secret": settings.webhook_secret,
}
r = await self.client.post(
f"{self.base_url}/subscriptions",
headers=self._headers(),
json=payload,
)
r.raise_for_status()
return r.json()
async def unsubscribe_webhook(self) -> dict:
return await self._request("DELETE", "/subscriptions")
r = await self.client.delete(
f"{self.base_url}/subscriptions",
headers=self._headers(),
)
r.raise_for_status()
return r.json()
async def upload_file(self, file_path: str) -> dict:
session = await self._get_session()
url = f"{MAX_API_URL}/uploads"
with open(file_path, "rb") as f:
data = aiohttp.FormData()
data.add_field("file", f, filename=os.path.basename(file_path))
async with session.post(url, headers={"Authorization": self.token}, data=data) as resp:
resp.raise_for_status()
return await resp.json()
async def get_subscriptions(self) -> dict:
r = await self.client.get(
f"{self.base_url}/subscriptions",
headers=self._headers(),
)
r.raise_for_status()
return r.json()
def verify_webhook_secret(self, header_value: str, expected_secret: str) -> bool:
if not expected_secret:
return True
return hmac.compare_digest(header_value, expected_secret)
async def send_message(
self,
user_id: int,
text: str,
attachments: Optional[list] = None,
format: Optional[str] = None,
notify: bool = True,
) -> dict:
payload = {"text": text, "notify": notify}
if attachments:
payload["attachments"] = attachments
if format:
payload["format"] = format
r = await self.client.post(
f"{self.base_url}/messages?user_id={user_id}",
headers=self._headers(),
json=payload,
)
r.raise_for_status()
return r.json()
async def send_file(self, user_id: int, file_path: str, caption: str = "") -> dict:
upload = await self.upload_file(file_path)
file_id = upload.get("file_id", "")
body = {"text": caption, "attachments": [{"type": "file", "payload": {"file_id": file_id}}]}
return await self._request("POST", "/messages", json=body, params={"user_id": user_id})
async def answer_callback(
self,
callback_id: str,
message: Optional[dict] = None,
notification: Optional[str] = None,
) -> dict:
payload = {}
if message is not None:
payload["message"] = message
if notification is not None:
payload["notification"] = notification
r = await self.client.post(
f"{self.base_url}/answers?callback_id={callback_id}",
headers=self._headers(),
json=payload,
)
r.raise_for_status()
return r.json()
async def get_file_url(self, file_id: str) -> str:
resp = await self._request("GET", f"/uploads/{file_id}")
return resp.get("url", "")
async def close(self):
await self.client.aclose()
async def send_contact_request(self, user_id: int, text: str = "Пожалуйста, отправьте ваш контакт") -> dict:
attachments = [{
"type": "contact_request",
"payload": {"text": text}
}]
return await self.send_message(user_id=user_id, text=text, attachments=attachments)
def verify_contact_hash(self, vcf_info: str, access_token: str, expected_hash: str) -> bool:
vcf_normalized = vcf_info.replace("\\r\\n", "\r\n")
computed = hmac.new(access_token.encode(), vcf_normalized.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, expected_hash)
max_api = MaxAPI()