Bot & Automation
jorgasisten
/root/hermes-projects/jorgasisten
apps/jorgasisten/services/spreadsheet_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.text import normalize_key, safe_string
SPREADSHEET_ID_RE = re.compile(r"/spreadsheets/d/([a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{30,})")
SPREADSHEET_URL_RE = re.compile(r"https?://docs\.google\.com/spreadsheets/d/[^\s]+", re.IGNORECASE)
@dataclass(frozen=True)
class SpreadsheetEntry:
key: str
name: str
spreadsheet_id: str
created_at: str
class SpreadsheetRegistry:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.path = Path(self.settings.spreadsheet_registry_path)
def list_entries(self) -> list[SpreadsheetEntry]:
state = self._load_state()
return [entry_from_payload(item) for item in state["entries"]]
def get_active_entry(self, chat_id: int | str) -> SpreadsheetEntry | None:
state = self._load_state()
chat_key = str(chat_id)
active_key = safe_string(state["chat_active"].get(chat_key)) or safe_string(state.get("default_key"))
return self.find_entry(active_key) or self._first_entry(state)
def get_default_entry(self) -> SpreadsheetEntry | None:
state = self._load_state()
default_key = safe_string(state.get("default_key"))
return self.find_entry(default_key) or self._first_entry(state)
def find_entry(self, query: str) -> SpreadsheetEntry | None:
normalized_query = normalize_key(query)
if not normalized_query:
return None
for entry in self.list_entries():
if normalize_key(entry.key) == normalized_query or normalize_key(entry.name) == normalized_query:
return entry
for entry in self.list_entries():
if normalized_query in normalize_key(entry.key) or normalized_query in normalize_key(entry.name):
return entry
return None
def add_entry(self, name: str, spreadsheet_id: str) -> SpreadsheetEntry:
cleaned_id = extract_spreadsheet_id(spreadsheet_id)
if not cleaned_id:
raise ValueError("ID atau link Google Sheets belum valid.")
state = self._load_state()
if any(item["spreadsheet_id"] == cleaned_id for item in state["entries"]):
raise ValueError("Spreadsheet ini sudah ada di daftar bot.")
entry = SpreadsheetEntry(
key=unique_key(slugify(name), state["entries"]),
name=safe_string(name) or f"Spreadsheet {cleaned_id[-6:]}",
spreadsheet_id=cleaned_id,
created_at=current_timestamp(),
)
state["entries"].append(entry_to_payload(entry))
if not safe_string(state.get("default_key")):
state["default_key"] = entry.key
self._save_state(state)
return entry
def set_active_entry(self, chat_id: int | str, query: str) -> SpreadsheetEntry:
entry = self.find_entry(query)
if entry is None:
raise ValueError("Spreadsheet belum ketemu di daftar bot.")
state = self._load_state()
state["chat_active"][str(chat_id)] = entry.key
self._save_state(state)
return entry
def set_default_entry(self, query: str) -> SpreadsheetEntry:
entry = self.find_entry(query)
if entry is None:
raise ValueError("Spreadsheet belum ketemu di daftar bot.")
state = self._load_state()
state["default_key"] = entry.key
self._save_state(state)
return entry
def delete_entry(self, query: str) -> SpreadsheetEntry:
entry = self.find_entry(query)
if entry is None:
raise ValueError("Spreadsheet belum ketemu di daftar bot.")
state = self._load_state()
remaining_entries = [item for item in state["entries"] if safe_string(item.get("key")) != entry.key]
if not remaining_entries:
raise ValueError("Minimal harus ada satu spreadsheet di daftar bot, pren.")
state["entries"] = remaining_entries
if safe_string(state.get("default_key")) == entry.key:
state["default_key"] = safe_string(remaining_entries[0].get("key"))
cleaned_chat_active: dict[str, str] = {}
fallback_key = safe_string(state.get("default_key"))
for chat_id, active_key in state["chat_active"].items():
if safe_string(active_key) == entry.key:
if fallback_key:
cleaned_chat_active[chat_id] = fallback_key
else:
cleaned_chat_active[chat_id] = active_key
state["chat_active"] = cleaned_chat_active
self._save_state(state)
return entry
def _first_entry(self, state: dict[str, Any]) -> SpreadsheetEntry | None:
entries = state.get("entries", [])
if not entries:
return None
return entry_from_payload(entries[0])
def _load_state(self) -> dict[str, Any]:
raw_state: dict[str, Any] = {"default_key": "", "entries": [], "chat_active": {}}
if self.path.exists():
loaded = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
raw_state.update(loaded)
raw_state["entries"] = normalize_entries(raw_state.get("entries", []))
raw_state["chat_active"] = normalize_chat_active(raw_state.get("chat_active", {}))
raw_state["default_key"] = safe_string(raw_state.get("default_key"))
raw_state = self._bootstrap_from_env_if_needed(raw_state)
return raw_state
def _bootstrap_from_env_if_needed(self, state: dict[str, Any]) -> dict[str, Any]:
if state["entries"]:
return state
spreadsheet_id = extract_spreadsheet_id(self.settings.google_sheets_spreadsheet_id)
if not spreadsheet_id:
return state
bootstrap_key = slugify(self.settings.default_spreadsheet_key) or "default"
bootstrap_entry = SpreadsheetEntry(
key=bootstrap_key,
name=humanize_key(bootstrap_key),
spreadsheet_id=spreadsheet_id,
created_at=current_timestamp(),
)
state["entries"] = [entry_to_payload(bootstrap_entry)]
state["default_key"] = bootstrap_entry.key
self._save_state(state)
return state
def _save_state(self, state: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"default_key": safe_string(state.get("default_key")),
"entries": normalize_entries(state.get("entries", [])),
"chat_active": normalize_chat_active(state.get("chat_active", {})),
}
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 extract_spreadsheet_id(value: str) -> str:
cleaned = safe_string(value)
match = SPREADSHEET_ID_RE.search(cleaned)
if not match:
return ""
return safe_string(match.group(1) or match.group(2))
def parse_add_spreadsheet_message(message: str) -> tuple[str, str]:
spreadsheet_id = extract_spreadsheet_id(message)
if not spreadsheet_id:
return "", ""
name = SPREADSHEET_URL_RE.sub("", message)
name = re.sub(SPREADSHEET_ID_RE, "", name)
name = re.sub(r"^\s*(?:tambah|add)\s+spreadsheet\s*", "", name, flags=re.IGNORECASE)
name = re.sub(r"\b(?:link|url|id)\b\s*:?", "", name, flags=re.IGNORECASE)
name = sanitize_spreadsheet_name(name)
return name or f"Spreadsheet {spreadsheet_id[-6:]}", spreadsheet_id
def parse_select_spreadsheet_message(message: str) -> str:
cleaned = re.sub(
r"^\s*(?:pakai|pilih|gunakan|switch|set)\s+spreadsheet(?:\s+aktif)?\s*",
"",
message,
flags=re.IGNORECASE,
)
return cleaned.strip(" -,:;")
def parse_default_spreadsheet_message(message: str) -> str:
cleaned = re.sub(
r"^\s*(?:jadikan|set)\s+(?:default\s+)?spreadsheet\s*",
"",
message,
flags=re.IGNORECASE,
)
return cleaned.strip(" -,:;")
def format_spreadsheet_list(
entries: list[SpreadsheetEntry],
active: SpreadsheetEntry | None,
default: SpreadsheetEntry | None = None,
) -> str:
if not entries:
return "belum ada spreadsheet di daftar bot, pren."
active_key = active.key if active else ""
default_key = default.key if default else ""
lines = ["nih daftar spreadsheet yang bisa dipakai bot, pren:"]
for index, entry in enumerate(entries, start=1):
markers: list[str] = []
if entry.key == active_key:
markers.append("aktif chat")
if entry.key == default_key:
markers.append("default")
marker = ", ".join(markers) if markers else "tersimpan"
lines.append(f"{index}. {entry.name} ({entry.key}) - {marker}")
return "\n".join(lines)
def format_spreadsheet_validation_error(error: Exception) -> str:
error_text = safe_string(error)
lowered = error_text.lower()
if "must not be an office file" in lowered or "office file" in lowered:
return (
"spreadsheet itu belum bisa dipakai karena filenya masih format excel/office, pren.\n\n"
"buka file itu di google sheets, lalu convert dulu jadi google sheets native.\n"
"biasanya bisa lewat File -> Save as Google Sheets.\n\n"
"setelah jadi google sheets biasa, baru kirim lagi perintah tambah spreadsheet-nya."
)
if "does not have permission" in lowered or "permission" in lowered or "forbidden" in lowered:
return (
"spreadsheet itu belum bisa diakses, pren.\n\n"
"cek 2 hal ini:\n"
"1. file/folder sudah di-share ke Google Service Account sebagai Editor\n"
"2. spreadsheet itu memang ada di folder yang sudah dishare ke bot"
)
if "requested entity was not found" in lowered or "not found" in lowered:
return (
"spreadsheet itu belum ketemu, pren.\n\n"
"cek lagi link atau spreadsheet id-nya. pastikan yang dikirim memang link google sheets yang benar."
)
return (
"gw belum bisa akses spreadsheet itu, pren.\n\n"
"cek 2 hal ini:\n"
"1. link/ID spreadsheet sudah benar\n"
"2. spreadsheet sudah di-share ke Google Service Account sebagai Editor"
)
def entry_from_payload(payload: dict[str, Any]) -> SpreadsheetEntry:
raw_name = safe_string(payload.get("name")) or "Spreadsheet"
cleaned_name = sanitize_spreadsheet_name(raw_name) or raw_name
return SpreadsheetEntry(
key=slugify(safe_string(payload.get("key"))) or "spreadsheet",
name=cleaned_name,
spreadsheet_id=extract_spreadsheet_id(safe_string(payload.get("spreadsheet_id"))),
created_at=safe_string(payload.get("created_at")) or current_timestamp(),
)
def entry_to_payload(entry: SpreadsheetEntry) -> dict[str, str]:
return {
"key": entry.key,
"name": entry.name,
"spreadsheet_id": entry.spreadsheet_id,
"created_at": entry.created_at,
}
def normalize_entries(entries: Any) -> list[dict[str, str]]:
if not isinstance(entries, list):
return []
normalized: list[dict[str, str]] = []
seen_keys: set[str] = set()
seen_ids: set[str] = set()
for item in entries:
if not isinstance(item, dict):
continue
entry = entry_from_payload(item)
if not entry.spreadsheet_id or entry.spreadsheet_id in seen_ids:
continue
key = unique_key(entry.key, normalized) if entry.key in seen_keys else entry.key
normalized.append(entry_to_payload(SpreadsheetEntry(key, entry.name, entry.spreadsheet_id, entry.created_at)))
seen_keys.add(key)
seen_ids.add(entry.spreadsheet_id)
return normalized
def normalize_chat_active(chat_active: Any) -> dict[str, str]:
if not isinstance(chat_active, dict):
return {}
return {
safe_string(chat_id): slugify(safe_string(key))
for chat_id, key in chat_active.items()
if safe_string(chat_id) and safe_string(key)
}
def slugify(value: str) -> str:
normalized = normalize_key(value)
if normalized:
return normalized[:40]
return "spreadsheet"
def unique_key(base_key: str, entries: list[dict[str, Any]]) -> str:
existing = {safe_string(item.get("key")) for item in entries}
key = base_key or "spreadsheet"
if key not in existing:
return key
suffix = 2
while f"{key}{suffix}" in existing:
suffix += 1
return f"{key}{suffix}"
def current_timestamp() -> str:
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
def sanitize_spreadsheet_name(value: str) -> str:
cleaned = SPREADSHEET_URL_RE.sub("", safe_string(value))
cleaned = re.sub(r"https?://docs\.google\.com/[^\s]+", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -,:;")
def humanize_key(value: str) -> str:
cleaned = safe_string(value).replace("_", " ").replace("-", " ").strip()
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.title() or "Default"