Проект разделен на два репозитория
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user