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 class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") 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=4000) 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