Поправлена интеграция с битриксом
This commit is contained in:
@@ -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}}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user