from __future__ import annotations import asyncio import base64 import hashlib import hmac import json import logging import mimetypes import re import secrets import uuid from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta from typing import Annotated, Any, Literal from urllib.parse import quote, urlparse import httpx import uvicorn from cryptography.hazmat.primitives.ciphers.aead import AESGCM from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import func, or_, select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.models import ( ConnectorSetup, DeliveryAckOutbox, DialogSession, InboxEvent, InstallRun, OutboundMessage, PortalInstallation, now, ) from app.postgres import create_postgres_engine logger = logging.getLogger("bitrix-local-app") BITRIX_SENDER_PREFIX = re.compile( r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?", re.IGNORECASE, ) CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote( '' '' "", safe="", ) class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") app_env: str = "production-like" bitrix_database_url: str bitrix_client_id: str bitrix_client_secret: str bitrix_connector_id: str = "han_mobile_app" bitrix_connector_name: str = "HAN Mobile App" bitrix_open_line_id: str = "8" bitrix_expected_domain: str = "han0107.bitrix24.ru" bitrix_public_base_url: str = "https://tohin.ru/bitrix" bitrix_application_token: str bitrix_internal_api_token: str = Field(min_length=16) bitrix_api_forward_url: str bitrix_api_forward_token: str = Field(min_length=16) bitrix_token_encryption_key: str bitrix_token_encryption_key_version: str = "v1" bitrix_http_timeout_sec: float = Field(default=15, ge=1, le=60) bitrix_http_max_concurrency: int = Field(default=2, ge=1, le=10) bitrix_retry_max_attempts: int = Field(default=10, ge=1, le=50) bitrix_retry_max_delay_sec: int = Field(default=300, ge=1, le=3600) bitrix_worker_poll_sec: float = Field(default=1, ge=0.05, le=30) class TokenCipher: def __init__(self, encoded_key: str, version: str) -> None: try: key = base64.urlsafe_b64decode(encoded_key + "=" * (-len(encoded_key) % 4)) except Exception as exc: raise ValueError("BITRIX_TOKEN_ENCRYPTION_KEY must be urlsafe base64") from exc if len(key) != 32: raise ValueError("BITRIX_TOKEN_ENCRYPTION_KEY must decode to 32 bytes") self.aead = AESGCM(key) self.version = version @staticmethod def aad(member_id: str, domain: str, token_type: str) -> bytes: return f"bitrix-local:{member_id}:{domain}:{token_type}".encode() def encrypt(self, value: str, member_id: str, domain: str, token_type: str) -> tuple[str, str]: nonce = secrets.token_bytes(12) ciphertext = self.aead.encrypt( nonce, value.encode(), self.aad(member_id, domain, token_type) ) return base64.b64encode(ciphertext).decode(), base64.b64encode(nonce).decode() def decrypt( self, ciphertext: str, nonce: str, member_id: str, domain: str, token_type: str ) -> str: return self.aead.decrypt( base64.b64decode(nonce), base64.b64decode(ciphertext), self.aad(member_id, domain, token_type), ).decode() class UserDto(BaseModel): model_config = ConfigDict(extra="forbid") id: uuid.UUID display_name: str = Field(min_length=1, max_length=255) class FileDto(BaseModel): model_config = ConfigDict(extra="forbid") attachment_id: uuid.UUID name: str = Field(min_length=1, max_length=255) mime_type: str = Field(min_length=1, max_length=255) size_bytes: int = Field(gt=0, le=5 * 1024 * 1024) download_url: str = Field(max_length=4096) class MessageDto(BaseModel): model_config = ConfigDict(extra="forbid") content_kind: Literal["text", "file"] text: str = Field(default="", max_length=10000) files: list[FileDto] = Field(default_factory=list, max_length=1) @model_validator(mode="after") def xor_content(self) -> MessageDto: if self.content_kind == "text" and (not self.text or self.files): raise ValueError("text message requires text and no files") if self.content_kind == "file" and (self.text or len(self.files) != 1): raise ValueError("file message requires exactly one file and empty text") return self class OutboundDto(BaseModel): model_config = ConfigDict(extra="forbid") message_id: uuid.UUID external_chat_id: uuid.UUID occurred_at: datetime user: UserDto message: MessageDto def canonical_fingerprint(value: dict[str, Any]) -> str: clean = json.loads(json.dumps(value, sort_keys=True, default=str)) for file in clean.get("message", {}).get("files", []): parsed = urlparse(file.get("download_url", "")) file["download_url"] = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" return hashlib.sha256( json.dumps(clean, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() def retry_delay(attempt: int, maximum: int) -> float: return secrets.randbelow(max(1, min(2 ** max(0, attempt - 1), maximum) * 1000)) / 1000 def safely_retryable(exc: Exception) -> bool: return isinstance(exc, (TimeoutError, httpx.ConnectError, httpx.TimeoutException)) or ( isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 502, 503, 504} ) def safe_error(request_id: str, code: str, message: str) -> dict[str, Any]: return {"error": {"code": code, "message": message, "request_id": request_id, "details": {}}} def insert_nested(target: dict[str, Any], key: str, value: Any) -> None: parts = [part for part in re.split(r"\[|\]", key) if part] cursor = target for part in parts[:-1]: cursor = cursor.setdefault(part, {}) cursor[parts[-1]] = value async def parse_callback(request: Request) -> dict[str, Any]: content_type = request.headers.get("content-type", "").split(";")[0].lower() if content_type == "application/json": value = await request.json() if not isinstance(value, dict): raise ValueError("object expected") return value if content_type in {"application/x-www-form-urlencoded", "multipart/form-data"}: result: dict[str, Any] = {} form = await request.form() if len(form) > 500: raise ValueError("too many fields") for key, value in form.multi_items(): insert_nested(result, key, str(value)) return result raise ValueError("unsupported content type") def validate_portal(domain: str, endpoint: str, expected: str) -> None: parsed = urlparse(endpoint) if domain.lower() != expected.lower(): raise ValueError("unexpected portal") if parsed.scheme != "https" or parsed.hostname != expected.lower(): raise ValueError("invalid client endpoint") def first(value: Any, *paths: tuple[str, ...]) -> Any: for path in paths: current = value for part in path: if isinstance(current, list) and part.isdigit(): index = int(part) current = current[index] if index < len(current) else None elif isinstance(current, list): current = current[0].get(part) if current and isinstance(current[0], dict) else None elif isinstance(current, dict): current = current.get(part) else: current = None if current is None: break if current not in (None, ""): return current return None def strip_bitrix_sender_prefix(text: str) -> str: return BITRIX_SENDER_PREFIX.sub("", text, count=1) def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None: event = str(payload.get("event", "")).upper() if event not in { "ONIMCONNECTORMESSAGEADD", "ONIMCONNECTORDIALOGSTART", "ONIMCONNECTORDIALOGFINISH", }: return None data = payload.get("data") or {} external = first( data, ("MESSAGES", "0", "chat", "id"), ("MESSAGES", "0", "chat", "external_chat_id"), ("CHAT", "ID"), ("external_chat_id",), ) try: external_id = str(uuid.UUID(str(external))) except (ValueError, TypeError): raise ValueError("external_chat_id is missing or invalid") bitrix_message_id = first( data, ("MESSAGES", "0", "im", "message_id"), ("MESSAGES", "0", "message", "id"), ("MESSAGE", "ID"), ("message_id",), ) if event == "ONIMCONNECTORDIALOGFINISH": event_type = "dialog.closed" message = None else: event_type = "message.new" text_value = str( first(data, ("MESSAGES", "0", "message", "text"), ("MESSAGE", "TEXT")) or "" ) text_value = strip_bitrix_sender_prefix(text_value) files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or [] if isinstance(files_raw, dict): files_raw = list(files_raw.values()) files = [] for item in files_raw: if not isinstance(item, dict): continue name = str(item.get("name") or item.get("NAME") or "attachment") declared_type = str( item.get("mimeType") or item.get("mime_type") or item.get("mime") or "" ) if not declared_type: candidate = str(item.get("type") or item.get("TYPE") or "") declared_type = candidate if "/" in candidate else "" guessed_type, _ = mimetypes.guess_type(name) normalized_file = { "name": name, "mime_type": declared_type or guessed_type or "application/octet-stream", "size_bytes": int(item.get("size") or item.get("SIZE") or 0), "download_url": str( item.get("url") or item.get("URL") or item.get("urlDownload") or item.get("downloadUrl") or item.get("downloadLink") or item.get("link") or item.get("URL_DOWNLOAD") or "" ), } file_id = ( item.get("id") or item.get("ID") or item.get("fileId") or item.get("file_id") or item.get("FILE_ID") ) if file_id: normalized_file["_bitrix_file_id"] = str(file_id) if not normalized_file["download_url"] and not file_id: logger.warning( "inbound file has no supported download reference; keys=%s", sorted(str(key) for key in item), ) files.append(normalized_file) if not text_value and not files: raise ValueError("empty message") message = {"text": text_value, "files": files} stable = f"{event}:{external_id}:{bitrix_message_id or ''}" raw_event_id = first(payload, ("event_id",), ("ts",)) event_id = str(raw_event_id) if raw_event_id else hashlib.sha256(stable.encode()).hexdigest() return { "event_id": event_id, "event_type": event_type, "external_chat_id": external_id, "bitrix_message_id": str(bitrix_message_id) if bitrix_message_id else None, "occurred_at": now().isoformat().replace("+00:00", "Z"), "message": message, } class BitrixClient: def __init__( self, client: httpx.AsyncClient, settings: Settings, cipher: TokenCipher, sessions: async_sessionmaker[AsyncSession], ) -> None: self.http = client self.settings = settings self.cipher = cipher self.sessions = sessions self.limit = asyncio.Semaphore(settings.bitrix_http_max_concurrency) self.refresh_lock = asyncio.Lock() async def ensure_fresh( self, portal: PortalInstallation, *, force: bool = False, stale_access_ciphertext: str | None = None, ) -> None: if not force and portal.expires_at and portal.expires_at > now() + timedelta(seconds=60): return async with self.refresh_lock: async with self.sessions() as session: lock_key = f"bitrix-oauth:{portal.member_id}" await session.execute( text("SELECT pg_advisory_lock(hashtext(:key))"), {"key": lock_key} ) await session.commit() try: current = await session.get(PortalInstallation, portal.id) refreshed_by_peer = ( force and stale_access_ciphertext is not None and current.access_ciphertext != stale_access_ciphertext ) still_fresh = ( not force and current.expires_at and current.expires_at > now() + timedelta(seconds=60) ) if refreshed_by_peer or still_fresh: portal.access_ciphertext = current.access_ciphertext portal.access_nonce = current.access_nonce portal.expires_at = current.expires_at await session.commit() return refresh = self.cipher.decrypt( current.refresh_ciphertext, current.refresh_nonce, current.member_id, current.domain, "refresh", ) member_id, domain = current.member_id, current.domain await session.commit() # No database transaction is held during the OAuth HTTP request. response = await self.http.post( "https://oauth.bitrix.info/oauth/token/", data={ "grant_type": "refresh_token", "client_id": self.settings.bitrix_client_id, "client_secret": self.settings.bitrix_client_secret, "refresh_token": refresh, }, follow_redirects=False, ) response.raise_for_status() body = response.json() current = await session.get(PortalInstallation, portal.id, with_for_update=True) if body.get("error"): current.install_status = "reauth_required" current.last_error_code = "oauth_invalid_grant" await session.commit() raise RuntimeError("oauth_refresh_failed") access_pair = self.cipher.encrypt( body["access_token"], member_id, domain, "access" ) refresh_pair = self.cipher.encrypt( body.get("refresh_token", refresh), member_id, domain, "refresh" ) current.access_ciphertext, current.access_nonce = access_pair current.refresh_ciphertext, current.refresh_nonce = refresh_pair current.expires_at = now() + timedelta(seconds=int(body.get("expires", 3600))) current.last_refresh_at = now() await session.commit() portal.access_ciphertext = current.access_ciphertext portal.access_nonce = current.access_nonce portal.expires_at = current.expires_at finally: await session.execute( text("SELECT pg_advisory_unlock(hashtext(:key))"), {"key": lock_key} ) await session.commit() async def call(self, portal: PortalInstallation, method: str, fields: dict[str, Any]) -> dict: await self.ensure_fresh(portal) url = f"https://{portal.domain}/rest/{method}.json" async def post() -> httpx.Response: token = self.cipher.decrypt( portal.access_ciphertext, portal.access_nonce, portal.member_id, portal.domain, "access", ) async with self.limit: return await self.http.post( url, data={**fields, "auth": token}, follow_redirects=False ) stale_access_ciphertext = portal.access_ciphertext response = await post() if response.status_code == 401: await self.ensure_fresh( portal, force=True, stale_access_ciphertext=stale_access_ciphertext, ) response = await post() response.raise_for_status() body = response.json() if body.get("error"): raise RuntimeError(str(body["error"])[:64]) result = body.get("result", body) return result if isinstance(result, dict) else {"value": result} async def setup(self, portal: PortalInstallation) -> dict[str, bool]: s = self.settings await self.call( portal, "imconnector.register", { "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", }, ) await self.call( portal, "imconnector.activate", {"CONNECTOR": s.bitrix_connector_id, "LINE": s.bitrix_open_line_id, "ACTIVE": "1"}, ) for event in ( "OnImConnectorMessageAdd", "OnImConnectorDialogStart", "OnImConnectorDialogFinish", ): await self.call( portal, "event.bind", {"event": event, "handler": f"{s.bitrix_public_base_url}/handler"}, ) return {"registered": True, "activated": True, "bindings": True} def create_app(settings: Settings | None = None) -> FastAPI: cfg = settings or Settings() cipher = TokenCipher(cfg.bitrix_token_encryption_key, cfg.bitrix_token_encryption_key_version) @asynccontextmanager async def lifespan(app: FastAPI): engine = create_postgres_engine( cfg.bitrix_database_url, pool_size=5, max_overflow=0, pool_pre_ping=True, ) app.state.engine = engine app.state.sessions = async_sessionmaker(engine, expire_on_commit=False) app.state.http = httpx.AsyncClient( timeout=httpx.Timeout(cfg.bitrix_http_timeout_sec, connect=3), limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), ) app.state.bitrix = BitrixClient(app.state.http, cfg, cipher, app.state.sessions) app.state.stop = asyncio.Event() app.state.workers = [ asyncio.create_task(worker_loop(app, "inbox"), name="bitrix-inbox"), asyncio.create_task(worker_loop(app, "ack"), name="bitrix-ack"), asyncio.create_task(worker_loop(app, "outbound"), name="bitrix-outbox"), asyncio.create_task(worker_loop(app, "setup"), name="bitrix-setup"), ] yield app.state.stop.set() for task in app.state.workers: task.cancel() for task in app.state.workers: with suppress(asyncio.CancelledError): await task await app.state.http.aclose() await engine.dispose() app = FastAPI( title="HAN Bitrix24 Local App", version="1.0.0", lifespan=lifespan, docs_url=None if cfg.app_env != "test" else "/docs", ) app.state.settings = cfg app.state.cipher = cipher @app.middleware("http") async def request_context(request: Request, call_next): request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) response = await call_next(request) response.headers["X-Request-ID"] = request.state.request_id return response @app.exception_handler(HTTPException) async def http_error(_: Request, exc: HTTPException): return JSONResponse(status_code=exc.status_code, content=exc.detail) @app.exception_handler(RequestValidationError) async def validation_error(request: Request, _: RequestValidationError): return JSONResponse( safe_error(request.state.request_id, "validation_error", "Request is invalid"), status_code=400, ) def internal_auth( request: Request, authorization: Annotated[str | None, Header()] = None ) -> None: candidate = ( authorization[7:] if authorization and authorization.startswith("Bearer ") else "" ) if not hmac.compare_digest(candidate, cfg.bitrix_internal_api_token): raise HTTPException( 401, safe_error( request.state.request_id, "service_unauthorized", "Authentication failed" ), ) @app.get("/health/live") async def live(): return {"status": "live"} @app.get("/health/ready") async def ready(request: Request): try: async with request.app.state.sessions() as session: portal = await active_portal(session) await session.scalar(select(func.now())) worker_ok = all(not task.done() for task in request.app.state.workers) if portal and portal.install_status == "installed" and worker_ok: return {"status": "ready", "portal": "installed", "workers": "running"} return JSONResponse( {"status": "not_ready", "reason": "portal_not_installed"}, status_code=503 ) except Exception: return JSONResponse( {"status": "not_ready", "reason": "database_unavailable"}, status_code=503 ) @app.get("/bitrix/install") @app.get("/bitrix/handler") async def callback_probe(): return {"status": "ok"} @app.post("/bitrix/install") async def install(request: Request): try: payload = await parse_callback(request) return await install_payload(request.app, payload) except ValueError: raise HTTPException( 400, safe_error(request.state.request_id, "validation_error", "Invalid callback") ) @app.post("/bitrix/handler") async def handler(request: Request): try: payload = await parse_callback(request) event = str(payload.get("event", "")).upper() if event == "ONAPPINSTALL": return await install_payload(request.app, payload) auth = payload.get("auth") or {} 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( data, ("CONNECTOR",), ("connector",), ("MESSAGES", "0", "connector"), ) line = first(data, ("LINE",), ("line",), ("MESSAGES", "0", "line")) if ( (domain and domain != cfg.bitrix_expected_domain.lower()) or (connector and str(connector) != cfg.bitrix_connector_id) or (line and str(line) != cfg.bitrix_open_line_id) ): raise HTTPException( 403, safe_error(request.state.request_id, "callback_forbidden", "Invalid callback"), ) member_id = str(auth.get("member_id") or "") if member_id: async with request.app.state.sessions() as session: portal = await active_portal(session) if not portal or not hmac.compare_digest(member_id, portal.member_id): raise HTTPException( 403, safe_error( request.state.request_id, "callback_forbidden", "Invalid callback", ), ) normalized = normalize_event(payload) if normalized is None: return {"status": "ignored"} created = await save_inbox(request.app, normalized) return JSONResponse( {"status": "accepted" if created else "duplicate"}, status_code=202 if created else 200, ) except HTTPException: raise except (ValueError, TypeError): raise HTTPException( 400, safe_error(request.state.request_id, "validation_error", "Invalid callback") ) @app.get("/bitrix/placement", response_class=HTMLResponse) async def placement(): return HTMLResponse( "HAN Mobile App" "

HAN Mobile App connector is managed automatically.

", headers={ "Content-Security-Policy": ( f"default-src 'none'; style-src 'unsafe-inline'; " f"frame-ancestors https://{cfg.bitrix_expected_domain}" ) }, ) @app.post( "/internal/openlines/v1/messages", dependencies=[Depends(internal_auth)], ) async def send_message( dto: OutboundDto, request: Request, idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, ): if idempotency_key != str(dto.message_id): raise HTTPException( 400, safe_error( request.state.request_id, "validation_error", "Idempotency-Key must equal message_id", ), ) body = dto.model_dump(mode="json") fp = canonical_fingerprint(body) async with request.app.state.sessions() as session: row = await session.scalar( select(OutboundMessage).where(OutboundMessage.message_id == dto.message_id) ) if row: if row.request_fingerprint != fp: raise HTTPException( 409, safe_error( request.state.request_id, "idempotency_key_reused", "Idempotency key was reused", ), ) if row.status == "delivered": return row.response_json if row.status in {"sending", "ambiguous"}: raise HTTPException( 503, safe_error( request.state.request_id, "delivery_in_progress", "Delivery state requires reconciliation", ), ) else: row = OutboundMessage( message_id=dto.message_id, external_chat_id=dto.external_chat_id, request_fingerprint=fp, payload_json=body, status="sending", lease_until=now() + timedelta(seconds=cfg.bitrix_http_timeout_sec + 5), ) session.add(row) try: await session.commit() except IntegrityError: await session.rollback() row = await session.scalar( select(OutboundMessage).where(OutboundMessage.message_id == dto.message_id) ) if not row or row.request_fingerprint != fp: raise HTTPException( 409, safe_error( request.state.request_id, "idempotency_key_reused", "Idempotency key was reused", ), ) if row.status == "delivered": return row.response_json raise HTTPException( 503, safe_error( request.state.request_id, "delivery_in_progress", "Delivery is already in progress", ), ) try: result = await asyncio.wait_for( deliver_outbound(request.app, row.id), timeout=cfg.bitrix_http_timeout_sec, ) except Exception as exc: logger.error( "outbound delivery failed", extra={ "request_id": request.state.request_id, "message_id": str(dto.message_id), "error_type": type(exc).__name__, }, ) async with request.app.state.sessions() as session: current = await session.get(OutboundMessage, row.id, with_for_update=True) if current: if safely_retryable(exc): current.status = "retry" current.next_attempt_at = now() + timedelta( seconds=retry_delay( max(1, current.attempt_count), cfg.bitrix_retry_max_delay_sec, ) ) current.last_error_code = "bitrix_delivery_retry" else: current.status = "ambiguous" current.last_error_code = "bitrix_delivery_ambiguous" current.lease_until = None await session.commit() raise HTTPException( 503, safe_error( request.state.request_id, "dependency_unavailable", "Bitrix is unavailable" ), ) return JSONResponse(result, status_code=201) @app.get( "/internal/openlines/v1/dialogs/{external_chat_id}", dependencies=[Depends(internal_auth)], ) async def dialog(external_chat_id: uuid.UUID, request: Request): async with request.app.state.sessions() as session: row = await session.scalar( select(DialogSession).where( DialogSession.external_chat_id == external_chat_id, DialogSession.record_status == "A", ) ) if not row: raise HTTPException( 404, safe_error(request.state.request_id, "dialog_not_found", "Dialog not found") ) return dialog_json(row) @app.get("/internal/openlines/v1/status", dependencies=[Depends(internal_auth)]) async def status(request: Request): async with request.app.state.sessions() as session: portal = await active_portal(session) inbox = await session.scalar( select(func.count()) .select_from(InboxEvent) .where(InboxEvent.status.in_(["received", "retry", "forwarding"])) ) dead = await session.scalar( select(func.count()) .select_from(InboxEvent) .where(InboxEvent.status == "dead_letter") ) return { "status": "ok" if portal else "degraded", "portal": portal.install_status if portal else "not_installed", "setup": portal.setup_status if portal else "not_started", "workers": {"running": all(not task.done() for task in request.app.state.workers)}, "backlog": {"inbox": inbox or 0, "dead_letter": dead or 0}, } @app.post("/internal/openlines/v1/setup/retry", dependencies=[Depends(internal_auth)]) async def setup_retry(request: Request): result = await reconcile_setup(request.app) return {"status": "completed" if all(result.values()) else "partial", "steps": result} return app async def active_portal(session: AsyncSession) -> PortalInstallation | None: return await session.scalar( select(PortalInstallation).where( PortalInstallation.record_status == "A", PortalInstallation.install_status == "installed", ) ) 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() endpoint = str(auth.get("client_endpoint") or auth.get("CLIENT_ENDPOINT") or "") member_id = str(auth.get("member_id") or auth.get("MEMBER_ID") or "") access = str(auth.get("access_token") or auth.get("ACCESS_TOKEN") or "") refresh = str(auth.get("refresh_token") or auth.get("REFRESH_TOKEN") or "") application = str( auth.get("application_token") or auth.get("APPLICATION_TOKEN") or app.state.settings.bitrix_application_token ) if not all((member_id, access, refresh, application)): raise ValueError("missing auth") validate_portal(domain, endpoint, app.state.settings.bitrix_expected_domain) cipher: TokenCipher = app.state.cipher encrypted = [ cipher.encrypt(value, member_id, domain, token_type) for value, token_type in ( (access, "access"), (refresh, "refresh"), (application, "application"), ) ] expires = int(auth.get("expires") or auth.get("EXPIRES") or 3600) async with app.state.sessions() as session: portal = await session.scalar( select(PortalInstallation).where(PortalInstallation.member_id == member_id) ) values = { "domain": domain, "client_endpoint": endpoint, "access_ciphertext": encrypted[0][0], "access_nonce": encrypted[0][1], "refresh_ciphertext": encrypted[1][0], "refresh_nonce": encrypted[1][1], "application_ciphertext": encrypted[2][0], "application_nonce": encrypted[2][1], "key_version": cipher.version, "expires_at": now() + timedelta(seconds=expires), "scope": str(auth.get("scope") or ""), "install_status": "installed", "setup_status": "pending", "record_status": "A", } if portal: for key, value in values.items(): setattr(portal, key, value) else: portal = PortalInstallation(member_id=member_id, **values) session.add(portal) await session.flush() session.add(InstallRun(portal_id=portal.id, status="tokens_saved", result_json={})) await session.commit() try: result = await reconcile_setup(app) return {"status": "installed" if all(result.values()) else "installed_with_errors"} except Exception: return {"status": "installed_with_errors"} async def uninstall_payload(app: FastAPI, payload: dict[str, Any]) -> None: auth = payload.get("auth") or {} member_id = str(auth.get("member_id") or auth.get("MEMBER_ID") or "") async with app.state.sessions() as session: portal = await session.scalar( select(PortalInstallation).where(PortalInstallation.member_id == member_id) ) if portal: portal.install_status = "uninstalled" portal.record_status = "D" portal.status_changed_at = now() portal.status_change_reason = "ONAPPUNINSTALL" await session.commit() async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool: fingerprint = hashlib.sha256( json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() row = InboxEvent( event_id=normalized["event_id"], event_type=normalized["event_type"], external_chat_id=uuid.UUID(normalized["external_chat_id"]), bitrix_message_id=normalized["bitrix_message_id"], payload_fingerprint=fingerprint, normalized_json=normalized, ) async with app.state.sessions() as session: session.add(row) try: await session.commit() return True except IntegrityError: await session.rollback() return False def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, str | None]: chat = first(result, ("CHAT_ID",), ("chat", "id"), ("DATA", "CHAT_ID")) session_id = first(result, ("ID",), ("SESSION_ID",), ("session", "id")) message_id = first(result, ("MESSAGE_ID",), ("message", "id")) return ( int(chat) if chat not in (None, "") else None, str(session_id) if session_id else None, str(message_id) if message_id else None, ) async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]: async with app.state.sessions() as session: row = await session.get(OutboundMessage, row_id) portal = await active_portal(session) if not row or not portal: raise RuntimeError("portal_not_installed") payload = row.payload_json message = payload["message"] fields: dict[str, Any] = { "CONNECTOR": app.state.settings.bitrix_connector_id, "LINE": app.state.settings.bitrix_open_line_id, "MESSAGES[0][user][id]": payload["user"]["id"], "MESSAGES[0][user][name]": payload["user"]["display_name"], "MESSAGES[0][message][id]": payload["message_id"], "MESSAGES[0][message][date]": payload["occurred_at"], "MESSAGES[0][message][text]": message["text"], "MESSAGES[0][chat][id]": payload["external_chat_id"], } if message["files"]: file = message["files"][0] fields["MESSAGES[0][message][files][0][url]"] = file["download_url"] fields["MESSAGES[0][message][files][0][name]"] = file["name"] result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields) chat_id, session_id, bitrix_message_id = extract_delivery(result) response = { "status": "delivered", "message_id": payload["message_id"], "external_chat_id": payload["external_chat_id"], "bitrix_message_id": bitrix_message_id, "dialog_session": {"bitrix_chat_id": chat_id, "session_id": session_id}, } async with app.state.sessions() as session: current = await session.get(OutboundMessage, row_id, with_for_update=True) current.status = "delivered" current.bitrix_message_id = bitrix_message_id current.response_json = response mapping = await session.scalar( select(DialogSession).where( DialogSession.external_chat_id == uuid.UUID(payload["external_chat_id"]), DialogSession.record_status == "A", ) ) if mapping: mapping.bitrix_chat_id = chat_id mapping.session_id = session_id mapping.status = "open" else: session.add( DialogSession( external_chat_id=uuid.UUID(payload["external_chat_id"]), bitrix_chat_id=chat_id, session_id=session_id, portal_id=portal.id, status="open", ) ) await session.commit() return response def dialog_json(row: DialogSession) -> dict[str, Any]: return { "external_chat_id": str(row.external_chat_id), "bitrix_chat_id": row.bitrix_chat_id, "session_id": row.session_id, "status": row.status, "updated_at": row.updated_at.isoformat().replace("+00:00", "Z"), } async def reconcile_setup(app: FastAPI) -> dict[str, bool]: async with app.state.sessions() as session: portal = await active_portal(session) if not portal: return {"registered": False, "activated": False, "bindings": False} try: result = await app.state.bitrix.setup(portal) except Exception: result = {"registered": False, "activated": False, "bindings": False} async with app.state.sessions() as session: setup = await session.scalar( select(ConnectorSetup).where( ConnectorSetup.portal_id == portal.id, ConnectorSetup.connector_id == app.state.settings.bitrix_connector_id, ConnectorSetup.line_id == app.state.settings.bitrix_open_line_id, ) ) if not setup: setup = ConnectorSetup( portal_id=portal.id, connector_id=app.state.settings.bitrix_connector_id, line_id=app.state.settings.bitrix_open_line_id, ) session.add(setup) setup.registered = result["registered"] setup.activated = result["activated"] setup.bindings_json = {"complete": result["bindings"]} setup.observed_at = now() # 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 if all(result.values()) else now() + timedelta( seconds=retry_delay( setup.attempt_count, app.state.settings.bitrix_retry_max_delay_sec ) ) ) current_portal = await session.get(PortalInstallation, portal.id) if all(result.values()): current_portal.setup_status = "ready" elif setup.attempt_count >= app.state.settings.bitrix_retry_max_attempts: current_portal.setup_status = "dead_letter" else: current_portal.setup_status = "retry" await session.commit() return result async def claim_one( session: AsyncSession, model, statuses: list[str], claimed_status: str | None = None, ): row = await session.scalar( select(model) .where( model.status.in_(statuses), model.next_attempt_at <= now(), or_(model.lease_until.is_(None), model.lease_until < now()), ) .order_by(model.next_attempt_at) .with_for_update(skip_locked=True) .limit(1) ) if row: if claimed_status is not None: row.status = claimed_status row.lease_until = now() + timedelta(seconds=30) row.attempt_count += 1 await session.commit() return row async def worker_loop(app: FastAPI, kind: str) -> None: while not app.state.stop.is_set(): try: if kind == "inbox": await process_inbox(app) elif kind == "ack": await process_ack(app) elif kind == "outbound": await process_outbound(app) else: await process_setup(app) except Exception as exc: logger.error( "worker iteration failed", extra={"worker_kind": kind, "error_type": type(exc).__name__}, ) try: await asyncio.wait_for(app.state.stop.wait(), app.state.settings.bitrix_worker_poll_sec) except TimeoutError: continue async def process_inbox(app: FastAPI) -> None: async with app.state.sessions() as session: row = await claim_one(session, InboxEvent, ["received", "retry"]) if not row: return try: payload = json.loads(json.dumps(row.normalized_json)) await resolve_inbound_file_urls(app, payload) response = await app.state.http.post( app.state.settings.bitrix_api_forward_url, json=payload, headers={ "Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}", "X-Request-ID": str(uuid.uuid4()), }, follow_redirects=False, ) duplicate = response.status_code in {200, 204} if response.status_code != 201 and not duplicate: response.raise_for_status() async with app.state.sessions() as session: current = await session.get(InboxEvent, row.id, with_for_update=True) current.status = "ack_pending" current.api_ack_status = "duplicate" if duplicate else "created" current.lease_until = None session.add( DeliveryAckOutbox( inbox_event_id=current.id, payload_json={ "external_chat_id": str(current.external_chat_id), "bitrix_message_id": current.bitrix_message_id, }, ) ) await session.commit() except Exception as exc: logger.error( "inbound forward failed", extra={ "inbox_id": str(row.id), "error_type": type(exc).__name__, }, ) await mark_retry(app, InboxEvent, row.id, "api_forward_failed") async def resolve_inbound_file_urls(app: FastAPI, payload: dict[str, Any]) -> None: message = payload.get("message") or {} for item in message.get("files") or []: file_id = item.pop("_bitrix_file_id", "") if item.get("download_url"): continue if not file_id: raise RuntimeError("bitrix_file_id_missing") async with app.state.sessions() as session: portal = await active_portal(session) if not portal: raise RuntimeError("portal_not_installed") result = await app.state.bitrix.call( portal, "im.v2.File.download", {"id": file_id}, ) download_url = result.get("downloadUrl") or result.get("urlDownload") if not download_url: raise RuntimeError("bitrix_file_download_url_missing") item["download_url"] = str(download_url) async def process_ack(app: FastAPI) -> None: async with app.state.sessions() as session: row = await claim_one(session, DeliveryAckOutbox, ["pending", "retry"]) portal = await active_portal(session) if not row or not portal: return try: await app.state.bitrix.call( portal, "imconnector.send.status.delivery", { "CONNECTOR": app.state.settings.bitrix_connector_id, "LINE": app.state.settings.bitrix_open_line_id, "MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"], "MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "", }, ) async with app.state.sessions() as session: current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True) event = await session.get(InboxEvent, current.inbox_event_id, with_for_update=True) current.status = "completed" current.lease_until = None event.status = "completed" event.delivery_ack_status = "sent" await session.commit() except Exception: await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed") async def process_outbound(app: FastAPI) -> None: async with app.state.sessions() as session: row = await claim_one( session, OutboundMessage, ["retry"], claimed_status="sending", ) if not row: return try: await asyncio.wait_for( deliver_outbound(app, row.id), timeout=app.state.settings.bitrix_http_timeout_sec, ) except Exception as exc: logger.error( "outbound retry failed", extra={ "outbound_id": str(row.id), "error_type": type(exc).__name__, }, ) await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed") async def process_setup(app: FastAPI) -> None: async with app.state.sessions() as session: portal = await active_portal(session) setup = ( await session.scalar( select(ConnectorSetup).where( ConnectorSetup.portal_id == portal.id, ConnectorSetup.connector_id == app.state.settings.bitrix_connector_id, ConnectorSetup.line_id == app.state.settings.bitrix_open_line_id, ) ) if portal else None ) due = setup is None or setup.next_retry_at is None or setup.next_retry_at <= now() if portal and portal.setup_status in {"pending", "retry"} and due: await reconcile_setup(app) async def mark_retry(app: FastAPI, model, row_id: uuid.UUID, error_code: str) -> None: async with app.state.sessions() as session: row = await session.get(model, row_id, with_for_update=True) if not row: return row.last_error_code = error_code row.lease_until = None if row.attempt_count >= app.state.settings.bitrix_retry_max_attempts: row.status = "dead_letter" else: row.status = "retry" row.next_attempt_at = now() + timedelta( seconds=retry_delay( row.attempt_count, app.state.settings.bitrix_retry_max_delay_sec ) ) await session.commit() app = create_app() def run() -> None: uvicorn.run("app.main:app", host="0.0.0.0", port=8080)