Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user