428 lines
16 KiB
Python
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")
|