diff --git a/requirements.txt b/requirements.txt index beb3b8e..1dcea12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-telegram-bot==21.3 +python-telegram-bot[job-queue]==21.3 openai>=1.52.0,<2.0 python-dotenv==1.0.1 chromadb==0.5.3 diff --git a/src/config.py b/src/config.py index f6d4d18..43dc51f 100644 --- a/src/config.py +++ b/src/config.py @@ -12,3 +12,4 @@ DB_PATH = os.getenv("DB_PATH", "/app/data/second_brain.db") CHROMA_PATH = os.getenv("CHROMA_PATH", "/app/data/chromadb") UPLOAD_PATH = os.getenv("UPLOAD_PATH", "/app/data/uploads") EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2") +BOT_TIMEZONE = os.getenv("BOT_TIMEZONE", "Asia/Jakarta") diff --git a/src/database.py b/src/database.py index 5357605..077b7ce 100644 --- a/src/database.py +++ b/src/database.py @@ -53,6 +53,19 @@ def init_db(): FOREIGN KEY (user_id) REFERENCES users(id) ); + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL, + user_db_id INTEGER NOT NULL, + message TEXT NOT NULL, + remind_at TEXT NOT NULL, + is_recurring INTEGER DEFAULT 0, + recur_type TEXT, + created_at TEXT DEFAULT (datetime('now')), + is_active INTEGER DEFAULT 1, + FOREIGN KEY (user_db_id) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -190,3 +203,46 @@ def get_conversation_history(user_id: int, limit: int = 10) -> list: def clear_conversation(user_id: int): with get_conn() as conn: conn.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) + + +# --- Reminder --- + +def create_reminder(telegram_id: int, user_db_id: int, message: str, remind_at: str, + is_recurring: bool = False, recur_type: str = None) -> int: + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO reminders (telegram_id, user_db_id, message, remind_at, is_recurring, recur_type) VALUES (?, ?, ?, ?, ?, ?)", + (telegram_id, user_db_id, message, remind_at, int(is_recurring), recur_type) + ) + return cur.lastrowid + + +def get_active_reminders() -> list: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM reminders WHERE is_active = 1 ORDER BY remind_at" + ).fetchall() + return [dict(r) for r in rows] + + +def get_user_reminders(user_db_id: int) -> list: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM reminders WHERE user_db_id = ? AND is_active = 1 ORDER BY remind_at", + (user_db_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def deactivate_reminder(reminder_id: int): + with get_conn() as conn: + conn.execute("UPDATE reminders SET is_active = 0 WHERE id = ?", (reminder_id,)) + + +def cancel_reminder(reminder_id: int, user_db_id: int) -> bool: + with get_conn() as conn: + cur = conn.execute( + "UPDATE reminders SET is_active = 0 WHERE id = ? AND user_db_id = ?", + (reminder_id, user_db_id) + ) + return cur.rowcount > 0 diff --git a/src/handlers/user.py b/src/handlers/user.py index fc865b3..006598d 100644 --- a/src/handlers/user.py +++ b/src/handlers/user.py @@ -1,14 +1,20 @@ import html +from datetime import datetime from pathlib import Path from telegram import Update from telegram.ext import ContextTypes from openai import AsyncOpenAI -from config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, ADMIN_TELEGRAM_ID +from config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, ADMIN_TELEGRAM_ID, BOT_TIMEZONE +from zoneinfo import ZoneInfo import database as db import rag +import reminder as reminder_module import document as doc_processor from guard import is_academic_topic, REJECT_MESSAGE +_pending_confirmations: dict = {} +REMINDER_KEYWORDS = ["ingatkan", "pengingat", "remind", "jadwalkan", "tolong ingat"] + _client = AsyncOpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL) ACADEMIC_SYSTEM_PROMPT = ( @@ -219,16 +225,106 @@ async def upload_doc(update: Update, context: ContextTypes.DEFAULT_TYPE): ) +async def list_reminders(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("⛔ Belum terdaftar.") + return + + reminders = db.get_user_reminders(user_data["id"]) + if not reminders: + await update.message.reply_text("Tidak ada pengingat aktif.") + return + + tz = ZoneInfo(BOT_TIMEZONE) + lines = ["⏰ Pengingat Aktif:\n"] + for r in reminders: + if r["is_recurring"]: + schedule_str = f"Setiap hari jam {r['remind_at'][:5]} WIB" + else: + try: + dt = datetime.fromisoformat(r["remind_at"]).replace(tzinfo=tz) + schedule_str = dt.strftime("%d %b %Y %H:%M WIB") + except Exception: + schedule_str = r["remind_at"] + lines.append(f"#{r['id']} — {html.escape(r['message'])}\n 📅 {schedule_str}") + lines.append("\nGunakan /cancel_remind ID untuk membatalkan.") + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + + +async def cancel_remind(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("⛔ Belum terdaftar.") + return + if not context.args: + await update.message.reply_text("Gunakan: /cancel_remind ID") + return + try: + reminder_id = int(context.args[0]) + except ValueError: + await update.message.reply_text("ID harus berupa angka.") + return + + success = db.cancel_reminder(reminder_id, user_data["id"]) + if not success: + await update.message.reply_text("❌ Pengingat tidak ditemukan.") + return + + for job in context.application.job_queue.get_jobs_by_name(f"reminder_{reminder_id}"): + job.schedule_removal() + await update.message.reply_text("✅ Pengingat berhasil dibatalkan.") + + async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): user_data = db.get_user(update.effective_user.id) if not user_data: await update.message.reply_text("⛔ Belum terdaftar. Gunakan /start TOKEN untuk registrasi.") return - question = update.message.text.strip() - if not question: + text = update.message.text.strip() + if not text: return + user_id = update.effective_user.id + + # Cek pending konfirmasi reminder + if user_id in _pending_confirmations: + answer = text.lower() + if answer in ("ya", "iya", "yes", "y"): + pending = _pending_confirmations.pop(user_id) + parsed = pending["parsed"] + reminder_id = db.create_reminder( + telegram_id=user_id, + user_db_id=user_data["id"], + message=parsed["message"], + remind_at=parsed["remind_at"], + is_recurring=parsed.get("is_recurring", False), + recur_type=parsed.get("recur_type"), + ) + reminder_module.schedule_new_reminder( + context.application.job_queue, reminder_id, user_id, parsed["message"], parsed + ) + await update.message.reply_text("✅ Pengingat berhasil disimpan! Gunakan /reminders untuk melihat daftar.") + return + elif answer in ("tidak", "batal", "cancel", "no", "n"): + _pending_confirmations.pop(user_id) + await update.message.reply_text("❌ Pengingat dibatalkan.") + return + else: + _pending_confirmations.pop(user_id) + + # Deteksi intent reminder dari plain text + text_lower = text.lower() + if any(kw in text_lower for kw in REMINDER_KEYWORDS): + await update.message.chat.send_action("typing") + parsed = await reminder_module.parse_reminder_intent(text, _client, DEEPSEEK_MODEL) + if parsed: + _pending_confirmations[user_id] = {"parsed": parsed} + await update.message.reply_text(reminder_module.format_confirmation(parsed), parse_mode="HTML") + return + + question = text await update.message.chat.send_action("typing") if not await is_academic_topic(question, _client, DEEPSEEK_MODEL): diff --git a/src/main.py b/src/main.py index 0636dc7..3babe4d 100644 --- a/src/main.py +++ b/src/main.py @@ -3,6 +3,7 @@ from telegram import Update from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters from config import TELEGRAM_BOT_TOKEN import database as db +import reminder as reminder_module from handlers import admin as admin_handler from handlers import user as user_handler @@ -16,10 +17,22 @@ async def error_handler(update: object, context) -> None: await update.message.reply_text(f"❌ Terjadi kesalahan: {str(context.error)[:200]}") +async def on_startup(app): + reminders = db.get_active_reminders() + for r in reminders: + reminder_module.schedule_reminder(app.job_queue, r) + logger.info(f"Loaded {len(reminders)} reminder(s) from database.") + + def main(): db.init_db() - app = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() + app = ( + ApplicationBuilder() + .token(TELEGRAM_BOT_TOKEN) + .post_init(on_startup) + .build() + ) app.add_error_handler(error_handler) @@ -30,6 +43,8 @@ def main(): app.add_handler(CommandHandler("ask", user_handler.ask)) app.add_handler(CommandHandler("clear", user_handler.clear_chat)) app.add_handler(CommandHandler("help", user_handler.help_cmd)) + app.add_handler(CommandHandler("reminders", user_handler.list_reminders)) + app.add_handler(CommandHandler("cancel_remind", user_handler.cancel_remind)) # --- Admin commands --- app.add_handler(CommandHandler("generate_token", admin_handler.generate_token)) @@ -50,7 +65,7 @@ def main(): )) app.add_handler(MessageHandler(filters.Document.ALL, user_handler.upload_doc)) - # Plain text → langsung tanya AI tanpa perlu /ask + # Plain text → AI chat + reminder detection app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, user_handler.handle_message)) print("🤖 Second Brain Bot is running...") diff --git a/src/reminder.py b/src/reminder.py new file mode 100644 index 0000000..6fc1397 --- /dev/null +++ b/src/reminder.py @@ -0,0 +1,130 @@ +import json +from datetime import datetime, time +from zoneinfo import ZoneInfo +from config import BOT_TIMEZONE +import database as db + +TIMEZONE = ZoneInfo(BOT_TIMEZONE) + +PARSE_PROMPT = """Anda adalah parser pengingat untuk bot dosen. Analisis pesan berikut. + +Waktu sekarang: {now} (WIB) +Hari Indonesia: Senin=Mon, Selasa=Tue, Rabu=Wed, Kamis=Thu, Jumat=Fri, Sabtu=Sat, Minggu=Sun + +Pesan: "{text}" + +Jika ini permintaan pengingat, kembalikan JSON: +Sekali: {{"is_reminder":true,"remind_at":"YYYY-MM-DDTHH:MM:SS","message":"isi ringkas","is_recurring":false,"recur_type":null}} +Harian: {{"is_reminder":true,"remind_at":"HH:MM:SS","message":"isi ringkas","is_recurring":true,"recur_type":"daily"}} + +Jika waktu tidak disebutkan gunakan 08:00:00. Jika bukan pengingat: {{"is_reminder":false}} +Kembalikan HANYA JSON valid.""" + + +async def parse_reminder_intent(text: str, client, model: str) -> dict | None: + now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M (%A, %d %B %Y)") + try: + response = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": PARSE_PROMPT.format(now=now, text=text)}], + max_tokens=150, + temperature=0, + ) + content = response.choices[0].message.content.strip() + if "```" in content: + content = content.split("```")[1] + if content.startswith("json"): + content = content[4:] + data = json.loads(content.strip()) + if data.get("is_reminder"): + return data + except Exception: + pass + return None + + +def format_confirmation(parsed: dict) -> str: + if parsed.get("is_recurring") and parsed.get("recur_type") == "daily": + time_str = parsed["remind_at"][:5] + return ( + f"⏰ Konfirmasi Pengingat Harian\n\n" + f"📌 Pesan: {parsed['message']}\n" + f"🕐 Jadwal: setiap hari jam {time_str} WIB\n\n" + f"Balas ya untuk konfirmasi atau batal untuk membatalkan." + ) + else: + try: + dt = datetime.fromisoformat(parsed["remind_at"]) + dt_str = dt.strftime("%A, %d %B %Y jam %H:%M WIB") + except Exception: + dt_str = parsed["remind_at"] + return ( + f"⏰ Konfirmasi Pengingat\n\n" + f"📌 Pesan: {parsed['message']}\n" + f"📅 Waktu: {dt_str}\n\n" + f"Balas ya untuk konfirmasi atau batal untuk membatalkan." + ) + + +async def _send_reminder(context): + data = context.job.data + await context.bot.send_message( + chat_id=data["telegram_id"], + text=f"⏰ Pengingat:\n{data['message']}", + parse_mode="HTML" + ) + if not data["is_recurring"]: + db.deactivate_reminder(data["reminder_id"]) + + +def schedule_reminder(job_queue, reminder: dict): + data = { + "telegram_id": reminder["telegram_id"], + "message": reminder["message"], + "reminder_id": reminder["id"], + "is_recurring": bool(reminder["is_recurring"]), + } + job_name = f"reminder_{reminder['id']}" + + if reminder["is_recurring"] and reminder["recur_type"] == "daily": + try: + parts = reminder["remind_at"].split(":") + h, m, s = int(parts[0]), int(parts[1]), int(parts[2]) if len(parts) > 2 else 0 + t = time(h, m, s, tzinfo=TIMEZONE) + job_queue.run_daily(_send_reminder, time=t, data=data, name=job_name) + except Exception: + pass + else: + try: + dt = datetime.fromisoformat(reminder["remind_at"]) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TIMEZONE) + now = datetime.now(TIMEZONE) + if dt > now: + job_queue.run_once(_send_reminder, when=dt, data=data, name=job_name) + else: + db.deactivate_reminder(reminder["id"]) + except Exception: + pass + + +def schedule_new_reminder(job_queue, reminder_id: int, telegram_id: int, message: str, parsed: dict): + data = { + "telegram_id": telegram_id, + "message": message, + "reminder_id": reminder_id, + "is_recurring": parsed.get("is_recurring", False), + } + job_name = f"reminder_{reminder_id}" + + if parsed.get("is_recurring") and parsed.get("recur_type") == "daily": + raw = parsed["remind_at"] + ":00" if parsed["remind_at"].count(":") == 1 else parsed["remind_at"] + parts = raw.split(":") + h, m, s = int(parts[0]), int(parts[1]), int(parts[2]) + t = time(h, m, s, tzinfo=TIMEZONE) + job_queue.run_daily(_send_reminder, time=t, data=data, name=job_name) + else: + dt = datetime.fromisoformat(parsed["remind_at"]) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TIMEZONE) + job_queue.run_once(_send_reminder, when=dt, data=data, name=job_name)