File size: 4,006 Bytes
42ce1bc
5f80ef1
 
 
42ce1bc
 
 
 
 
 
5f80ef1
 
7786593
 
 
 
 
 
 
 
 
 
42ce1bc
 
 
 
5f80ef1
42ce1bc
 
 
 
 
 
 
5f80ef1
42ce1bc
 
 
 
 
5f80ef1
 
f95285c
 
 
 
 
 
 
 
42ce1bc
 
 
 
 
 
 
 
 
7786593
 
5f80ef1
42ce1bc
 
 
 
0b65b92
42ce1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
"""
lumen_limits.py. Скользящее окно rate limit и очередь кнопок уточнений.
Выделено из bot.py (P2 аудита). Хранилища в памяти процесса, без Telegram и LLM.
bot.py реэкспортирует имена, тесты через bot.X не менялись.
"""
from __future__ import annotations

import time
from typing import Any

# Числа через bot._env_number с отложенным импортом.
# Опечатка в env не роняет старт (аудит 26.09.2026).
def _env_number(name: str, default: float | int, *, cast: type = float, min_value: float | None = None) -> float | int:
    try:
        import bot
        return bot._env_number(name, default, cast=cast, min_value=min_value)
    except Exception:
        return cast(default)

RATE_LIMIT_MAX_REQUESTS = _env_number("RATE_LIMIT_MAX_REQUESTS", 5, cast=int, min_value=1)
RATE_LIMIT_WINDOW_SEC = _env_number("RATE_LIMIT_WINDOW_SEC", 30, min_value=1)
MAX_RATE_LIMIT_KEYS = _env_number("MAX_RATE_LIMIT_KEYS", 20000, cast=int, min_value=100)
user_rate_limits: dict[int, list[float]] = {}


def _cleanup_rate_limit_dict() -> None:
    """Чистка раз в час. Пустые ключи копились навсегда и давали медленную утечку."""
    now = time.time()
    stale = [uid for uid, ts in user_rate_limits.items() if not ts or now - ts[-1] > 3600]
    for uid in stale:
        user_rate_limits.pop(uid, None)


def _check_and_register_rate_limit(user_id: int | None) -> bool:
    """Окно 5/30с. При отказе метка не пишется, иначе отказ продлевал бы сам себя."""
    if not user_id:
        return False
    now = time.time()
    if user_id not in user_rate_limits and len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
        _cleanup_rate_limit_dict()
        # Чистка убирает только протухших, при флуде свежими ID нужен жесткий снос (аудит).
        # Сносим до 90 процентов потолка, чтобы не сканировать словарь на каждый новый ID.
        if len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
            ordered = sorted(
                user_rate_limits,
                key=lambda uid: user_rate_limits[uid][-1] if user_rate_limits[uid] else 0,
            )
            drop = len(ordered) - int(MAX_RATE_LIMIT_KEYS * 0.9) + 1
            for uid in ordered[:drop]:
                user_rate_limits.pop(uid, None)
    timestamps = user_rate_limits.setdefault(user_id, [])
    while timestamps and now - timestamps[0] > RATE_LIMIT_WINDOW_SEC:
        timestamps.pop(0)
    if len(timestamps) >= RATE_LIMIT_MAX_REQUESTS:
        return True
    timestamps.append(now)
    return False


PICK_TTL_SEC = _env_number("PICK_TTL_SEC", 300, min_value=1)
MAX_PENDING_PICKS = _env_number("MAX_PENDING_PICKS", 500, cast=int, min_value=1)
# Только память. После рестарта кнопки протухают, это штатно.
_pending_picks: dict[str, dict[str, Any]] = {}


def _purge_expired_picks(now: float | None = None) -> None:
    """Чистка протухших при создании новой: отдельный цикл не заводили."""
    now = time.monotonic() if now is None else now
    for token in [t for t, rec in _pending_picks.items() if rec["expires"] <= now]:
        del _pending_picks[token]


def _enforce_pending_picks_cap() -> None:
    """Потолок памяти на кнопки-уточнения: сверх лимита сносим самые
    близкие к протуханию (AUD-G-001)."""
    overflow = len(_pending_picks) - MAX_PENDING_PICKS
    if overflow <= 0:
        return
    oldest = sorted(_pending_picks, key=lambda t: _pending_picks[t]["expires"])[:overflow]
    for token in oldest:
        del _pending_picks[token]