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")