SilverElixir commited on
Commit
60ad579
·
1 Parent(s): 44b41c9

Chat memory: sender directory and combined 100-message cap

Browse files
Files changed (3) hide show
  1. bot.py +10 -0
  2. lumen_chat_state.py +101 -0
  3. 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),