Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user