ForcePilot/backend/package/yuxi/channels/adapters/imessage/audit.py

91 lines
3.0 KiB
Python
Raw Normal View History

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]