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