from __future__ import annotations import hashlib import random import re from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from typing import Any PHONE_RE = re.compile(r"^\+7[0-9]{10}$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") class WorkflowState(StrEnum): CREATED = "created" RUNNING = "running" WAITING_CRM = "waiting_crm" WAITING_RETRY = "waiting_retry" WAITING_MANUAL = "waiting_manual" SUCCEEDED = "succeeded" FAILED = "failed" CANCELLED = "cancelled" TRANSITIONS: dict[WorkflowState, frozenset[WorkflowState]] = { WorkflowState.CREATED: frozenset({WorkflowState.RUNNING, WorkflowState.CANCELLED}), WorkflowState.RUNNING: frozenset( { WorkflowState.WAITING_CRM, WorkflowState.WAITING_RETRY, WorkflowState.WAITING_MANUAL, WorkflowState.SUCCEEDED, WorkflowState.FAILED, WorkflowState.CANCELLED, } ), WorkflowState.WAITING_CRM: frozenset( { WorkflowState.RUNNING, WorkflowState.WAITING_RETRY, WorkflowState.WAITING_MANUAL, WorkflowState.FAILED, } ), WorkflowState.WAITING_RETRY: frozenset( {WorkflowState.RUNNING, WorkflowState.WAITING_MANUAL, WorkflowState.FAILED} ), WorkflowState.WAITING_MANUAL: frozenset( {WorkflowState.RUNNING, WorkflowState.CANCELLED} ), WorkflowState.SUCCEEDED: frozenset(), WorkflowState.FAILED: frozenset(), WorkflowState.CANCELLED: frozenset(), } def assert_transition(current: WorkflowState, target: WorkflowState) -> None: if target not in TRANSITIONS[current]: raise ValueError(f"forbidden workflow transition {current} -> {target}") @dataclass(frozen=True) class ContactCandidate: b24_id: str created_at: datetime crm_user_id: str | None def validate_phone(value: str) -> str: if not PHONE_RE.fullmatch(value): raise ValueError("phone must be Russian E.164 +7XXXXXXXXXX") return value def choose_newest(candidates: list[ContactCandidate]) -> ContactCandidate | None: if not candidates: return None return max(candidates, key=lambda item: (item.created_at, int(item.b24_id))) def select_email(items: list[dict[str, Any]]) -> str | None: valid = [ item for item in items if isinstance(item.get("VALUE"), str) and EMAIL_RE.fullmatch(item["VALUE"]) ] work = next((item for item in valid if item.get("VALUE_TYPE") == "WORK"), None) selected = work or (valid[0] if valid else None) return selected["VALUE"] if selected else None def safe_hash(value: str | None) -> str | None: return hashlib.sha256(value.encode()).hexdigest() if value is not None else None def full_jitter_delay( attempt: int, base: float, maximum: float, *, rng: random.Random | None = None ) -> float: ceiling = min(maximum, base * (2 ** max(0, attempt - 1))) return (rng or random.SystemRandom()).uniform(0, ceiling) def parse_crm_datetime(value: str) -> datetime: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) return parsed.astimezone(UTC)