Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1 @@
"""HAN Chat API backend."""
@@ -0,0 +1,110 @@
import asyncio
import time
from dataclasses import dataclass
from typing import Any
import httpx
import jwt
import phonenumbers
from jwt import ExpiredSignatureError, InvalidTokenError, PyJWK
from app.settings import Settings
class AuthError(Exception):
def __init__(self, code: str = "unauthorized") -> None:
self.code = code
@dataclass(frozen=True, slots=True)
class Principal:
subject: str
phone_number: str | None
claims: dict[str, Any]
class JWKSValidator:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self._keys: dict[str, PyJWK] = {}
self._loaded_at = 0.0
self._lock = asyncio.Lock()
@property
def has_keys(self) -> bool:
return bool(self._keys)
async def refresh(self) -> None:
async with self._lock:
discovery_url = (
f"{str(self.settings.keycloak_internal_url).rstrip('/')}"
f"/realms/{self.settings.keycloak_realm}/.well-known/openid-configuration"
)
discovery = (await self.http.get(discovery_url, timeout=3)).raise_for_status().json()
jwks_uri = discovery["jwks_uri"]
public_prefix = str(self.settings.keycloak_public_url).rstrip("/")
internal_prefix = str(self.settings.keycloak_internal_url).rstrip("/")
if jwks_uri.startswith(public_prefix):
jwks_uri = internal_prefix + jwks_uri[len(public_prefix) :]
payload = (await self.http.get(jwks_uri, timeout=3)).raise_for_status().json()
self._keys = {
key["kid"]: PyJWK.from_dict(key)
for key in payload.get("keys", [])
if key.get("kid") and key.get("kty") == "RSA"
}
self._loaded_at = time.monotonic()
async def validate(self, token: str) -> Principal:
try:
header = jwt.get_unverified_header(token)
except InvalidTokenError as exc:
raise AuthError() from exc
if header.get("alg") != "RS256" or not header.get("kid"):
raise AuthError()
kid = str(header["kid"])
stale = time.monotonic() - self._loaded_at > self.settings.jwks_cache_ttl_seconds
if stale or kid not in self._keys:
try:
await self.refresh()
except (httpx.HTTPError, KeyError, ValueError):
if (
kid not in self._keys
or time.monotonic() - self._loaded_at > self.settings.jwks_stale_grace_seconds
):
raise AuthError() from None
key = self._keys.get(kid)
if key is None:
raise AuthError()
try:
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self.settings.keycloak_audience,
issuer=self.settings.issuer,
options={"require": ["exp", "sub"]},
leeway=30,
)
except ExpiredSignatureError as exc:
raise AuthError("token_expired") from exc
except InvalidTokenError as exc:
raise AuthError() from exc
subject = claims.get("sub")
if not isinstance(subject, str) or not subject:
raise AuthError()
return Principal(subject, canonical_phone(claims), claims)
def canonical_phone(claims: dict[str, Any]) -> str | None:
for name in ("phone_number", "preferred_username"):
value = claims.get(name)
if not isinstance(value, str):
continue
try:
number = phonenumbers.parse(value, None)
except phonenumbers.NumberParseException:
continue
if phonenumbers.is_valid_number(number) and value.startswith("+"):
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
return None
@@ -0,0 +1,21 @@
from collections.abc import Mapping
CHAT_MESSAGE_MAX_LENGTH_KEY = "chat.message.max_length"
CHAT_MESSAGE_TRANSPORT_MAX_LENGTH = 10_000
def validate_chat_settings(values: Mapping[str, str]) -> None:
raw = values.get(CHAT_MESSAGE_MAX_LENGTH_KEY)
if raw is None:
return
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: canonical integer value expected")
if not 1 <= value <= CHAT_MESSAGE_TRANSPORT_MAX_LENGTH:
raise ValueError(
f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: value must be between 1 "
f"and {CHAT_MESSAGE_TRANSPORT_MAX_LENGTH}"
)
@@ -0,0 +1 @@
"""Operational command-line entry points."""
@@ -0,0 +1,127 @@
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
from typing import Any
import yaml
from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
def load_seed(path: Path) -> list[dict[str, Any]]:
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or document.get("schema_version") != 1:
raise ValueError("settings file must have schema_version: 1")
settings = document.get("settings")
if not isinstance(settings, dict) or not settings:
raise ValueError("settings file must contain a non-empty settings mapping")
rows: list[dict[str, Any]] = []
for key, raw in settings.items():
if not isinstance(key, str) or not key.strip():
raise ValueError("setting keys must be non-empty strings")
if not isinstance(raw, dict):
raise ValueError(f"{key}: setting must be a mapping")
value_type = raw.get("type")
if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
raise ValueError(f"{key}: setting must be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
rows.append(
{
"setting_key": key,
"setting_value": serialize_value(key, value_type, raw.get("value")),
"value_type": value_type,
"is_public": raw["public"],
"description": description,
"record_status": "A",
}
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows
def serialize_value(key: str, value_type: str, value: Any) -> str:
if value_type == "boolean":
if not isinstance(value, bool):
raise ValueError(f"{key}: boolean value expected")
return str(value).lower()
if value_type == "integer":
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"{key}: integer value expected")
return str(value)
if value_type == "string_list":
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return ",".join(value)
if isinstance(value, str):
return value
raise ValueError(f"{key}: string or list of strings expected")
if not isinstance(value, str):
raise ValueError(f"{key}: string value expected")
return value
async def seed(path: Path) -> int:
rows = load_seed(path)
database = Database(get_settings().database_url)
try:
async with database.sessions() as session:
for row in rows:
statement = insert(AppSetting).values(**row)
excluded = statement.excluded
statement = statement.on_conflict_do_update(
index_elements=[AppSetting.setting_key],
set_={
"setting_value": excluded.setting_value,
"value_type": excluded.value_type,
"is_public": excluded.is_public,
"description": excluded.description,
"record_status": "A",
"updated_at": func.now(),
},
where=or_(
AppSetting.setting_value.is_distinct_from(excluded.setting_value),
AppSetting.value_type.is_distinct_from(excluded.value_type),
AppSetting.is_public.is_distinct_from(excluded.is_public),
AppSetting.description.is_distinct_from(excluded.description),
AppSetting.record_status != "A",
),
)
await session.execute(statement)
await session.commit()
finally:
await database.close()
return len(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="Idempotently seed application settings")
parser.add_argument("--file", required=True, type=Path)
args = parser.parse_args()
count = asyncio.run(seed(args.file))
print(f"Application settings seeded: {count}")
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.db import AppSetting, Database
from app.services import REQUIRED_SETTINGS
from app.settings import get_settings
async def validate() -> int:
database = Database(get_settings().database_url)
try:
async with database.sessions() as session:
active_keys = set(
(
await session.execute(
select(AppSetting.setting_key).where(AppSetting.record_status == "A")
)
)
.scalars()
.all()
)
finally:
await database.close()
missing = sorted(REQUIRED_SETTINGS - active_keys)
if missing:
raise RuntimeError(f"Mandatory application settings are missing: {', '.join(missing)}")
return len(REQUIRED_SETTINGS)
def main() -> None:
count = asyncio.run(validate())
print(f"Mandatory application settings validated: {count}")
if __name__ == "__main__":
main()
@@ -0,0 +1,435 @@
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.postgres import create_postgres_engine
SCHEMA = "han_app"
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")
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status_change_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(), onupdate=func.now()
)
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
class UserIdentity(Common, Base):
__tablename__ = "user_identities"
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
phone_number: Mapped[str] = mapped_column(String(32))
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class ClientProfile(Common, Base):
__tablename__ = "client_profiles"
__table_args__ = ({"schema": SCHEMA},)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
)
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
full_name: Mapped[str | None] = mapped_column(String(255))
citizenship: Mapped[str | None] = mapped_column(String(128))
russian_phone: Mapped[str | None] = mapped_column(String(32))
foreign_phone: Mapped[str | None] = mapped_column(String(32))
email: Mapped[str | None] = mapped_column(String(320))
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class UserConsent(Common, Base):
__tablename__ = "user_consents"
__table_args__ = (
UniqueConstraint("user_id", "consent_type", "document_version"),
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
consent_type: Mapped[str] = mapped_column(String(32))
document_version: Mapped[str] = mapped_column(String(64))
accepted: Mapped[bool] = mapped_column(Boolean)
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
client_ip: Mapped[str | None] = mapped_column(INET)
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
device_json: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
class UxSession(Common, Base):
__tablename__ = "ux_sessions"
__table_args__ = (
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
start_reason: Mapped[str] = mapped_column(String(32))
platform: Mapped[str] = mapped_column(String(32))
app_version: Mapped[str] = mapped_column(String(64))
device_id_hash: Mapped[str | None] = mapped_column(String(64))
device_id: Mapped[str | None] = mapped_column(String(255))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class Dialog(Common, Base):
__tablename__ = "dialogs"
__table_args__ = (
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
status: Mapped[str] = mapped_column(String(32), default="open")
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Message(Common, Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint("sender_type IN ('client','company')"),
CheckConstraint("content_kind IN ('text','file')"),
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
CheckConstraint(
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
),
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
UniqueConstraint("dialog_id", "client_idempotency_key"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
)
sender_type: Mapped[str] = mapped_column(String(16))
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))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class MessageAttachment(Common, Base):
__tablename__ = "message_attachments"
__table_args__ = (
CheckConstraint("direction IN ('client_upload','company_inbound')"),
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
CheckConstraint("size_bytes > 0"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
message_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
)
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
direction: Mapped[str] = mapped_column(String(32))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
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))
class Document(Common, Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64))
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class SafetyTask(Base):
__tablename__ = "safety_tasks"
__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)
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
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())
class DeliveryOutbox(Base):
__tablename__ = "delivery_outbox"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
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))
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
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())
class IdempotencyRecord(Base):
__tablename__ = "idempotency_records"
__table_args__ = (
UniqueConstraint("scope", "user_id", "idempotency_key"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
scope: Mapped[str] = mapped_column(String(128))
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
idempotency_key: Mapped[str] = mapped_column(String(128))
request_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
response_status: Mapped[int | None] = mapped_column(Integer)
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=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())
class OpenLinesInboxReceipt(Base):
__tablename__ = "openlines_inbox_receipts"
__table_args__ = (
UniqueConstraint("event_id"),
UniqueConstraint("external_chat_id", "bitrix_message_id"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id: Mapped[str] = mapped_column(String(255))
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
event_type: Mapped[str] = mapped_column(String(32))
payload_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
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())
class AuditEvent(Base):
__tablename__ = "audit_events"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_type: Mapped[str] = mapped_column(String(128))
actor_type: Mapped[str] = mapped_column(String(32))
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
request_id: Mapped[str] = mapped_column(String(64))
trace_id: Mapped[str | None] = mapped_column(String(64))
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
outcome: Mapped[str] = mapped_column(String(32))
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AppSetting(Base):
__tablename__ = "app_settings"
__table_args__ = ({"schema": SCHEMA},)
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
setting_value: Mapped[str] = mapped_column(Text)
value_type: Mapped[str] = mapped_column(String(32))
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
record_status: Mapped[str] = mapped_column(String(1), default="A")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TextResource(Common, Base):
__tablename__ = "text_resources"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
text_value: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class PopularQuestion(Common, Base):
__tablename__ = "popular_questions"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
question_text: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class SyncQueue(Base):
__tablename__ = "sync_queue"
__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))
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())
class EntityExternalMapping(Base):
__tablename__ = "entity_external_mapping"
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
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_id: Mapped[str] = mapped_column(String(128))
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)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessions() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()
@@ -0,0 +1,460 @@
import asyncio
import hashlib
import ipaddress
import json
import socket
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from urllib.parse import urlparse
import boto3
import httpx
from botocore.config import Config
from redis.asyncio import Redis
from app.settings import Settings
RATE_LIMIT_LUA = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}
"""
class DependencyFailure(Exception):
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)
class CircuitBreaker:
threshold: int
open_seconds: float
failures: int = 0
opened_at: float | None = None
def allow(self) -> bool:
if self.opened_at is None:
return True
if time.monotonic() - self.opened_at >= self.open_seconds:
self.opened_at = None
self.failures = max(0, self.threshold - 1)
return True
return False
def success(self) -> None:
self.failures = 0
self.opened_at = None
def failure(self) -> None:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.monotonic()
class RateLimiter:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def consume(self, key: str, limit: int, window: int) -> int:
try:
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
except Exception as exc:
raise DependencyFailure() from exc
if int(count) > limit:
return max(1, int(ttl))
return 0
@staticmethod
def key(identity_type: str, identity: str, route: str, window: int) -> str:
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
bucket = int(time.time()) // window
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
class RedisIdempotency:
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
return json.loads(raw) if raw else None
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
await self.redis.set(
f"han:api:idem:{scope}:{user_id}:{key_hash}",
json.dumps(value, separators=(",", ":"), default=str),
ex=86400,
)
class SafetyClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.message_safety_circuit_failure_threshold,
settings.message_safety_circuit_open_sec,
)
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
return await self._call(
"POST",
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, location: str, request_id: str) -> dict[str, Any]:
path = self._poll_path(location)
return await self._call(
"GET",
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()
headers = {
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
"X-Request-ID": request_id,
}
try:
response = await self.http.request(
method,
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
headers=headers,
**kwargs,
)
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
if response.status_code >= 500:
self.breaker.failure()
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:
self.breaker.failure()
raise DependencyFailure() from exc
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:
self.settings = settings
self.http = http
self.breaker = CircuitBreaker(
settings.bitrix_local_app_circuit_failure_threshold,
settings.bitrix_local_app_circuit_open_sec,
)
async def send(
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
) -> dict[str, Any]:
if not self.breaker.allow():
raise DependencyFailure()
try:
response = await self.http.post(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/messages",
json=payload,
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
"Idempotency-Key": str(message_id),
"X-Request-ID": request_id,
},
timeout=self.settings.bitrix_local_app_http_timeout_sec,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
self.breaker.failure()
raise DependencyFailure(timeout=True) from exc
except httpx.HTTPError as exc:
self.breaker.failure()
raise DependencyFailure() from exc
self.breaker.success()
return response.json()
async def ready(self) -> bool:
try:
response = await self.http.get(
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
"/internal/openlines/v1/status",
headers={
"Authorization": "Bearer "
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
},
timeout=2,
)
return response.is_success
except httpx.HTTPError:
return False
async def fresh_openlines_payload(
payload: dict[str, Any], s3: "S3Client"
) -> dict[str, Any]:
result = json.loads(json.dumps(payload, default=str))
for file in result.get("message", {}).get("files", []):
bucket = file.pop("_storage_bucket")
key = file.pop("_object_key")
file["download_url"] = await s3.presign_get(bucket, key)
return result
class S3Client:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.client = boto3.client(
"s3",
endpoint_url=str(settings.selectel_s3_endpoint_url),
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
config=Config(
signature_version="s3v4",
connect_timeout=3,
read_timeout=10,
retries={"max_attempts": 2},
s3={"addressing_style": "virtual"},
),
)
async def ready(self) -> bool:
try:
for bucket in (
self.settings.selectel_s3_bucket_quarantine,
self.settings.selectel_s3_bucket_attachments,
self.settings.selectel_s3_bucket_documents,
):
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
return True
except Exception:
return False
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"put_object",
Params={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": key,
"ContentType": mime,
},
ExpiresIn=ttl,
)
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
return await asyncio.to_thread(
self.client.generate_presigned_url,
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=ttl,
)
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,
*,
version_id: str,
etag: str,
) -> None:
await asyncio.to_thread(
self.client.copy_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
CopySource={
"Bucket": self.settings.selectel_s3_bucket_quarantine,
"Key": source_key,
"VersionId": version_id,
},
CopySourceIfMatch=etag,
)
# 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(
self.client.delete_object,
Bucket=self.settings.selectel_s3_bucket_quarantine,
Key=key,
)
async def delete(self, bucket: str, key: str) -> None:
await asyncio.to_thread(self.client.delete_object, Bucket=bucket, Key=key)
async def upload_inbound(
self,
http: httpx.AsyncClient,
download_url: str,
destination_key: str,
mime_type: str,
max_bytes: int,
) -> tuple[int, str]:
parsed = urlparse(download_url)
if parsed.scheme != "https" or not parsed.hostname:
raise DependencyFailure("unsafe_inbound_url")
try:
addresses = await asyncio.to_thread(
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
)
except OSError as exc:
raise DependencyFailure("unsafe_inbound_url") from exc
if any(
ipaddress.ip_address(address[4][0]).is_private
or ipaddress.ip_address(address[4][0]).is_loopback
or ipaddress.ip_address(address[4][0]).is_link_local
or ipaddress.ip_address(address[4][0]).is_reserved
for address in addresses
):
raise DependencyFailure("unsafe_inbound_url")
data = bytearray()
try:
async with http.stream(
"GET", download_url, timeout=10, follow_redirects=False
) as response:
response.raise_for_status()
if response.headers.get("content-type", "").split(";")[0] != mime_type:
raise DependencyFailure("inbound_mime_mismatch")
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > max_bytes:
raise DependencyFailure("inbound_file_too_large")
except httpx.HTTPError as exc:
raise DependencyFailure() from exc
await asyncio.to_thread(
self.client.put_object,
Bucket=self.settings.selectel_s3_bucket_attachments,
Key=destination_key,
Body=bytes(data),
ContentType=mime_type,
)
return len(data), hashlib.sha256(data).hexdigest()
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
REDACTED = "[REDACTED]"
_SENSITIVE_KEY = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
r"database[_-]?url|redis[_-]?url|dsn|callback[_-]?url)",
re.IGNORECASE,
)
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
_QUERY_SECRET = re.compile(
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
re.IGNORECASE,
)
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
def sanitize_text(value: str) -> str:
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
def sanitize_value(value: Any) -> Any:
if isinstance(value, str):
return sanitize_text(value)
if isinstance(value, Mapping):
return {
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_value(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_value(item) for item in value)
return value
def redact_event(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
return sanitize_value(event_dict)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
from opentelemetry import metrics
meter = metrics.get_meter("han.api")
HTTP_REQUESTS = meter.create_counter(
"han_http_requests_total",
description="Completed HTTP requests",
)
HTTP_DURATION = meter.create_histogram(
"han_http_request_duration_seconds",
unit="s",
description="HTTP request duration",
)
AUTH_BOOTSTRAP = meter.create_counter(
"han_auth_bootstrap_total",
description="Authentication bootstrap outcomes",
)
RATE_LIMIT_DECISIONS = meter.create_counter(
"han_rate_limit_decisions_total",
description="Rate-limit decisions",
)
@@ -0,0 +1,303 @@
import secrets
import time
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import SCHEMA, Base, Common
def uuid7() -> uuid.UUID:
"""Generate an RFC 9562 UUIDv7 without relying on Python 3.14."""
timestamp_ms = int(time.time_ns() // 1_000_000) & ((1 << 48) - 1)
value = timestamp_ms << 80
value |= 0x7 << 76
value |= secrets.randbits(12) << 64
value |= 0b10 << 62
value |= secrets.randbits(62)
return uuid.UUID(int=value)
class NotificationCtaAction(Common, Base):
__tablename__ = "notification_cta_actions"
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str] = mapped_column(String(255))
requires_auth: Mapped[bool] = mapped_column(Boolean, default=False)
required_instance_fields: Mapped[list[str]] = mapped_column(
ARRAY(String(64)), default=list, server_default="{}"
)
class NotificationButton(Common, Base):
__tablename__ = "notification_buttons"
__table_args__ = (
UniqueConstraint("code"),
CheckConstraint("NOT applies_hidden_ttl OR sets_hidden"),
CheckConstraint("NOT submits_documents OR close_reason IS NOT NULL"),
{"schema": SCHEMA},
)
code: Mapped[str] = mapped_column(String(32))
label: Mapped[str] = mapped_column(String(64))
sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
applies_hidden_ttl: Mapped[bool] = mapped_column(Boolean, default=False)
close_reason: Mapped[str | None] = mapped_column(String(32))
submits_documents: Mapped[bool] = mapped_column(Boolean, default=False)
class NotificationColorToken(Common, Base):
__tablename__ = "notification_color_tokens"
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str] = mapped_column(String(255))
sort_order: Mapped[int] = mapped_column(SmallInteger)
class NotificationType(Common, Base):
__tablename__ = "notification_types"
__table_args__ = (
UniqueConstraint("code"),
CheckConstraint("contour IN ('G','P')"),
CheckConstraint("contour <> 'G' OR countable = false"),
CheckConstraint("contour <> 'G' OR cta_action <> 'open_detail'"),
CheckConstraint(
"cta_action <> 'open_detail' OR (cta_sets_hidden = false AND cta_close_reason IS NULL)"
),
CheckConstraint(
"cta_action = 'open_detail' OR "
"(documents_allowed = false AND hide_on_document_download = false "
"AND required_detail_blocks = '{}')"
),
CheckConstraint("NOT hide_on_document_download OR documents_allowed"),
CheckConstraint("cta_action <> 'open_detail' OR button_primary_code IS NOT NULL"),
CheckConstraint(
"cta_action = 'open_detail' OR "
"(button_primary_code IS NULL AND button_secondary_code IS NULL)"
),
CheckConstraint("button_secondary_code IS NULL OR button_primary_code IS NOT NULL"),
CheckConstraint(
"button_secondary_code IS NULL OR button_secondary_code <> button_primary_code"
),
{"schema": SCHEMA},
)
code: Mapped[str] = mapped_column(String(32))
contour: Mapped[str] = mapped_column(String(1))
priority: Mapped[int] = mapped_column(SmallInteger)
countable: Mapped[bool] = mapped_column(Boolean)
label: Mapped[str] = mapped_column(String(64))
color_token: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_color_tokens.code", ondelete="RESTRICT")
)
icon_code: Mapped[str | None] = mapped_column(String(32))
cta_text: Mapped[str] = mapped_column(String(64))
cta_action: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_cta_actions.code", ondelete="RESTRICT")
)
cta_sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
cta_close_reason: Mapped[str | None] = mapped_column(String(32))
button_primary_code: Mapped[str | None] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
)
button_secondary_code: Mapped[str | None] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
)
hidden_ttl_days: Mapped[int | None] = mapped_column(SmallInteger)
documents_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
hide_on_document_download: Mapped[bool] = mapped_column(Boolean, default=False)
required_detail_blocks: Mapped[list[str]] = mapped_column(
ARRAY(String(64)), default=list, server_default="{}"
)
class NotificationSource(Common, Base):
__tablename__ = "notification_sources"
__table_args__ = (UniqueConstraint("code"), UniqueConstraint("token_hash"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str | None] = mapped_column(Text)
token_hash: Mapped[str] = mapped_column(String(128))
token_rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Notification(Common, Base):
__tablename__ = "notifications"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_notifications_source_key"),
CheckConstraint("lifecycle_status IN ('active','closed')"),
CheckConstraint("visibility IN ('visible','hidden')"),
CheckConstraint(
"close_reason IS NULL OR close_reason IN "
"('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')"
),
CheckConstraint(
"lifecycle_status <> 'closed' OR (close_reason IS NOT NULL AND closed_at IS NOT NULL)"
),
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
Index(
"ix_notifications_user_active",
"user_id",
"lifecycle_status",
"visibility",
"notification_datetime",
"id",
),
Index("ix_notifications_expire", "date_expired"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
notification_type: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
)
source: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_sources.code", ondelete="RESTRICT")
)
external_id: Mapped[str] = mapped_column(String(128))
request_fingerprint: Mapped[str] = mapped_column(String(64))
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
header: Mapped[str] = mapped_column(String(255))
text: Mapped[str | None] = mapped_column(String(1024))
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
payment_url: Mapped[str | None] = mapped_column(Text)
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
details_schema_version: Mapped[int] = mapped_column(SmallInteger, default=1)
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
visibility: Mapped[str] = mapped_column(String(16), default="visible")
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
close_reason: Mapped[str | None] = mapped_column(String(32))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class GuestNotification(Common, Base):
__tablename__ = "guest_notifications"
__table_args__ = (
CheckConstraint("lifecycle_status IN ('active','closed')"),
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
CheckConstraint("instruction_url IS NULL OR instruction_url LIKE 'https://%'"),
Index("ix_guest_notifications_active", "lifecycle_status", "notification_datetime", "id"),
Index("ix_guest_notifications_expire", "date_expired"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
notification_type: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
)
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
header: Mapped[str] = mapped_column(String(255))
text: Mapped[str | None] = mapped_column(String(1024))
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
instruction_url: Mapped[str | None] = mapped_column(Text)
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class NotificationDocument(Common, Base):
__tablename__ = "notification_documents"
__table_args__ = (
UniqueConstraint("notification_id", "document_id"),
{"schema": SCHEMA},
)
notification_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.notifications.id", ondelete="RESTRICT")
)
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.documents.id", ondelete="RESTRICT")
)
sort_order: Mapped[int] = mapped_column(SmallInteger, default=0)
download_url_issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ClientUploadDraft(Base):
__tablename__ = "client_upload_drafts"
__table_args__ = (
CheckConstraint("context_type IN ('notification')"),
CheckConstraint("size_bytes > 0"),
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"),
Index("ix_client_upload_drafts_created", "created_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
context_type: Mapped[str] = mapped_column(String(32))
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
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")
submission_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(), onupdate=func.now()
)
class ClientDocument(Common, Base):
__tablename__ = "client_documents"
__table_args__ = (
UniqueConstraint("storage_bucket", "object_key"),
UniqueConstraint("source_draft_id"),
Index("ix_client_documents_context", "context_type", "context_id"),
Index("ix_client_documents_user_submitted", "user_id", "submitted_at"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
context_type: Mapped[str] = mapped_column(String(32))
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
submission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
source_draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64))
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -0,0 +1,505 @@
import hashlib
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import AuthError
from app.db import UserIdentity
from app.notification_models import NotificationSource
from app.notification_schemas import (
NotificationCancelRequest,
NotificationCreateRequest,
UploadCompleteRequest,
UploadInitRequest,
)
from app.notification_service import (
apply_read_or_hide,
authenticate_source,
cancel_notification,
catalog,
complete_upload,
create_notification,
discard_upload,
document_download,
init_upload,
invoke_cta_state,
list_notifications,
list_uploads,
notification_dto,
owned_notification,
press_button,
public_notifications,
unread_count,
)
from app.schemas import TextMessageRequest
from app.services import (
AuditContext,
DomainError,
SettingsSnapshot,
create_dialog,
load_settings,
resolve_user,
send_message,
)
router = APIRouter()
async def db_session(request: Request):
async for value in request.app.state.db.session():
yield value
Session = Annotated[AsyncSession, Depends(db_session)]
async def user_dependency(
request: Request,
db: Session,
authorization: Annotated[str | None, Header()] = None,
) -> UserIdentity:
if not authorization or not authorization.startswith("Bearer "):
raise AuthError()
principal = await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
return await resolve_user(db, principal)
User = Annotated[UserIdentity, Depends(user_dependency)]
async def snapshot_dependency(db: Session) -> SettingsSnapshot:
return await load_settings(db)
Snapshot = Annotated[SettingsSnapshot, Depends(snapshot_dependency)]
async def source_dependency(
db: Session, authorization: Annotated[str | None, Header()] = None
) -> NotificationSource:
return await authenticate_source(db, authorization)
Source = Annotated[NotificationSource, Depends(source_dependency)]
def context(request: Request, ux_session: str | None = None) -> AuditContext:
try:
ux_id = uuid.UUID(ux_session) if ux_session else None
except ValueError:
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
return AuditContext(
request_id=request.state.request_id,
trace_id=request.state.trace_id,
ux_session_id=ux_id,
user_agent_hash=request.state.user_agent_hash,
client_ip=None,
)
async def rate_limit(
request: Request,
identity: str,
route: str,
limit: tuple[int, int],
*,
fail_closed: bool,
) -> None:
key = request.app.state.rate_limiter.key("notification", identity, route, limit[1])
try:
retry_after = await request.app.state.rate_limiter.consume(key, *limit)
except Exception:
if fail_closed:
raise DomainError(
"dependency_unavailable", 503, "Rate limit service is unavailable"
) from None
return
if retry_after:
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": retry_after},
)
@router.get("/api/v1/public/notifications", tags=["notifications"])
async def public_list(request: Request, db: Session, settings: Snapshot):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
return {
"items": await public_notifications(db, settings.integer("notification.home.max_items"))
}
@router.get("/api/v1/public/notification-types", tags=["notifications"])
async def type_catalog(
request: Request,
response: Response,
db: Session,
settings: Snapshot,
if_none_match: Annotated[str | None, Header()] = None,
):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
items = await catalog(db)
digest = hashlib.sha256(
json.dumps(items, default=str, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
etag = f'"{digest}"'
if if_none_match == etag:
return Response(status_code=304, headers={"ETag": etag})
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "public, max-age=3600"
return {"items": items}
@router.get("/api/v1/notifications", tags=["notifications"])
async def personal_list(
request: Request,
db: Session,
user: User,
settings: Snapshot,
place: str = Query(pattern="^(home|center)$"),
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
limit = settings.integer(
"notification.home.max_items" if place == "home" else "notification.center.max_items"
)
return {"items": await list_notifications(db, user.id, place, limit)}
@router.get("/api/v1/notifications/counter", tags=["notifications"])
async def counter(request: Request, db: Session, user: User, settings: Snapshot):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
return {
"unread_count": await unread_count(
db, user.id, settings.integer("notification.center.max_items")
)
}
@router.get("/api/v1/notifications/{notification_id}", tags=["notifications"])
async def detail(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
item, kind = await owned_notification(db, user.id, notification_id)
if kind.cta_action != "open_detail":
raise DomainError("not_found", 404, "Resource was not found")
return await notification_dto(db, item, kind)
@router.post("/api/v1/notifications/{notification_id}/read", tags=["notifications"])
async def mark_read(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"read",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/hide", tags=["notifications"])
async def hide(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"hide",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post(
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
tags=["notifications"],
)
async def button(
notification_id: uuid.UUID,
button_code: str,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await press_button(
db,
user.id,
notification_id,
button_code,
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/cta", tags=["notifications"])
async def cta(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
audit_context = context(request, x_ux_session_id)
item, kind = await owned_notification(db, user.id, notification_id, action=True)
chat_result = None
if kind.cta_action == "send_chat_message":
dialog, _status = await create_dialog(
db, user, audit_context, f"notification-dialog:{item.id}"
)
chat_result = await send_message(
db,
user,
uuid.UUID(str(dialog["dialog_id"])),
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
f"notification-message:{item.id}",
audit_context,
settings,
request.app.state.settings,
request.app.state.safety,
request.app.state.openlines,
request.app.state.s3,
request.app.state.realtime,
)
_item, _kind, result = await invoke_cta_state(
db,
user.id,
notification_id,
settings,
request.app.state.realtime,
audit_context,
chat_result=chat_result,
)
return result
@router.get(
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
tags=["notifications"],
)
async def download(
notification_id: uuid.UUID,
document_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"download",
settings.limit("rate_limit.download_url.per_user"),
fail_closed=True,
)
return await document_download(
db,
user.id,
notification_id,
document_id,
settings,
request.app.state.s3,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/uploads/init", status_code=201, tags=["uploads"])
async def upload_init(
body: UploadInitRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await init_upload(db, user.id, body, settings, request.app.state.s3)
@router.post("/api/v1/uploads/{draft_id}/complete", tags=["uploads"])
async def upload_complete(
draft_id: uuid.UUID,
body: UploadCompleteRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await complete_upload(
db,
user.id,
draft_id,
body,
request.app.state.s3,
request.app.state.safety,
request.state.request_id,
)
@router.get("/api/v1/uploads", tags=["uploads"])
async def uploads(
request: Request,
db: Session,
user: User,
settings: Snapshot,
context_type: str,
context_id: uuid.UUID,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
if context_type != "notification":
raise DomainError("validation_error", 400, "Unsupported upload context")
return {"items": await list_uploads(db, user.id, context_type, context_id)}
@router.delete("/api/v1/uploads/{draft_id}", status_code=204, tags=["uploads"])
async def upload_delete(
draft_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
await discard_upload(db, user.id, draft_id, request.app.state.s3)
return Response(status_code=204)
@router.post("/internal/notifications/v1/notifications", tags=["internal"])
async def internal_create(
body: NotificationCreateRequest,
request: Request,
db: Session,
source: Source,
):
result, status = await create_notification(
db,
body,
source,
request.app.state.s3,
request.app.state.realtime,
context(request),
)
return JSONResponse(json.loads(json.dumps(result, default=str)), status_code=status)
@router.post("/internal/notifications/v1/notifications/cancel", tags=["internal"])
async def internal_cancel(
body: NotificationCancelRequest,
request: Request,
db: Session,
source: Source,
):
return await cancel_notification(db, body, source, request.app.state.realtime, context(request))
@@ -0,0 +1,72 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import Field, HttpUrl, model_validator
from app.schemas import StrictModel
class TodoItem(StrictModel):
number: int
text: str = Field(min_length=1, max_length=1024)
class CompanyDocumentInput(StrictModel):
object_key: str = Field(min_length=1, max_length=1024)
title: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
checksum_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
class NotificationDetailsInput(StrictModel):
deadline: datetime | None = None
details_header: str | None = Field(default=None, max_length=255)
details_text: str | None = Field(default=None, max_length=4000)
todo_header: str | None = Field(default=None, max_length=255)
todo_plan: list[TodoItem] | None = Field(default=None, min_length=1)
send_documents: bool = False
documents: list[CompanyDocumentInput] | None = Field(default=None, min_length=1)
class NotificationCreateRequest(StrictModel):
user_id: uuid.UUID
notification_type: str = Field(min_length=1, max_length=32)
source: str = Field(min_length=1, max_length=32)
external_id: str = Field(min_length=1, max_length=128)
notification_datetime: datetime
header: str = Field(min_length=1, max_length=255)
text: str | None = Field(default=None, max_length=1024)
priority_override: int | None = None
date_expired: datetime | None = None
price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
old_price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
payment_url: HttpUrl | None = None
chat_message_text: str | None = Field(default=None, max_length=1024)
details: NotificationDetailsInput | None = None
@model_validator(mode="after")
def prices(self) -> "NotificationCreateRequest":
if self.old_price is not None and self.price is None:
raise ValueError("old_price requires price")
return self
class NotificationCancelRequest(StrictModel):
source: str = Field(min_length=1, max_length=32)
external_id: str = Field(min_length=1, max_length=128)
close_reason: Literal["cancelled", "paid"]
class UploadInitRequest(StrictModel):
context_type: Literal["notification"]
context_id: uuid.UUID
file_name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
class UploadCompleteRequest(StrictModel):
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
from collections.abc import Mapping
OTP_SETTING_KEYS = {
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"otp.phone.code_length",
"otp.phone.ttl_seconds",
"otp.phone.sms_order_timeout_ms",
}
def validate_otp_settings(values: Mapping[str, str]) -> None:
parsed: dict[str, int] = {}
for key in OTP_SETTING_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{key}: canonical integer value expected")
parsed[key] = value
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
for key in positive:
if key in parsed and parsed[key] <= 0:
raise ValueError(f"{key}: value must be positive")
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
code_length = parsed.get("otp.phone.code_length")
if code_length is not None and not 4 <= code_length <= 10:
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
if ttl_seconds is not None and (
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
):
raise ValueError(
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
)
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
import asyncpg
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
def asyncpg_dsn(url: str) -> str:
if url.startswith("postgresql+asyncpg://"):
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
return url
def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine:
dsn = asyncpg_dsn(url)
async def connect():
return await asyncpg.connect(dsn=dsn)
return create_async_engine(
"postgresql+asyncpg://",
async_creator=connect,
**engine_options,
)
@@ -0,0 +1,98 @@
import asyncio
import json
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from typing import Any
from redis.asyncio import Redis
DIALOG_CHANNEL_PREFIX = "han:rt:dialog:"
USER_CHANNEL_PREFIX = "han:rt:user:"
# Backward-compatible name used by existing chat integrations.
CHANNEL_PREFIX = DIALOG_CHANNEL_PREFIX
class LocalFanout:
def __init__(self) -> None:
self._queues: set[asyncio.Queue[dict[str, Any]]] = set()
async def publish(self, event: dict[str, Any]) -> None:
for queue in tuple(self._queues):
with suppress(asyncio.QueueFull):
queue.put_nowait(event)
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
self._queues.add(queue)
try:
while True:
yield await queue.get()
finally:
self._queues.discard(queue)
class RealtimeFanout:
def __init__(self, redis: Redis, local: LocalFanout | None = None) -> None:
self.redis = redis
self.local = local or LocalFanout()
async def publish(self, event: dict[str, Any]) -> None:
event = {"event_id": str(uuid.uuid4()), **event}
channel = DIALOG_CHANNEL_PREFIX + str(event["dialog_id"])
try:
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
except Exception:
await self.local.publish(event)
async def publish_user(self, user_id: uuid.UUID, event: dict[str, Any]) -> None:
event = {"event_id": str(uuid.uuid4()), "_user_id": str(user_id), **event}
channel = USER_CHANNEL_PREFIX + str(user_id)
try:
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
except Exception:
await self.local.publish(event)
async def events(
self,
dialog_ids: set[uuid.UUID],
user_id: uuid.UUID | None = None,
notifications: bool = False,
) -> AsyncIterator[dict[str, Any]]:
channels = [DIALOG_CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
if notifications and user_id is not None:
channels.append(USER_CHANNEL_PREFIX + str(user_id))
if not channels:
await asyncio.Event().wait()
return
pubsub = self.redis.pubsub()
try:
await pubsub.subscribe(*channels)
except Exception:
await pubsub.aclose()
async for event in self.local.subscribe():
dialog_match = event.get("dialog_id") and uuid.UUID(
str(event["dialog_id"])
) in dialog_ids
user_match = (
notifications
and user_id is not None
and event.get("_user_id") == str(user_id)
)
if dialog_match or user_match:
payload = dict(event)
payload.pop("_user_id", None)
yield payload
return
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
if message:
payload = json.loads(message["data"])
payload.pop("_user_id", None)
yield payload
else:
await asyncio.sleep(0)
finally:
await pubsub.unsubscribe(*channels)
await pubsub.aclose()
@@ -0,0 +1,161 @@
import hashlib
import hmac
import json
import uuid
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
from app.chat_settings import CHAT_MESSAGE_TRANSPORT_MAX_LENGTH
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class OtpSettingsResponse(StrictModel):
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
min_seconds_between_attempts: int = Field(strict=True, ge=0)
max_verify_attempts: int = Field(strict=True, gt=0)
code_length: int = Field(strict=True, ge=4, le=10)
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
sms_order_timeout_ms: int = Field(strict=True, gt=0)
version: str = Field(min_length=1, max_length=64)
cache_ttl_seconds: int = Field(strict=True, gt=0)
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
device_id: str | None = Field(default=None, max_length=255)
class ConsentChoice(StrictModel):
accepted: bool
version: str = Field(min_length=1, max_length=64)
class ConsentSet(StrictModel):
personal_data: ConsentChoice
user_agreement: ConsentChoice
marketing: ConsentChoice
class BootstrapRequest(StrictModel):
consents: ConsentSet
device: Device
class ConsentsRequest(StrictModel):
consents: ConsentSet
class SessionStartRequest(StrictModel):
start_reason: Literal["first_launch", "cold_start", "idle_timeout"]
device: Device
class TextMessageRequest(StrictModel):
content_kind: Literal["text"]
text: str = Field(min_length=1, max_length=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
class FileMessageRequest(StrictModel):
content_kind: Literal["file"]
attachment_id: uuid.UUID
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
MessageRequest = Annotated[
TextMessageRequest | FileMessageRequest, Field(discriminator="content_kind")
]
class AttachmentInitRequest(StrictModel):
file_name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
class AttachmentCompleteRequest(StrictModel):
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
class OpenLinesFile(StrictModel):
name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
download_url: HttpUrl
class OpenLinesMessage(StrictModel):
text: str = Field(default="", max_length=4000)
files: list[OpenLinesFile] = Field(default_factory=list, max_length=1)
@model_validator(mode="after")
def non_empty(self) -> "OpenLinesMessage":
if not self.text.strip() and not self.files:
raise ValueError("message must contain text or file")
return self
class OpenLinesInbox(StrictModel):
event_id: str = Field(min_length=1, max_length=255)
event_type: Literal["message.new", "dialog.closed"]
external_chat_id: uuid.UUID
bitrix_message_id: str | None = Field(default=None, max_length=255)
occurred_at: datetime
message: OpenLinesMessage | None = None
@model_validator(mode="after")
def event_shape(self) -> "OpenLinesInbox":
if self.event_type == "message.new" and (
not self.bitrix_message_id or self.message is None
):
raise ValueError("message.new requires bitrix_message_id and message")
return self
class DialogStatus(StrEnum):
OPEN = "open"
WAITING_COMPANY = "waiting_for_company"
WAITING_CLIENT = "waiting_for_client"
CLOSED = "closed"
def canonical_fingerprint(
method: str, route: str, path_params: dict[str, str], body: object, user_id: uuid.UUID
) -> str:
value = {
"method": method.upper(),
"route": route,
"path": dict(sorted(path_params.items())),
"body": body,
"user_id": str(user_id),
}
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
).hexdigest()
def encode_cursor(data: dict[str, str], secret: bytes) -> str:
payload = json.dumps({"v": 1, **data}, sort_keys=True, separators=(",", ":")).encode()
signature = hmac.digest(secret, payload, "sha256")
return urlsafe_b64encode(payload + signature).decode().rstrip("=")
def decode_cursor(value: str, secret: bytes) -> dict[str, str]:
try:
raw = urlsafe_b64decode(value + "=" * (-len(value) % 4))
payload, signature = raw[:-32], raw[-32:]
if not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")):
raise ValueError("invalid cursor")
decoded = json.loads(payload)
if decoded.pop("v") != 1:
raise ValueError("unsupported cursor")
return decoded
except (ValueError, KeyError, json.JSONDecodeError) as exc:
raise ValueError("invalid cursor") from exc
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
from functools import lru_cache
from typing import Literal
from pydantic import AnyHttpUrl, Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
)
app_env: str = Field(alias="APP_ENV")
api_port: int = Field(default=8000, alias="API_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
database_url: str = Field(alias="DATABASE_URL")
redis_url: str = Field(alias="REDIS_URL")
redis_realtime_url: str = Field(alias="REDIS_REALTIME_URL")
keycloak_public_url: AnyHttpUrl = Field(alias="KEYCLOAK_PUBLIC_URL")
keycloak_internal_url: AnyHttpUrl = Field(alias="KEYCLOAK_INTERNAL_URL")
keycloak_realm: str = Field(alias="KEYCLOAK_REALM")
keycloak_audience: str = Field(alias="KEYCLOAK_AUDIENCE")
jwks_cache_ttl_seconds: int = 300
jwks_stale_grace_seconds: int = 900
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"
)
message_safety_task_poll_interval_sec: float = Field(
default=2, alias="MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC"
)
message_safety_task_poll_max_sec: float = Field(
default=300, alias="MESSAGE_SAFETY_TASK_POLL_MAX_SEC"
)
message_safety_circuit_failure_threshold: int = Field(
default=5, alias="MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD"
)
message_safety_circuit_open_sec: int = Field(
default=30, alias="MESSAGE_SAFETY_CIRCUIT_OPEN_SEC"
)
bitrix_local_app_base_url: AnyHttpUrl = Field(alias="BITRIX_LOCAL_APP_BASE_URL")
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
bitrix_local_app_http_timeout_sec: float = Field(
default=20, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
)
bitrix_local_app_circuit_failure_threshold: int = Field(
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
)
bitrix_local_app_circuit_open_sec: int = Field(
default=30, alias="BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC"
)
keycloak_settings_bridge_token: SecretStr = Field(alias="KEYCLOAK_SETTINGS_BRIDGE_TOKEN")
selectel_s3_endpoint_url: AnyHttpUrl = Field(alias="SELECTEL_S3_ENDPOINT_URL")
selectel_s3_bucket_documents: str = Field(alias="SELECTEL_S3_BUCKET_DOCUMENTS")
selectel_s3_bucket_attachments: str = Field(alias="SELECTEL_S3_BUCKET_ATTACHMENTS")
selectel_s3_bucket_quarantine: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
selectel_s3_access_key: SecretStr = Field(alias="SELECTEL_S3_ACCESS_KEY")
selectel_s3_secret_key: SecretStr = Field(alias="SELECTEL_S3_SECRET_KEY")
otel_exporter_otlp_endpoint: str | None = Field(
default=None, alias="OTEL_EXPORTER_OTLP_ENDPOINT"
)
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
notifications_token_producer_test: SecretStr | None = Field(
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}"
@lru_cache
def get_settings() -> Settings:
return Settings()
@@ -0,0 +1,111 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
_runtime: TelemetryRuntime | None = None
def _resource(service_name: str) -> Resource:
return Resource.create(
{
"service.name": service_name,
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
global _runtime
if _runtime is not None:
return _runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return None
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "api-backend"))
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
RedisInstrumentor().instrument()
BotocoreInstrumentor().instrument()
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
return _runtime
def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health/live,/health/ready,/nginx-health/live",
)
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
event_dict["span_id"] = format(context.span_id, "016x")
return event_dict
def current_trace_id() -> str | None:
context = trace.get_current_span().get_span_context()
return format(context.trace_id, "032x") if context.is_valid else None
@@ -0,0 +1,383 @@
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
import httpx
import redis.asyncio as redis
import structlog
from sqlalchemy import delete, select
from app.db import (
Database,
DeliveryOutbox,
Dialog,
Message,
MessageAttachment,
SafetyTask,
UserIdentity,
)
from app.integrations import (
DependencyFailure,
OpenLinesClient,
S3Client,
SafetyClient,
fresh_openlines_payload,
)
from app.notification_models import ClientUploadDraft
from app.notification_service import expire_notifications
from app.realtime import RealtimeFanout
from app.services import load_settings, publish_dialog_status, publish_message_status
from app.settings import Settings, get_settings
log = structlog.get_logger()
async def delivery_once(
db: Database,
client: OpenLinesClient,
s3: S3Client,
fanout: RealtimeFanout,
settings: Settings,
worker_id: str,
batch_size: int = 20,
) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(DeliveryOutbox)
.where(
DeliveryOutbox.status.in_(["pending", "retry"]),
DeliveryOutbox.next_attempt_at <= datetime.now(UTC),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
ids = [row.id for row in rows]
for row in rows:
row.status = "processing"
row.locked_at = datetime.now(UTC)
row.locked_by = worker_id
await session.commit()
for row_id in ids:
async with db.sessions() as session:
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
if row is None:
continue
message = None
dialog = None
try:
payload = await fresh_openlines_payload(row.payload_json, s3)
await client.send(row.message_id, payload, f"worker-{worker_id}")
row.status = "delivered"
message = await session.get(Message, row.message_id)
if message:
message.delivery_status = "delivered"
dialog = await session.get(Dialog, message.dialog_id)
if dialog:
dialog.status = "waiting_for_company"
dialog.last_message_at = datetime.now(UTC)
except DependencyFailure:
row.attempt_count += 1
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=min(3600, 2**row.attempt_count)
)
row.last_error_code = "dependency_unavailable"
row.locked_at = None
row.locked_by = None
await session.commit()
if message:
await publish_message_status(fanout, message, settings)
if dialog:
await publish_dialog_status(fanout, dialog)
return len(ids)
async def safety_once(
db: Database,
safety: SafetyClient,
s3: S3Client,
fanout: RealtimeFanout,
settings: Settings,
worker_id: str,
batch_size: int = 20,
) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(SafetyTask)
.where(
SafetyTask.status.in_(["polling", "failed"]),
SafetyTask.next_poll_at <= datetime.now(UTC),
SafetyTask.expires_at > datetime.now(UTC),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
ids = [row.id for row in rows]
for row in rows:
row.locked_at, row.locked_by = datetime.now(UTC), worker_id
await session.commit()
for task_id in ids:
async with db.sessions() as session:
task = await session.get(SafetyTask, task_id, with_for_update=True)
if task is None:
continue
message = None
try:
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)
if task.attachment_id
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,
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 = (
"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"
if attachment and attachment.quarantine_object_key:
await s3.delete_quarantine(attachment.quarantine_object_key)
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 as exc:
task.attempt_count += 1
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)
)
task.locked_at = None
task.locked_by = None
await session.commit()
if message:
await publish_message_status(fanout, message, settings)
return len(ids)
async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int:
async with db.sessions() as session:
rows = (
(
await session.execute(
select(MessageAttachment)
.where(
MessageAttachment.record_status == "A",
MessageAttachment.quarantine_object_key.is_not(None),
MessageAttachment.upload_expires_at < datetime.now(UTC),
MessageAttachment.message_id.is_(None),
)
.with_for_update(skip_locked=True)
.limit(batch_size)
)
)
.scalars()
.all()
)
for row in rows:
if row.quarantine_object_key:
await s3.delete_quarantine(row.quarantine_object_key)
row.record_status = "D"
row.status_changed_at = datetime.now(UTC)
row.status_change_reason = "expired_quarantine_cleanup"
await session.commit()
return len(rows)
async def loop(kind: str) -> None:
settings = get_settings()
db = Database(settings.database_url)
http = httpx.AsyncClient()
safety = SafetyClient(settings, http)
openlines = OpenLinesClient(settings, http)
s3 = S3Client(settings)
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
fanout = RealtimeFanout(redis_rt)
worker_id = f"{kind}-{uuid.uuid4()}"
try:
while True:
count = 0
if kind == "delivery":
count = await delivery_once(db, openlines, s3, fanout, settings, worker_id)
elif kind == "safety":
count = await safety_once(db, safety, s3, fanout, settings, worker_id)
else:
count = await cleanup_once(db, s3)
if not count:
await asyncio.sleep(settings.worker_poll_interval_sec)
finally:
await http.aclose()
await redis_rt.aclose()
await db.close()
async def notification_expire_loop() -> None:
settings = get_settings()
db = Database(settings.database_url)
try:
while True:
async with db.sessions() as session:
snapshot = await load_settings(session)
run_at = snapshot.values["notification.expire_job.run_at"]
hour, minute = (int(value) for value in run_at.split(":", 1))
now = datetime.now(UTC)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
await asyncio.sleep((target - now).total_seconds())
async with db.sessions() as session:
personal, guest = await expire_notifications(session)
log.info(
"notification.expired_batch",
personal_count=personal,
guest_count=guest,
)
finally:
await db.close()
async def notification_draft_cleanup_once(db: Database, s3: S3Client) -> int:
async with db.sessions() as session:
snapshot = await load_settings(session)
cutoff = datetime.now(UTC) - timedelta(
days=snapshot.integer("notification.upload_draft.ttl_days")
)
rows = (
(
await session.execute(
select(ClientUploadDraft)
.where(ClientUploadDraft.created_at < cutoff)
.with_for_update(skip_locked=True)
.limit(100)
)
)
.scalars()
.all()
)
for row in rows:
if row.state != "submitted":
if row.quarantine_object_key:
await s3.delete_quarantine(row.quarantine_object_key)
elif row.object_key:
await s3.delete(row.storage_bucket, row.object_key)
await session.execute(
delete(ClientUploadDraft).where(ClientUploadDraft.id == row.id)
)
await session.commit()
return len(rows)
async def notification_draft_cleanup_loop() -> None:
settings = get_settings()
db = Database(settings.database_url)
s3 = S3Client(settings)
try:
while True:
count = await notification_draft_cleanup_once(db, s3)
if count < 100:
await asyncio.sleep(86400)
finally:
await db.close()
def delivery_main() -> None:
asyncio.run(loop("delivery"))
def safety_main() -> None:
asyncio.run(loop("safety"))
def cleanup_main() -> None:
asyncio.run(loop("cleanup"))
def notification_expire_main() -> None:
asyncio.run(notification_expire_loop())
def notification_draft_cleanup_main() -> None:
asyncio.run(notification_draft_cleanup_loop())