refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
import logging
import secrets
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
try:
from redis.asyncio import Redis
except ModuleNotFoundError: # pragma: no cover - local dev environments may not be installed yet
Redis = None # type: ignore[assignment]
from config.settings import Settings
logger = logging.getLogger(__name__)
_redis: Optional["Redis"] = None
def redis_key(settings: Settings, *parts: object) -> str:
prefix = (settings.REDIS_KEY_PREFIX or "remnawave-tg-shop").strip(":")
clean = [str(part).strip(":") for part in parts if str(part).strip(":")]
return ":".join([prefix, *clean])
async def get_redis(settings: Settings) -> Optional["Redis"]:
global _redis
if not settings.REDIS_URL:
return None
if Redis is None:
logger.warning("REDIS_URL is set but redis package is not installed")
return None
if _redis is None:
_redis = Redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
health_check_interval=30,
)
return _redis
async def close_redis() -> None:
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
async def cache_get_json(settings: Settings, key: str) -> Any:
redis = await get_redis(settings)
if redis is None:
return None
raw = await redis.get(key)
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid JSON in Redis cache key %s", key)
return None
async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds: int) -> None:
redis = await get_redis(settings)
if redis is None:
return
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
async def cache_delete(settings: Settings, *keys: str) -> None:
redis = await get_redis(settings)
if redis is None or not keys:
return
await redis.delete(*keys)
@asynccontextmanager
async def redis_lock(
settings: Settings,
name: str,
*,
ttl_seconds: int,
) -> AsyncIterator[bool]:
redis = await get_redis(settings)
if redis is None:
yield True
return
key = redis_key(settings, "lock", name)
token = secrets.token_urlsafe(16)
acquired = bool(await redis.set(key, token, nx=True, ex=ttl_seconds))
try:
yield acquired
finally:
if acquired:
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
"""
try:
await redis.eval(script, 1, key, token)
except Exception:
logger.exception("Failed to release Redis lock %s", key)
async def sleep_or_stop(stop_event: asyncio.Event, seconds: float) -> None:
try:
await asyncio.wait_for(stop_event.wait(), timeout=seconds)
except asyncio.TimeoutError:
return
+63
View File
@@ -0,0 +1,63 @@
import json
import logging
import time
from typing import Any, Dict, Optional
from bot.infra.redis import get_redis, redis_key
from config.settings import Settings
logger = logging.getLogger(__name__)
def webhook_queue_key(settings: Settings) -> str:
return redis_key(settings, "queue", settings.WEBHOOK_QUEUE_NAME)
async def enqueue_webhook_event(
settings: Settings,
provider: str,
payload: Dict[str, Any],
*,
event_id: Optional[str] = None,
) -> bool:
redis = await get_redis(settings)
if redis is None:
return False
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
if dedupe_id:
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
return True
message = {
"provider": provider,
"event_id": dedupe_id,
"payload": payload,
"enqueued_at": time.time(),
}
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
return True
async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]:
redis = await get_redis(settings)
if redis is None:
return None
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
if not item:
return None
_, raw = item
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid webhook queue payload discarded")
return None
async def webhook_queue_depth(settings: Settings) -> int:
redis = await get_redis(settings)
if redis is None:
return 0
return int(await redis.llen(webhook_queue_key(settings)))