Загрузка данных
"""Санитизированная эталонная реализация автоматического отзыва сессий ChatGPT.
НАЗНАЧЕНИЕ
==========
В этом файле показан изолированный алгоритм обнаружения и отзыва неожиданной
сессии ChatGPT. Он намеренно не зависит от исходного приложения: здесь нет
моделей базы данных, идентификаторов арендаторов, меток учётных записей, адресов
электронной почты, рабочих URL, настроек прокси, ключей шифрования, уведомлений
или настоящих токенов.
Это эталонная выдержка, а не готовая замена полной службы. Два используемых ниже
эндпоинта ChatGPT являются внутренними веб-эндпоинтами и могут измениться без
предупреждения. Перед интеграцией этого кода необходимо проверить актуальное
поведение API, правила авторизации и применимые условия использования.
ЧТО ОЗНАЧАЕТ «ПРИНУДИТЕЛЬНО ЗАВЕРШИТЬ СЕССИЮ»
=============================================
Под принудительным завершением понимается отзыв одной конкретной сессии по её
идентификатору:
POST /backend-api/accounts/sessions/revoke
JSON: {"session_id": "..."}
Это НЕ означает выход из всех сессий, сброс пароля, удаление ключей доступа,
доступ к почтовому ящику или восстановление учётной записи. Такие операции
намеренно не входят в область ответственности этого файла.
РЕШЕНИЯ ПО БЕЗОПАСНОСТИ
=======================
1. Первый успешный запрос, давший достоверные данные, формирует базовый набор.
Существующие сессии не отзываются при первом включении защиты.
2. Сессия, уже присутствующая в базовом наборе, сохраняется.
3. Текущая сессия обработчика или браузера всегда сохраняется. Её отзыв может
лишить автоматизацию доступа к учётной записи.
4. Новая сессия сохраняется, если ровно одно событие успешной доставки TOTP-кода
соответствует времени входа и это событие соответствует ровно одной новой
сессии.
5. Сессия без пригодной метки времени входа направляется на ручную проверку.
6. Новая сессия без однозначного подтверждения TOTP-кодом отзывается немедленно.
7. За один цикл отзывается не более MAX_REVOKES_PER_CYCLE сессий.
8. Сессия после неудачного отзыва не добавляется в базовый набор, поэтому
следующая попытка выполняется в одном из последующих циклов.
Поле API ``is_trusted_device`` намеренно НЕ используется как правило авторизации.
Это метаданные устройства от удалённой службы, а не локальное подтверждение того,
что сессия была одобрена.
ТРЕБОВАНИЯ К ИНТЕГРАЦИИ
=======================
Вызывающая сторона должна предоставить постоянное хранилище и распределённую
блокировку. Перед каждым разрушающим отзывом снова вызывается
``lease_is_valid``. Реализация блокировки должна исключать одновременную
обработку одной учётной записи двумя обработчиками и прекращать владение
устаревшего владельца через ограниченный интервал.
События TOTP следует регистрировать только после успешной доставки кода
предполагаемому получателю. Они загружаются повторно после HTTP-запроса сессий,
что снижает вероятность состояния гонки, когда код доставляется во время
получения списка сессий.
``persist_state`` должен атомарно сохранять базовый набор и счётчики, пока
владение той же блокировкой сохраняется. Доставка уведомлений должна быть
идемпотентной и никогда не должна откатывать успешный удалённый отзыв.
ОБРАЩЕНИЕ С СЕКРЕТАМИ
=====================
- Никогда не задавайте учётные данные непосредственно в этом файле.
- Загружайте учётные данные из диспетчера секретов во время выполнения.
- Никогда не записывайте в журнал экземпляры Credentials, заголовки запросов,
файлы cookie, тела ответов, идентификаторы сессий и устройств, сведения о
местоположении или метки времени событий TOTP.
- В полях учётных данных используется ``repr=False``, чтобы снизить риск
случайного раскрытия, но это не заменяет надёжное хранение секретов и
фильтрацию журналов.
- Не передавайте этот файл кому-либо после добавления в него настоящих учётных
данных.
ЗАВИСИМОСТЬ
===========
HTTP-адаптер использует ``aiohttp``. Классификатор и модель состояния используют
только стандартную библиотеку Python.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Literal
import aiohttp
CHATGPT_ORIGIN = "https://chatgpt.com"
SESSIONS_PATH = "/backend-api/accounts/sessions"
REVOKE_PATH = "/backend-api/accounts/sessions/revoke"
CODE_MATCH_BEFORE_SECONDS = 60
CODE_MATCH_AFTER_SECONDS = 60
MAX_REVOKES_PER_CYCLE = 5
FETCH_TIMEOUT_SECONDS = 25
REVOKE_TIMEOUT_SECONDS = 20
MAX_SESSION_ID_LENGTH = 220
DecisionName = Literal[
"baseline",
"current_device",
"authorized_by_code",
"manual_review",
"revoke",
]
def utc_now() -> datetime:
"""Вернуть дату и время с часовым поясом UTC; все сравнения используют UTC."""
return datetime.now(timezone.utc)
def parse_datetime(value: object) -> datetime | None:
"""Разобрать дату и время в формате ISO и привести их к UTC.
Значения без часового пояса считаются заданными в UTC. Для некорректных или
пустых входных данных возвращается None, поскольку предполагать метку времени
при принятии решения о разрушающем действии небезопасно.
"""
if isinstance(value, datetime):
parsed = value
else:
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def parse_retry_after(value: str | None, now: datetime | None = None) -> int:
"""Разобрать Retry-After как количество секунд или дату HTTP.
Нулевое значение означает, что сервер не указал пригодную задержку. В этом
случае обработчик всё равно применяет экспоненциальную задержку повторов.
"""
text = str(value or "").strip()
if not text:
return 0
try:
return max(0, int(float(text)))
except ValueError:
pass
try:
retry_at = parsedate_to_datetime(text)
except (TypeError, ValueError, OverflowError):
return 0
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
seconds = (retry_at.astimezone(timezone.utc) - (now or utc_now())).total_seconds()
return max(0, int(seconds))
@dataclass(slots=True)
class Credentials:
"""Учётные данные, используемые HTTP-адаптером только во время выполнения.
Необходимо передать хотя бы одно из значений access_token или session_token.
``account_id`` является значением заголовка учётной записи ChatGPT, а не
идентификатором приложения или базы данных. Настоящие значения должны
поступать из диспетчера секретов и никогда не должны сериализоваться вместе
с состоянием защиты.
"""
account_id: str = field(default="", repr=False)
access_token: str = field(default="", repr=False)
session_token: str = field(default="", repr=False)
access_token_expires_at: datetime | None = field(default=None, repr=False)
def validate(self) -> None:
if not self.access_token and not self.session_token:
raise ValueError("Требуется токен доступа ChatGPT или токен сессии")
def access_token_is_expired(self, now: datetime | None = None) -> bool:
expires_at = parse_datetime(self.access_token_expires_at)
return bool(expires_at and expires_at <= (now or utc_now()))
@dataclass(frozen=True, slots=True)
class SessionDevice:
"""Минимальные метаданные сессии, необходимые для решения о защите.
``context_id`` содержит необязательные локальные метаданные. Он может
идентифицировать получателя или рабочий процесс, получивший TOTP-код, но не
должен содержать пароль или токен. ``is_trusted_device`` сохраняется только
для отображения и аудита и никогда не используется для авторизации новой
сессии.
"""
session_id: str
signed_in_at: datetime | None
is_current_device: bool
is_trusted_device: bool
display_name: str = "Устройство"
context_id: str = ""
@dataclass(frozen=True, slots=True)
class CodeEvent:
"""Подтверждение успешной доставки TOTP-кода.
Создавайте это событие только после успешной доставки. ``event_id`` должен
быть стабильным несекретным идентификатором для дедупликации. ``context_id``
может быть идентификатором заказа, рабочего процесса или получателя; сессии
с пустым контекстом может соответствовать любое событие.
"""
event_id: str
issued_at: datetime
context_id: str = ""
@dataclass(frozen=True, slots=True)
class SessionDecision:
"""Результат чистого классификатора для одной видимой сейчас сессии."""
session: SessionDevice
decision: DecisionName
reason: str
matched_event_id: str = ""
@dataclass(frozen=True, slots=True)
class Classification:
"""Все решения и идентификаторы, безопасные для следующего базового набора."""
decisions: tuple[SessionDecision, ...]
next_baseline_ids: frozenset[str]
def selected(self, decision: DecisionName) -> list[SessionDecision]:
return [item for item in self.decisions if item.decision == decision]
@dataclass(slots=True)
class ProtectionState:
"""Постоянное состояние одной физической учётной записи ChatGPT.
Рабочая реализация репозитория должна хранить этот объект по ключу в области
учётной записи и атомарно обновлять его при владении блокировкой.
Идентификаторы сессий после неудачного отзыва и сессий на ручной проверке
намеренно не входят в ``baseline_ids``.
"""
initialized: bool = False
baseline_ids: set[str] = field(default_factory=set)
fetch_attempts: int = 0
revoked_count: int = 0
failed_count: int = 0
last_checked_at: datetime | None = None
last_ok_at: datetime | None = None
last_action_at: datetime | None = None
last_action_status: str = ""
last_error: str = ""
next_check_at: datetime | None = None
@dataclass(frozen=True, slots=True)
class FetchResult:
"""Результат запроса списка сессий без конфиденциальных данных."""
ok: bool
devices: tuple[Mapping[str, Any], ...] = ()
status: str = ""
http_status: int = 0
retry_after_seconds: int = 0
error: str = ""
@dataclass(frozen=True, slots=True)
class RevokeResult:
"""Результат запроса отзыва одной сессии без конфиденциальных данных."""
ok: bool
http_status: int = 0
already_absent: bool = False
retry_after_seconds: int = 0
error: str = ""
@dataclass(frozen=True, slots=True)
class CycleResult:
"""Сводка для метрик и журналов без материалов учётных данных."""
status: str
checked: int = 0
baselined: int = 0
authorized: int = 0
manual_review: int = 0
revoke_candidates: int = 0
revoked: int = 0
failed: int = 0
next_delay_seconds: int = 0
lease_lost: bool = False
def normalize_session(raw: Mapping[str, Any]) -> SessionDevice | None:
"""Преобразовать объект удалённого устройства в минимальную внутреннюю модель.
Со временем API использовал несколько полей идентификатора, поэтому
предпочтение отдаётся session_id, а hashed_device_id и render_id используются
как резервные варианты. Объект без стабильного идентификатора отклоняется.
"""
session_id = str(
raw.get("session_id")
or raw.get("hashed_device_id")
or raw.get("render_id")
or ""
).strip()[:MAX_SESSION_ID_LENGTH]
if not session_id:
return None
signed_in_at = None
try:
timestamp = int(raw.get("last_signed_in_timestamp_second") or 0)
except (TypeError, ValueError, OverflowError):
timestamp = 0
if timestamp > 0:
try:
signed_in_at = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
signed_in_at = None
if signed_in_at is None:
signed_in_at = parse_datetime(raw.get("last_signed_in_at"))
display_name = str(
raw.get("display_name")
or raw.get("device_model")
or raw.get("platform")
or "Устройство"
).strip()[:120]
return SessionDevice(
session_id=session_id,
signed_in_at=signed_in_at,
is_current_device=bool(raw.get("is_current_device", False)),
is_trusted_device=bool(raw.get("is_trusted_device", False)),
display_name=display_name or "Устройство",
context_id=str(raw.get("context_id") or raw.get("order_id") or "").strip()[:80],
)
def code_event_matches(session: SessionDevice, event: CodeEvent) -> bool:
"""Вернуть True, если событие кода подходит по времени и контексту."""
if session.signed_in_at is None:
return False
if session.context_id and event.context_id != session.context_id:
return False
before = timedelta(seconds=CODE_MATCH_BEFORE_SECONDS)
after = timedelta(seconds=CODE_MATCH_AFTER_SECONDS)
return event.issued_at - before <= session.signed_in_at <= event.issued_at + after
def classify_sessions(
sessions: Sequence[SessionDevice],
baseline_ids: set[str] | frozenset[str],
code_events: Sequence[CodeEvent],
) -> Classification:
"""Классифицировать сессии без побочных эффектов в сети, БД или уведомлениях.
Авторизация намеренно требует взаимно-однозначного соответствия. Если одно
событие может объяснить две сессии или одну сессию можно объяснить двумя
событиями, неоднозначность не разрешается автоматически. Затронутая сессия
направляется на ручную проверку.
"""
baseline = {str(value).strip() for value in baseline_ids if str(value).strip()}
unique_sessions = {item.session_id: item for item in sessions if item.session_id}
ordered = sorted(
unique_sessions.values(),
key=lambda item: (item.signed_in_at or datetime.min.replace(tzinfo=timezone.utc), item.session_id),
)
matches_by_session: dict[str, list[CodeEvent]] = {}
session_ids_by_event: dict[str, set[str]] = {}
for session in ordered:
if session.session_id in baseline or session.is_current_device or session.signed_in_at is None:
continue
matches = [event for event in code_events if code_event_matches(session, event)]
matches_by_session[session.session_id] = matches
for event in matches:
session_ids_by_event.setdefault(event.event_id, set()).add(session.session_id)
decisions: list[SessionDecision] = []
next_baseline: set[str] = set()
for session in ordered:
if session.session_id in baseline:
next_baseline.add(session.session_id)
decisions.append(SessionDecision(
session,
"baseline",
"Сессия уже присутствовала в доверенном базовом наборе",
))
continue
if session.is_current_device:
next_baseline.add(session.session_id)
decisions.append(SessionDecision(
session,
"current_device",
"Текущая сессия обработчика или браузера никогда не отзывается",
))
continue
matches = matches_by_session.get(session.session_id, [])
if matches:
matches.sort(key=lambda event: abs((session.signed_in_at - event.issued_at).total_seconds()))
closest = matches[0]
one_event_for_session = len(matches) == 1
one_session_for_event = len(session_ids_by_event.get(closest.event_id, set())) == 1
if one_event_for_session and one_session_for_event:
next_baseline.add(session.session_id)
decisions.append(SessionDecision(
session,
"authorized_by_code",
"Ровно одно событие доставки TOTP соответствует ровно одной новой сессии",
matched_event_id=closest.event_id,
))
else:
decisions.append(SessionDecision(
session,
"manual_review",
"Данные TOTP неоднозначны; автоматический отзыв намеренно пропущен",
))
continue
if session.signed_in_at is None:
decisions.append(SessionDecision(
session,
"manual_review",
"Нет достоверной метки времени входа; автоматическое разрушающее действие небезопасно",
))
continue
decisions.append(SessionDecision(
session,
"revoke",
"У новой сессии нет однозначного подтверждения доставленным TOTP-кодом",
))
return Classification(tuple(decisions), frozenset(next_baseline))
class ChatGPTSessionClient:
"""Небольшой HTTP-адаптер для получения и отзыва сессий ChatGPT.
Тела ответов никогда не включаются в объекты ошибок, поскольку могут
содержать данные учётной записи или устройства. Вызывающая сторона должна
записывать в журнал только категории статусов и ошибок.
"""
def __init__(self, credentials: Credentials) -> None:
credentials.validate()
self._credentials = credentials
def _headers(self, target_path: str) -> dict[str, str]:
credentials = self._credentials
headers = {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"oai-language": "en-US",
"referer": CHATGPT_ORIGIN + "/",
"user-agent": "Mozilla/5.0 session-protection-reference/1.0",
"x-openai-target-path": target_path,
"x-openai-target-route": target_path,
}
if credentials.access_token:
headers["authorization"] = f"Bearer {credentials.access_token}"
if credentials.account_id:
headers["chatgpt-account-id"] = credentials.account_id
if credentials.session_token:
cookie = f"__Secure-next-auth.session-token={credentials.session_token}"
if credentials.account_id:
cookie += f"; _account={credentials.account_id}"
headers["cookie"] = cookie
return headers
async def fetch_sessions(self) -> FetchResult:
"""Получить достоверный список сессий.
Пустой список или явно заданное ``show_session_manager=false`` считается
признаком недоступности, а не достоверным результатом. Это не позволяет
временной проблеме API стереть базовый набор и изменить решения о доверии.
"""
if self._credentials.access_token_is_expired():
return FetchResult(False, status="token_expired", error="Срок действия токена доступа истёк")
timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession() as http:
async with http.get(
CHATGPT_ORIGIN + SESSIONS_PATH,
headers=self._headers(SESSIONS_PATH),
timeout=timeout,
) as response:
status = int(response.status)
retry_after = parse_retry_after(response.headers.get("Retry-After"))
try:
payload = await response.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
payload = None
except (aiohttp.ClientError, TimeoutError):
return FetchResult(False, status="network_error", error="Запрос списка сессий завершился с ошибкой")
if not 200 <= status < 300:
return FetchResult(
False,
status="api_error",
http_status=status,
retry_after_seconds=retry_after,
error=f"Эндпоинт списка сессий вернул HTTP {status}",
)
if not isinstance(payload, dict):
return FetchResult(
False,
status="invalid_response",
http_status=status,
error="Ответ не является объектом",
)
devices = payload.get("devices")
manager_explicitly_disabled = (
"show_session_manager" in payload and payload.get("show_session_manager") is not True
)
if not isinstance(devices, list) or not devices or manager_explicitly_disabled:
return FetchResult(
False,
status="session_manager_unavailable",
http_status=status,
error="Диспетчер сессий пуст или недоступен",
)
safe_devices = tuple(item for item in devices if isinstance(item, Mapping))
if not safe_devices:
return FetchResult(
False,
status="invalid_response",
http_status=status,
error="В ответе нет корректных объектов устройств",
)
return FetchResult(True, devices=safe_devices, status="ok", http_status=status)
async def revoke_session(self, session_id: str) -> RevokeResult:
"""Отозвать ровно одну сессию; HTTP 404 означает, что она уже отсутствует."""
clean_id = str(session_id or "").strip()[:MAX_SESSION_ID_LENGTH]
if not clean_id:
return RevokeResult(False, error="Требуется идентификатор сессии")
if self._credentials.access_token_is_expired():
return RevokeResult(False, error="Срок действия токена доступа истёк")
headers = self._headers(REVOKE_PATH)
headers["content-type"] = "application/json"
timeout = aiohttp.ClientTimeout(total=REVOKE_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession() as http:
async with http.post(
CHATGPT_ORIGIN + REVOKE_PATH,
json={"session_id": clean_id},
headers=headers,
timeout=timeout,
) as response:
status = int(response.status)
retry_after = parse_retry_after(response.headers.get("Retry-After"))
await response.read()
except (aiohttp.ClientError, TimeoutError):
return RevokeResult(False, error="Запрос на отзыв сессии завершился с ошибкой")
if 200 <= status < 300:
return RevokeResult(True, http_status=status)
if status == 404:
return RevokeResult(True, http_status=status, already_absent=True)
return RevokeResult(
False,
http_status=status,
retry_after_seconds=retry_after,
error=f"Эндпоинт отзыва сессии вернул HTTP {status}",
)
LoadCodeEvents = Callable[[], Awaitable[Sequence[CodeEvent]]]
PersistState = Callable[[ProtectionState], Awaitable[None]]
LeaseIsValid = Callable[[], Awaitable[bool]]
Heartbeat = Callable[[], Awaitable[None]]
Notify = Callable[[str, SessionDevice, str], Awaitable[None]]
async def _no_op() -> None:
return None
async def _no_notification(_status: str, _session: SessionDevice, _error: str) -> None:
return None
def fetch_retry_delay(
result: FetchResult,
attempt: int,
normal_interval_seconds: int,
) -> int:
"""Рассчитать ограниченную задержку повтора после сбоя запроса сессий."""
attempt = max(1, int(attempt))
normal = max(1, int(normal_interval_seconds))
if result.http_status == 429:
exponential = min(900, 30 * (2 ** min(attempt - 1, 5)))
return max(normal, result.retry_after_seconds, exponential)
if result.status == "network_error" or result.http_status >= 500:
exponential = min(300, 15 * (2 ** min(attempt - 1, 4)))
return max(normal, exponential)
return normal
async def run_protection_cycle(
*,
client: ChatGPTSessionClient,
state: ProtectionState,
load_code_events: LoadCodeEvents,
persist_state: PersistState,
lease_is_valid: LeaseIsValid,
slow_interval_seconds: int = 30,
fast_interval_seconds: int = 5,
heartbeat: Heartbeat = _no_op,
notify: Notify = _no_notification,
) -> CycleResult:
"""Выполнить полный цикл получения, классификации, отзыва и сохранения.
Вызывающая сторона уже должна владеть блокировкой. Состояние сохраняется при
обычном успехе и сбое получения данных, но никогда не сохраняется после
потери владения блокировкой. Уведомления доставляются по мере возможности и
не влияют на результат отзыва.
"""
if not await lease_is_valid():
return CycleResult(status="lease_lost", lease_lost=True)
checked_at = utc_now()
fetch = await client.fetch_sessions()
await heartbeat()
state.last_checked_at = checked_at
if not fetch.ok:
state.fetch_attempts += 1
delay = fetch_retry_delay(fetch, state.fetch_attempts, slow_interval_seconds)
state.last_error = fetch.error or fetch.status
state.next_check_at = checked_at + timedelta(seconds=delay)
if not await lease_is_valid():
return CycleResult(status="lease_lost", lease_lost=True)
await persist_state(state)
return CycleResult(status="fetch_failed", next_delay_seconds=delay)
normalized = [normalize_session(raw) for raw in fetch.devices]
sessions = [item for item in normalized if item is not None]
current_ids = {item.session_id for item in sessions}
if not state.initialized:
state.initialized = True
state.baseline_ids = current_ids
state.fetch_attempts = 0
state.last_ok_at = checked_at
state.last_action_at = checked_at
state.last_action_status = "baseline"
state.last_error = ""
state.next_check_at = checked_at + timedelta(seconds=max(1, slow_interval_seconds))
if not await lease_is_valid():
return CycleResult(status="lease_lost", lease_lost=True)
await persist_state(state)
return CycleResult(
status="baseline_created",
checked=len(sessions),
baselined=len(current_ids),
next_delay_seconds=max(1, slow_interval_seconds),
)
# Повторная загрузка после HTTP-запроса учитывает коды, доставленные во время запроса.
code_events = list(await load_code_events())
classification = classify_sessions(sessions, state.baseline_ids, code_events)
revoke_candidates = classification.selected("revoke")
manual_review = classification.selected("manual_review")
authorized = classification.selected("authorized_by_code")
revoked_ids: set[str] = set()
revoked = 0
failed = 0
attempted = 0
for decision in revoke_candidates[:MAX_REVOKES_PER_CYCLE]:
if not await lease_is_valid():
return CycleResult(status="lease_lost", lease_lost=True)
attempted += 1
result = await client.revoke_session(decision.session.session_id)
await heartbeat()
if result.ok:
revoked_ids.add(decision.session.session_id)
revoked += 1
try:
await notify("revoked", decision.session, "")
except Exception:
pass
else:
failed += 1
try:
await notify("revoke_failed", decision.session, result.error)
except Exception:
pass
for decision in manual_review:
try:
await notify("manual_review", decision.session, decision.reason)
except Exception:
pass
if not await lease_is_valid():
return CycleResult(status="lease_lost", lease_lost=True)
# Идентификаторы отозванных сессий, неудачных отзывов, сессий на ручной проверке
# и ещё не обработанных кандидатов не считаются доверенными. Здесь остаются
# только базовые, текущие и авторизованные кодом сессии.
state.baseline_ids = set(classification.next_baseline_ids & current_ids) - revoked_ids
state.fetch_attempts = 0
state.revoked_count += revoked
state.failed_count += failed
state.last_ok_at = checked_at
state.last_error = f"Количество сессий, которые не удалось отозвать: {failed}" if failed else ""
remaining_candidates = len(revoke_candidates) - attempted
needs_fast_retry = bool(failed or manual_review or remaining_candidates > 0)
delay = max(1, fast_interval_seconds if needs_fast_retry else slow_interval_seconds)
state.next_check_at = utc_now() + timedelta(seconds=delay)
if revoked or failed:
state.last_action_at = checked_at
state.last_action_status = "revoke_failed" if failed else "revoked"
elif manual_review:
state.last_action_at = checked_at
state.last_action_status = "manual_review"
await persist_state(state)
return CycleResult(
status="completed",
checked=len(sessions),
authorized=len(authorized),
manual_review=len(manual_review),
revoke_candidates=len(revoke_candidates),
revoked=revoked,
failed=failed,
next_delay_seconds=delay,
)
# Минимальная схема интеграции (намеренно не является исполняемым кодом):
#
# lease, state = await repository.claim_due_monitor(stale_after_seconds=180)
# if lease:
# credentials = await secret_manager.get_chatgpt_credentials(lease.account_ref)
# client = ChatGPTSessionClient(credentials)
# result = await run_protection_cycle(
# client=client,
# state=state,
# load_code_events=lambda: repository.load_delivered_code_events(lease),
# persist_state=lambda value: repository.save_if_owned(lease, value),
# lease_is_valid=lambda: repository.lease_is_owned(lease),
# heartbeat=lambda: repository.refresh_lease(lease),
# notify=lambda status, session, error: notifier.send_idempotently(
# status=status,
# session_fingerprint=hash_for_notification(session.session_id),
# error_category=error,
# ),
# )