From f6726fe4b8ce11028a01bb68de017bcfd27de022 Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 16 Jul 2026 16:27:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=20=D0=B1=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codebase/backend/bitrix-local-app/app/main.py | 60 ++++++++++++++++--- .../bitrix-local-app/tests/test_core.py | 4 +- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/codebase/backend/bitrix-local-app/app/main.py b/codebase/backend/bitrix-local-app/app/main.py index b2e3f8d..9f69fbe 100644 --- a/codebase/backend/bitrix-local-app/app/main.py +++ b/codebase/backend/bitrix-local-app/app/main.py @@ -12,7 +12,7 @@ import uuid from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta from typing import Annotated, Any, Literal -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx import uvicorn @@ -40,6 +40,13 @@ from app.postgres import create_postgres_engine logger = logging.getLogger("bitrix-local-app") +CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote( + '' + '' + "", + safe="", +) + class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") @@ -235,7 +242,11 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None: except (ValueError, TypeError): raise ValueError("external_chat_id is missing or invalid") bitrix_message_id = first( - data, ("MESSAGES", "0", "message", "id"), ("MESSAGE", "ID"), ("message_id",) + data, + ("MESSAGES", "0", "im", "message_id"), + ("MESSAGES", "0", "message", "id"), + ("MESSAGE", "ID"), + ("message_id",), ) if event == "ONIMCONNECTORDIALOGFINISH": event_type = "dialog.closed" @@ -386,6 +397,10 @@ class BitrixClient: { "ID": s.bitrix_connector_id, "NAME": s.bitrix_connector_name, + "ICON[DATA_IMAGE]": CONNECTOR_ICON_DATA_URI, + "ICON[COLOR]": "#2F80ED", + "ICON[SIZE]": "70%", + "ICON[POSITION]": "center", "PLACEMENT_HANDLER": f"{s.bitrix_public_base_url}/placement", }, ) @@ -527,16 +542,15 @@ def create_app(settings: Settings | None = None) -> FastAPI: event = str(payload.get("event", "")).upper() if event == "ONAPPINSTALL": return await install_payload(request.app, payload) - if event == "ONAPPUNINSTALL": - await uninstall_payload(request.app, payload) - return {"status": "uninstalled"} auth = payload.get("auth") or {} - token = str(auth.get("application_token") or "") - if not hmac.compare_digest(token, cfg.bitrix_application_token): + if not await valid_callback_token(request.app, auth): raise HTTPException( 403, safe_error(request.state.request_id, "callback_forbidden", "Invalid callback"), ) + if event == "ONAPPUNINSTALL": + await uninstall_payload(request.app, payload) + return {"status": "uninstalled"} domain = str(auth.get("domain") or "").lower() data = payload.get("data") or {} connector = first( @@ -760,6 +774,33 @@ async def active_portal(session: AsyncSession) -> PortalInstallation | None: ) +async def valid_callback_token(app: FastAPI, auth: dict[str, Any]) -> bool: + token = str(auth.get("application_token") or auth.get("APPLICATION_TOKEN") or "") + member_id = str(auth.get("member_id") or auth.get("MEMBER_ID") or "") + domain = str(auth.get("domain") or auth.get("DOMAIN") or "").lower() + if not all((token, member_id, domain)): + return False + async with app.state.sessions() as session: + portal = await session.scalar( + select(PortalInstallation).where( + PortalInstallation.member_id == member_id, + PortalInstallation.domain == domain, + PortalInstallation.record_status == "A", + PortalInstallation.install_status == "installed", + ) + ) + if not portal: + return False + expected = app.state.cipher.decrypt( + portal.application_ciphertext, + portal.application_nonce, + portal.member_id, + portal.domain, + "application", + ) + return hmac.compare_digest(token, expected) + + async def install_payload(app: FastAPI, payload: dict[str, Any]) -> dict[str, str]: auth = payload.get("auth") or {} domain = str(auth.get("domain") or auth.get("DOMAIN") or "").lower() @@ -967,7 +1008,10 @@ async def reconcile_setup(app: FastAPI) -> dict[str, bool]: setup.activated = result["activated"] setup.bindings_json = {"complete": result["bindings"]} setup.observed_at = now() - setup.attempt_count += 1 + # SQLAlchemy applies column defaults during INSERT, not when the Python + # object is constructed. A new setup therefore has None here until it + # is flushed. + setup.attempt_count = (setup.attempt_count or 0) + 1 setup.last_error_code = None if all(result.values()) else "connector_setup_failed" setup.next_retry_at = ( None diff --git a/codebase/backend/bitrix-local-app/tests/test_core.py b/codebase/backend/bitrix-local-app/tests/test_core.py index 0ad0512..9272007 100644 --- a/codebase/backend/bitrix-local-app/tests/test_core.py +++ b/codebase/backend/bitrix-local-app/tests/test_core.py @@ -41,8 +41,9 @@ def test_normalize_message_and_finish(): "data": { "MESSAGES": [ { + "im": {"chat_id": 1807, "message_id": 86497}, "chat": {"id": external}, - "message": {"id": "b-1", "text": "Ответ", "files": []}, + "message": {"text": "Ответ", "files": []}, } ] }, @@ -50,6 +51,7 @@ def test_normalize_message_and_finish(): ) assert message["event_type"] == "message.new" assert message["external_chat_id"] == external + assert message["bitrix_message_id"] == "86497" closed = normalize_event( {"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}} )