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")