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:
+81
-143
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user