Files
han-app/codebase/backend/bitrix-local-app/app/main.py
T

1315 lines
51 KiB
Python

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")
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):
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, (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 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 ""
)
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(
"<!doctype html><meta charset=utf-8><title>HAN Mobile App</title>"
"<p>HAN Mobile App connector is managed automatically.</p>",
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 deliver_outbound(request.app, row.id)
except Exception as exc:
logger.exception(
"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:
logger.exception("worker iteration failed", extra={"worker_kind": kind})
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.exception(
"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 deliver_outbound(app, row.id)
except Exception as exc:
logger.exception(
"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)