feat: add natural language reminder with LLM parsing and confirmation flow
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user