from __future__ import annotations import json import time from pathlib import Path from typing import Any from yuxi.utils.logging_config import logger class AuditLogger: def __init__(self, log_dir: str | None = None): self._log_dir = Path(log_dir or str(Path.home() / ".yuxi" / "imessage" / "audit")) self._log_dir.mkdir(parents=True, exist_ok=True) self._buffer: list[dict[str, Any]] = [] def record( self, event: str, channel_chat_id: str = "", channel_user_id: str = "", decision: str = "", reason: str = "", metadata: dict[str, Any] | None = None, ) -> None: entry = { "timestamp": time.time(), "event": event, "channel_chat_id": channel_chat_id, "channel_user_id": channel_user_id, "decision": decision, "reason": reason, "metadata": metadata or {}, } self._buffer.append(entry) logger.info(f"[iMessage/Audit] {event}: {decision} — {reason}") def record_dm_access(self, sender: str, allowed: bool, reason: str = "") -> None: self.record( event="dm_access", channel_user_id=sender, decision="allowed" if allowed else "blocked", reason=reason, ) def record_group_access(self, chat_guid: str, allowed: bool, reason: str = "") -> None: self.record( event="group_access", channel_chat_id=chat_guid, decision="allowed" if allowed else "blocked", reason=reason, ) def record_pairing(self, code: str, action: str, sender: str) -> None: self.record( event="pairing", channel_user_id=sender, decision=action, reason=f"code={code}", ) async def flush(self) -> None: if not self._buffer: return date_str = time.strftime("%Y-%m-%d") file_path = self._log_dir / f"audit-{date_str}.jsonl" with file_path.open("a", encoding="utf-8") as f: for entry in self._buffer: f.write(json.dumps(entry, ensure_ascii=False) + "\n") count = len(self._buffer) self._buffer.clear() logger.debug(f"[iMessage/Audit] Flushed {count} audit entries") def get_recent(self, limit: int = 100) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] files = sorted(self._log_dir.glob("audit-*.jsonl"), reverse=True) for fp in files: if len(result) >= limit: break try: lines = fp.read_text(encoding="utf-8").strip().split("\n") for line in reversed(lines): if not line.strip(): continue result.append(json.loads(line)) if len(result) >= limit: break except (json.JSONDecodeError, OSError): continue return result[:limit]