162 lines
5.0 KiB
Python
162 lines
5.0 KiB
Python
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
|