Law/app/services/chat_presence.py
2026-02-27 20:43:57 +03:00

195 lines
6.3 KiB
Python

from __future__ import annotations
import json
import threading
from datetime import datetime, timezone
from typing import Any
import redis
from app.core.config import settings
_DEFAULT_TYPING_TTL_SECONDS = 9
_redis_client: redis.Redis | None = None
_redis_lock = threading.Lock()
_memory_lock = threading.Lock()
_memory_state: dict[str, dict[str, dict[str, Any]]] = {}
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _iso_utc(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat()
def _get_redis_client() -> redis.Redis | None:
global _redis_client
if _redis_client is not None:
return _redis_client
with _redis_lock:
if _redis_client is not None:
return _redis_client
try:
client = redis.Redis.from_url(
settings.REDIS_URL,
decode_responses=True,
socket_timeout=0.25,
socket_connect_timeout=0.25,
)
client.ping()
_redis_client = client
return _redis_client
except Exception:
_redis_client = None
return None
def _request_actors_key(request_key: str) -> str:
return f"chat:typing:req:{request_key}:actors"
def _actor_payload_key(request_key: str, actor_key: str) -> str:
return f"chat:typing:req:{request_key}:actor:{actor_key}"
def set_typing_presence(
*,
request_key: str,
actor_key: str,
actor_label: str,
actor_role: str,
typing: bool,
ttl_seconds: int = _DEFAULT_TYPING_TTL_SECONDS,
) -> None:
normalized_request = str(request_key or "").strip()
normalized_actor = str(actor_key or "").strip()
if not normalized_request or not normalized_actor:
return
ttl = max(2, int(ttl_seconds or _DEFAULT_TYPING_TTL_SECONDS))
if typing:
payload = {
"actor_key": normalized_actor,
"actor_label": str(actor_label or "").strip() or "Собеседник",
"actor_role": str(actor_role or "").strip().upper() or "UNKNOWN",
"updated_at": _iso_utc(_utc_now()),
}
else:
payload = None
client = _get_redis_client()
if client is not None:
actors_key = _request_actors_key(normalized_request)
actor_payload_key = _actor_payload_key(normalized_request, normalized_actor)
try:
pipe = client.pipeline()
if payload is None:
pipe.delete(actor_payload_key)
pipe.srem(actors_key, normalized_actor)
else:
pipe.sadd(actors_key, normalized_actor)
pipe.setex(actor_payload_key, ttl, json.dumps(payload, ensure_ascii=False))
pipe.expire(actors_key, max(60, ttl * 8))
pipe.execute()
return
except Exception:
pass
with _memory_lock:
actors = _memory_state.setdefault(normalized_request, {})
if payload is None:
actors.pop(normalized_actor, None)
if not actors:
_memory_state.pop(normalized_request, None)
return
expires_at = _utc_now().timestamp() + ttl
actors[normalized_actor] = {**payload, "expires_at": expires_at}
def list_typing_presence(
*,
request_key: str,
exclude_actor_key: str | None = None,
) -> list[dict[str, Any]]:
normalized_request = str(request_key or "").strip()
if not normalized_request:
return []
excluded = str(exclude_actor_key or "").strip()
now_ts = _utc_now().timestamp()
client = _get_redis_client()
if client is not None:
actors_key = _request_actors_key(normalized_request)
try:
members = list(client.smembers(actors_key) or [])
if not members:
return []
keys = [_actor_payload_key(normalized_request, str(member)) for member in members]
rows = client.mget(keys)
stale_members: list[str] = []
result: list[dict[str, Any]] = []
for actor, raw in zip(members, rows):
actor_str = str(actor)
if not raw:
stale_members.append(actor_str)
continue
try:
payload = json.loads(str(raw))
except Exception:
stale_members.append(actor_str)
continue
if excluded and actor_str == excluded:
continue
result.append(
{
"actor_key": actor_str,
"actor_label": str(payload.get("actor_label") or "Собеседник"),
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
"updated_at": str(payload.get("updated_at") or ""),
}
)
if stale_members:
try:
client.srem(actors_key, *stale_members)
except Exception:
pass
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return result
except Exception:
pass
with _memory_lock:
actors = _memory_state.get(normalized_request) or {}
stale: list[str] = []
result: list[dict[str, Any]] = []
for actor_key, payload in actors.items():
expires_at = float(payload.get("expires_at") or 0)
if expires_at <= now_ts:
stale.append(actor_key)
continue
if excluded and actor_key == excluded:
continue
result.append(
{
"actor_key": actor_key,
"actor_label": str(payload.get("actor_label") or "Собеседник"),
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
"updated_at": str(payload.get("updated_at") or ""),
}
)
for actor_key in stale:
actors.pop(actor_key, None)
if not actors:
_memory_state.pop(normalized_request, None)
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return result
def clear_presence_for_tests() -> None:
with _memory_lock:
_memory_state.clear()