import json import os import time import uuid from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Dict, List, Optional from redis.asyncio import Redis REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") REMINDER_PREFIX = "daarion:reminders" REMINDER_BY_ID = f"{REMINDER_PREFIX}:by_id" REMINDER_SCHEDULE = f"{REMINDER_PREFIX}:schedule" REMINDER_TTL_SECONDS = int(os.getenv("DAARION_REMINDER_TTL_SECONDS", str(30 * 24 * 3600))) _redis: Optional[Redis] = None @dataclass class Reminder: reminder_id: str agent_id: str chat_id: str user_id: str text: str due_ts: int created_at: str def to_dict(self) -> Dict[str, Any]: return { "reminder_id": self.reminder_id, "agent_id": self.agent_id, "chat_id": self.chat_id, "user_id": self.user_id, "text": self.text, "due_ts": self.due_ts, "created_at": self.created_at, } async def redis_client() -> Redis: global _redis if _redis is None: _redis = Redis.from_url(REDIS_URL, decode_responses=True) return _redis async def close_redis() -> None: global _redis if _redis is not None: await _redis.close() _redis = None def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() def _iso_from_ts(ts: int) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() async def create_reminder(agent_id: str, chat_id: str, user_id: str, text: str, due_ts: int) -> Dict[str, Any]: reminder = Reminder( reminder_id=f"rem_{uuid.uuid4().hex[:16]}", agent_id=agent_id, chat_id=str(chat_id), user_id=str(user_id), text=text.strip(), due_ts=int(due_ts), created_at=_iso_now(), ) r = await redis_client() key = f"{REMINDER_BY_ID}:{reminder.reminder_id}" payload = json.dumps(reminder.to_dict(), ensure_ascii=False) await r.set(key, payload, ex=REMINDER_TTL_SECONDS) await r.zadd(REMINDER_SCHEDULE, {reminder.reminder_id: float(reminder.due_ts)}) result = reminder.to_dict() result["due_at"] = _iso_from_ts(reminder.due_ts) return result async def list_reminders(agent_id: str, chat_id: str, user_id: str, limit: int = 10) -> List[Dict[str, Any]]: r = await redis_client() now_ts = int(time.time()) ids = await r.zrangebyscore(REMINDER_SCHEDULE, min=now_ts - 365 * 24 * 3600, max="+inf", start=0, num=max(1, limit * 5)) out: List[Dict[str, Any]] = [] for reminder_id in ids: raw = await r.get(f"{REMINDER_BY_ID}:{reminder_id}") if not raw: continue try: item = json.loads(raw) except json.JSONDecodeError: continue if item.get("agent_id") != agent_id: continue if str(item.get("chat_id")) != str(chat_id): continue if str(item.get("user_id")) != str(user_id): continue item["due_at"] = _iso_from_ts(int(item.get("due_ts", 0))) out.append(item) if len(out) >= limit: break return out async def cancel_reminder(reminder_id: str, agent_id: str, chat_id: str, user_id: str) -> bool: r = await redis_client() key = f"{REMINDER_BY_ID}:{reminder_id}" raw = await r.get(key) if not raw: return False try: item = json.loads(raw) except json.JSONDecodeError: return False if item.get("agent_id") != agent_id or str(item.get("chat_id")) != str(chat_id) or str(item.get("user_id")) != str(user_id): return False await r.delete(key) await r.zrem(REMINDER_SCHEDULE, reminder_id) return True async def pop_due_reminders(limit: int = 20) -> List[Dict[str, Any]]: r = await redis_client() now_ts = int(time.time()) ids = await r.zrangebyscore(REMINDER_SCHEDULE, min="-inf", max=now_ts, start=0, num=max(1, limit)) out: List[Dict[str, Any]] = [] for reminder_id in ids: removed = await r.zrem(REMINDER_SCHEDULE, reminder_id) if removed == 0: continue raw = await r.get(f"{REMINDER_BY_ID}:{reminder_id}") if not raw: continue await r.delete(f"{REMINDER_BY_ID}:{reminder_id}") try: item = json.loads(raw) item["due_at"] = _iso_from_ts(int(item.get("due_ts", now_ts))) out.append(item) except json.JSONDecodeError: continue return out