feat: add natural language reminder with LLM parsing and confirmation flow

This commit is contained in:
Power BI Dev
2026-06-04 10:52:36 +07:00
parent 2dfa6f95fb
commit 7acdd5347c
6 changed files with 304 additions and 6 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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")
+56
View File
@@ -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
+99 -3
View File
@@ -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 = ["⏰ <b>Pengingat Aktif:</b>\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']} — <i>{html.escape(r['message'])}</i>\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):
+17 -2
View File
@@ -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...")
+130
View File
@@ -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"⏰ <b>Konfirmasi Pengingat Harian</b>\n\n"
f"📌 Pesan: <i>{parsed['message']}</i>\n"
f"🕐 Jadwal: setiap hari jam <b>{time_str} WIB</b>\n\n"
f"Balas <b>ya</b> untuk konfirmasi atau <b>batal</b> 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"⏰ <b>Konfirmasi Pengingat</b>\n\n"
f"📌 Pesan: <i>{parsed['message']}</i>\n"
f"📅 Waktu: <b>{dt_str}</b>\n\n"
f"Balas <b>ya</b> untuk konfirmasi atau <b>batal</b> untuk membatalkan."
)
async def _send_reminder(context):
data = context.job.data
await context.bot.send_message(
chat_id=data["telegram_id"],
text=f"⏰ <b>Pengingat:</b>\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)