1301 lines
51 KiB
Python
1301 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) 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",
|
|
)
|
|
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"
|
|
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]):
|
|
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:
|
|
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"])
|
|
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)
|