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)