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, {name}!\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(
"๐ Mode Admin Aktif\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"
"/start TOKEN_ANDA",
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"โ
Registrasi berhasil!\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{html.escape(content)}",
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 = ["๐ง Catatan Terbaru:\n"]
for m in memories:
lines.append(f"โข {html.escape(m['content'][:150])}\n {m['created_at'][:16]}")
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 '{html.escape(query)}':\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 /upload",
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 = ["โฐ 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
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 = (
"๐ง Second Brain Bot โ Panduan\n\n"
"Catatan:\n"
"/remember teks โ Simpan catatan baru\n"
"/recall โ Lihat 10 catatan terbaru\n"
"/recall kata kunci โ Cari dengan AI (semantik)\n\n"
"Dokumen:\n"
"๐ Kirim file + caption /upload โ Upload PDF/DOCX/TXT\n\n"
"Tanya AI:\n"
"/ask pertanyaan โ Tanya AI dengan konteks Second Brain\n"
"/clear โ Hapus riwayat percakapan\n\n"
"Bot ini khusus untuk topik akademik dan perkuliahan."
)
await update.message.reply_text(text, parse_mode="HTML")