Поправлена интеграция с битриксом

This commit is contained in:
mi
2026-07-16 16:27:16 +03:00
parent 3a3d26bd6e
commit f6726fe4b8
2 changed files with 55 additions and 9 deletions
+52 -8
View File
@@ -12,7 +12,7 @@ import uuid
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse from urllib.parse import quote, urlparse
import httpx import httpx
import uvicorn import uvicorn
@@ -40,6 +40,13 @@ from app.postgres import create_postgres_engine
logger = logging.getLogger("bitrix-local-app") logger = logging.getLogger("bitrix-local-app")
CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
'<path fill="#fff" d="M20 20h15v25h30V20h15v60H65V60H35v20H20z"/>'
"</svg>",
safe="",
)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore") model_config = SettingsConfigDict(extra="ignore")
@@ -235,7 +242,11 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValueError("external_chat_id is missing or invalid") raise ValueError("external_chat_id is missing or invalid")
bitrix_message_id = first( 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": if event == "ONIMCONNECTORDIALOGFINISH":
event_type = "dialog.closed" event_type = "dialog.closed"
@@ -386,6 +397,10 @@ class BitrixClient:
{ {
"ID": s.bitrix_connector_id, "ID": s.bitrix_connector_id,
"NAME": s.bitrix_connector_name, "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", "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() event = str(payload.get("event", "")).upper()
if event == "ONAPPINSTALL": if event == "ONAPPINSTALL":
return await install_payload(request.app, payload) return await install_payload(request.app, payload)
if event == "ONAPPUNINSTALL":
await uninstall_payload(request.app, payload)
return {"status": "uninstalled"}
auth = payload.get("auth") or {} auth = payload.get("auth") or {}
token = str(auth.get("application_token") or "") if not await valid_callback_token(request.app, auth):
if not hmac.compare_digest(token, cfg.bitrix_application_token):
raise HTTPException( raise HTTPException(
403, 403,
safe_error(request.state.request_id, "callback_forbidden", "Invalid callback"), 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() domain = str(auth.get("domain") or "").lower()
data = payload.get("data") or {} data = payload.get("data") or {}
connector = first( 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]: async def install_payload(app: FastAPI, payload: dict[str, Any]) -> dict[str, str]:
auth = payload.get("auth") or {} auth = payload.get("auth") or {}
domain = str(auth.get("domain") or auth.get("DOMAIN") or "").lower() 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.activated = result["activated"]
setup.bindings_json = {"complete": result["bindings"]} setup.bindings_json = {"complete": result["bindings"]}
setup.observed_at = now() 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.last_error_code = None if all(result.values()) else "connector_setup_failed"
setup.next_retry_at = ( setup.next_retry_at = (
None None
@@ -41,8 +41,9 @@ def test_normalize_message_and_finish():
"data": { "data": {
"MESSAGES": [ "MESSAGES": [
{ {
"im": {"chat_id": 1807, "message_id": 86497},
"chat": {"id": external}, "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["event_type"] == "message.new"
assert message["external_chat_id"] == external assert message["external_chat_id"] == external
assert message["bitrix_message_id"] == "86497"
closed = normalize_event( closed = normalize_event(
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}} {"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
) )