Bot & Automation

Activity Reminder Bot

/root/hermes-projects/Activity Reminder Bot

apps/activity_reminder_bot/services/sheets.py text
import base64
import json
import logging
from datetime import date, datetime, time
from functools import lru_cache
from typing import Any

from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build

from activity_reminder_bot.core.config import get_settings
from activity_reminder_bot.models import Reminder, ReminderDraft, ReminderRepeat, ReminderStatus

logger = logging.getLogger(__name__)

SHEET_COLUMNS = [
    "ID",
    "User ID",
    "Nama User",
    "Nama Kegiatan",
    "Deskripsi",
    "Tanggal",
    "Jam",
    "Repeat",
    "Status",
    "Created At",
    "Updated At",
    "Notified At",
    "Alarm Message ID",
    "Alarm Count",
]


def spreadsheet_column_letter(column_number: int) -> str:
    result = ""
    current = column_number
    while current:
        current, remainder = divmod(current - 1, 26)
        result = chr(65 + remainder) + result
    return result


def parse_datetime_value(value: str) -> datetime | None:
    value = str(value or "").strip()
    if not value:
        return None
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
        try:
            return datetime.strptime(value, fmt)
        except ValueError:
            continue
    return None


class GoogleSheetsReminderRepository:
    def __init__(self) -> None:
        self.settings = get_settings()
        self._layout_ready = False

    @property
    def spreadsheet_id(self) -> str:
        return self.settings.google_sheets_spreadsheet_id.strip()

    @property
    def worksheet_name(self) -> str:
        return self.settings.google_sheets_worksheet_name.strip() or "Reminders"

    def is_configured(self) -> bool:
        return self.settings.is_sheets_configured

    def ensure_layout(self) -> None:
        if not self.is_configured():
            raise RuntimeError("Google Sheets belum dikonfigurasi.")
        if self._layout_ready:
            return

        service = self._build_sheets_service()
        sheet_id = self._ensure_worksheet_exists(service)
        end_column = spreadsheet_column_letter(len(SHEET_COLUMNS))
        service.spreadsheets().values().update(
            spreadsheetId=self.spreadsheet_id,
            range=f"{self.worksheet_name}!A1:{end_column}1",
            valueInputOption="RAW",
            body={"values": [SHEET_COLUMNS]},
        ).execute()

        try:
            service.spreadsheets().batchUpdate(
                spreadsheetId=self.spreadsheet_id,
                body={
                    "requests": [
                        {
                            "updateSheetProperties": {
                                "properties": {"sheetId": sheet_id, "gridProperties": {"frozenRowCount": 1}},
                                "fields": "gridProperties.frozenRowCount",
                            }
                        },
                        {
                            "repeatCell": {
                                "range": {
                                    "sheetId": sheet_id,
                                    "startRowIndex": 0,
                                    "endRowIndex": 1,
                                    "startColumnIndex": 0,
                                    "endColumnIndex": len(SHEET_COLUMNS),
                                },
                                "cell": {
                                    "userEnteredFormat": {
                                        "backgroundColor": {"red": 0.9, "green": 0.95, "blue": 1.0},
                                        "horizontalAlignment": "CENTER",
                                        "textFormat": {"bold": True},
                                    }
                                },
                                "fields": "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)",
                            }
                        },
                        {
                            "autoResizeDimensions": {
                                "dimensions": {
                                    "sheetId": sheet_id,
                                    "dimension": "COLUMNS",
                                    "startIndex": 0,
                                    "endIndex": len(SHEET_COLUMNS),
                                }
                            }
                        },
                    ]
                },
            ).execute()
        except Exception as exc:
            logger.warning("Sheet formatting skipped: %s", exc)
        self._layout_ready = True

    def create(self, reminder: ReminderDraft, public_id: str, now: datetime) -> Reminder:
        self.ensure_layout()
        created = Reminder(
            public_id=public_id,
            user_id=reminder.user_id,
            user_name=reminder.user_name,
            activity_name=reminder.activity_name,
            description=reminder.description,
            reminder_date=reminder.reminder_date,
            reminder_time=reminder.reminder_time,
            repeat=reminder.repeat,
            status=ReminderStatus.active,
            created_at=now,
            updated_at=now,
        )
        service = self._build_sheets_service()
        service.spreadsheets().values().append(
            spreadsheetId=self.spreadsheet_id,
            range=f"{self.worksheet_name}!A:{spreadsheet_column_letter(len(SHEET_COLUMNS))}",
            valueInputOption="RAW",
            insertDataOption="INSERT_ROWS",
            body={"values": [self._row_values(created)]},
        ).execute()
        return created

    def list_all(self, include_deleted: bool = False) -> list[Reminder]:
        if not self.is_configured():
            return []
        self.ensure_layout()
        service = self._build_sheets_service()
        response = service.spreadsheets().values().get(
            spreadsheetId=self.spreadsheet_id,
            range=f"{self.worksheet_name}!A:{spreadsheet_column_letter(len(SHEET_COLUMNS))}",
        ).execute()
        values = response.get("values", [])
        if len(values) <= 1:
            return []
        reminders: list[Reminder] = []
        for row_number, raw_row in enumerate(values[1:], start=2):
            if self._is_non_data_row(raw_row):
                continue
            try:
                reminder = self._reminder_from_row(raw_row, row_number)
            except ValueError as exc:
                logger.warning("Skipping invalid sheet row %s: %s", row_number, exc)
                continue
            if not include_deleted and reminder.status == ReminderStatus.deleted:
                continue
            reminders.append(reminder)
        return reminders

    def get_by_id(self, public_id: str) -> Reminder | None:
        for reminder in self.list_all(include_deleted=True):
            if reminder.public_id == public_id:
                return reminder
        return None

    def list_for_user(self, user_id: int, include_history: bool = False) -> list[Reminder]:
        statuses = {ReminderStatus.active, ReminderStatus.snoozed, ReminderStatus.notified}
        if include_history:
            statuses.update({ReminderStatus.done, ReminderStatus.deleted})
        return [
            reminder
            for reminder in self.list_all(include_deleted=include_history)
            if reminder.user_id == user_id and reminder.status in statuses
        ]

    def search(self, user_id: int, query: str, limit: int = 10) -> list[Reminder]:
        keywords = [word.lower() for word in query.split() if word.strip()]
        if not keywords:
            return []
        results: list[Reminder] = []
        for reminder in reversed(self.list_for_user(user_id, include_history=True)):
            haystack = " ".join(
                [
                    reminder.activity_name,
                    reminder.description,
                    reminder.reminder_date.strftime("%d %B %Y"),
                    reminder.reminder_date.isoformat(),
                    reminder.status.value,
                ]
            ).lower()
            if all(keyword in haystack for keyword in keywords):
                results.append(reminder)
            if len(results) >= limit:
                break
        return results

    def cleanup_user_history(self, user_id: int) -> dict[str, int]:
        return self._cleanup_rows(user_id=user_id, statuses={ReminderStatus.done, ReminderStatus.deleted})

    def cleanup_invalid_rows(self) -> dict[str, int]:
        return self._cleanup_rows(include_invalid=True)

    def _cleanup_rows(
        self,
        user_id: int | None = None,
        statuses: set[ReminderStatus] | None = None,
        include_invalid: bool = False,
    ) -> dict[str, int]:
        if not self.is_configured():
            return {"deleted": 0}
        self.ensure_layout()
        service = self._build_sheets_service()
        response = service.spreadsheets().values().get(
            spreadsheetId=self.spreadsheet_id,
            range=f"{self.worksheet_name}!A:{spreadsheet_column_letter(len(SHEET_COLUMNS))}",
        ).execute()
        values = response.get("values", [])
        row_numbers_to_delete: list[int] = []

        for row_number, raw_row in enumerate(values[1:], start=2):
            if self._is_non_data_row(raw_row):
                if include_invalid:
                    row_numbers_to_delete.append(row_number)
                continue
            try:
                reminder = self._reminder_from_row(raw_row, row_number)
            except ValueError:
                if include_invalid:
                    row_numbers_to_delete.append(row_number)
                continue
            if user_id is not None and reminder.user_id != user_id:
                continue
            if statuses and reminder.status in statuses:
                row_numbers_to_delete.append(row_number)

        if row_numbers_to_delete:
            self._delete_row_numbers(service, row_numbers_to_delete)
        return {"deleted": len(row_numbers_to_delete)}

    def _delete_row_numbers(self, service, row_numbers: list[int]) -> None:
        sheet_id = self._get_sheet_id()
        requests = [
            {
                "deleteDimension": {
                    "range": {
                        "sheetId": sheet_id,
                        "dimension": "ROWS",
                        "startIndex": row_number - 1,
                        "endIndex": row_number,
                    }
                }
            }
            for row_number in sorted(set(row_numbers), reverse=True)
        ]
        service.spreadsheets().batchUpdate(
            spreadsheetId=self.spreadsheet_id,
            body={"requests": requests},
        ).execute()

    def _is_non_data_row(self, raw_row: list[Any]) -> bool:
        if not raw_row or not any(str(value).strip() for value in raw_row):
            return True
        first_cell = str(raw_row[0]).strip().lower()
        return first_cell in {"", "id", "public id", "reminder id"}

    def update(self, public_id: str, updates: dict[str, Any], now: datetime) -> Reminder:
        reminder = self.get_by_id(public_id)
        if not reminder or not reminder.row_number:
            raise ValueError("Reminder tidak ditemukan.")
        data = reminder.model_dump()
        data.update(updates)
        data["updated_at"] = now
        updated = Reminder(**data)

        end_column = spreadsheet_column_letter(len(SHEET_COLUMNS))
        service = self._build_sheets_service()
        service.spreadsheets().values().update(
            spreadsheetId=self.spreadsheet_id,
            range=f"{self.worksheet_name}!A{reminder.row_number}:{end_column}{reminder.row_number}",
            valueInputOption="RAW",
            body={"values": [self._row_values(updated)]},
        ).execute()
        return updated

    def _row_values(self, reminder: Reminder) -> list[Any]:
        return [
            reminder.public_id,
            str(reminder.user_id),
            reminder.user_name,
            reminder.activity_name,
            reminder.description,
            reminder.reminder_date.isoformat(),
            reminder.reminder_time.strftime("%H:%M"),
            reminder.repeat.value,
            reminder.status.value,
            reminder.created_at.strftime("%Y-%m-%d %H:%M:%S"),
            reminder.updated_at.strftime("%Y-%m-%d %H:%M:%S"),
            reminder.notified_at.strftime("%Y-%m-%d %H:%M:%S") if reminder.notified_at else "",
            str(reminder.alarm_message_id or ""),
            str(reminder.alarm_count),
        ]

    def _reminder_from_row(self, raw_row: list[Any], row_number: int) -> Reminder:
        row = list(raw_row) + [""] * (len(SHEET_COLUMNS) - len(raw_row))
        try:
            reminder_date = date.fromisoformat(str(row[5]).strip())
            reminder_time = time.fromisoformat(str(row[6]).strip())
        except ValueError as exc:
            raise ValueError("Tanggal/Jam tidak valid") from exc

        created_at = parse_datetime_value(str(row[9])) or datetime.combine(reminder_date, reminder_time)
        updated_at = parse_datetime_value(str(row[10])) or created_at
        notified_at = parse_datetime_value(str(row[11]))
        alarm_message_id = int(str(row[12]).strip()) if str(row[12]).strip().isdigit() else None
        alarm_count = int(str(row[13]).strip()) if str(row[13]).strip().isdigit() else 0

        return Reminder(
            public_id=str(row[0]).strip(),
            user_id=int(str(row[1]).strip()),
            user_name=str(row[2]).strip(),
            activity_name=str(row[3]).strip(),
            description=str(row[4]).strip(),
            reminder_date=reminder_date,
            reminder_time=reminder_time,
            repeat=ReminderRepeat(str(row[7]).strip() or ReminderRepeat.none.value),
            status=ReminderStatus(str(row[8]).strip() or ReminderStatus.active.value),
            created_at=created_at,
            updated_at=updated_at,
            notified_at=notified_at,
            alarm_message_id=alarm_message_id,
            alarm_count=alarm_count,
            row_number=row_number,
        )

    def _get_sheet_id(self) -> int:
        spreadsheet = self._build_sheets_service().spreadsheets().get(
            spreadsheetId=self.spreadsheet_id,
            fields="sheets.properties",
        ).execute()
        for sheet in spreadsheet.get("sheets", []):
            properties = sheet.get("properties", {})
            if properties.get("title") == self.worksheet_name:
                return int(properties["sheetId"])
        raise RuntimeError(f"Worksheet tidak ditemukan: {self.worksheet_name}")

    def _ensure_worksheet_exists(self, service) -> int:
        spreadsheet = service.spreadsheets().get(
            spreadsheetId=self.spreadsheet_id,
            fields="sheets.properties",
        ).execute()
        for sheet in spreadsheet.get("sheets", []):
            properties = sheet.get("properties", {})
            if properties.get("title") == self.worksheet_name:
                return int(properties["sheetId"])

        response = service.spreadsheets().batchUpdate(
            spreadsheetId=self.spreadsheet_id,
            body={"requests": [{"addSheet": {"properties": {"title": self.worksheet_name}}}]},
        ).execute()
        replies = response.get("replies", [])
        if not replies:
            raise RuntimeError(f"Gagal membuat worksheet: {self.worksheet_name}")
        return int(replies[0]["addSheet"]["properties"]["sheetId"])

    def _build_sheets_service(self):
        service_account_info = json.loads(
            base64.b64decode(self.settings.google_service_account_json_base64).decode("utf-8")
        )
        credentials = Credentials.from_service_account_info(
            service_account_info,
            scopes=["https://www.googleapis.com/auth/spreadsheets"],
        )
        return build("sheets", "v4", credentials=credentials, cache_discovery=False)


@lru_cache(maxsize=1)
def get_reminder_repository() -> GoogleSheetsReminderRepository:
    return GoogleSheetsReminderRepository()