first init

This commit is contained in:
Power BI Dev
2026-06-04 08:33:41 +07:00
commit f62a711f5d
13 changed files with 893 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
TELEGRAM_BOT_TOKEN=isi_token_dari_botfather
OPENAI_API_KEY=isi_api_key_openai
OPENAI_MODEL=gpt-4o
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
ADMIN_TELEGRAM_ID=isi_telegram_id_anda
DB_PATH=/app/data/second_brain.db
CHROMA_PATH=/app/data/chromadb
UPLOAD_PATH=/app/data/uploads
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["python", "src/main.py"]
View File
+6
View File
@@ -0,0 +1,6 @@
python-telegram-bot==20.7
openai==1.30.0
python-dotenv==1.0.1
chromadb==0.5.3
pdfplumber==0.11.0
python-docx==1.1.2
+13
View File
@@ -0,0 +1,13 @@
import os
from dotenv import load_dotenv
load_dotenv()
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")
OPENAI_EMBEDDING_MODEL = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
ADMIN_TELEGRAM_ID = int(os.environ["ADMIN_TELEGRAM_ID"])
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")
+192
View File
@@ -0,0 +1,192 @@
import sqlite3
import secrets
import string
from config import DB_PATH
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
label TEXT,
created_at TEXT DEFAULT (datetime('now')),
used_by_telegram_id INTEGER,
used_at TEXT,
is_active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER UNIQUE NOT NULL,
full_name TEXT,
username TEXT,
registered_at TEXT DEFAULT (datetime('now')),
token_used TEXT,
is_active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
content TEXT NOT NULL,
scope TEXT DEFAULT 'private',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
filename TEXT NOT NULL,
original_name TEXT,
scope TEXT DEFAULT 'private',
chunk_count INTEGER DEFAULT 0,
uploaded_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
""")
# --- Token ---
def generate_token(label: str) -> str:
alphabet = string.ascii_uppercase + string.digits
token = "DOSEN-" + "".join(secrets.choice(alphabet) for _ in range(8))
with get_conn() as conn:
conn.execute("INSERT INTO tokens (token, label) VALUES (?, ?)", (token, label))
return token
def use_token(token: str, telegram_id: int):
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM tokens WHERE token = ? AND is_active = 1 AND used_by_telegram_id IS NULL",
(token,)
).fetchone()
if not row:
return None
conn.execute(
"UPDATE tokens SET used_by_telegram_id = ?, used_at = datetime('now'), is_active = 0 WHERE token = ?",
(telegram_id, token)
)
return dict(row)
# --- User ---
def register_user(telegram_id: int, full_name: str, username: str, token: str) -> bool:
try:
with get_conn() as conn:
conn.execute(
"INSERT INTO users (telegram_id, full_name, username, token_used) VALUES (?, ?, ?, ?)",
(telegram_id, full_name, username, token)
)
return True
except sqlite3.IntegrityError:
return False
def get_user(telegram_id: int):
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE telegram_id = ? AND is_active = 1", (telegram_id,)
).fetchone()
return dict(row) if row else None
def get_all_users() -> list:
with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY registered_at DESC").fetchall()
return [dict(r) for r in rows]
def get_all_tokens() -> list:
with get_conn() as conn:
rows = conn.execute("SELECT * FROM tokens ORDER BY created_at DESC").fetchall()
return [dict(r) for r in rows]
def revoke_user(telegram_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("UPDATE users SET is_active = 0 WHERE telegram_id = ?", (telegram_id,))
return cur.rowcount > 0
# --- Memory ---
def save_memory_meta(user_id: int, content: str, scope: str = "private") -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO memories (user_id, content, scope) VALUES (?, ?, ?)",
(user_id, content, scope)
)
return cur.lastrowid
def get_recent_memories(user_id: int, limit: int = 10) -> list:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM memories WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, limit)
).fetchall()
return [dict(r) for r in rows]
# --- Document ---
def save_document_meta(user_id: int, filename: str, original_name: str, scope: str, chunk_count: int) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO documents (user_id, filename, original_name, scope, chunk_count) VALUES (?, ?, ?, ?, ?)",
(user_id, filename, original_name, scope, chunk_count)
)
return cur.lastrowid
def get_global_documents() -> list:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM documents WHERE scope = 'global' ORDER BY uploaded_at DESC"
).fetchall()
return [dict(r) for r in rows]
# --- Conversation ---
def save_conversation(user_id: int, role: str, content: str):
with get_conn() as conn:
conn.execute(
"INSERT INTO conversations (user_id, role, content) VALUES (?, ?, ?)",
(user_id, role, content)
)
def get_conversation_history(user_id: int, limit: int = 10) -> list:
with get_conn() as conn:
rows = conn.execute(
"SELECT role, content FROM conversations WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, limit)
).fetchall()
return [dict(r) for r in reversed(rows)]
def clear_conversation(user_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,))
+61
View File
@@ -0,0 +1,61 @@
import os
import uuid
from pathlib import Path
from config import UPLOAD_PATH
def ensure_upload_dir():
Path(UPLOAD_PATH).mkdir(parents=True, exist_ok=True)
def save_file(file_bytes: bytes, original_name: str) -> str:
ensure_upload_dir()
ext = Path(original_name).suffix.lower()
filename = f"{uuid.uuid4()}{ext}"
filepath = os.path.join(UPLOAD_PATH, filename)
with open(filepath, "wb") as f:
f.write(file_bytes)
return filename
def extract_text(filename: str) -> str:
filepath = os.path.join(UPLOAD_PATH, filename)
ext = Path(filename).suffix.lower()
if ext == ".pdf":
return _extract_pdf(filepath)
elif ext in (".docx", ".doc"):
return _extract_docx(filepath)
elif ext == ".txt":
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
return ""
def _extract_pdf(filepath: str) -> str:
import pdfplumber
pages = []
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n".join(pages)
def _extract_docx(filepath: str) -> str:
from docx import Document
doc = Document(filepath)
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list:
words = text.split()
chunks = []
i = 0
while i < len(words):
chunk = " ".join(words[i:i + chunk_size])
if chunk.strip():
chunks.append(chunk)
i += chunk_size - overlap
return chunks
+57
View File
@@ -0,0 +1,57 @@
"""
Memeriksa apakah pertanyaan user relevan dengan konteks akademik/perkuliahan.
Bot ini hanya melayani topik: pengajaran, riset, kurikulum, administrasi dosen,
jurnal ilmiah, dan kegiatan kampus.
"""
ACADEMIC_SYSTEM_CHECK = """Anda adalah penjaga topik untuk bot akademik dosen.
Tugasnya HANYA menentukan apakah pertanyaan berikut relevan dengan konteks akademik/perkuliahan.
Topik yang DIIZINKAN:
- Pengajaran, metode pembelajaran, kurikulum, silabus, RPP
- Riset, jurnal ilmiah, publikasi, metodologi penelitian
- Administrasi kampus, akademik, birokrasi dosen
- Bimbingan mahasiswa, skripsi, tesis, disertasi
- Teknologi pendidikan, e-learning, LMS
- Statistik, analisis data untuk keperluan riset
- Bahasa ilmiah, penulisan akademik
- Konferensi, seminar, workshop akademik
Topik yang DITOLAK (contoh, tidak terbatas pada ini):
- Saham, investasi, trading, kripto, keuangan pribadi
- Hiburan, gosip, olahraga (kecuali untuk riset)
- Resep masakan, gaya hidup
- Pertanyaan pribadi yang tidak berkaitan dengan profesi dosen
- Politik praktis, agama (kecuali dalam konteks kajian akademik)
Jawab HANYA dengan satu kata: IZIN atau TOLAK"""
async def is_academic_topic(question: str, client, model: str) -> bool:
"""Mengembalikan True jika pertanyaan relevan dengan topik akademik."""
try:
response = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": ACADEMIC_SYSTEM_CHECK},
{"role": "user", "content": question}
],
max_tokens=5,
temperature=0,
)
verdict = response.choices[0].message.content.strip().upper()
return verdict == "IZIN"
except Exception:
# Jika guard gagal, izinkan saja agar bot tidak mati total
return True
REJECT_MESSAGE = (
"⚠️ Maaf, bot ini khusus untuk kegiatan akademik dan perkuliahan.\n\n"
"Saya dapat membantu dengan:\n"
"• 📚 Pengajaran & kurikulum\n"
"• 🔬 Riset & penulisan jurnal\n"
"• 🎓 Bimbingan mahasiswa\n"
"• 🏫 Administrasi kampus\n\n"
"Untuk pertanyaan di luar topik akademik, silakan gunakan layanan lain."
)
View File
+165
View File
@@ -0,0 +1,165 @@
from pathlib import Path
from telegram import Update
from telegram.ext import ContextTypes
from config import ADMIN_TELEGRAM_ID
import database as db
import rag
import document as doc_processor
def is_admin(telegram_id: int) -> bool:
return telegram_id == ADMIN_TELEGRAM_ID
async def generate_token(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
if not context.args:
await update.message.reply_text("Gunakan: /generate_token Nama Dosen")
return
label = " ".join(context.args)
token = db.generate_token(label)
await update.message.reply_text(
f"✅ Token berhasil dibuat untuk *{label}*\n\n"
f"🔑 Token: `{token}`\n\n"
f"📋 Instruksi untuk dosen:\n"
f"1. Buka bot ini\n"
f"2. Kirim: `/start {token}`",
parse_mode="Markdown"
)
async def list_users(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
users = db.get_all_users()
if not users:
await update.message.reply_text("Belum ada dosen terdaftar.")
return
lines = ["👥 *Daftar Dosen Terdaftar:*\n"]
for u in users:
status = "" if u["is_active"] else ""
lines.append(f"{status} *{u['full_name']}* — ID: `{u['telegram_id']}`\n 📅 {u['registered_at'][:10]}")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
async def list_tokens(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
tokens = db.get_all_tokens()
if not tokens:
await update.message.reply_text("Belum ada token dibuat.")
return
lines = ["🔑 *Daftar Token:*\n"]
for t in tokens:
used = f"✅ Terpakai (`{t['used_by_telegram_id']}`)" if t["used_by_telegram_id"] else "⏳ Belum dipakai"
lines.append(f"• `{t['token']}` — {t['label']}\n {used}")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
async def revoke_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
if not context.args:
await update.message.reply_text("Gunakan: /revoke TELEGRAM_ID")
return
try:
target_id = int(context.args[0])
except ValueError:
await update.message.reply_text("ID harus berupa angka.")
return
success = db.revoke_user(target_id)
msg = f"✅ Akses `{target_id}` berhasil dicabut." if success else "❌ User tidak ditemukan."
await update.message.reply_text(msg, parse_mode="Markdown")
async def upload_global(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Admin upload dokumen ke knowledge base global — semua dosen bisa mengaksesnya."""
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
doc = update.message.document
if not doc:
await update.message.reply_text(
"📎 Kirim file PDF, DOCX, atau TXT dengan caption `/upload_global`",
parse_mode="Markdown"
)
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 global...")
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=update.effective_user.id,
filename=filename,
original_name=doc.file_name,
scope="global",
chunk_count=len(chunks)
)
await rag.add_document_chunks(user_id=0, doc_id=doc_id, chunks=chunks, scope="global")
await update.message.reply_text(
f"{doc.file_name} berhasil ditambahkan ke knowledge base global!\n"
f"📄 {len(chunks)} bagian tersimpan — semua dosen dapat mengaksesnya via /ask."
)
async def list_global_docs(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
docs = db.get_global_documents()
if not docs:
await update.message.reply_text("Belum ada dokumen global.")
return
lines = ["🌐 *Dokumen Knowledge Base Global:*\n"]
for d in docs:
lines.append(f"• *{d['original_name']}* ({d['chunk_count']} bagian)\n 📅 {d['uploaded_at'][:16]}")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
async def help_admin(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_admin(update.effective_user.id):
await update.message.reply_text("⛔ Akses ditolak.")
return
text = (
"👑 *Perintah Admin:*\n\n"
"*Manajemen Akses:*\n"
"/generate_token `Nama` — Buat token untuk dosen baru\n"
"/list_tokens — Lihat semua token\n"
"/list_users — Lihat semua dosen terdaftar\n"
"/revoke `ID` — Cabut akses dosen\n\n"
"*Knowledge Base Global:*\n"
"/list_global_docs — Lihat dokumen global\n"
"📎 Kirim file + caption `/upload_global` — Upload dokumen global"
)
await update.message.reply_text(text, parse_mode="Markdown")
+251
View File
@@ -0,0 +1,251 @@
from pathlib import Path
from telegram import Update
from telegram.ext import ContextTypes
from openai import AsyncOpenAI
from config import OPENAI_API_KEY, OPENAI_MODEL, ADMIN_TELEGRAM_ID
import database as db
import rag
import document as doc_processor
from guard import is_academic_topic, REJECT_MESSAGE
_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
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:
await update.message.reply_text(
f"👋 Selamat datang kembali, *{existing['full_name']}*!\n"
f"🧠 Second Brain Anda aktif. Gunakan /help untuk panduan.",
parse_mode="Markdown"
)
return
if user.id == ADMIN_TELEGRAM_ID:
await update.message.reply_text(
"👑 *Mode Admin Aktif*\n\nGunakan /help_admin untuk melihat semua perintah admin.",
parse_mode="Markdown"
)
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="Markdown"
)
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"👤 {full_name}\n"
f"🧠 Second Brain Anda telah aktif.\n\n"
f"Gunakan /help untuk panduan lengkap.",
parse_mode="Markdown"
)
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_{content}_", parse_mode="Markdown")
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:
# Tampilkan 10 catatan terbaru
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"{m['content'][:150]}\n _{m['created_at'][:16]}_")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
return
# Pencarian semantik
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 '{query}':\n"]
for i, r in enumerate(results, 1):
scope_label = "🌐 Global" if r["scope"] == "global" else "🔒 Pribadi"
# cosine distance: 0 = identik, 2 = berlawanan → konversi ke persentase
relevance = round(max(0, (1 - r["distance"])) * 100, 1)
lines.append(f"{i}. [{scope_label}] {r['content'][:200]}\n {relevance}% relevan\n")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
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")
# Guard: hanya izinkan topik akademik
if not await is_academic_topic(question, _client, OPENAI_MODEL):
await update.message.reply_text(REJECT_MESSAGE)
return
# Cari konteks relevan dari Second Brain
relevant = await rag.search(user_data["id"], question, n_results=5)
context_text = ""
if relevant:
context_text = "\n\nKonteks dari Second Brain pengguna:\n" + "\n".join(
f"[{'Global' if r['scope'] == 'global' else 'Pribadi'}] {r['content']}"
for r in relevant
)
history = db.get_conversation_history(user_data["id"])
system_prompt = (
f"{ACADEMIC_SYSTEM_PROMPT}\n\n"
f"Nama dosen: {user_data['full_name']}"
f"{context_text}"
)
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history)
messages.append({"role": "user", "content": question})
try:
response = await _client.chat.completions.create(
model=OPENAI_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):
"""Upload dokumen ke Second Brain pribadi."""
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="Markdown"
)
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"{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 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="Markdown")
+49
View File
@@ -0,0 +1,49 @@
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters
from config import TELEGRAM_BOT_TOKEN
import database as db
from handlers import admin as admin_handler
from handlers import user as user_handler
def main():
db.init_db()
app = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build()
# --- User commands ---
app.add_handler(CommandHandler("start", user_handler.start))
app.add_handler(CommandHandler("remember", user_handler.remember))
app.add_handler(CommandHandler("recall", user_handler.recall))
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))
# --- Admin commands ---
app.add_handler(CommandHandler("generate_token", admin_handler.generate_token))
app.add_handler(CommandHandler("list_users", admin_handler.list_users))
app.add_handler(CommandHandler("list_tokens", admin_handler.list_tokens))
app.add_handler(CommandHandler("revoke", admin_handler.revoke_user))
app.add_handler(CommandHandler("list_global_docs", admin_handler.list_global_docs))
app.add_handler(CommandHandler("help_admin", admin_handler.help_admin))
# --- Document upload via caption ---
# User: kirim file dengan caption "/upload"
app.add_handler(MessageHandler(
filters.Document.ALL & filters.CaptionRegex(r"^/upload$"),
user_handler.upload_doc
))
# Admin: kirim file dengan caption "/upload_global"
app.add_handler(MessageHandler(
filters.Document.ALL & filters.CaptionRegex(r"^/upload_global$"),
admin_handler.upload_global
))
# Fallback: dokumen tanpa caption yang valid
app.add_handler(MessageHandler(filters.Document.ALL, user_handler.upload_doc))
print("🤖 Second Brain Bot is running...")
app.run_polling()
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
import asyncio
import chromadb
from openai import AsyncOpenAI
from config import CHROMA_PATH, OPENAI_API_KEY, OPENAI_EMBEDDING_MODEL
_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
_chroma = chromadb.PersistentClient(path=CHROMA_PATH)
def _get_collection(name: str):
return _chroma.get_or_create_collection(name=name, metadata={"hnsw:space": "cosine"})
async def _embed(text: str) -> list:
resp = await _client.embeddings.create(model=OPENAI_EMBEDDING_MODEL, input=text)
return resp.data[0].embedding
async def add_memory(user_id: int, memory_id: int, content: str, scope: str = "private"):
embedding = await _embed(content)
collection_name = "global" if scope == "global" else f"user_{user_id}"
collection = _get_collection(collection_name)
await asyncio.to_thread(
collection.add,
ids=[f"mem_{memory_id}"],
embeddings=[embedding],
documents=[content],
metadatas=[{"type": "memory", "user_id": str(user_id), "scope": scope}]
)
async def add_document_chunks(user_id: int, doc_id: int, chunks: list, scope: str = "private"):
collection_name = "global" if scope == "global" else f"user_{user_id}"
collection = _get_collection(collection_name)
ids = [f"doc_{doc_id}_chunk_{i}" for i in range(len(chunks))]
embeddings = []
for chunk in chunks:
emb = await _embed(chunk)
embeddings.append(emb)
await asyncio.to_thread(
collection.add,
ids=ids,
embeddings=embeddings,
documents=chunks,
metadatas=[{"type": "document", "doc_id": str(doc_id), "user_id": str(user_id), "scope": scope} for _ in chunks]
)
async def search(user_id: int, query: str, n_results: int = 5) -> list:
query_embedding = await _embed(query)
results = []
for collection_name, scope_label in [(f"user_{user_id}", "private"), ("global", "global")]:
try:
col = _get_collection(collection_name)
count = await asyncio.to_thread(col.count)
if count == 0:
continue
res = await asyncio.to_thread(
col.query,
query_embeddings=[query_embedding],
n_results=min(n_results, count)
)
for i, doc in enumerate(res["documents"][0]):
results.append({
"content": doc,
"scope": scope_label,
"distance": res["distances"][0][i],
})
except Exception:
continue
results.sort(key=lambda x: x["distance"])
return results[:n_results]