Spaces:
Running
Running
SilverElixir commited on
Commit ·
60ad579
1
Parent(s): 44b41c9
Chat memory: sender directory and combined 100-message cap
Browse files- bot.py +10 -0
- lumen_chat_state.py +101 -0
- lumen_state_storage.py +2 -0
bot.py
CHANGED
|
@@ -433,6 +433,7 @@ from lumen_chat_state import (
|
|
| 433 |
MAX_CHAT_LIMIT,
|
| 434 |
PRUNED_CHAT_TARGET,
|
| 435 |
MAX_CHAT_HISTORY_LEN,
|
|
|
|
| 436 |
_STATE_DIR,
|
| 437 |
STATE_FILE_PATH,
|
| 438 |
GLOBAL_QUOTA_FILE,
|
|
@@ -509,6 +510,10 @@ from lumen_chat_state import (
|
|
| 509 |
_user_key_for_message,
|
| 510 |
_user_daily_reset_in,
|
| 511 |
_trim_history,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
)
|
| 513 |
|
| 514 |
# Простой трекер для rate limiting и очередь кнопок-уточнений живут в
|
|
@@ -829,6 +834,7 @@ __all__ = [
|
|
| 829 |
"MAX_CHAT_LIMIT",
|
| 830 |
"PRUNED_CHAT_TARGET",
|
| 831 |
"MAX_CHAT_HISTORY_LEN",
|
|
|
|
| 832 |
"STATE_FILE_PATH",
|
| 833 |
"GLOBAL_QUOTA_FILE",
|
| 834 |
"_CHATS_DIR",
|
|
@@ -905,6 +911,10 @@ __all__ = [
|
|
| 905 |
"_mark_rate_limited",
|
| 906 |
"_record_quota_usage",
|
| 907 |
"_trim_history",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
"_pending_picks",
|
| 909 |
"_purge_expired_picks",
|
| 910 |
"_enforce_pending_picks_cap",
|
|
|
|
| 433 |
MAX_CHAT_LIMIT,
|
| 434 |
PRUNED_CHAT_TARGET,
|
| 435 |
MAX_CHAT_HISTORY_LEN,
|
| 436 |
+
MAX_TOTAL_MEMORY_MESSAGES,
|
| 437 |
_STATE_DIR,
|
| 438 |
STATE_FILE_PATH,
|
| 439 |
GLOBAL_QUOTA_FILE,
|
|
|
|
| 510 |
_user_key_for_message,
|
| 511 |
_user_daily_reset_in,
|
| 512 |
_trim_history,
|
| 513 |
+
_trim_combined,
|
| 514 |
+
_note_sender,
|
| 515 |
+
_sender_display,
|
| 516 |
+
_resolve_nick,
|
| 517 |
)
|
| 518 |
|
| 519 |
# Простой трекер для rate limiting и очередь кнопок-уточнений живут в
|
|
|
|
| 834 |
"MAX_CHAT_LIMIT",
|
| 835 |
"PRUNED_CHAT_TARGET",
|
| 836 |
"MAX_CHAT_HISTORY_LEN",
|
| 837 |
+
"MAX_TOTAL_MEMORY_MESSAGES",
|
| 838 |
"STATE_FILE_PATH",
|
| 839 |
"GLOBAL_QUOTA_FILE",
|
| 840 |
"_CHATS_DIR",
|
|
|
|
| 911 |
"_mark_rate_limited",
|
| 912 |
"_record_quota_usage",
|
| 913 |
"_trim_history",
|
| 914 |
+
"_trim_combined",
|
| 915 |
+
"_note_sender",
|
| 916 |
+
"_sender_display",
|
| 917 |
+
"_resolve_nick",
|
| 918 |
"_pending_picks",
|
| 919 |
"_purge_expired_picks",
|
| 920 |
"_enforce_pending_picks_cap",
|
lumen_chat_state.py
CHANGED
|
@@ -40,12 +40,17 @@ MAX_MEDIA_RECENT_IDS = 8 # хранится ОТДЕЛЬНО на каждог
|
|
| 40 |
# Живых отправителей медиа в чате десятки, 500 покрывает большие группы с запасом.
|
| 41 |
# Потолок только против подделки user_id и раздувания снапшота, легитимные чаты не задевает.
|
| 42 |
MAX_MEDIA_BUCKETS_PER_CHAT = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
class ChatState(TypedDict, total=False):
|
| 46 |
history: list[dict[str, Any]]
|
| 47 |
ctx: "deque[str]"
|
| 48 |
recent_media_ids: dict[str, "deque[tuple[str, str]]"]
|
|
|
|
| 49 |
last_activity: float
|
| 50 |
lang: str
|
| 51 |
|
|
@@ -388,6 +393,7 @@ def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
|
| 388 |
"history": history,
|
| 389 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 390 |
"recent_media_ids": media_buckets,
|
|
|
|
| 391 |
# Настенные часы, а не monotonic: тот сбрасывается рестартом и делал все
|
| 392 |
# чаты "активными" в /stats. Снимкам без метки — 0: такой чат неактивен.
|
| 393 |
"last_activity": _restore_last_activity(s),
|
|
@@ -395,6 +401,7 @@ def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
|
| 395 |
# раньше теряло (прод-баг: /lang слетал при каждом деплое).
|
| 396 |
"lang": normalize_lang(s.get("lang")),
|
| 397 |
}
|
|
|
|
| 398 |
|
| 399 |
|
| 400 |
def _restore_last_activity(s: dict[str, Any]) -> float:
|
|
@@ -406,6 +413,99 @@ def _restore_last_activity(s: dict[str, Any]) -> float:
|
|
| 406 |
return float(raw)
|
| 407 |
return 0.0
|
| 408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
def _save_chat_index() -> bool:
|
| 410 |
"""True/False для повтора в _flush_state_now: раньше неуспех молча терялся (аудит A5-1)."""
|
| 411 |
import bot
|
|
@@ -755,6 +855,7 @@ def get_state(chat_id: int) -> dict[str, Any]:
|
|
| 755 |
"history": [],
|
| 756 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 757 |
"recent_media_ids": {},
|
|
|
|
| 758 |
# Настенные часы: monotonic обнуляется рестартом и ломал /stats.
|
| 759 |
"last_activity": time.time(),
|
| 760 |
}
|
|
|
|
| 40 |
# Живых отправителей медиа в чате десятки, 500 покрывает большие группы с запасом.
|
| 41 |
# Потолок только против подделки user_id и раздувания снапшота, легитимные чаты не задевает.
|
| 42 |
MAX_MEDIA_BUCKETS_PER_CHAT = 500
|
| 43 |
+
# Общий лимит памяти чата в сообщениях, включая фоновые (требование владельца 08.10.2026).
|
| 44 |
+
MAX_TOTAL_MEMORY_MESSAGES = 100
|
| 45 |
+
# Сколько отправителей помним на чат: ники для подписей и резолва прозвищ.
|
| 46 |
+
MAX_KNOWN_SENDERS_PER_CHAT = 500
|
| 47 |
|
| 48 |
|
| 49 |
class ChatState(TypedDict, total=False):
|
| 50 |
history: list[dict[str, Any]]
|
| 51 |
ctx: "deque[str]"
|
| 52 |
recent_media_ids: dict[str, "deque[tuple[str, str]]"]
|
| 53 |
+
user_names: dict[str, dict[str, str]]
|
| 54 |
last_activity: float
|
| 55 |
lang: str
|
| 56 |
|
|
|
|
| 393 |
"history": history,
|
| 394 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 395 |
"recent_media_ids": media_buckets,
|
| 396 |
+
"user_names": _restore_user_names(s.get("user_names")),
|
| 397 |
# Настенные часы, а не monotonic: тот сбрасывается рестартом и делал все
|
| 398 |
# чаты "активными" в /stats. Снимкам без метки — 0: такой чат неактивен.
|
| 399 |
"last_activity": _restore_last_activity(s),
|
|
|
|
| 401 |
# раньше теряло (прод-баг: /lang слетал при каждом деплое).
|
| 402 |
"lang": normalize_lang(s.get("lang")),
|
| 403 |
}
|
| 404 |
+
_trim_combined(chat_state[cid])
|
| 405 |
|
| 406 |
|
| 407 |
def _restore_last_activity(s: dict[str, Any]) -> float:
|
|
|
|
| 413 |
return float(raw)
|
| 414 |
return 0.0
|
| 415 |
|
| 416 |
+
|
| 417 |
+
def _sender_display(username: str | None, first_name: str | None) -> str:
|
| 418 |
+
"""Показываемое имя: @username, иначе first_name, иначе User."""
|
| 419 |
+
if isinstance(username, str) and username.strip():
|
| 420 |
+
return "@" + username.strip().lstrip("@")
|
| 421 |
+
if isinstance(first_name, str) and first_name.strip():
|
| 422 |
+
return first_name.strip()[:64]
|
| 423 |
+
return "User"
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _note_sender(state: dict[str, Any], user_id: int | None, username: str | None, first_name: str | None) -> str:
|
| 427 |
+
"""Запоминает отправителя в state[user_names], возвращает показное имя."""
|
| 428 |
+
display = _sender_display(username, first_name)
|
| 429 |
+
try:
|
| 430 |
+
if user_id is None:
|
| 431 |
+
return display
|
| 432 |
+
bucket = state.get("user_names")
|
| 433 |
+
if not isinstance(bucket, dict):
|
| 434 |
+
bucket = {}
|
| 435 |
+
state["user_names"] = bucket
|
| 436 |
+
key = str(user_id)
|
| 437 |
+
entry = bucket.get(key)
|
| 438 |
+
if not isinstance(entry, dict):
|
| 439 |
+
entry = {}
|
| 440 |
+
bucket[key] = entry
|
| 441 |
+
if isinstance(username, str) and username.strip():
|
| 442 |
+
entry["username"] = username.strip().lstrip("@")[:64]
|
| 443 |
+
if isinstance(first_name, str) and first_name.strip():
|
| 444 |
+
entry["first_name"] = first_name.strip()[:64]
|
| 445 |
+
if len(bucket) > MAX_KNOWN_SENDERS_PER_CHAT:
|
| 446 |
+
# Старейшие вытесняем: словарь упорядочен вставкой.
|
| 447 |
+
for old_key in list(bucket.keys())[:len(bucket) - MAX_KNOWN_SENDERS_PER_CHAT]:
|
| 448 |
+
bucket.pop(old_key, None)
|
| 449 |
+
except Exception:
|
| 450 |
+
pass
|
| 451 |
+
return display
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _restore_user_names(raw: Any) -> dict[str, dict[str, str]]:
|
| 455 |
+
"""Словарь отправителей из снимка, мусор отбрасываем."""
|
| 456 |
+
clean: dict[str, dict[str, str]] = {}
|
| 457 |
+
if not isinstance(raw, dict):
|
| 458 |
+
return clean
|
| 459 |
+
for uid, entry in list(raw.items())[:MAX_KNOWN_SENDERS_PER_CHAT]:
|
| 460 |
+
try:
|
| 461 |
+
key = str(int(uid))
|
| 462 |
+
except (TypeError, ValueError):
|
| 463 |
+
continue
|
| 464 |
+
if not isinstance(entry, dict):
|
| 465 |
+
continue
|
| 466 |
+
item: dict[str, str] = {}
|
| 467 |
+
for field in ("username", "first_name"):
|
| 468 |
+
val = entry.get(field)
|
| 469 |
+
if isinstance(val, str) and val.strip():
|
| 470 |
+
item[field] = val.strip()[:64]
|
| 471 |
+
if item:
|
| 472 |
+
clean[key] = item
|
| 473 |
+
return clean
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _resolve_nick(state: dict[str, Any], nick: str) -> str | None:
|
| 477 |
+
"""Прозвище в показное имя: ищет по username и first_name без регистра."""
|
| 478 |
+
try:
|
| 479 |
+
needle = (nick or "").strip().lstrip("@").lower()
|
| 480 |
+
if not needle:
|
| 481 |
+
return None
|
| 482 |
+
bucket = state.get("user_names") or {}
|
| 483 |
+
if not isinstance(bucket, dict):
|
| 484 |
+
return None
|
| 485 |
+
for entry in bucket.values():
|
| 486 |
+
if not isinstance(entry, dict):
|
| 487 |
+
continue
|
| 488 |
+
for field in ("username", "first_name"):
|
| 489 |
+
val = entry.get(field)
|
| 490 |
+
if isinstance(val, str) and needle in val.lower():
|
| 491 |
+
return _sender_display(entry.get("username"), entry.get("first_name"))
|
| 492 |
+
except Exception:
|
| 493 |
+
pass
|
| 494 |
+
return None
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _trim_combined(state: dict[str, Any]) -> None:
|
| 498 |
+
"""Держит history + ctx в общем лимите: старые фоновые уходят первыми."""
|
| 499 |
+
try:
|
| 500 |
+
history = state.get("history")
|
| 501 |
+
ctx = state.get("ctx")
|
| 502 |
+
if not isinstance(history, list) or ctx is None:
|
| 503 |
+
return
|
| 504 |
+
while len(history) + len(ctx) > MAX_TOTAL_MEMORY_MESSAGES and len(ctx) > 0:
|
| 505 |
+
ctx.popleft()
|
| 506 |
+
except Exception:
|
| 507 |
+
pass
|
| 508 |
+
|
| 509 |
def _save_chat_index() -> bool:
|
| 510 |
"""True/False для повтора в _flush_state_now: раньше неуспех молча терялся (аудит A5-1)."""
|
| 511 |
import bot
|
|
|
|
| 855 |
"history": [],
|
| 856 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
| 857 |
"recent_media_ids": {},
|
| 858 |
+
"user_names": {},
|
| 859 |
# Настенные часы: monotonic обнуляется рестартом и ломал /stats.
|
| 860 |
"last_activity": time.time(),
|
| 861 |
}
|
lumen_state_storage.py
CHANGED
|
@@ -143,6 +143,8 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 143 |
"recent_media_ids": {
|
| 144 |
uid: list(dq) for uid, dq in state.get("recent_media_ids", {}).items()
|
| 145 |
},
|
|
|
|
|
|
|
| 146 |
# Настенные часы для подсчёта активных в /stats: monotonic сбрасывается
|
| 147 |
# рестартом и делал все чаты "активными", time.time() переживает его.
|
| 148 |
"last_activity": state.get("last_activity", 0.0),
|
|
|
|
| 143 |
"recent_media_ids": {
|
| 144 |
uid: list(dq) for uid, dq in state.get("recent_media_ids", {}).items()
|
| 145 |
},
|
| 146 |
+
# Ники переживают рестарт: без них после деплоя бот снова не знал бы авторов.
|
| 147 |
+
"user_names": dict(state.get("user_names", {}) or {}),
|
| 148 |
# Настенные часы для подсчёта активных в /stats: monotonic сбрасывается
|
| 149 |
# рестартом и делал все чаты "активными", time.time() переживает его.
|
| 150 |
"last_activity": state.get("last_activity", 0.0),
|