Bot & Automation
jorgasisten
/root/hermes-projects/jorgasisten
apps/jorgasisten/services/whatsapp_access_registry.py
text
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jorgasisten.core.config import Settings, get_settings
from jorgasisten.services.access_control import ROLE_GUEST, ROLE_OPERATOR, ROLE_VIEWER, AccessRole
from jorgasisten.services.text import safe_string
PERSISTED_WA_ROLES = {ROLE_VIEWER, ROLE_OPERATOR}
@dataclass(frozen=True)
class WhatsAppAccessEntry:
number: str
role: AccessRole
added_at: str
added_by: str
class WhatsAppAccessRegistry:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.path = Path(self.settings.whatsapp_access_registry_path)
def list_entries(self) -> list[WhatsAppAccessEntry]:
state = self._load_state()
entries: list[WhatsAppAccessEntry] = []
for number, payload in state["numbers"].items():
role = safe_string(payload.get("role")).lower()
if role not in PERSISTED_WA_ROLES:
continue
entries.append(
WhatsAppAccessEntry(
number=number,
role=role, # type: ignore[arg-type]
added_at=safe_string(payload.get("added_at")) or current_timestamp(),
added_by=safe_string(payload.get("added_by")) or "-",
)
)
return sorted(entries, key=lambda item: item.number)
def get_role(self, number: str | None, owner_number: str | None = None) -> AccessRole:
normalized_number = normalize_whatsapp_number(number)
normalized_owner = normalize_whatsapp_number(owner_number)
if normalized_number and normalized_owner and normalized_number == normalized_owner:
return ROLE_OPERATOR
state = self._load_state()
payload = state["numbers"].get(normalized_number, {})
role = safe_string(payload.get("role")).lower()
if role in PERSISTED_WA_ROLES:
return role # type: ignore[return-value]
return ROLE_GUEST
def grant_access(self, number: str, role: str = ROLE_OPERATOR, *, added_by: str = "-") -> WhatsAppAccessEntry:
normalized_number = normalize_whatsapp_number(number)
if not normalized_number:
raise ValueError("Nomor WhatsApp belum valid. Pakai format 628xxx atau 08xxx.")
normalized_role = safe_string(role).lower() or ROLE_OPERATOR
if normalized_role not in PERSISTED_WA_ROLES:
raise ValueError("Role WhatsApp hanya bisa viewer atau operator.")
state = self._load_state()
state["numbers"][normalized_number] = {
"role": normalized_role,
"added_at": current_timestamp(),
"added_by": safe_string(added_by) or "-",
}
self._save_state(state)
return WhatsAppAccessEntry(
number=normalized_number,
role=normalized_role, # type: ignore[arg-type]
added_at=safe_string(state["numbers"][normalized_number]["added_at"]),
added_by=safe_string(state["numbers"][normalized_number]["added_by"]),
)
def revoke_access(self, number: str) -> WhatsAppAccessEntry:
normalized_number = normalize_whatsapp_number(number)
state = self._load_state()
payload = state["numbers"].get(normalized_number)
if not normalized_number or not isinstance(payload, dict):
raise ValueError("Nomor WhatsApp itu belum ada di daftar akses.")
role = safe_string(payload.get("role")).lower()
entry = WhatsAppAccessEntry(
number=normalized_number,
role=role if role in PERSISTED_WA_ROLES else ROLE_OPERATOR, # type: ignore[arg-type]
added_at=safe_string(payload.get("added_at")) or current_timestamp(),
added_by=safe_string(payload.get("added_by")) or "-",
)
state["numbers"].pop(normalized_number, None)
self._save_state(state)
return entry
def _load_state(self) -> dict[str, Any]:
state: dict[str, Any] = {"numbers": {}}
if self.path.exists():
loaded = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
state.update(loaded)
state["numbers"] = normalize_numbers_payload(state.get("numbers", {}))
return state
def _save_state(self, state: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"numbers": normalize_numbers_payload(state.get("numbers", {}))}
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def normalize_whatsapp_number(value: str | None) -> str:
cleaned = safe_string(value)
if not cleaned:
return ""
cleaned = cleaned.split("@", 1)[0]
digits = re.sub(r"\D+", "", cleaned)
if not digits:
return ""
if digits.startswith("0"):
digits = f"62{digits[1:]}"
elif digits.startswith("8"):
digits = f"62{digits}"
return digits
def normalize_numbers_payload(raw_numbers: Any) -> dict[str, dict[str, str]]:
if not isinstance(raw_numbers, dict):
return {}
normalized: dict[str, dict[str, str]] = {}
for raw_number, payload in raw_numbers.items():
number = normalize_whatsapp_number(raw_number)
if not number or not isinstance(payload, dict):
continue
normalized[number] = {
"role": safe_string(payload.get("role")).lower(),
"added_at": safe_string(payload.get("added_at")) or current_timestamp(),
"added_by": safe_string(payload.get("added_by")) or "-",
}
return normalized
def current_timestamp() -> str:
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")