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

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,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