Files
if-second-brain/src/handlers/user.py
T

428 lines
16 KiB
Python

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, 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_blocked, REJECT_MESSAGE
_pending_confirmations: dict = {}
REMINDER_KEYWORDS = ["ingatkan", "pengingat", "remind", "jadwalkan", "tolong ingat"]
REMINDER_BYPASS_KEYWORDS = ["ingatkan", "pengingat", "remind", "diingatkan", "jadwalkan", "kapan diingat"]
def _build_context(user_db_id: int, rag_results: list) -> str:
parts = []
# Waktu sekarang
now = datetime.now(ZoneInfo(BOT_TIMEZONE))
parts.append(f"Waktu sekarang: {now.strftime('%A, %d %B %Y %H:%M WIB')}")
# Reminder aktif
reminders = db.get_user_reminders(user_db_id)
if reminders:
lines = ["Agenda/Pengingat aktif pengguna:"]
for r in reminders:
if r["is_recurring"]:
lines.append(f"- [Setiap hari {r['remind_at'][:5]} WIB] {r['message']}")
else:
try:
dt = datetime.fromisoformat(r["remind_at"]).replace(tzinfo=ZoneInfo(BOT_TIMEZONE))
lines.append(f"- [{dt.strftime('%a, %d %b %Y %H:%M')} WIB] {r['message']}")
except Exception:
lines.append(f"- {r['message']}")
parts.append("\n".join(lines))
# RAG context
if rag_results:
rag_lines = ["Konteks dari Second Brain pengguna:"] + [
f"[{'Global' if r['scope'] == 'global' else 'Pribadi'}] {r['content']}"
for r in rag_results
]
parts.append("\n".join(rag_lines))
return "\n\n".join(parts)
_client = AsyncOpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
ACADEMIC_SYSTEM_PROMPT = (
"Anda adalah asisten akademik personal untuk dosen di perguruan tinggi Indonesia. "
"Fokus HANYA pada topik akademik: pengajaran, riset, kurikulum, bimbingan mahasiswa, "
"penulisan jurnal, administrasi kampus, dan teknologi pendidikan. "
"Tolak dengan sopan jika ditanya di luar topik tersebut. "
"Jawab dalam bahasa yang sama dengan pertanyaan pengguna."
)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
existing = db.get_user(user.id)
if existing:
name = html.escape(existing['full_name'])
await update.message.reply_text(
f"👋 Selamat datang kembali, <b>{name}</b>!\n"
f"🧠 Second Brain Anda aktif. Gunakan /help untuk panduan.",
parse_mode="HTML"
)
return
if user.id == ADMIN_TELEGRAM_ID:
full_name = user.full_name or user.username or "Admin"
db.register_user(user.id, full_name, user.username or "", "ADMIN")
await update.message.reply_text(
"👑 <b>Mode Admin Aktif</b>\n\n"
"Anda juga terdaftar sebagai dosen dan dapat menggunakan semua fitur.\n"
"Gunakan /help_admin untuk perintah admin, /help untuk fitur dosen.",
parse_mode="HTML"
)
return
if not context.args:
await update.message.reply_text(
"👋 Halo! Bot ini khusus untuk dosen dengan token registrasi.\n\n"
"Hubungi admin untuk mendapatkan token, lalu kirim:\n"
"<code>/start TOKEN_ANDA</code>",
parse_mode="HTML"
)
return
token = context.args[0]
token_data = db.use_token(token, user.id)
if not token_data:
await update.message.reply_text("❌ Token tidak valid atau sudah digunakan.")
return
full_name = user.full_name or user.username or "Dosen"
db.register_user(user.id, full_name, user.username or "", token)
await update.message.reply_text(
f"✅ <b>Registrasi berhasil!</b>\n\n"
f"👤 {html.escape(full_name)}\n"
f"🧠 Second Brain Anda telah aktif.\n\n"
f"Gunakan /help untuk panduan lengkap.",
parse_mode="HTML"
)
async def remember(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")
return
if not context.args:
await update.message.reply_text("Gunakan: /remember Catatan yang ingin disimpan")
return
content = " ".join(context.args)
mem_id = db.save_memory_meta(user_data["id"], content, scope="private")
await rag.add_memory(user_data["id"], mem_id, content, scope="private")
await update.message.reply_text(
f"✅ Tersimpan di Second Brain:\n<i>{html.escape(content)}</i>",
parse_mode="HTML"
)
async def recall(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:
memories = db.get_recent_memories(user_data["id"])
if not memories:
await update.message.reply_text("Second Brain Anda masih kosong. Gunakan /remember untuk menyimpan catatan.")
return
lines = ["🧠 <b>Catatan Terbaru:</b>\n"]
for m in memories:
lines.append(f"• {html.escape(m['content'][:150])}\n <i>{m['created_at'][:16]}</i>")
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
return
query = " ".join(context.args)
await update.message.chat.send_action("typing")
results = await rag.search(user_data["id"], query)
if not results:
await update.message.reply_text("Tidak ada hasil yang relevan ditemukan.")
return
lines = [f"🔍 Hasil pencarian '<i>{html.escape(query)}</i>':\n"]
for i, r in enumerate(results, 1):
scope_label = "🌐 Global" if r["scope"] == "global" else "🔒 Pribadi"
relevance = round(max(0, (1 - r["distance"])) * 100, 1)
lines.append(f"{i}. [{scope_label}] {html.escape(r['content'][:200])}\n {relevance}% relevan\n")
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
async def ask(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: /ask Pertanyaan Anda")
return
question = " ".join(context.args)
await update.message.chat.send_action("typing")
if is_blocked(question):
await update.message.reply_text(REJECT_MESSAGE)
return
relevant = await rag.search(user_data["id"], question, n_results=5)
extra_context = _build_context(user_data["id"], relevant)
history = db.get_conversation_history(user_data["id"])
system_prompt = (
f"{ACADEMIC_SYSTEM_PROMPT}\n\n"
f"Nama dosen: {user_data['full_name']}\n\n"
f"{extra_context}"
)
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history)
messages.append({"role": "user", "content": question})
try:
response = await _client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=messages,
max_tokens=1500,
)
answer = response.choices[0].message.content
except Exception as e:
await update.message.reply_text(f"❌ Gagal menghubungi AI: {str(e)[:100]}")
return
db.save_conversation(user_data["id"], "user", question)
db.save_conversation(user_data["id"], "assistant", answer)
await update.message.reply_text(answer)
async def upload_doc(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
doc = update.message.document
if not doc:
await update.message.reply_text(
"📎 Kirim file PDF, DOCX, atau TXT dengan caption <code>/upload</code>",
parse_mode="HTML"
)
return
allowed_ext = {".pdf", ".docx", ".txt"}
if Path(doc.file_name).suffix.lower() not in allowed_ext:
await update.message.reply_text("❌ Format tidak didukung. Gunakan PDF, DOCX, atau TXT.")
return
await update.message.reply_text("⏳ Memproses dokumen Anda...")
file = await doc.get_file()
file_bytes = bytes(await file.download_as_bytearray())
filename = doc_processor.save_file(file_bytes, doc.file_name)
text = doc_processor.extract_text(filename)
if not text.strip():
await update.message.reply_text("❌ Gagal mengekstrak teks dari dokumen.")
return
chunks = doc_processor.chunk_text(text)
doc_id = db.save_document_meta(
user_id=user_data["id"],
filename=filename,
original_name=doc.file_name,
scope="private",
chunk_count=len(chunks)
)
await rag.add_document_chunks(user_id=user_data["id"], doc_id=doc_id, chunks=chunks, scope="private")
await update.message.reply_text(
f"✅ {html.escape(doc.file_name)} berhasil diproses!\n"
f"📄 {len(chunks)} bagian tersimpan di Second Brain Anda.\n"
f"Gunakan /ask untuk bertanya tentang isi dokumen ini."
)
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
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()
is_reminder_related = any(kw in text_lower for kw in REMINDER_BYPASS_KEYWORDS)
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 is_blocked(question):
await update.message.reply_text(REJECT_MESSAGE)
return
relevant = await rag.search(user_data["id"], question, n_results=5)
extra_context = _build_context(user_data["id"], relevant)
history = db.get_conversation_history(user_data["id"])
system_prompt = (
f"{ACADEMIC_SYSTEM_PROMPT}\n\n"
f"Nama dosen: {user_data['full_name']}\n\n"
f"{extra_context}"
)
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history)
messages.append({"role": "user", "content": question})
try:
response = await _client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=messages,
max_tokens=1500,
)
answer = response.choices[0].message.content
except Exception as e:
await update.message.reply_text(f"❌ Gagal menghubungi AI: {str(e)[:100]}")
return
db.save_conversation(user_data["id"], "user", question)
db.save_conversation(user_data["id"], "assistant", answer)
await update.message.reply_text(answer)
async def clear_chat(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
db.clear_conversation(user_data["id"])
await update.message.reply_text("🧹 Riwayat percakapan berhasil dihapus.")
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id == ADMIN_TELEGRAM_ID:
await update.message.reply_text("Gunakan /help_admin untuk perintah admin.")
return
user_data = db.get_user(update.effective_user.id)
if not user_data:
await update.message.reply_text("Gunakan /start TOKEN untuk registrasi.")
return
text = (
"🧠 <b>Second Brain Bot — Panduan</b>\n\n"
"<b>Catatan:</b>\n"
"/remember <code>teks</code> — Simpan catatan baru\n"
"/recall — Lihat 10 catatan terbaru\n"
"/recall <code>kata kunci</code> — Cari dengan AI (semantik)\n\n"
"<b>Dokumen:</b>\n"
"📎 Kirim file + caption <code>/upload</code> — Upload PDF/DOCX/TXT\n\n"
"<b>Tanya AI:</b>\n"
"/ask <code>pertanyaan</code> — Tanya AI dengan konteks Second Brain\n"
"/clear — Hapus riwayat percakapan\n\n"
"<i>Bot ini khusus untuk topik akademik dan perkuliahan.</i>"
)
await update.message.reply_text(text, parse_mode="HTML")