Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)

This commit is contained in:
mi
2026-08-13 18:52:42 +03:00
parent 5100ba9fc3
commit 99605b1c77
144 changed files with 15295 additions and 1120 deletions
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import asyncio
import ssl
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from urllib.parse import urljoin, urlsplit
import httpx
class CrmOutcome(StrEnum):
SUCCEEDED = "succeeded"
RETRY = "retry"
UNCERTAIN = "uncertain"
PERMANENT = "permanent"
RATE_LIMITED = "rate_limited"
@dataclass(frozen=True)
class CrmResult:
outcome: CrmOutcome
result: Any = None
error_code: str | None = None
http_status: int | None = None
retry_after: float | None = None
class CrmClient:
"""Host-locked, verified-TLS Bitrix client; redirects are never followed."""
def __init__(self, credential_url: str, approved_host: str, timeout: float) -> None:
parsed = urlsplit(credential_url)
if parsed.scheme != "https" or parsed.hostname != approved_host:
raise ValueError("credential URL is outside approved Bitrix host")
self._base_url = credential_url.rstrip("/") + "/"
self._host = approved_host
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(timeout),
verify=ssl.create_default_context(),
follow_redirects=False,
limits=httpx.Limits(max_connections=4, max_keepalive_connections=2),
headers={"Accept": "application/json"},
)
async def close(self) -> None:
await self._client.aclose()
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
if method not in ALLOWED_METHODS:
raise ValueError("unapproved CRM method")
url = urljoin(self._base_url, method + ".json")
if urlsplit(url).hostname != self._host:
raise ValueError("CRM host changed during URL construction")
try:
response = await self._client.post(url, json=params)
except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError):
return CrmResult(CrmOutcome.RETRY, error_code="crm_network")
except httpx.TimeoutException:
outcome = CrmOutcome.UNCERTAIN if mutating else CrmOutcome.RETRY
return CrmResult(outcome, error_code="crm_timeout")
if response.is_redirect:
return CrmResult(
CrmOutcome.PERMANENT,
error_code="crm_redirect_rejected",
http_status=response.status_code,
)
retry_after = _retry_after(response)
if response.status_code in (408, 429) or response.status_code >= 500:
return CrmResult(
CrmOutcome.RETRY,
error_code=f"crm_http_{response.status_code}",
http_status=response.status_code,
retry_after=retry_after,
)
try:
payload = response.json()
except ValueError:
return CrmResult(
CrmOutcome.PERMANENT,
error_code="crm_malformed_response",
http_status=response.status_code,
)
error = payload.get("error")
if error == "QUERY_LIMIT_EXCEEDED":
return CrmResult(CrmOutcome.RATE_LIMITED, error_code=error, retry_after=retry_after)
if error == "OPERATION_TIME_LIMIT":
return CrmResult(CrmOutcome.RETRY, error_code=error, retry_after=retry_after)
if error:
permanent = error in {
"ERROR_METHOD_NOT_FOUND",
"ERROR_WRONG_AUTH_TYPE",
"INVALID_CREDENTIALS",
"ACCESS_DENIED",
"ERROR_ARGUMENT",
}
return CrmResult(
CrmOutcome.PERMANENT if permanent else CrmOutcome.RETRY,
error_code=str(error)[:64],
http_status=response.status_code,
)
return CrmResult(CrmOutcome.SUCCEEDED, result=payload.get("result"))
async def batch(self, commands: list[tuple[str, dict[str, Any]]]) -> list[CrmResult]:
if not commands:
return []
if len(commands) > 50:
raise ValueError("Bitrix batch limit exceeded")
cmd = {
str(index): f"{method}?{httpx.QueryParams(params)}"
for index, (method, params) in enumerate(commands)
if method in ALLOWED_METHODS
}
batch = await self.call("batch", {"halt": 0, "cmd": cmd}, mutating=True)
if batch.outcome != CrmOutcome.SUCCEEDED:
return [batch for _ in commands]
result = batch.result or {}
successes = result.get("result", {})
errors = result.get("result_error", {})
return [
CrmResult(CrmOutcome.SUCCEEDED, result=successes.get(str(i)))
if str(i) in successes
else CrmResult(CrmOutcome.RETRY, error_code=str(errors.get(str(i), "batch_missing")))
for i in range(len(commands))
]
ALLOWED_METHODS = frozenset(
{
"batch",
"crm.duplicate.findbycomm",
"crm.contact.get",
"crm.contact.add",
"crm.contact.update",
"crm.contact.userfield.list",
"crm.item.list",
"crm.item.get",
"crm.item.add",
"crm.item.update",
}
)
def _retry_after(response: httpx.Response) -> float | None:
value = response.headers.get("Retry-After")
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
try:
retry_at = datetime.fromisoformat(value).astimezone(UTC)
return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
except ValueError:
return None
class TokenBucket:
def __init__(self, refill_per_second: float, burst: int, max_in_flight: int) -> None:
self.refill_per_second = refill_per_second
self.burst = float(burst)
self.tokens = float(burst)
self.updated_at = asyncio.get_running_loop().time()
self._lock = asyncio.Lock()
self._slots = asyncio.Semaphore(max_in_flight)
async def acquire(self) -> None:
await self._slots.acquire()
while True:
async with self._lock:
now = asyncio.get_running_loop().time()
self.tokens = min(
self.burst, self.tokens + (now - self.updated_at) * self.refill_per_second
)
self.updated_at = now
if self.tokens >= 1:
self.tokens -= 1
return
delay = (1 - self.tokens) / self.refill_per_second
await asyncio.sleep(delay)
def release(self) -> None:
self._slots.release()