Реализованы сервисы ВМ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
+78 -3
View File
@@ -34,6 +34,10 @@ class Base(DeclarativeBase):
type_annotation_map = {dict[str, Any]: JSON}
class BitrixBase(DeclarativeBase):
"""Models owned by bitrix-sync, excluded from han_app create_all."""
class Common:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
@@ -142,6 +146,9 @@ class Message(Common, Base):
content_kind: Mapped[str] = mapped_column(String(16))
text: Mapped[str] = mapped_column(Text, default="")
safety_status: Mapped[str] = mapped_column(String(16))
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
delivery_status: Mapped[str] = mapped_column(String(16))
external_message_id: Mapped[str | None] = mapped_column(String(255))
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
@@ -152,7 +159,7 @@ class MessageAttachment(Common, Base):
__tablename__ = "message_attachments"
__table_args__ = (
CheckConstraint("direction IN ('client_upload','company_inbound')"),
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
CheckConstraint("size_bytes > 0"),
{"schema": SCHEMA},
)
@@ -171,6 +178,8 @@ class MessageAttachment(Common, Base):
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -193,10 +202,15 @@ class SafetyTask(Base):
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_id: Mapped[str] = mapped_column(String(255), unique=True)
poll_location: Mapped[str] = mapped_column(String(1024))
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
status: Mapped[str] = mapped_column(String(16))
processing_mode: Mapped[str | None] = mapped_column(String(16))
config_version: Mapped[int | None] = mapped_column(BigInteger)
rules_version: Mapped[str | None] = mapped_column(String(128))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -321,18 +335,32 @@ class PopularQuestion(Common, Base):
class SyncQueue(Base):
__tablename__ = "sync_queue"
__table_args__ = ({"schema": SCHEMA},)
__table_args__ = (
CheckConstraint(
"status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')"
),
Index("ix_sync_queue_claim", "status", "next_attempt_at", "created_at"),
Index("ix_sync_queue_entity_history", "entity_type", "entity_id", "created_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_type: Mapped[str] = mapped_column(String(64))
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
dedup_key: Mapped[str] = mapped_column(String(255))
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
locked_by: Mapped[str | None] = mapped_column(String(128))
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
lease_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancel_reason: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -347,6 +375,53 @@ class EntityExternalMapping(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class BitrixEntityExternalMapping(BitrixBase):
__tablename__ = "entity_external_mapping"
__table_args__ = ({"schema": "bitrix_sync"},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
external_system: Mapped[str] = mapped_column(String(32), default="bitrix24")
external_entity_type: Mapped[str] = mapped_column(String(32), default="contact")
external_id: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16), default="active")
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
close_reason: Mapped[str | None] = mapped_column(String(64))
workflow_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
Index(
"uq_sync_queue_active_dedup",
SyncQueue.dedup_key,
unique=True,
postgresql_where=SyncQueue.status.in_(["pending", "leased", "retry_wait"]),
)
Index(
"ix_sync_queue_expired_lease",
SyncQueue.locked_until,
postgresql_where=SyncQueue.status == "leased",
)
Index(
"uq_external_mapping_active_entity",
BitrixEntityExternalMapping.external_system,
BitrixEntityExternalMapping.entity_type,
BitrixEntityExternalMapping.entity_id,
unique=True,
postgresql_where=BitrixEntityExternalMapping.status == "active",
)
Index(
"uq_external_mapping_active_external",
BitrixEntityExternalMapping.external_system,
BitrixEntityExternalMapping.external_entity_type,
BitrixEntityExternalMapping.external_id,
unique=True,
postgresql_where=BitrixEntityExternalMapping.status == "active",
)
class Database:
def __init__(self, url: str) -> None:
self.engine: AsyncEngine = create_postgres_engine(url, pool_pre_ping=True)
+125 -12
View File
@@ -6,6 +6,7 @@ import socket
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from urllib.parse import urlparse
@@ -25,9 +26,19 @@ return {current, ttl}
class DependencyFailure(Exception):
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
def __init__(
self,
code: str = "dependency_unavailable",
timeout: bool = False,
*,
terminal: bool = False,
retryable: bool = True,
) -> None:
super().__init__(code)
self.code = code
self.timeout = timeout
self.terminal = terminal
self.retryable = retryable
@dataclass(slots=True)
@@ -106,20 +117,37 @@ class SafetyClient:
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
return await self._call(
"POST",
"/internal/safety/v1/messages/check",
f"{self.settings.message_safety_api_prefix}/messages/check",
request_id,
json=payload,
timeout=self.settings.message_safety_post_timeout_sec,
)
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
async def poll(self, location: str, request_id: str) -> dict[str, Any]:
path = self._poll_path(location)
return await self._call(
"GET",
f"/internal/safety/v1/messages/tasks/{task_id}",
path,
request_id,
timeout=2,
)
def _poll_path(self, location: str) -> str:
expected_prefix = f"{self.settings.message_safety_api_prefix}/messages/tasks/"
parsed = urlparse(location)
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
if not parsed.path.startswith(expected_prefix):
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
task_id = parsed.path.removeprefix(expected_prefix)
try:
uuid.UUID(task_id)
except ValueError as exc:
raise DependencyFailure(
"invalid_safety_location", terminal=True, retryable=False
) from exc
return parsed.path
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
@@ -140,9 +168,31 @@ class SafetyClient:
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if response.status_code == 401 or response.status_code >= 500:
if response.status_code >= 500:
self.breaker.failure()
raise DependencyFailure()
code, terminal, retryable = "dependency_unavailable", False, True
try:
details = response.json().get("error", {}).get("details", {})
code = response.json().get("error", {}).get("code", code)
terminal = details.get("terminal") is True
retryable = details.get("retryable") is not False
except (AttributeError, ValueError):
pass
raise DependencyFailure(code, terminal=terminal, retryable=retryable)
if response.status_code not in (200, 202, 403):
if response.status_code == 401:
self.breaker.failure()
code = "safety_request_rejected"
try:
code = response.json().get("error", {}).get("code", code)
except (AttributeError, ValueError):
pass
retryable = response.status_code in (404, 429)
raise DependencyFailure(
code,
terminal=not retryable,
retryable=retryable,
)
try:
body = response.json()
except ValueError as exc:
@@ -151,10 +201,66 @@ class SafetyClient:
if not isinstance(body, dict):
self.breaker.failure()
raise DependencyFailure()
status = response.status_code
expected_verdict = {200: "allow", 202: "pending", 403: "deny"}[status]
if (
body.get("verdict") != expected_verdict
or body.get("processing_mode") not in ("standard", "mock")
or type(body.get("config_version")) is not int
or not body.get("rules_version")
):
self.breaker.failure()
raise DependencyFailure("invalid_safety_response")
if status == 202:
location = response.headers.get("Location")
retry_after = response.headers.get("Retry-After")
if (
body["processing_mode"] != "standard"
or not location
or not retry_after
or not body.get("task_id")
or not body.get("expires_at")
or type(body.get("poll_after_ms")) is not int
):
self.breaker.failure()
raise DependencyFailure("invalid_safety_response")
try:
location_task_id = self._poll_path(location).rsplit("/", 1)[-1]
except DependencyFailure as exc:
self.breaker.failure()
raise DependencyFailure("invalid_safety_response") from exc
if body["task_id"] != location_task_id:
self.breaker.failure()
raise DependencyFailure("invalid_safety_response")
try:
if int(retry_after) <= 0 or body["poll_after_ms"] <= 0:
raise ValueError
datetime_value = body["expires_at"].replace("Z", "+00:00")
datetime.fromisoformat(datetime_value)
except (AttributeError, TypeError, ValueError) as exc:
self.breaker.failure()
raise DependencyFailure("invalid_safety_response") from exc
body["_location"] = location
body["_retry_after"] = retry_after
elif not body.get("rule_id") or (
status == 403 and body.get("reason_code") != "message_blocked"
):
self.breaker.failure()
raise DependencyFailure("invalid_safety_response")
self.breaker.success()
body["_status"] = response.status_code
return body
async def ready(self) -> bool:
try:
response = await self.http.get(
f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready",
timeout=2,
)
return response.status_code == 200
except httpx.HTTPError:
return False
class OpenLinesClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
@@ -272,7 +378,14 @@ class S3Client:
async def head(self, bucket: str, key: str) -> dict[str, Any]:
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
async def promote(self, source_key: str, destination_key: str) -> None:
async def promote(
self,
source_key: str,
destination_key: str,
*,
version_id: str,
etag: str,
) -> None:
await asyncio.to_thread(
self.client.copy_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
@@ -280,13 +393,13 @@ class S3Client:
CopySource={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": source_key,
"VersionId": version_id,
},
CopySourceIfMatch=etag,
)
await asyncio.to_thread(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=source_key,
)
# Keep the immutable source version until quarantine lifecycle expiry.
# A crash after copy but before the DB checkpoint can then safely retry
# the same conditional copy without losing its source.
async def delete_quarantine(self, key: str) -> None:
await asyncio.to_thread(
+7 -10
View File
@@ -138,12 +138,15 @@ async def lifespan(app: FastAPI):
app.state.settings = settings
app.state.db = Database(settings.database_url)
app.state.http = httpx.AsyncClient()
app.state.safety_http = httpx.AsyncClient(
verify=settings.message_safety_ca_file or True
)
app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
app.state.jwks = JWKSValidator(settings, app.state.http)
app.state.rate_limiter = RateLimiter(app.state.redis)
app.state.idempotency = RedisIdempotency(app.state.redis)
app.state.safety = SafetyClient(settings, app.state.http)
app.state.safety = SafetyClient(settings, app.state.safety_http)
app.state.openlines = OpenLinesClient(settings, app.state.http)
app.state.s3 = S3Client(settings)
app.state.realtime = RealtimeFanout(app.state.redis_rt)
@@ -168,6 +171,7 @@ async def lifespan(app: FastAPI):
with suppress(asyncio.CancelledError):
await task
await app.state.http.aclose()
await app.state.safety_http.aclose()
await app.state.redis.aclose()
await app.state.redis_rt.aclose()
await app.state.db.close()
@@ -175,7 +179,7 @@ async def lifespan(app: FastAPI):
telemetry.shutdown()
EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
app = FastAPI(
@@ -486,14 +490,7 @@ async def ready(request: Request, db: Session):
except Exception:
components[name] = "failed"
components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
try:
safety_response = await request.app.state.http.get(
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
timeout=2,
)
components["safety"] = "ok" if safety_response.is_success else "failed"
except httpx.HTTPError:
components["safety"] = "failed"
components["safety"] = "ok" if await request.app.state.safety.ready() else "failed"
components["openlines"] = "ok" if await request.app.state.openlines.ready() else "degraded"
components["s3"] = "ok" if await request.app.state.s3.ready() else "degraded"
critical = {"postgres", "settings", "redis", "jwks", "safety"}
@@ -240,7 +240,7 @@ class ClientUploadDraft(Base):
__table_args__ = (
CheckConstraint("context_type IN ('notification')"),
CheckConstraint("size_bytes > 0"),
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
CheckConstraint("state IN ('draft','submitted','discarded')"),
Index("ix_client_upload_drafts_context", "user_id", "context_type", "context_id"),
Index("ix_client_upload_drafts_scan", "scan_status", "updated_at"),
@@ -262,6 +262,11 @@ class ClientUploadDraft(Base):
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
state: Mapped[str] = mapped_column(String(16), default="draft")
@@ -1004,7 +1004,14 @@ async def complete_upload(
or metadata.get("ContentType") != draft.mime_type
):
raise DomainError("attachment_invalid", 400, "Uploaded metadata differs")
version_id, etag = metadata.get("VersionId"), metadata.get("ETag")
if not version_id or not etag:
raise DomainError(
"dependency_unavailable", 503, "Versioned object metadata is unavailable"
)
draft.checksum_sha256 = checksum
draft.quarantine_version_id = str(version_id)
draft.quarantine_etag = str(etag)
verdict = await safety.check(
{
"message_id": str(draft.id),
@@ -1013,6 +1020,8 @@ async def complete_upload(
"attachment": {
"attachment_id": str(draft.id),
"quarantine_object_key": draft.quarantine_object_key,
"quarantine_version_id": draft.quarantine_version_id,
"quarantine_etag": draft.quarantine_etag,
"checksum": body.checksum,
"mime_type": draft.mime_type,
"size_bytes": draft.size_bytes,
@@ -1020,25 +1029,33 @@ async def complete_upload(
},
request_id,
)
if verdict["_status"] == 203:
if verdict["_status"] == 202:
deadline = datetime.now(UTC) + timedelta(
seconds=safety.settings.message_safety_task_poll_max_sec
)
while verdict["_status"] == 203 and datetime.now(UTC) < deadline:
while verdict["_status"] == 202 and datetime.now(UTC) < deadline:
await asyncio.sleep(safety.settings.message_safety_task_poll_interval_sec)
verdict = await safety.poll(verdict["task_id"], request_id)
verdict = await safety.poll(verdict["_location"], request_id)
draft.safety_processing_mode = verdict.get("processing_mode")
draft.safety_config_version = verdict.get("config_version")
draft.safety_rules_version = verdict.get("rules_version")
if verdict["_status"] == 200:
destination = (
f"attachments/users/{user_id}/{draft.context_type}/{draft.context_id}/{draft.id}"
)
await s3.promote(draft.quarantine_object_key or draft.object_key, destination)
await s3.promote(
draft.quarantine_object_key or draft.object_key,
destination,
version_id=draft.quarantine_version_id or "",
etag=draft.quarantine_etag or "",
)
draft.storage_bucket = s3.settings.selectel_s3_bucket_attachments
draft.object_key = destination
draft.quarantine_object_key = None
draft.scan_status = "clean"
elif verdict["_status"] == 403 or (
verdict["_status"] == 400 and verdict.get("verdict") == "deny"
):
draft.scan_status = (
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
)
elif verdict["_status"] == 403:
if draft.quarantine_object_key:
await s3.delete_quarantine(draft.quarantine_object_key)
draft.scan_status = "infected"
+34 -10
View File
@@ -737,7 +737,14 @@ async def complete_attachment(
raise DomainError("dependency_unavailable", 503, "Object storage is unavailable") from exc
if int(head["ContentLength"]) != item.size_bytes or head.get("ContentType") != item.mime_type:
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
version_id, etag = head.get("VersionId"), head.get("ETag")
if not version_id or not etag:
raise DomainError(
"dependency_unavailable", 503, "Versioned object metadata is unavailable"
)
item.checksum_sha256 = checksum
item.quarantine_version_id = str(version_id)
item.quarantine_etag = str(etag)
item.completed_at = datetime.now(UTC)
session.add(
audit(
@@ -886,6 +893,8 @@ async def send_message(
payload["attachment"] = {
"attachment_id": str(attachment.id),
"quarantine_object_key": attachment.quarantine_object_key,
"quarantine_version_id": attachment.quarantine_version_id,
"quarantine_etag": attachment.quarantine_etag,
"checksum": f"sha256:{attachment.checksum_sha256}",
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
@@ -893,14 +902,20 @@ async def send_message(
outbox: DeliveryOutbox | None = None
try:
verdict = await safety.check(payload, context.request_id)
if verdict["_status"] == 203:
task: SafetyTask | None = None
if verdict["_status"] == 202:
task_id = verdict["task_id"]
task = SafetyTask(
task_id=task_id,
poll_location=verdict["_location"],
message_id=message.id,
attachment_id=attachment.id if attachment else None,
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
status="polling",
processing_mode=verdict["processing_mode"],
config_version=verdict["config_version"],
rules_version=verdict["rules_version"],
expires_at=datetime.fromisoformat(verdict["expires_at"].replace("Z", "+00:00")),
deadline_at=now
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
next_poll_at=now,
@@ -910,16 +925,18 @@ async def send_message(
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
while time_monotonic() < deadline:
await sleep(settings.message_safety_task_poll_interval_sec)
verdict = await safety.poll(task_id, context.request_id)
if verdict["_status"] != 203:
verdict = await safety.poll(task.poll_location, context.request_id)
if verdict["_status"] != 202:
break
task.poll_location = verdict["_location"]
else:
raise DependencyFailure(timeout=True)
if verdict["_status"] == 403 or (
verdict["_status"] == 400
and verdict.get("error", {}).get("code") == "stub_final_error"
and verdict.get("verdict") == "deny"
):
message.safety_processing_mode = verdict["processing_mode"]
message.safety_config_version = verdict["config_version"]
message.safety_rules_version = verdict["rules_version"]
if task:
task.status = "completed"
if verdict["_status"] == 403:
message.text = ""
message.safety_status = "blocked"
message.delivery_status = "rejected"
@@ -957,11 +974,18 @@ async def send_message(
raise DependencyFailure()
if attachment and attachment.quarantine_object_key:
destination = f"attachments/dialogs/{dialog_id}/{attachment.id}"
await s3.promote(attachment.quarantine_object_key, destination)
await s3.promote(
attachment.quarantine_object_key,
destination,
version_id=attachment.quarantine_version_id or "",
etag=attachment.quarantine_etag or "",
)
attachment.storage_bucket = settings.selectel_s3_bucket_attachments
attachment.object_key = destination
attachment.quarantine_object_key = None
attachment.scan_status = "clean"
attachment.scan_status = (
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
)
message.safety_status = "allowed"
outbox = DeliveryOutbox(
message_id=message.id,
+15 -1
View File
@@ -1,6 +1,7 @@
from functools import lru_cache
from typing import Literal
from pydantic import AnyHttpUrl, Field, SecretStr
from pydantic import AnyHttpUrl, Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -25,6 +26,10 @@ class Settings(BaseSettings):
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
message_safety_ca_file: str | None = Field(default=None, alias="MESSAGE_SAFETY_CA_FILE")
message_safety_api_prefix: Literal["/internal/safety/v2"] = Field(
default="/internal/safety/v2", alias="MESSAGE_SAFETY_API_PREFIX"
)
message_safety_post_timeout_sec: float = Field(
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
)
@@ -71,6 +76,15 @@ class Settings(BaseSettings):
default=None, alias="NOTIFICATIONS_TOKEN_PRODUCER_TEST"
)
@model_validator(mode="after")
def require_safety_tls_in_deployed_environments(self) -> "Settings":
if self.app_env not in {"local", "test"}:
if str(self.message_safety_url).split(":", 1)[0] != "https":
raise ValueError("MESSAGE_SAFETY_URL must use HTTPS")
if not self.message_safety_ca_file:
raise ValueError("MESSAGE_SAFETY_CA_FILE is required")
return self
@property
def issuer(self) -> str:
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
+77 -7
View File
@@ -7,7 +7,15 @@ import redis.asyncio as redis
import structlog
from sqlalchemy import delete, select
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
from app.db import (
Database,
DeliveryOutbox,
Dialog,
Message,
MessageAttachment,
SafetyTask,
UserIdentity,
)
from app.integrations import (
DependencyFailure,
OpenLinesClient,
@@ -107,7 +115,7 @@ async def safety_once(
.where(
SafetyTask.status.in_(["polling", "failed"]),
SafetyTask.next_poll_at <= datetime.now(UTC),
SafetyTask.deadline_at > datetime.now(UTC),
SafetyTask.expires_at > datetime.now(UTC),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
@@ -127,7 +135,7 @@ async def safety_once(
continue
message = None
try:
verdict = await safety.poll(task.task_id, f"worker-{worker_id}")
verdict = await safety.poll(task.poll_location, f"worker-{worker_id}")
message = await session.get(Message, task.message_id)
attachment = (
await session.get(MessageAttachment, task.attachment_id)
@@ -135,16 +143,70 @@ async def safety_once(
else None
)
if verdict["_status"] == 200 and message:
message.safety_processing_mode = verdict["processing_mode"]
message.safety_config_version = verdict["config_version"]
message.safety_rules_version = verdict["rules_version"]
if attachment and attachment.quarantine_object_key:
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
await s3.promote(attachment.quarantine_object_key, destination)
await s3.promote(
attachment.quarantine_object_key,
destination,
version_id=attachment.quarantine_version_id or "",
etag=attachment.quarantine_etag or "",
)
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
attachment.object_key = destination
attachment.quarantine_object_key = None
attachment.scan_status = "clean"
attachment.scan_status = (
"bypassed"
if verdict["processing_mode"] == "mock"
else "clean"
)
message.safety_status = "allowed"
task.status = "completed"
dialog = await session.get(Dialog, message.dialog_id)
user = (
await session.get(UserIdentity, dialog.user_id)
if dialog
else None
)
if dialog and user:
session.add(
DeliveryOutbox(
message_id=message.id,
external_chat_id=dialog.id,
payload_json={
"message_id": str(message.id),
"external_chat_id": str(dialog.id),
"occurred_at": message.occurred_at.isoformat(),
"user": {
"id": str(user.id),
"display_name": user.phone_number,
},
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": (
[{
"attachment_id": str(attachment.id),
"name": attachment.safe_file_name,
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
"_storage_bucket": attachment.storage_bucket,
"_object_key": attachment.object_key,
}]
if attachment
else []
),
},
},
next_attempt_at=datetime.now(UTC),
)
)
elif verdict["_status"] == 403 and message:
message.safety_processing_mode = verdict["processing_mode"]
message.safety_config_version = verdict["config_version"]
message.safety_rules_version = verdict["rules_version"]
message.text = ""
message.safety_status = "blocked"
message.delivery_status = "rejected"
@@ -153,10 +215,18 @@ async def safety_once(
attachment.scan_status = "infected"
task.status = "completed"
else:
if verdict["_status"] == 202:
task.poll_location = verdict["_location"]
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
except DependencyFailure:
except DependencyFailure as exc:
task.attempt_count += 1
task.status = "failed"
terminal = exc.terminal or (
exc.code == "task_not_found" and task.attempt_count >= 2
)
task.status = "terminal_failed" if terminal else "failed"
task.last_error_code = exc.code
if terminal and message:
message.delivery_status = "failed"
task.next_poll_at = datetime.now(UTC) + timedelta(
seconds=min(300, 2**task.attempt_count)
)