Bot & Automation

Activity Reminder Bot

/root/hermes-projects/Activity Reminder Bot

apps/activity_reminder_bot/bot/handlers.py text
from datetime import timedelta
from typing import Any

from aiogram import F, Router
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from reminder_core.parsers import ReminderTextParser

from activity_reminder_bot.bot.keyboards import (
    cleanup_confirm_keyboard,
    cleanup_menu_keyboard,
    delete_confirm_keyboard,
    edit_field_keyboard,
    main_menu_keyboard,
    preview_keyboard,
    reminder_action_keyboard,
    repeat_keyboard,
    snooze_keyboard,
)
from activity_reminder_bot.bot.states import ReminderForm
from activity_reminder_bot.core.config import get_settings
from activity_reminder_bot.models import ReminderDraft, ReminderRepeat
from activity_reminder_bot.services.formatters import format_list, format_preview, format_reminder_card
from activity_reminder_bot.services.reminders import ReminderService

router = Router()


def user_display_name(message: Message) -> str:
    user = message.from_user
    if not user:
        return "Telegram User"
    name = " ".join(part for part in [user.first_name, user.last_name] if part)
    return name or user.username or str(user.id)


def user_id_from_message(message: Message) -> int:
    if not message.from_user:
        raise ValueError("User Telegram tidak valid.")
    return int(message.from_user.id)


def draft_to_state(draft: ReminderDraft) -> dict[str, Any]:
    return {
        "user_id": draft.user_id,
        "user_name": draft.user_name,
        "activity_name": draft.activity_name,
        "description": draft.description,
        "reminder_date": draft.reminder_date.isoformat(),
        "reminder_time": draft.reminder_time.strftime("%H:%M"),
        "repeat": draft.repeat.value,
    }


def draft_from_state(data: dict[str, Any]) -> ReminderDraft:
    from datetime import date, time

    return ReminderDraft(
        user_id=int(data["user_id"]),
        user_name=str(data["user_name"]),
        activity_name=str(data["activity_name"]),
        description=str(data.get("description", "")),
        reminder_date=date.fromisoformat(str(data["reminder_date"])),
        reminder_time=time.fromisoformat(str(data["reminder_time"])),
        repeat=ReminderRepeat(str(data.get("repeat", ReminderRepeat.none.value))),
    )


async def build_draft_from_text(message: Message, text: str) -> ReminderDraft:
    settings = get_settings()
    parser = ReminderTextParser(settings.app_timezone)
    service = ReminderService()
    parsed = parser.parse(text, now=service.now())
    return ReminderDraft(
        user_id=user_id_from_message(message),
        user_name=user_display_name(message),
        activity_name=parsed.activity_name,
        description=parsed.description,
        reminder_date=parsed.due_at.date(),
        reminder_time=parsed.due_at.time().replace(second=0, microsecond=0),
        repeat=parsed.repeat,
    )


@router.message(CommandStart())
async def handle_start(message: Message, state: FSMContext) -> None:
    await state.clear()
    await message.answer(
        "Halo, saya Activity Reminder Bot.\n\n"
        "Tulis reminder secara natural, contoh:\n"
        "Besok jam 08:00 meeting dengan klien\n"
        "Bayar VPS tanggal 15 jam 19:00\n"
        "Setiap Senin jam 09:00 upload konten",
        reply_markup=main_menu_keyboard(),
    )


@router.message(Command("menu"))
async def handle_menu_command(message: Message, state: FSMContext) -> None:
    await state.clear()
    await message.answer("Menu utama ditampilkan.", reply_markup=main_menu_keyboard())


@router.message(Command("search"))
async def handle_search_command(message: Message) -> None:
    query = (message.text or "").replace("/search", "", 1).strip()
    if not query:
        await message.answer("Tulis keyword pencarian. Contoh: /search meeting")
        return
    await send_search_results(message, query)


@router.message(F.text == "➕ Tambah Reminder")
async def handle_add_menu(message: Message, state: FSMContext) -> None:
    await state.set_state(ReminderForm.waiting_for_text)
    await message.answer(
        "Tulis kegiatan dan waktunya.\n\nContoh: Besok jam 08:00 meeting dengan klien",
        reply_markup=main_menu_keyboard(),
    )


@router.message(F.text == "📅 Jadwal Hari Ini")
async def handle_today(message: Message) -> None:
    service = ReminderService()
    reminders = await service.list_today(user_id_from_message(message))
    await message.answer(format_list("📅 Jadwal Hari Ini", reminders), reply_markup=main_menu_keyboard())
    for reminder in reminders[:5]:
        await message.answer(format_reminder_card(reminder), reply_markup=reminder_action_keyboard(reminder.public_id))


@router.message(F.text == "🗓️ Jadwal Minggu Ini")
async def handle_week(message: Message) -> None:
    service = ReminderService()
    reminders = await service.list_week(user_id_from_message(message))
    await message.answer(format_list("🗓️ Jadwal Minggu Ini", reminders), reply_markup=main_menu_keyboard())
    for reminder in reminders[:5]:
        await message.answer(format_reminder_card(reminder), reply_markup=reminder_action_keyboard(reminder.public_id))


@router.message(F.text == "🔍 Cari Reminder")
async def handle_search_menu(message: Message, state: FSMContext) -> None:
    await state.set_state(ReminderForm.waiting_for_search)
    await message.answer("Masukkan keyword pencarian. Contoh: meeting, VPS, 15 Juni")


@router.message(F.text == "📜 History")
async def handle_history(message: Message) -> None:
    service = ReminderService()
    reminders = await service.history(user_id_from_message(message))
    await message.answer(format_list("📜 History Reminder", reminders), reply_markup=main_menu_keyboard())


@router.message(F.text == "🧹 Bersihkan Data")
async def handle_cleanup_menu(message: Message) -> None:
    await message.answer(
        "🧹 Bersihkan Data\n\n"
        "Pilih data yang ingin dibersihkan.\n\n"
        "Hapus Selesai/Dihapus: menghapus riwayat reminder milik kamu yang sudah selesai atau sudah dihapus.\n"
        "Bersihkan Row Rusak: menghapus baris kosong/rusak di Google Sheets.",
        reply_markup=cleanup_menu_keyboard(),
    )


@router.message(F.text == "⚙️ Settings")
async def handle_settings(message: Message) -> None:
    settings = get_settings()
    sheets_status = "Aktif" if settings.is_sheets_configured else "Belum lengkap"
    telegram_status = "Aktif" if settings.is_telegram_configured else "Belum lengkap"
    await message.answer(
        "⚙️ Settings\n\n"
        f"Timezone: {settings.app_timezone}\n"
        f"Google Sheets: {sheets_status}\n"
        f"Telegram Token: {telegram_status}\n"
        f"Scheduler Scan: {settings.scheduler_scan_seconds} detik\n\n"
        "Semua secret diatur melalui file .env di VPS.",
        reply_markup=main_menu_keyboard(),
    )


@router.callback_query(F.data.startswith("cleanup:confirm:"))
async def handle_cleanup_confirm(callback: CallbackQuery) -> None:
    action = str(callback.data).split(":")[-1]
    descriptions = {
        "history": (
            "Riwayat reminder kamu yang statusnya Selesai atau Dihapus "
            "akan dihapus permanen dari Google Sheets."
        ),
        "invalid": "Baris kosong/rusak di Google Sheets akan dihapus permanen.",
    }
    await callback.message.answer(
        "Konfirmasi bersih-bersih\n\n"
        f"{descriptions.get(action, 'Data akan dibersihkan permanen.')}\n\n"
        "Lanjutkan?",
        reply_markup=cleanup_confirm_keyboard(action),
    )
    await callback.answer()


@router.callback_query(F.data.startswith("cleanup:run:"))
async def handle_cleanup_run(callback: CallbackQuery) -> None:
    action = str(callback.data).split(":")[-1]
    service = ReminderService()
    if action == "history":
        result = await service.cleanup_user_history(int(callback.from_user.id))
        label = "riwayat Selesai/Dihapus"
    elif action == "invalid":
        result = await service.cleanup_invalid_rows()
        label = "row kosong/rusak"
    else:
        await callback.answer("Aksi tidak valid.", show_alert=True)
        return
    await callback.message.answer(
        f"✅ Cleanup selesai.\n\nData dibersihkan: {result['deleted']} {label}.",
        reply_markup=main_menu_keyboard(),
    )
    await callback.answer()


@router.callback_query(F.data == "cleanup:cancel")
async def handle_cleanup_cancel(callback: CallbackQuery) -> None:
    await callback.message.answer("Cleanup dibatalkan.", reply_markup=main_menu_keyboard())
    await callback.answer()


@router.message(ReminderForm.waiting_for_search)
async def handle_search_text(message: Message, state: FSMContext) -> None:
    await state.clear()
    await send_search_results(message, message.text or "")


async def send_search_results(message: Message, query: str) -> None:
    service = ReminderService()
    results = await service.search(user_id_from_message(message), query)
    await message.answer(format_list(f"🔍 Hasil Pencarian: {query}", results), reply_markup=main_menu_keyboard())
    for reminder in results[:5]:
        await message.answer(format_reminder_card(reminder), reply_markup=reminder_action_keyboard(reminder.public_id))


@router.message(ReminderForm.waiting_for_text)
async def handle_create_text(message: Message, state: FSMContext) -> None:
    await create_preview(message, state)


@router.message(F.text)
async def handle_free_text(message: Message, state: FSMContext) -> None:
    await create_preview(message, state)


async def create_preview(message: Message, state: FSMContext) -> None:
    text = message.text or ""
    try:
        draft = await build_draft_from_text(message, text)
    except ValueError as exc:
        await message.answer(str(exc), reply_markup=main_menu_keyboard())
        return
    await state.set_state(ReminderForm.preview)
    await state.update_data(draft=draft_to_state(draft))
    await message.answer(format_preview(draft), reply_markup=preview_keyboard())


@router.callback_query(F.data == "create:save")
async def handle_create_save(callback: CallbackQuery, state: FSMContext) -> None:
    data = await state.get_data()
    if "draft" not in data:
        await callback.answer("Preview tidak ditemukan.", show_alert=True)
        return
    service = ReminderService()
    if not service.is_configured():
        await callback.message.answer("Google Sheets belum dikonfigurasi. Isi .env terlebih dahulu.")
        await callback.answer()
        return
    reminder = await service.create(draft_from_state(data["draft"]))
    await state.clear()
    await callback.message.answer(
        "✅ Reminder disimpan ke Google Sheets.\n\n" + format_reminder_card(reminder),
        reply_markup=reminder_action_keyboard(reminder.public_id),
    )
    await callback.answer()


@router.callback_query(F.data == "create:edit")
async def handle_create_edit(callback: CallbackQuery, state: FSMContext) -> None:
    await state.set_state(ReminderForm.waiting_for_text)
    await callback.message.answer("Tulis ulang reminder dengan format yang benar.")
    await callback.answer()


@router.callback_query(F.data == "create:cancel")
async def handle_create_cancel(callback: CallbackQuery, state: FSMContext) -> None:
    await state.clear()
    await callback.message.answer("❌ Reminder dibatalkan.", reply_markup=main_menu_keyboard())
    await callback.answer()


@router.callback_query(F.data.startswith("reminder:edit:"))
async def handle_edit_menu(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    await callback.message.answer("Pilih field yang ingin diubah.", reply_markup=edit_field_keyboard(public_id))
    await callback.answer()


@router.callback_query(F.data.startswith("edit:repeat:"))
async def handle_edit_repeat(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    await callback.message.answer("Pilih repeat reminder.", reply_markup=repeat_keyboard(public_id))
    await callback.answer()


@router.callback_query(F.data.startswith("edit:"))
async def handle_edit_field(callback: CallbackQuery, state: FSMContext) -> None:
    _, field, public_id = str(callback.data).split(":", 2)
    await state.set_state(ReminderForm.waiting_for_edit_value)
    await state.update_data(edit_field=field, edit_public_id=public_id)
    hints = {
        "activity_name": "Tulis nama kegiatan baru.",
        "datetime": "Tulis tanggal/jam baru. Contoh: Besok jam 08:00",
        "description": "Tulis catatan/deskripsi baru.",
    }
    await callback.message.answer(hints.get(field, "Tulis nilai baru."))
    await callback.answer()


@router.message(ReminderForm.waiting_for_edit_value)
async def handle_edit_value(message: Message, state: FSMContext) -> None:
    data = await state.get_data()
    field = str(data.get("edit_field"))
    public_id = str(data.get("edit_public_id"))
    value = message.text or ""
    updates: dict[str, Any] = {}
    if field == "datetime":
        draft = await build_draft_from_text(message, value)
        updates["reminder_date"] = draft.reminder_date
        updates["reminder_time"] = draft.reminder_time
        updates["status"] = "Aktif"
        updates["notified_at"] = None
    elif field == "activity_name":
        updates["activity_name"] = value.strip()
    elif field == "description":
        updates["description"] = value.strip()
    else:
        await message.answer("Field edit tidak valid.")
        await state.clear()
        return
    service = ReminderService()
    reminder = await service.update(public_id, updates)
    await state.clear()
    await message.answer("✅ Reminder diperbarui.\n\n" + format_reminder_card(reminder))


@router.callback_query(F.data.startswith("repeat:"))
async def handle_repeat_value(callback: CallbackQuery) -> None:
    _, repeat_value, public_id = str(callback.data).split(":", 2)
    service = ReminderService()
    reminder = await service.update(public_id, {"repeat": ReminderRepeat(repeat_value)})
    await callback.message.answer("✅ Repeat diperbarui.\n\n" + format_reminder_card(reminder))
    await callback.answer()


@router.callback_query(F.data.startswith("reminder:delete:"))
async def handle_delete_menu(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    await callback.message.answer("Hapus reminder ini?", reply_markup=delete_confirm_keyboard(public_id))
    await callback.answer()


@router.callback_query(F.data.startswith("delete:yes:"))
async def handle_delete_yes(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    service = ReminderService()
    await service.delete(public_id)
    await callback.message.answer("🗑️ Reminder dihapus.")
    await callback.answer()


@router.callback_query(F.data.startswith("delete:no:"))
async def handle_delete_no(callback: CallbackQuery) -> None:
    await callback.message.answer("Penghapusan dibatalkan.")
    await callback.answer()


@router.callback_query(F.data.startswith("reminder:snooze_menu:"))
async def handle_snooze_menu(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    await callback.message.answer("Pilih durasi tunda.", reply_markup=snooze_keyboard(public_id))
    await callback.answer()


@router.callback_query(F.data.startswith("snooze:"))
async def handle_snooze(callback: CallbackQuery) -> None:
    _, minutes, public_id = str(callback.data).split(":", 2)
    service = ReminderService()
    reminder = await service.get(public_id)
    if not reminder:
        await callback.answer("Reminder tidak ditemukan.", show_alert=True)
        return
    updated = await service.snooze(reminder, timedelta(minutes=int(minutes)))
    await callback.message.answer("⏳ Reminder ditunda.\n\n" + format_reminder_card(updated))
    await callback.answer()


@router.callback_query(F.data.startswith("alarm:done:"))
async def handle_alarm_done(callback: CallbackQuery) -> None:
    public_id = str(callback.data).split(":")[-1]
    service = ReminderService()
    reminder = await service.get(public_id)
    if not reminder:
        await callback.answer("Reminder tidak ditemukan.", show_alert=True)
        return
    updated = await service.complete(reminder)
    await callback.message.answer("✅ Reminder selesai.\n\n" + format_reminder_card(updated))
    await callback.answer()


@router.callback_query(F.data.startswith("reschedule:"))
async def handle_reschedule(callback: CallbackQuery, state: FSMContext) -> None:
    public_id = str(callback.data).split(":")[-1]
    await state.set_state(ReminderForm.waiting_for_reschedule)
    await state.update_data(reschedule_public_id=public_id)
    await callback.message.answer("Tulis jadwal baru. Contoh: Besok jam 14:00")
    await callback.answer()


@router.message(ReminderForm.waiting_for_reschedule)
async def handle_reschedule_text(message: Message, state: FSMContext) -> None:
    data = await state.get_data()
    public_id = str(data.get("reschedule_public_id"))
    draft = await build_draft_from_text(message, message.text or "")
    service = ReminderService()
    reminder = await service.update(
        public_id,
        {
            "reminder_date": draft.reminder_date,
            "reminder_time": draft.reminder_time,
            "status": "Aktif",
            "notified_at": None,
        },
    )
    await state.clear()
    await message.answer("🔁 Reminder dijadwalkan ulang.\n\n" + format_reminder_card(reminder))