Commit ·
ce37a9c
0
Parent(s):
Initial commit for Hugging Face
Browse files- .gitignore +17 -0
- Dockerfile +24 -0
- README.md +95 -0
- TODO.md +33 -0
- config.js +35 -0
- index.js +450 -0
- lib/contextManager.js +75 -0
- lib/groqHandler.js +52 -0
- lib/markdownParser.js +36 -0
- lib/ragHandler.js +121 -0
- lib/recapManager.js +84 -0
- lib/reminderService.js +106 -0
- lib/statsTracker.js +72 -0
- lib/toolHandler.js +138 -0
- package.json +39 -0
- syncSession.js +34 -0
- tools/fileConverter.js +86 -0
- tools/fileGenerator.js +51 -0
- tools/imageGenerator.js +70 -0
- tools/manageReminder.js +52 -0
- tools/schemas/fileConverter.json +14 -0
- tools/schemas/fileGenerator.json +23 -0
- tools/schemas/imageGenerator.json +14 -0
- tools/schemas/manageReminder.json +23 -0
- tools/schemas/stickerMaker.json +15 -0
- tools/schemas/webSearch.json +14 -0
- tools/stickerMaker.js +56 -0
- tools/webSearch.js +69 -0
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
venv/
|
| 3 |
+
.env
|
| 4 |
+
.env.example
|
| 5 |
+
session/
|
| 6 |
+
temp_files/
|
| 7 |
+
*.log
|
| 8 |
+
session_b64.txt
|
| 9 |
+
session_small.zip
|
| 10 |
+
session.zip
|
| 11 |
+
b64.txt
|
| 12 |
+
test_file_gen.js
|
| 13 |
+
hf_upload.js
|
| 14 |
+
package-lock.json
|
| 15 |
+
.DS_Store
|
| 16 |
+
.gemini/
|
| 17 |
+
.gemini_security/
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-bullseye
|
| 2 |
+
|
| 3 |
+
# Install dependencies sistem (FFmpeg & LibreOffice)
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
ffmpeg \
|
| 6 |
+
libreoffice \
|
| 7 |
+
unzip \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Set working directory
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Copy package.json dan install dependencies
|
| 14 |
+
COPY package*.json ./
|
| 15 |
+
RUN npm install
|
| 16 |
+
|
| 17 |
+
# Copy seluruh kode bot
|
| 18 |
+
COPY . .
|
| 19 |
+
|
| 20 |
+
# Port yang dibuka oleh HF
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
# Jalankan bot
|
| 24 |
+
CMD ["npm", "start"]
|
README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🤖 WhatsApp AI Bot (Baileys + Gemini + RAG + Local Tools)
|
| 2 |
+
|
| 3 |
+
Bot WhatsApp canggih berbasis `Baileys` yang terintegrasi dengan berbagai provider AI (Multi-Modal) menggunakan sistem **Native Tool Calling** dan **RAG (Retrieval-Augmented Generation)**. Bot ini dirancang untuk performa tinggi, efisiensi token, dan kemampuan pengolahan file lokal yang kuat.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 🚀 Fitur Utama
|
| 8 |
+
|
| 9 |
+
### 1. Multi-Modal AI & Fallback
|
| 10 |
+
* **Engine Utama**: Gemini 2.5 Flash (Mendukung input Teks, Gambar, Video, dan Stiker).
|
| 11 |
+
* **Engine Cadangan (Auto-Fallback)**: Jika Gemini mengalami error atau limit, bot otomatis beralih ke **Groq (Llama-3.3-70b)** untuk memastikan layanan tetap online.
|
| 12 |
+
* **Konteks Cerdas**: AI mengingat riwayat percakapan secara terpisah antara Chat Pribadi dan Chat Grup.
|
| 13 |
+
|
| 14 |
+
### 2. Sistem RAG (Retrieval-Augmented Generation)
|
| 15 |
+
* **Auto-Extraction**: Membaca otomatis file PDF, DOCX, TXT, dan PPTX yang dikirim user.
|
| 16 |
+
* **Intelligent Analysis**: Menggunakan Groq (Llama-3) untuk merangkum dan memahami isi dokumen secara instan saat file diterima.
|
| 17 |
+
* **Context Injection**: Hasil analisis dokumen disuntikkan langsung ke percakapan berikutnya agar AI memahami konteks file tanpa perlu memanggil tool manual.
|
| 18 |
+
|
| 19 |
+
### 3. Tools Modular (Native Function Calling)
|
| 20 |
+
* **Web Search**: Mencari informasi terbaru secara real-time di Google.
|
| 21 |
+
* **Sticker Maker**: Konversi Gambar & Video (6 detik) menjadi stiker secara lokal menggunakan FFmpeg. (Gunakan perintah `.sticker` atau minta AI).
|
| 22 |
+
* **Universal File Converter**: Mengubah format file apapun secara lokal (PDF <-> Word, Image <-> PDF, Audio Extraction, dll) menggunakan **LibreOffice** & **Sharp**.
|
| 23 |
+
* **Image Generator**: Membuat gambar inovatif melalui OpenRouter (Flux.2 & Seedream).
|
| 24 |
+
* **Smart Reminder**: Penjadwalan pengingat otomatis yang tetap aktif meskipun bot restart.
|
| 25 |
+
|
| 26 |
+
### 4. Aktivitas & UX
|
| 27 |
+
* **Status Indicator**: Menampilkan status seperti _"Mencari di Google..."_ atau _"Membuat stiker..."_ untuk feedback yang transparan.
|
| 28 |
+
* **Sticker Reactive**: AI akan bereaksi secara interaktif terhadap visual stiker yang dikirim oleh user.
|
| 29 |
+
* **Logging Detail**: Seluruh aktivitas pesan, penggunaan tool, dan error dicatat secara rinci di terminal menggunakan `pino-pretty`.
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## 🛠️ Persyaratan Sistem
|
| 34 |
+
|
| 35 |
+
Bot ini menggunakan engine lokal untuk konversi file agar tidak bergantung pada API berbayar:
|
| 36 |
+
1. **Node.js** v18 atau lebih tinggi.
|
| 37 |
+
2. **FFmpeg**: Untuk pembuatan stiker dan konversi media.
|
| 38 |
+
3. **LibreOffice**: Untuk konversi dokumen (PDF, Word, Excel).
|
| 39 |
+
|
| 40 |
+
**Install di Ubuntu/Debian:**
|
| 41 |
+
```bash
|
| 42 |
+
sudo apt update
|
| 43 |
+
sudo apt install ffmpeg libreoffice -y
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## ⚙️ Instalasi & Setup
|
| 49 |
+
|
| 50 |
+
1. **Clone / Download Project**
|
| 51 |
+
2. **Install Dependency:**
|
| 52 |
+
```bash
|
| 53 |
+
npm install
|
| 54 |
+
```
|
| 55 |
+
3. **Konfigurasi Environment:**
|
| 56 |
+
Salin `.env.example` menjadi `.env` dan isi API Key Anda:
|
| 57 |
+
```env
|
| 58 |
+
GOOGLE_AI_API_KEY=your_key
|
| 59 |
+
GROQ_API_KEY=your_key
|
| 60 |
+
OPENROUTER_API_KEY=your_key
|
| 61 |
+
PHONE_NUMBER=6285607277006
|
| 62 |
+
```
|
| 63 |
+
4. **Jalankan Bot:**
|
| 64 |
+
```bash
|
| 65 |
+
npm start
|
| 66 |
+
```
|
| 67 |
+
5. **Pairing:** Masukkan kode pairing yang muncul di terminal ke WhatsApp Anda (Linked Devices).
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 📂 Struktur Project
|
| 72 |
+
|
| 73 |
+
```text
|
| 74 |
+
├── index.js # Entry point & Logika Orchestration
|
| 75 |
+
├── config.js # Konfigurasi API & Environment
|
| 76 |
+
├── lib/
|
| 77 |
+
│ ├── contextManager.js # Pengelola memori percakapan
|
| 78 |
+
│ ├── toolHandler.js # Registry & Eksekutor Tool
|
| 79 |
+
│ ├── ragHandler.js # Ekstraksi teks dokumen cerdas (OCR support)
|
| 80 |
+
│ ├── groqHandler.js # Analisis dokumen cepat via Groq
|
| 81 |
+
│ ├── reminderService.js # Layanan pengingat latar belakang
|
| 82 |
+
│ └── markdownParser.js # Parser format pesan WhatsApp
|
| 83 |
+
├── tools/ # Implementasi fungsi tools lokal
|
| 84 |
+
└── session/ # Penyimpanan sesi & data (reminders, doc_store)
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## 🛡️ Keamanan & Privasi
|
| 90 |
+
* **Offline Processing**: Konversi file dilakukan secara lokal di server Anda.
|
| 91 |
+
* **Channel Blocking**: Bot tidak akan merespons pesan dari WhatsApp Channels/Newsletter untuk menjaga kuota AI.
|
| 92 |
+
* **Auto-Cleanup**: File sementara otomatis dihapus setelah diproses untuk menjaga kerahasiaan data.
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
*Dibuat dengan ❤️ untuk sistem bot WhatsApp yang lebih cerdas dan responsif.*
|
TODO.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project To-Do List: WhatsApp Bot with Baileys + AI (RAG & Tools)
|
| 2 |
+
|
| 3 |
+
- [x] **Project Setup**
|
| 4 |
+
- [x] Initialize `package.json`
|
| 5 |
+
- [x] Install dependencies (`@whiskeysockets/baileys`, `@google/generative-ai`, `dotenv`, `pino`, etc.)
|
| 6 |
+
- [x] Create directory structure (`session`, `tools`, `tools/schemas`, `lib`)
|
| 7 |
+
- [x] Create `.env.example` and `config.js`
|
| 8 |
+
|
| 9 |
+
- [x] **Core Architecture (Baileys)**
|
| 10 |
+
- [x] Implement `index.js` connection logic
|
| 11 |
+
- [x] Add Pairing Code authentication (+62 856-0727-7006 default)
|
| 12 |
+
- [x] Implement Session handling
|
| 13 |
+
- [x] Implement Message Handler (Basic)
|
| 14 |
+
|
| 15 |
+
- [x] **AI Integration & Logic**
|
| 16 |
+
- [x] Create `lib/contextManager.js` (Memory per chat/group)
|
| 17 |
+
- [x] Create `lib/toolHandler.js` (Dynamic Registry & Execution)
|
| 18 |
+
- [x] Implement Native Tool Calling integration with Gemini SDK
|
| 19 |
+
- [x] Implement `lib/markdownParser.js` (AI MD -> WA MD)
|
| 20 |
+
|
| 21 |
+
- [x] **RAG System**
|
| 22 |
+
- [x] Create `lib/ragHandler.js` (Document extraction & Chunking)
|
| 23 |
+
- [x] Implement file support (PDF, DOCX, TXT)
|
| 24 |
+
- [x] Implement retrieval logic (`tools/documentSearch.js`)
|
| 25 |
+
|
| 26 |
+
- [x] **Tools Implementation**
|
| 27 |
+
- [x] Create `tools/schemas/webSearch.json` & `tools/webSearch.js`
|
| 28 |
+
- [x] Create `tools/schemas/fileGenerator.json` & `tools/fileGenerator.js`
|
| 29 |
+
- [x] Implement UX Status Indicators (e.g., "> Mengecek di google")
|
| 30 |
+
|
| 31 |
+
- [x] **Final Polish**
|
| 32 |
+
- [x] Verify structure against requirements
|
| 33 |
+
- [x] Test build/lint (Project structure created and ready to run)
|
config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
require('dotenv').config();
|
| 2 |
+
|
| 3 |
+
const config = {
|
| 4 |
+
ai: {
|
| 5 |
+
groq: {
|
| 6 |
+
apiKey: process.env.GROQ_API_KEY,
|
| 7 |
+
fastModel: process.env.GROQ_FAST_MODEL || 'llama-3.1-8b-instant',
|
| 8 |
+
powerfulModel: process.env.GROQ_POWERFUL_MODEL || 'llama-3.3-70b-versatile'
|
| 9 |
+
},
|
| 10 |
+
openRouter: {
|
| 11 |
+
apiKey: process.env.OPENROUTER_API_KEY,
|
| 12 |
+
model: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b:free'
|
| 13 |
+
},
|
| 14 |
+
google: {
|
| 15 |
+
apiKey: process.env.GOOGLE_AI_API_KEY,
|
| 16 |
+
model: process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'
|
| 17 |
+
},
|
| 18 |
+
huggingFace: {
|
| 19 |
+
apiKey: process.env.HUGGINGFACE_API_KEY,
|
| 20 |
+
model: process.env.HUGGINGFACE_MODEL || 'google/gemma-3-27b-it'
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
search: {
|
| 24 |
+
apiKey: process.env.GOOGLE_SEARCH_API_KEY,
|
| 25 |
+
cseId: process.env.GOOGLE_CSE_ID
|
| 26 |
+
},
|
| 27 |
+
cloudConvertApiKey: process.env.CLOUDCONVERT_API_KEY,
|
| 28 |
+
whatsapp: {
|
| 29 |
+
phoneNumber: process.env.PHONE_NUMBER || process.env.DEFAULT_NUMBER || '6285607277006',
|
| 30 |
+
sessionPath: './session',
|
| 31 |
+
authType: 'pairing' // 'qr' or 'pairing'
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
module.exports = config;
|
index.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const {
|
| 2 |
+
default: makeWASocket,
|
| 3 |
+
useMultiFileAuthState,
|
| 4 |
+
DisconnectReason,
|
| 5 |
+
fetchLatestBaileysVersion,
|
| 6 |
+
makeCacheableSignalKeyStore,
|
| 7 |
+
downloadMediaMessage,
|
| 8 |
+
proto
|
| 9 |
+
} = require('@whiskeysockets/baileys');
|
| 10 |
+
const pino = require('pino');
|
| 11 |
+
const { Boom } = require('@hapi/boom');
|
| 12 |
+
const fs = require('fs-extra');
|
| 13 |
+
const path = require('path');
|
| 14 |
+
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
| 15 |
+
const axios = require('axios');
|
| 16 |
+
const Groq = require('groq-sdk');
|
| 17 |
+
const mime = require('mime-types');
|
| 18 |
+
const express = require('express');
|
| 19 |
+
|
| 20 |
+
const config = require('./config');
|
| 21 |
+
const app = express();
|
| 22 |
+
const PORT = process.env.PORT || 7860; // Port default Hugging Face
|
| 23 |
+
|
| 24 |
+
app.get('/', (req, res) => {
|
| 25 |
+
res.send('Bot WhatsApp is running perfectly!');
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
app.listen(PORT, () => {
|
| 29 |
+
logger.info(`HTTP Server is active on port ${PORT}`);
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
const toolHandler = require('./lib/toolHandler');
|
| 33 |
+
const contextManager = require('./lib/contextManager');
|
| 34 |
+
const ragHandler = require('./lib/ragHandler');
|
| 35 |
+
const groqHandler = require('./lib/groqHandler');
|
| 36 |
+
const reminderService = require('./lib/reminderService');
|
| 37 |
+
const statsTracker = require('./lib/statsTracker');
|
| 38 |
+
const recapManager = require('./lib/recapManager');
|
| 39 |
+
const { parseMarkdownToWhatsApp } = require('./lib/markdownParser');
|
| 40 |
+
|
| 41 |
+
// --- INITIALIZATION ---
|
| 42 |
+
const TEMP_DIR = path.join(__dirname, 'temp_files');
|
| 43 |
+
const SESSION_DIR = path.join(__dirname, 'session');
|
| 44 |
+
const DOC_STORE_DIR = path.join(__dirname, 'session/doc_store');
|
| 45 |
+
|
| 46 |
+
fs.ensureDirSync(TEMP_DIR);
|
| 47 |
+
fs.ensureDirSync(SESSION_DIR);
|
| 48 |
+
fs.ensureDirSync(DOC_STORE_DIR);
|
| 49 |
+
|
| 50 |
+
// Clear temp files on startup
|
| 51 |
+
fs.emptyDirSync(TEMP_DIR);
|
| 52 |
+
|
| 53 |
+
// Enhanced Logger
|
| 54 |
+
const logger = pino({
|
| 55 |
+
level: 'info',
|
| 56 |
+
transport: {
|
| 57 |
+
target: 'pino-pretty',
|
| 58 |
+
options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' }
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// AI Setup
|
| 63 |
+
const genAI = new GoogleGenerativeAI(config.ai.google.apiKey);
|
| 64 |
+
const groq = new Groq({ apiKey: config.ai.groq.apiKey });
|
| 65 |
+
|
| 66 |
+
const pendingDocumentContexts = new Map();
|
| 67 |
+
const model = genAI.getGenerativeModel({
|
| 68 |
+
model: config.ai.google.model,
|
| 69 |
+
tools: toolHandler.getTools()
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
async function startBot() {
|
| 73 |
+
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
| 74 |
+
const { version, isLatest } = await fetchLatestBaileysVersion();
|
| 75 |
+
|
| 76 |
+
logger.info(`Starting WhatsApp Bot v${version.join('.')} (Production Mode)`);
|
| 77 |
+
|
| 78 |
+
const sock = makeWASocket({
|
| 79 |
+
version,
|
| 80 |
+
logger: pino({ level: 'silent' }),
|
| 81 |
+
printQRInTerminal: config.whatsapp.authType !== 'pairing',
|
| 82 |
+
auth: {
|
| 83 |
+
creds: state.creds,
|
| 84 |
+
keys: makeCacheableSignalKeyStore(state.keys, pino({ level: 'silent' })),
|
| 85 |
+
},
|
| 86 |
+
browser: ['Ubuntu', 'Chrome', '20.0.04'],
|
| 87 |
+
generateHighQualityLinkPreview: true,
|
| 88 |
+
getMessage: async (key) => {
|
| 89 |
+
// This helps with downloading quoted media
|
| 90 |
+
return proto.Message.fromObject({});
|
| 91 |
+
}
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// Pairing Code logic
|
| 95 |
+
if (config.whatsapp.authType === 'pairing' && !sock.authState.creds.me) {
|
| 96 |
+
setTimeout(async () => {
|
| 97 |
+
try {
|
| 98 |
+
const phoneNumber = config.whatsapp.phoneNumber.replace(/[^0-9]/g, '');
|
| 99 |
+
if (!phoneNumber) {
|
| 100 |
+
logger.error('Phone number missing in .env for pairing!');
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
const code = await sock.requestPairingCode(phoneNumber);
|
| 104 |
+
console.log(`\n\n[PAIRING CODE]: ${code}\n\n`);
|
| 105 |
+
} catch (err) {
|
| 106 |
+
logger.error(`Failed to request pairing code: ${err.message}`);
|
| 107 |
+
}
|
| 108 |
+
}, 5000);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
sock.ev.on('creds.update', saveCreds);
|
| 112 |
+
|
| 113 |
+
sock.ev.on('connection.update', (update) => {
|
| 114 |
+
const { connection, lastDisconnect } = update;
|
| 115 |
+
if (connection === 'close') {
|
| 116 |
+
const shouldReconnect = (lastDisconnect.error instanceof Boom)
|
| 117 |
+
? lastDisconnect.error.output.statusCode !== DisconnectReason.loggedOut
|
| 118 |
+
: true;
|
| 119 |
+
|
| 120 |
+
logger.error(`Connection lost: ${lastDisconnect.error?.message}. Reconnecting: ${shouldReconnect}`);
|
| 121 |
+
|
| 122 |
+
if (shouldReconnect) {
|
| 123 |
+
setTimeout(startBot, 5000); // 5s delay before reconnect
|
| 124 |
+
} else {
|
| 125 |
+
logger.fatal('Logged out. Please delete session folder and restart.');
|
| 126 |
+
process.exit(1);
|
| 127 |
+
}
|
| 128 |
+
} else if (connection === 'open') {
|
| 129 |
+
logger.info('SUCCESS: Bot is now online and connected.');
|
| 130 |
+
reminderService.init(sock);
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
| 135 |
+
if (type !== 'notify') return;
|
| 136 |
+
|
| 137 |
+
for (const msg of messages) {
|
| 138 |
+
try {
|
| 139 |
+
if (!msg.message || msg.key.fromMe) continue;
|
| 140 |
+
|
| 141 |
+
const remoteJid = msg.key.remoteJid;
|
| 142 |
+
|
| 143 |
+
// Ignore Channels
|
| 144 |
+
if (remoteJid?.endsWith('@newsletter')) continue;
|
| 145 |
+
|
| 146 |
+
const pushName = msg.pushName || 'User';
|
| 147 |
+
const isGroup = remoteJid.endsWith('@g.us');
|
| 148 |
+
|
| 149 |
+
const text = msg.message.conversation || msg.message.extendedTextMessage?.text || msg.message.imageMessage?.caption || msg.message.videoMessage?.caption || '';
|
| 150 |
+
const isImage = !!msg.message.imageMessage;
|
| 151 |
+
const isVideo = !!msg.message.videoMessage;
|
| 152 |
+
const isSticker = !!msg.message.stickerMessage;
|
| 153 |
+
const documentMessage = msg.message.documentMessage;
|
| 154 |
+
|
| 155 |
+
logger.info({ event: 'INCOMING', from: pushName, chat: remoteJid, text: text.slice(0, 50) });
|
| 156 |
+
|
| 157 |
+
// --- TRACK STATISTICS ---
|
| 158 |
+
statsTracker.addActivity(remoteJid, 'user', text);
|
| 159 |
+
|
| 160 |
+
// --- INTERACTIVE RECAP FLOW ---
|
| 161 |
+
if (recapManager.isInRecap(remoteJid)) {
|
| 162 |
+
await sock.sendPresenceUpdate('composing', remoteJid);
|
| 163 |
+
const nextStepText = await recapManager.getNextStep(remoteJid, text);
|
| 164 |
+
if (nextStepText) {
|
| 165 |
+
return await sock.sendMessage(remoteJid, { text: parseMarkdownToWhatsApp(nextStepText) });
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Trigger Recap (Manual for test or auto check)
|
| 170 |
+
if (text.toLowerCase() === '.recap') {
|
| 171 |
+
const intro = await recapManager.initiateRecap(remoteJid, 'monthly');
|
| 172 |
+
if (intro) {
|
| 173 |
+
return await sock.sendMessage(remoteJid, { text: parseMarkdownToWhatsApp(intro) });
|
| 174 |
+
} else {
|
| 175 |
+
return await sock.sendMessage(remoteJid, { text: "_Belum ada data yang cukup untuk membuat recap bulan lalu._" });
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if (documentMessage) { await handleDocument(sock, msg, documentMessage);
|
| 180 |
+
continue;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if (text.startsWith('.sticker') || text.startsWith('.stiker')) {
|
| 184 |
+
logger.info(`Manual sticker command from ${pushName} (${remoteJid})`);
|
| 185 |
+
await handleStickerCommand(sock, msg);
|
| 186 |
+
continue;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if (text.toLowerCase() === '.newchat') {
|
| 190 |
+
contextManager.clearHistory(remoteJid);
|
| 191 |
+
logger.info(`Context cleared for ${remoteJid} by ${pushName}`);
|
| 192 |
+
return await sock.sendMessage(remoteJid, { text: "_Konteks percakapan telah dihapus. Mari mulai obrolan baru!_" });
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
if (!text && !isImage && !isVideo && !isSticker) continue;
|
| 196 |
+
|
| 197 |
+
// --- CONTEXT PREPARATION ---
|
| 198 |
+
let finalUserText = text;
|
| 199 |
+
if (pendingDocumentContexts.has(remoteJid)) {
|
| 200 |
+
const docContext = pendingDocumentContexts.get(remoteJid);
|
| 201 |
+
finalUserText = `Ini adalah isi dokumen yang saya miliki:\n'${docContext}'\n${text || "Apa isi dokumen ini?"}`;
|
| 202 |
+
pendingDocumentContexts.delete(remoteJid);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (isSticker) {
|
| 206 |
+
finalUserText = `[Sticker Received] ${text || "React to this sticker visually."}`;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
let mediaParts = [];
|
| 210 |
+
let currentMediaPath = null;
|
| 211 |
+
let currentMimeType = null;
|
| 212 |
+
|
| 213 |
+
if (isImage || isVideo || isSticker) {
|
| 214 |
+
const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger: pino({ level: 'silent' }), reuploadRequest: sock.updateMediaMessage });
|
| 215 |
+
const mimeType = isImage ? 'image/jpeg' : (isVideo ? 'video/mp4' : 'image/webp');
|
| 216 |
+
mediaParts.push({ inlineData: { data: buffer.toString('base64'), mimeType } });
|
| 217 |
+
|
| 218 |
+
currentMimeType = mimeType;
|
| 219 |
+
currentMediaPath = path.join(TEMP_DIR, `in_${Date.now()}.${mimeType.split('/')[1]}`);
|
| 220 |
+
fs.writeFileSync(currentMediaPath, buffer);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
contextManager.addMessage(remoteJid, finalUserText, 'user');
|
| 224 |
+
await sock.sendPresenceUpdate('composing', remoteJid);
|
| 225 |
+
|
| 226 |
+
// --- AI CORE LOGIC ---
|
| 227 |
+
try {
|
| 228 |
+
let textResponse = await processWithGemini(sock, msg, remoteJid, text, mediaParts, currentMediaPath, currentMimeType);
|
| 229 |
+
|
| 230 |
+
if (textResponse) {
|
| 231 |
+
contextManager.addMessage(remoteJid, textResponse, 'model');
|
| 232 |
+
await sock.sendMessage(remoteJid, { text: parseMarkdownToWhatsApp(textResponse) });
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
} catch (geminiError) {
|
| 236 |
+
logger.warn(`Gemini Error: ${geminiError.message}. Attempting Groq Fallback...`);
|
| 237 |
+
|
| 238 |
+
try {
|
| 239 |
+
let fallbackResponse = await processWithGroq(sock, msg, remoteJid, finalUserText);
|
| 240 |
+
if (fallbackResponse) {
|
| 241 |
+
contextManager.addMessage(remoteJid, fallbackResponse, 'model');
|
| 242 |
+
await sock.sendMessage(remoteJid, { text: parseMarkdownToWhatsApp(fallbackResponse) });
|
| 243 |
+
}
|
| 244 |
+
} catch (groqError) {
|
| 245 |
+
logger.error(`Critical AI Failure: ${groqError.message}`);
|
| 246 |
+
await sock.sendMessage(remoteJid, { text: '_Maaf, sistem AI sedang tidak responsif. Mohon coba lagi nanti._' });
|
| 247 |
+
}
|
| 248 |
+
} finally {
|
| 249 |
+
if (currentMediaPath && fs.existsSync(currentMediaPath)) fs.unlinkSync(currentMediaPath);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
} catch (loopError) {
|
| 253 |
+
logger.error(`Message Processing Error: ${loopError.message}`);
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
});
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// --- AI HANDLERS ---
|
| 260 |
+
|
| 261 |
+
async function processWithGemini(sock, msg, remoteJid, text, mediaParts, currentMediaPath, currentMimeType) {
|
| 262 |
+
const history = contextManager.getHistory(remoteJid);
|
| 263 |
+
const chatHistory = history.length > 0 ? history.slice(0, -1) : [];
|
| 264 |
+
|
| 265 |
+
const chat = model.startChat({
|
| 266 |
+
history: chatHistory,
|
| 267 |
+
generationConfig: { maxOutputTokens: 2048, temperature: 0.7 },
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
const messageParts = [];
|
| 271 |
+
if (text) messageParts.push(text);
|
| 272 |
+
messageParts.push(...mediaParts);
|
| 273 |
+
|
| 274 |
+
let result = await chat.sendMessage(messageParts.length > 0 ? messageParts : "Analyze this.");
|
| 275 |
+
let response = await result.response;
|
| 276 |
+
let textResponse = response.text();
|
| 277 |
+
let functionCalls = response.functionCalls();
|
| 278 |
+
|
| 279 |
+
let loopCount = 0;
|
| 280 |
+
while (functionCalls && functionCalls.length > 0 && loopCount < 5) {
|
| 281 |
+
loopCount++;
|
| 282 |
+
const functionResponses = [];
|
| 283 |
+
|
| 284 |
+
for (const call of functionCalls) {
|
| 285 |
+
const { name, args } = call;
|
| 286 |
+
|
| 287 |
+
if (name === 'webSearch') await sock.sendMessage(remoteJid, { text: `> _Mencari di Google..._` });
|
| 288 |
+
if (name === 'stickerMaker') await sock.sendMessage(remoteJid, { text: `> _Membuat stiker..._` });
|
| 289 |
+
if (name === 'imageGenerator') await sock.sendMessage(remoteJid, { text: `> _Membuat gambar..._` });
|
| 290 |
+
if (['fileGenerator', 'fileConverter'].includes(name)) await sock.sendMessage(remoteJid, { text: `> _Membuat file..._` });
|
| 291 |
+
|
| 292 |
+
let toolContext = { remoteJid, filePath: currentMediaPath, mimeType: currentMimeType };
|
| 293 |
+
if (!toolContext.filePath && ['stickerMaker', 'fileConverter', 'imageGenerator'].includes(name)) {
|
| 294 |
+
const media = await downloadMediaForTool(sock, msg);
|
| 295 |
+
if (media) {
|
| 296 |
+
toolContext.filePath = media.filePath;
|
| 297 |
+
toolContext.mimeType = media.mimeType;
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
const toolResult = await toolHandler.executeTool(name, args, toolContext);
|
| 302 |
+
|
| 303 |
+
// Handle Side Effects
|
| 304 |
+
if (name === 'imageGenerator' && toolResult.success) {
|
| 305 |
+
await sock.sendMessage(remoteJid, { image: { url: toolResult.imageUrl }, caption: `_Generated via Gemini_` });
|
| 306 |
+
} else if (['fileGenerator', 'fileConverter'].includes(name) && toolResult.success) {
|
| 307 |
+
const fileName = path.basename(toolResult.filePath);
|
| 308 |
+
await sock.sendMessage(remoteJid, {
|
| 309 |
+
document: { url: toolResult.filePath },
|
| 310 |
+
mimetype: mime.lookup(fileName) || 'application/octet-stream',
|
| 311 |
+
fileName: fileName
|
| 312 |
+
});
|
| 313 |
+
} else if (name === 'stickerMaker' && toolResult.success) {
|
| 314 |
+
await sock.sendMessage(remoteJid, { sticker: { url: toolResult.stickerPath } });
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
functionResponses.push({ functionResponse: { name, response: { content: toolResult } } });
|
| 318 |
+
if (toolContext.filePath && fs.existsSync(toolContext.filePath)) fs.unlinkSync(toolContext.filePath);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
result = await chat.sendMessage(functionResponses);
|
| 322 |
+
response = await result.response;
|
| 323 |
+
textResponse = response.text();
|
| 324 |
+
functionCalls = response.functionCalls();
|
| 325 |
+
}
|
| 326 |
+
return textResponse;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
async function processWithGroq(sock, msg, remoteJid, text) {
|
| 330 |
+
const history = contextManager.getHistory(remoteJid);
|
| 331 |
+
const groqMessages = [{ role: "system", content: "Anda adalah asisten WhatsApp cerdas dengan akses ke berbagai tools. Jawab dalam Bahasa Indonesia." }];
|
| 332 |
+
|
| 333 |
+
for (const m of history) {
|
| 334 |
+
groqMessages.push({
|
| 335 |
+
role: m.role === 'model' ? 'assistant' : 'user',
|
| 336 |
+
content: m.parts[0].text
|
| 337 |
+
});
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
const tools = toolHandler.getOpenAITools();
|
| 341 |
+
let loopCount = 0;
|
| 342 |
+
|
| 343 |
+
while (loopCount < 5) {
|
| 344 |
+
loopCount++;
|
| 345 |
+
const completion = await groq.chat.completions.create({
|
| 346 |
+
messages: groqMessages,
|
| 347 |
+
model: config.ai.groq.powerfulModel || "llama-3.3-70b-versatile",
|
| 348 |
+
tools: tools,
|
| 349 |
+
tool_choice: "auto"
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
const responseMessage = completion.choices[0].message;
|
| 353 |
+
groqMessages.push(responseMessage);
|
| 354 |
+
|
| 355 |
+
if (responseMessage.tool_calls) {
|
| 356 |
+
for (const toolCall of responseMessage.tool_calls) {
|
| 357 |
+
const { name, arguments: argStr } = toolCall.function;
|
| 358 |
+
const args = JSON.parse(argStr);
|
| 359 |
+
|
| 360 |
+
if (name === 'webSearch') await sock.sendMessage(remoteJid, { text: `> _Mencari di Google..._` });
|
| 361 |
+
if (name === 'stickerMaker') await sock.sendMessage(remoteJid, { text: `> _Membuat stiker..._` });
|
| 362 |
+
if (name === 'imageGenerator') await sock.sendMessage(remoteJid, { text: `> _Membuat gambar..._` });
|
| 363 |
+
if (['fileGenerator', 'fileConverter'].includes(name)) await sock.sendMessage(remoteJid, { text: `> _Membuat file..._` });
|
| 364 |
+
|
| 365 |
+
let toolContext = { remoteJid };
|
| 366 |
+
if (['stickerMaker', 'fileConverter', 'imageGenerator'].includes(name)) {
|
| 367 |
+
const media = await downloadMediaForTool(sock, msg);
|
| 368 |
+
if (media) {
|
| 369 |
+
toolContext.filePath = media.filePath;
|
| 370 |
+
toolContext.mimeType = media.mimeType;
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
const toolResult = await toolHandler.executeTool(name, args, toolContext);
|
| 375 |
+
|
| 376 |
+
if (name === 'imageGenerator' && toolResult.success) {
|
| 377 |
+
await sock.sendMessage(remoteJid, { image: { url: toolResult.imageUrl }, caption: `_Generated via Groq_` });
|
| 378 |
+
} else if (['fileGenerator', 'fileConverter'].includes(name) && toolResult.success) {
|
| 379 |
+
const fileName = path.basename(toolResult.filePath);
|
| 380 |
+
await sock.sendMessage(remoteJid, {
|
| 381 |
+
document: { url: toolResult.filePath },
|
| 382 |
+
mimetype: mime.lookup(fileName) || 'application/octet-stream',
|
| 383 |
+
fileName: fileName
|
| 384 |
+
});
|
| 385 |
+
} else if (name === 'stickerMaker' && toolResult.success) {
|
| 386 |
+
await sock.sendMessage(remoteJid, { sticker: { url: toolResult.stickerPath } });
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
groqMessages.push({ tool_call_id: toolCall.id, role: "tool", name, content: JSON.stringify(toolResult) });
|
| 390 |
+
if (toolContext.filePath && fs.existsSync(toolContext.filePath)) fs.unlinkSync(toolContext.filePath);
|
| 391 |
+
}
|
| 392 |
+
} else {
|
| 393 |
+
return responseMessage.content;
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
return "Gagal memproses permintaan.";
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// --- UTILITIES ---
|
| 400 |
+
|
| 401 |
+
async function downloadMediaForTool(sock, msg) {
|
| 402 |
+
const quoted = msg.message.extendedTextMessage?.contextInfo?.quotedMessage;
|
| 403 |
+
const message = quoted || msg.message;
|
| 404 |
+
const mediaMsg = message.imageMessage || message.videoMessage || message.documentMessage || message.audioMessage || message.stickerMessage;
|
| 405 |
+
if (!mediaMsg) return null;
|
| 406 |
+
|
| 407 |
+
const buffer = await downloadMediaMessage({ message: message }, 'buffer', {}, { logger: pino({ level: 'silent' }), reuploadRequest: sock.updateMediaMessage });
|
| 408 |
+
const ext = mediaMsg.mimetype?.split('/')[1]?.split(';')[0] || 'bin';
|
| 409 |
+
const tempPath = path.join(TEMP_DIR, `tool_${Date.now()}.${ext}`);
|
| 410 |
+
fs.writeFileSync(tempPath, buffer);
|
| 411 |
+
return { filePath: tempPath, mimeType: mediaMsg.mimetype };
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
async function handleStickerCommand(sock, msg) {
|
| 415 |
+
|
| 416 |
+
const remoteJid = msg.key.remoteJid;
|
| 417 |
+
|
| 418 |
+
await sock.sendMessage(remoteJid, { text: '> _Membuat stiker..._' });
|
| 419 |
+
|
| 420 |
+
const media = await downloadMediaForTool(sock, msg);
|
| 421 |
+
if (!media) return sock.sendMessage(remoteJid, { text: '_Kirim/balas media dengan .sticker_'});
|
| 422 |
+
|
| 423 |
+
const result = await toolHandler.executeTool('stickerMaker', { target: 'auto' }, media);
|
| 424 |
+
if (result.success) await sock.sendMessage(remoteJid, { sticker: { url: result.stickerPath } });
|
| 425 |
+
if (fs.existsSync(media.filePath)) fs.unlinkSync(media.filePath);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
async function handleDocument(sock, msg, docMsg) {
|
| 429 |
+
const remoteJid = msg.key.remoteJid;
|
| 430 |
+
const fileName = docMsg.fileName;
|
| 431 |
+
const tempPath = path.join(TEMP_DIR, fileName);
|
| 432 |
+
|
| 433 |
+
await sock.sendMessage(remoteJid, { text: '> _Membaca dokumen..._'});
|
| 434 |
+
try {
|
| 435 |
+
const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger: pino({ level: 'silent' }), reuploadRequest: sock.updateMediaMessage });
|
| 436 |
+
fs.writeFileSync(tempPath, buffer);
|
| 437 |
+
const text = await ragHandler.extractText(tempPath, docMsg.mimetype);
|
| 438 |
+
if (text?.trim().length > 0) {
|
| 439 |
+
const analysis = await groqHandler.analyzeDocument(text, "Buat rangkuman detail.");
|
| 440 |
+
pendingDocumentContexts.set(remoteJid, analysis);
|
| 441 |
+
await sock.sendMessage(remoteJid, { text: `> _Dokumen "${fileName}" selesai dibaca. Dokumennya mau di apain?_` });
|
| 442 |
+
}
|
| 443 |
+
fs.unlinkSync(tempPath);
|
| 444 |
+
} catch (err) {
|
| 445 |
+
logger.error(`RAG Error: ${err.message}`);
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// --- START ---
|
| 450 |
+
startBot().catch(err => logger.fatal(`Startup Crash: ${err.message}`));
|
lib/contextManager.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
|
| 4 |
+
class ContextManager {
|
| 5 |
+
constructor(limit = 20) {
|
| 6 |
+
this.history = new Map(); // Key: remoteJid, Value: Array of messages
|
| 7 |
+
this.limit = limit;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Get chat history for a specific JID
|
| 12 |
+
* @param {string} jid - The Chat ID (user or group)
|
| 13 |
+
* @returns {Array} - Array of message objects { role, parts: [{ text }] }
|
| 14 |
+
*/
|
| 15 |
+
getHistory(jid) {
|
| 16 |
+
if (!this.history.has(jid)) {
|
| 17 |
+
this.history.set(jid, []);
|
| 18 |
+
}
|
| 19 |
+
return this.history.get(jid);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Add a message to the history
|
| 24 |
+
* @param {string} jid
|
| 25 |
+
* @param {string} text
|
| 26 |
+
* @param {string} role - 'user' | 'model' | 'system'
|
| 27 |
+
*/
|
| 28 |
+
addMessage(jid, text, role = 'user') {
|
| 29 |
+
const history = this.getHistory(jid);
|
| 30 |
+
|
| 31 |
+
let finalRole = role;
|
| 32 |
+
let finalText = text;
|
| 33 |
+
|
| 34 |
+
// Gemini only accepts 'user' and 'model'. Handle 'system' or 'ai'.
|
| 35 |
+
if (role === 'ai') {
|
| 36 |
+
finalRole = 'model';
|
| 37 |
+
} else if (role === 'system') {
|
| 38 |
+
finalRole = 'user';
|
| 39 |
+
finalText = `[System Notice]: ${text}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const message = {
|
| 43 |
+
role: finalRole,
|
| 44 |
+
parts: [{ text: finalText }]
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
history.push(message);
|
| 48 |
+
|
| 49 |
+
// Prune if exceeds limit (keep system prompt if we had one, but we are using native tools so maybe less reliance on system prompt in history)
|
| 50 |
+
if (history.length > this.limit) {
|
| 51 |
+
// Remove the oldest message (index 0)
|
| 52 |
+
history.shift();
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Clear history for a JID
|
| 58 |
+
* @param {string} jid
|
| 59 |
+
*/
|
| 60 |
+
clearHistory(jid) {
|
| 61 |
+
this.history.delete(jid);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Get formatted history for Gemini API
|
| 66 |
+
* @param {string} jid
|
| 67 |
+
*/
|
| 68 |
+
getFormattedHistory(jid) {
|
| 69 |
+
return this.getHistory(jid);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Singleton instance
|
| 74 |
+
const contextManager = new ContextManager();
|
| 75 |
+
module.exports = contextManager;
|
lib/groqHandler.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Groq = require('groq-sdk');
|
| 2 |
+
const config = require('../config');
|
| 3 |
+
const pino = require('pino');
|
| 4 |
+
|
| 5 |
+
const logger = pino({
|
| 6 |
+
level: 'info',
|
| 7 |
+
transport: {
|
| 8 |
+
target: 'pino-pretty',
|
| 9 |
+
options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' }
|
| 10 |
+
}
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
const groq = new Groq({
|
| 14 |
+
apiKey: config.ai.groq.apiKey
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
class GroqHandler {
|
| 18 |
+
async analyzeDocument(docText, query) {
|
| 19 |
+
try {
|
| 20 |
+
logger.info(`Groq Analysis Started: Text length ${docText.length}, Query: "${query}"`);
|
| 21 |
+
|
| 22 |
+
const safeText = docText.slice(0, 25000);
|
| 23 |
+
|
| 24 |
+
const completion = await groq.chat.completions.create({
|
| 25 |
+
messages: [
|
| 26 |
+
{
|
| 27 |
+
role: "system",
|
| 28 |
+
content: "Anda adalah asisten analisis dokumen yang cerdas. Tugas anda adalah membaca konteks teks yang diberikan dan menjawab pertanyaan pengguna berdasarkan teks tersebut. Jawablah dalam Bahasa Indonesia yang jelas dan ringkas. Jika diminta merangkum, buatlah rangkuman poin-poin."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
role: "user",
|
| 32 |
+
content: `KONTEKS DOKUMEN:\n${safeText}\n\nPERTANYAAN USER:\n${query}`
|
| 33 |
+
}
|
| 34 |
+
],
|
| 35 |
+
model: config.ai.groq.fastModel || "llama-3.1-8b-instant",
|
| 36 |
+
temperature: 0.5,
|
| 37 |
+
max_tokens: 4096,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const result = completion.choices[0]?.message?.content || "Gagal mendapatkan analisis dari Groq.";
|
| 41 |
+
logger.info(`Groq Analysis Finished. Output length: ${result.length}`);
|
| 42 |
+
|
| 43 |
+
return result;
|
| 44 |
+
} catch (error) {
|
| 45 |
+
logger.error(`Groq Error: ${error.message}`);
|
| 46 |
+
return "Terjadi kesalahan saat model kedua mencoba membaca dokumen.";
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const groqHandler = new GroqHandler();
|
| 52 |
+
module.exports = groqHandler;
|
lib/markdownParser.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Parses Markdown from AI response to WhatsApp compatible format.
|
| 3 |
+
* WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```monospace```
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
function parseMarkdownToWhatsApp(text) {
|
| 7 |
+
if (!text) return '';
|
| 8 |
+
|
| 9 |
+
let formattedText = text;
|
| 10 |
+
|
| 11 |
+
// Bold: **text** or __text__ -> *text*
|
| 12 |
+
formattedText = formattedText.replace(/\*\*(.*?)\*\*/g, '*$1*');
|
| 13 |
+
formattedText = formattedText.replace(/__(.*?)__/g, '*$1*');
|
| 14 |
+
|
| 15 |
+
// Italic: *text* (if not matched by bold) or _text_ -> _text_
|
| 16 |
+
// Note: This is tricky because * is also used for lists.
|
| 17 |
+
// We try to match pairs of * that are not part of a list or bold.
|
| 18 |
+
// A simple approximation:
|
| 19 |
+
formattedText = formattedText.replace(/(?<!\*)\*(?![*\s])(.*?)(?<!\s)\*(?!\*)/g, '_$1_');
|
| 20 |
+
|
| 21 |
+
// Headers: # Header -> *Header*
|
| 22 |
+
formattedText = formattedText.replace(/^#{1,6}\s+(.*)$/gm, '*$1*');
|
| 23 |
+
|
| 24 |
+
// Strikethrough: ~~text~~ -> ~text~
|
| 25 |
+
formattedText = formattedText.replace(/~~(.*?)~~/g, '~$1~');
|
| 26 |
+
|
| 27 |
+
// Links: [text](url) -> text (url)
|
| 28 |
+
formattedText = formattedText.replace(/\[(.*?)\]\((.*?)\)/g, '$1 ($2)');
|
| 29 |
+
|
| 30 |
+
// Lists: - item or * item -> • item (WhatsApp renders bullet points better with •)
|
| 31 |
+
// formattedText = formattedText.replace(/^[\*\-]\s+(.*)$/gm, '• $1');
|
| 32 |
+
|
| 33 |
+
return formattedText;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
module.exports = { parseMarkdownToWhatsApp };
|
lib/ragHandler.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const pdf = require('pdf-parse');
|
| 4 |
+
const mammoth = require('mammoth');
|
| 5 |
+
const officeParser = require('officeparser');
|
| 6 |
+
const XLSX = require('xlsx');
|
| 7 |
+
const Tesseract = require('tesseract.js');
|
| 8 |
+
const mime = require('mime-types');
|
| 9 |
+
|
| 10 |
+
class RagHandler {
|
| 11 |
+
constructor() {
|
| 12 |
+
this.storePath = path.join(__dirname, '../session/doc_store');
|
| 13 |
+
fs.ensureDirSync(this.storePath);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Membersihkan teks hasil ekstraksi dari whitespace berlebih dan karakter aneh.
|
| 18 |
+
*/
|
| 19 |
+
cleanText(text) {
|
| 20 |
+
if (!text) return "";
|
| 21 |
+
return text
|
| 22 |
+
.replace(/\r\n/g, '\n') // Normalize newlines
|
| 23 |
+
.replace(/\t/g, ' ') // Tabs to spaces
|
| 24 |
+
.replace(/ +/g, ' ') // Multiple spaces to single space
|
| 25 |
+
.replace(/\n\s*\n/g, '\n\n') // Max 2 newlines
|
| 26 |
+
.trim();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Smart Extraction Router
|
| 31 |
+
*/
|
| 32 |
+
async extractText(filePath, mimeType) {
|
| 33 |
+
try {
|
| 34 |
+
let extractedText = "";
|
| 35 |
+
const ext = path.extname(filePath).toLowerCase();
|
| 36 |
+
|
| 37 |
+
// 1. PDF Handling
|
| 38 |
+
if (mimeType === 'application/pdf' || ext === '.pdf') {
|
| 39 |
+
const dataBuffer = fs.readFileSync(filePath);
|
| 40 |
+
const data = await pdf(dataBuffer);
|
| 41 |
+
extractedText = data.text;
|
| 42 |
+
|
| 43 |
+
// Jika PDF kosong (mungkin scanned), fallback ke OCR (Optional logic could go here,
|
| 44 |
+
// but pdf-parse is usually fast. OCR on PDF pages requires splitting which is heavy).
|
| 45 |
+
if (extractedText.trim().length < 50) {
|
| 46 |
+
extractedText += "\n[Catatan Sistem: Teks sangat sedikit. Dokumen ini mungkin berisi gambar scan yang sulit dibaca secara langsung.]";
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
// 2. Word (DOCX)
|
| 50 |
+
else if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === '.docx') {
|
| 51 |
+
const result = await mammoth.extractRawText({ path: filePath });
|
| 52 |
+
extractedText = result.value;
|
| 53 |
+
}
|
| 54 |
+
// 3. Excel / CSV (XLSX, XLS, CSV)
|
| 55 |
+
else if (
|
| 56 |
+
mimeType.includes('spreadsheet') ||
|
| 57 |
+
mimeType.includes('excel') ||
|
| 58 |
+
ext === '.xlsx' || ext === '.xls' || ext === '.csv'
|
| 59 |
+
) {
|
| 60 |
+
const workbook = XLSX.readFile(filePath);
|
| 61 |
+
const sheetNames = workbook.SheetNames;
|
| 62 |
+
let excelData = [];
|
| 63 |
+
|
| 64 |
+
sheetNames.forEach(name => {
|
| 65 |
+
const sheet = workbook.Sheets[name];
|
| 66 |
+
const csv = XLSX.utils.sheet_to_csv(sheet);
|
| 67 |
+
if (csv && csv.trim().length > 0) {
|
| 68 |
+
excelData.push(`--- Sheet: ${name} ---
|
| 69 |
+
${csv}`);
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
extractedText = excelData.join('\n\n');
|
| 73 |
+
}
|
| 74 |
+
// 4. PowerPoint (PPTX, PPT)
|
| 75 |
+
else if (mimeType.includes('presentation') || mimeType.includes('powerpoint') || ext === '.pptx' || ext === '.ppt') {
|
| 76 |
+
extractedText = await new Promise((resolve, reject) => {
|
| 77 |
+
officeParser.parseOffice(filePath, (data, err) => {
|
| 78 |
+
if (err) resolve(""); // Fail gracefully
|
| 79 |
+
else resolve(data);
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
// 5. Images (OCR)
|
| 84 |
+
else if (mimeType.startsWith('image/') || ['.jpg', '.jpeg', '.png', '.bmp'].includes(ext)) {
|
| 85 |
+
console.log(`Starting OCR for ${filePath}...`);
|
| 86 |
+
const { data: { text } } = await Tesseract.recognize(filePath, 'ind', { // 'ind' for Indonesian
|
| 87 |
+
logger: m => {} // Silent logger
|
| 88 |
+
});
|
| 89 |
+
extractedText = text;
|
| 90 |
+
}
|
| 91 |
+
// 6. Plain Text / Code
|
| 92 |
+
else {
|
| 93 |
+
// Fallback for .txt, .js, .py, .json, etc.
|
| 94 |
+
extractedText = fs.readFileSync(filePath, 'utf8');
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return this.cleanText(extractedText);
|
| 98 |
+
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error('Smart Extraction Error:', error);
|
| 101 |
+
return `[Error: Gagal mengekstrak teks dari file ini. Format mungkin rusak atau tidak didukung.]`;
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
async saveDocumentContext(jid, text) {
|
| 106 |
+
const filePath = path.join(this.storePath, `${jid.replace(/\D/g, '')}.txt`);
|
| 107 |
+
await fs.writeFile(filePath, text, 'utf8');
|
| 108 |
+
return filePath;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
async getDocumentContext(jid) {
|
| 112 |
+
const filePath = path.join(this.storePath, `${jid.replace(/\D/g, '')}.txt`);
|
| 113 |
+
if (fs.existsSync(filePath)) {
|
| 114 |
+
return await fs.readFile(filePath, 'utf8');
|
| 115 |
+
}
|
| 116 |
+
return null;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const ragHandler = new RagHandler();
|
| 121 |
+
module.exports = ragHandler;
|
lib/recapManager.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const groqHandler = require('./groqHandler');
|
| 2 |
+
const statsTracker = require('./statsTracker');
|
| 3 |
+
|
| 4 |
+
class RecapManager {
|
| 5 |
+
constructor() {
|
| 6 |
+
this.activeRecaps = new Map(); // jid -> { type, step, data }
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Memulai proses recap
|
| 11 |
+
*/
|
| 12 |
+
async initiateRecap(jid, type = 'monthly') {
|
| 13 |
+
const now = new Date();
|
| 14 |
+
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
| 15 |
+
const year = lastMonth.getFullYear().toString();
|
| 16 |
+
const month = (lastMonth.getMonth() + 1).toString().padStart(2, '0');
|
| 17 |
+
|
| 18 |
+
const stats = statsTracker.getUserStats(jid, year, month);
|
| 19 |
+
if (!stats || stats.total_messages < 5) return null;
|
| 20 |
+
|
| 21 |
+
const monthName = lastMonth.toLocaleString('id-ID', { month: 'long' });
|
| 22 |
+
|
| 23 |
+
// State awal
|
| 24 |
+
this.activeRecaps.set(jid, {
|
| 25 |
+
type,
|
| 26 |
+
step: 0,
|
| 27 |
+
monthName,
|
| 28 |
+
stats,
|
| 29 |
+
history: [] // Untuk menjaga konsistensi narasi AI
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
return this.getNextStep(jid);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async getNextStep(jid, userReply = '') {
|
| 36 |
+
const session = this.activeRecaps.get(jid);
|
| 37 |
+
if (!session) return null;
|
| 38 |
+
|
| 39 |
+
session.step++;
|
| 40 |
+
|
| 41 |
+
const { stats, monthName, step, type } = session;
|
| 42 |
+
|
| 43 |
+
// Prompt dasar untuk AI agar gaya bicaranya "Deep & Trendy"
|
| 44 |
+
const systemPrompt = `Anda adalah asisten yang sedang memberikan kilas balik (recap) ${type} kepada user.
|
| 45 |
+
Gunakan gaya bahasa anak muda zaman sekarang yang puitis tapi santai, gunakan plesetan atau tren terkait bulan ${monthName}.
|
| 46 |
+
Urutan ini harus terasa personal (psychologically moving).
|
| 47 |
+
Jangan berikan semua info sekaligus. HANYA berikan bagian untuk STEP ${step}.
|
| 48 |
+
Gunakan Bahasa Indonesia yang sangat akrab.`;
|
| 49 |
+
|
| 50 |
+
let prompt = "";
|
| 51 |
+
|
| 52 |
+
if (type === 'monthly') {
|
| 53 |
+
switch(step) {
|
| 54 |
+
case 1: // Hook & Intro
|
| 55 |
+
prompt = "Berikan kalimat pembuka yang sangat menarik tentang perjalanan kita di bulan ${monthName}. Mention bahwa kita sudah melewati banyak hal bersama.";
|
| 56 |
+
break;
|
| 57 |
+
case 2: // Stats Dasar
|
| 58 |
+
prompt = "Berikan data: Kita ngobrol selama ${stats.days_active.length} hari dengan total ${stats.total_messages} pesan. Berikan komentar unik tentang angka ini.";
|
| 59 |
+
break;
|
| 60 |
+
case 3: // Puncak & Kebiasaan
|
| 61 |
+
const peakHour = Object.entries(stats.hourly_activity).sort((a,b) => b[1]-a[1])[0][0];
|
| 62 |
+
prompt = `Bahas tentang puncak aktivitas kita yang biasanya jam ${peakHour}.00. Juga bahas pola/kebiasaan baru yang kamu tangkap dari topik ini: ${stats.topics_summary.join(", ")}. Tanya pendapat user tentang kebiasaan ini untuk mengakhiri recap.`;
|
| 63 |
+
break;
|
| 64 |
+
default:
|
| 65 |
+
this.activeRecaps.delete(jid);
|
| 66 |
+
return null;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const response = await groqHandler.analyzeDocument(JSON.stringify(stats), `${systemPrompt}\n\n${prompt}`);
|
| 71 |
+
|
| 72 |
+
// Jika step terakhir, hapus sesi
|
| 73 |
+
if (step >= 3) this.activeRecaps.delete(jid);
|
| 74 |
+
|
| 75 |
+
return response;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
isInRecap(jid) {
|
| 79 |
+
return this.activeRecaps.has(jid);
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const recapManager = new RecapManager();
|
| 84 |
+
module.exports = recapManager;
|
lib/reminderService.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const schedule = require('node-schedule');
|
| 2 |
+
const fs = require('fs-extra');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
const pino = require('pino');
|
| 5 |
+
|
| 6 |
+
const logger = pino({
|
| 7 |
+
level: 'info',
|
| 8 |
+
transport: {
|
| 9 |
+
target: 'pino-pretty',
|
| 10 |
+
options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' }
|
| 11 |
+
}
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
const REMINDERS_FILE = path.join(__dirname, '../session/reminders.json');
|
| 15 |
+
|
| 16 |
+
class ReminderService {
|
| 17 |
+
constructor() {
|
| 18 |
+
this.jobs = new Map(); // id -> job
|
| 19 |
+
this.sock = null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
init(sock) {
|
| 23 |
+
this.sock = sock;
|
| 24 |
+
|
| 25 |
+
// Ensure file exists to prevent watch error
|
| 26 |
+
if (!fs.existsSync(REMINDERS_FILE)) {
|
| 27 |
+
fs.writeJSONSync(REMINDERS_FILE, [], { spaces: 2 });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
this.loadAndScheduleAll();
|
| 31 |
+
|
| 32 |
+
// Use watchFile for better cross-platform stability in production
|
| 33 |
+
fs.watchFile(REMINDERS_FILE, { interval: 5000 }, (curr, prev) => {
|
| 34 |
+
if (curr.mtime !== prev.mtime) {
|
| 35 |
+
logger.info('Reminders file changed, reloading...');
|
| 36 |
+
this.loadAndScheduleAll();
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
loadAndScheduleAll() {
|
| 42 |
+
try {
|
| 43 |
+
if (!fs.existsSync(REMINDERS_FILE)) return;
|
| 44 |
+
const reminders = fs.readJSONSync(REMINDERS_FILE);
|
| 45 |
+
|
| 46 |
+
reminders.forEach(r => {
|
| 47 |
+
if (r.status === 'pending' && !this.jobs.has(r.id)) {
|
| 48 |
+
const scheduledDate = new Date(r.time);
|
| 49 |
+
const now = new Date();
|
| 50 |
+
|
| 51 |
+
if (scheduledDate > now) {
|
| 52 |
+
this.scheduleJob(r);
|
| 53 |
+
} else {
|
| 54 |
+
// Mark missed reminders as done or notify immediately
|
| 55 |
+
logger.warn(`Missed reminder found for [${r.id}] scheduled at ${r.time}. Marking as done.`);
|
| 56 |
+
this.markAsDone(r.id);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
} catch (e) {
|
| 61 |
+
logger.error(`Error loading reminders: ${e.message}`);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
scheduleJob(reminder) {
|
| 66 |
+
// Prevent duplicate jobs
|
| 67 |
+
if (this.jobs.has(reminder.id)) return;
|
| 68 |
+
|
| 69 |
+
const job = schedule.scheduleJob(new Date(reminder.time), async () => {
|
| 70 |
+
try {
|
| 71 |
+
if (this.sock) {
|
| 72 |
+
logger.info({ event: 'REMINDER_FIRED', to: reminder.jid, task: reminder.task });
|
| 73 |
+
await this.sock.sendMessage(reminder.jid, {
|
| 74 |
+
text: `⏰ *REMINDER CERDAS*\n\nHalo! Saya diingatkan untuk memberitahu Anda:\n\n> "${reminder.task}" `
|
| 75 |
+
});
|
| 76 |
+
this.markAsDone(reminder.id);
|
| 77 |
+
this.jobs.delete(reminder.id);
|
| 78 |
+
}
|
| 79 |
+
} catch (err) {
|
| 80 |
+
logger.error(`Failed to send reminder [${reminder.id}]: ${err.message}`);
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
if (job) {
|
| 85 |
+
this.jobs.set(reminder.id, job);
|
| 86 |
+
logger.info(`Scheduled reminder [${reminder.id}] for ${reminder.time}`);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
markAsDone(id) {
|
| 91 |
+
try {
|
| 92 |
+
// Read fresh data to avoid overwriting changes from other processes
|
| 93 |
+
const reminders = fs.readJSONSync(REMINDERS_FILE);
|
| 94 |
+
const index = reminders.findIndex(r => r.id === id);
|
| 95 |
+
if (index !== -1) {
|
| 96 |
+
reminders[index].status = 'done';
|
| 97 |
+
fs.writeJSONSync(REMINDERS_FILE, reminders, { spaces: 2 });
|
| 98 |
+
}
|
| 99 |
+
} catch (e) {
|
| 100 |
+
logger.error(`Error marking reminder as done: ${e.message}`);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const reminderService = new ReminderService();
|
| 106 |
+
module.exports = reminderService;
|
lib/statsTracker.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
|
| 4 |
+
const STATS_FILE = path.join(__dirname, '../session/stats.json');
|
| 5 |
+
|
| 6 |
+
class StatsTracker {
|
| 7 |
+
constructor() {
|
| 8 |
+
fs.ensureFileSync(STATS_FILE);
|
| 9 |
+
this.data = this.loadData();
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
loadData() {
|
| 13 |
+
try {
|
| 14 |
+
return fs.readJSONSync(STATS_FILE);
|
| 15 |
+
} catch (e) {
|
| 16 |
+
return {};
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
saveData() {
|
| 21 |
+
fs.writeJSONSync(STATS_FILE, this.data, { spaces: 2 });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Mencatat setiap pesan masuk
|
| 26 |
+
*/
|
| 27 |
+
addActivity(jid, role = 'user', text = '') {
|
| 28 |
+
const now = new Date();
|
| 29 |
+
const year = now.getFullYear().toString();
|
| 30 |
+
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
| 31 |
+
const day = now.getDate().toString().padStart(2, '0');
|
| 32 |
+
|
| 33 |
+
if (!this.data[jid]) this.data[jid] = {};
|
| 34 |
+
if (!this.data[jid][year]) this.data[jid][year] = {};
|
| 35 |
+
if (!this.data[jid][year][month]) this.data[jid][year][month] = {
|
| 36 |
+
total_messages: 0,
|
| 37 |
+
days_active: [],
|
| 38 |
+
hourly_activity: {},
|
| 39 |
+
topics_summary: [] // Cuplikan singkat untuk bahan AI
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const monthData = this.data[jid][year][month];
|
| 43 |
+
monthData.total_messages++;
|
| 44 |
+
|
| 45 |
+
if (!monthData.days_active.includes(day)) {
|
| 46 |
+
monthData.days_active.push(day);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const hour = now.getHours().toString();
|
| 50 |
+
monthData.hourly_activity[hour] = (monthData.hourly_activity[hour] || 0) + 1;
|
| 51 |
+
|
| 52 |
+
// Simpan sedikit sampel teks (maks 10 sampel per bulan untuk privasi & efisiensi)
|
| 53 |
+
if (role === 'user' && text.length > 10 && monthData.topics_summary.length < 30) {
|
| 54 |
+
if (Math.random() > 0.7) { // Random sampling
|
| 55 |
+
monthData.topics_summary.push(text.slice(0, 100));
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
this.saveData();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
getUserStats(jid, year, month) {
|
| 63 |
+
return this.data[jid]?.[year]?.[month] || null;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
getYearlyStats(jid, year) {
|
| 67 |
+
return this.data[jid]?.[year] || null;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const statsTracker = new StatsTracker();
|
| 72 |
+
module.exports = statsTracker;
|
lib/toolHandler.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const pino = require('pino');
|
| 4 |
+
|
| 5 |
+
const logger = pino({
|
| 6 |
+
level: 'info',
|
| 7 |
+
transport: {
|
| 8 |
+
target: 'pino-pretty',
|
| 9 |
+
options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' }
|
| 10 |
+
}
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
class ToolHandler {
|
| 14 |
+
constructor() {
|
| 15 |
+
this.toolsPath = path.join(__dirname, '../tools');
|
| 16 |
+
this.schemasPath = path.join(__dirname, '../tools/schemas');
|
| 17 |
+
this.registry = new Map();
|
| 18 |
+
this._initRegistry();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
_initRegistry() {
|
| 22 |
+
if (!fs.existsSync(this.schemasPath)) return;
|
| 23 |
+
|
| 24 |
+
const schemas = fs.readdirSync(this.schemasPath).filter(f => f.endsWith('.json'));
|
| 25 |
+
|
| 26 |
+
for (const schemaFile of schemas) {
|
| 27 |
+
const toolName = path.basename(schemaFile, '.json');
|
| 28 |
+
const implFile = path.join(this.toolsPath, `${toolName}.js`);
|
| 29 |
+
|
| 30 |
+
if (fs.existsSync(implFile)) {
|
| 31 |
+
this.registry.set(toolName, {
|
| 32 |
+
schemaPath: path.join(this.schemasPath, schemaFile),
|
| 33 |
+
implPath: implFile
|
| 34 |
+
});
|
| 35 |
+
} else {
|
| 36 |
+
logger.warn(`Tool schema found for "${toolName}" but implementation file is missing.`);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
getTools() {
|
| 42 |
+
const tools = [];
|
| 43 |
+
for (const [name, paths] of this.registry) {
|
| 44 |
+
try {
|
| 45 |
+
const schema = fs.readJSONSync(paths.schemaPath);
|
| 46 |
+
schema.name = name;
|
| 47 |
+
tools.push(schema);
|
| 48 |
+
} catch (err) {
|
| 49 |
+
logger.error(`Error reading schema for ${name}: ${err.message}`);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
return [{ function_declarations: tools }];
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Get tools in OpenAI/Groq format
|
| 57 |
+
*/
|
| 58 |
+
getOpenAITools() {
|
| 59 |
+
const tools = [];
|
| 60 |
+
for (const [name, paths] of this.registry) {
|
| 61 |
+
try {
|
| 62 |
+
const schema = fs.readJSONSync(paths.schemaPath);
|
| 63 |
+
|
| 64 |
+
// Convert Gemini-style schema to OpenAI-style
|
| 65 |
+
const convertSchema = (obj) => {
|
| 66 |
+
if (typeof obj !== 'object' || obj === null) return obj;
|
| 67 |
+
|
| 68 |
+
const newObj = Array.isArray(obj) ? [] : {};
|
| 69 |
+
for (const key in obj) {
|
| 70 |
+
if (key === 'type' && typeof obj[key] === 'string') {
|
| 71 |
+
// Map Gemini types to JSON Schema types
|
| 72 |
+
const typeMap = {
|
| 73 |
+
'STRING': 'string',
|
| 74 |
+
'NUMBER': 'number',
|
| 75 |
+
'INTEGER': 'integer',
|
| 76 |
+
'BOOLEAN': 'boolean',
|
| 77 |
+
'ARRAY': 'array',
|
| 78 |
+
'OBJECT': 'object'
|
| 79 |
+
};
|
| 80 |
+
newObj[key] = typeMap[obj[key]] || obj[key].toLowerCase();
|
| 81 |
+
} else {
|
| 82 |
+
newObj[key] = convertSchema(obj[key]);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
return newObj;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const openAISchema = {
|
| 89 |
+
type: "function",
|
| 90 |
+
function: {
|
| 91 |
+
name: name,
|
| 92 |
+
description: schema.description,
|
| 93 |
+
parameters: convertSchema(schema.parameters)
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
tools.push(openAISchema);
|
| 97 |
+
} catch (err) {
|
| 98 |
+
logger.error(`Error formatting OpenAI schema for ${name}: ${err.message}`);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
return tools.length > 0 ? tools : undefined;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
async executeTool(name, args, context = {}) {
|
| 105 |
+
if (!this.registry.has(name)) {
|
| 106 |
+
logger.error(`Execution failed: Tool "${name}" not found in registry.`);
|
| 107 |
+
throw new Error(`Tool ${name} not found`);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const { implPath } = this.registry.get(name);
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
logger.info({ event: 'TOOL_EXEC_START', tool: name, args });
|
| 114 |
+
|
| 115 |
+
const toolModule = require(implPath);
|
| 116 |
+
if (typeof toolModule.execute !== 'function') {
|
| 117 |
+
throw new Error(`Tool ${name} does not export an 'execute' function.`);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const result = await toolModule.execute(args, context);
|
| 121 |
+
|
| 122 |
+
logger.info({
|
| 123 |
+
event: 'TOOL_EXEC_END',
|
| 124 |
+
tool: name,
|
| 125 |
+
success: !!(result && !result.error),
|
| 126 |
+
output: typeof result === 'object' ? JSON.stringify(result).slice(0, 500) : result
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
return result;
|
| 130 |
+
} catch (error) {
|
| 131 |
+
logger.error({ event: 'TOOL_EXEC_ERROR', tool: name, error: error.message });
|
| 132 |
+
return { error: error.message };
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const toolHandler = new ToolHandler();
|
| 138 |
+
module.exports = toolHandler;
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "whatsapp2",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "",
|
| 5 |
+
"main": "index.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"start": "node syncSession.js && node index.js",
|
| 8 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"express": "^4.18.2",
|
| 12 |
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
| 13 |
+
"@google/generative-ai": "^0.24.1",
|
| 14 |
+
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
| 15 |
+
"axios": "^1.13.4",
|
| 16 |
+
"canvas": "^3.2.1",
|
| 17 |
+
"cloudconvert": "^3.0.0",
|
| 18 |
+
"docx": "^9.5.1",
|
| 19 |
+
"dotenv": "^17.2.3",
|
| 20 |
+
"fluent-ffmpeg": "^2.1.3",
|
| 21 |
+
"fs-extra": "^11.3.3",
|
| 22 |
+
"groq-sdk": "^0.37.0",
|
| 23 |
+
"mammoth": "^1.11.0",
|
| 24 |
+
"mime-types": "^3.0.2",
|
| 25 |
+
"node-schedule": "^2.1.1",
|
| 26 |
+
"officeparser": "^6.0.4",
|
| 27 |
+
"pdf-parse": "^2.4.5",
|
| 28 |
+
"pdfkit": "^0.17.2",
|
| 29 |
+
"pino": "^10.3.0",
|
| 30 |
+
"pino-pretty": "^13.1.3",
|
| 31 |
+
"puppeteer": "^24.36.1",
|
| 32 |
+
"puppeteer-extra": "^3.3.6",
|
| 33 |
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
| 34 |
+
"qrcode-terminal": "^0.12.0",
|
| 35 |
+
"sharp": "^0.34.5",
|
| 36 |
+
"tesseract.js": "^7.0.0",
|
| 37 |
+
"xlsx": "^0.18.5"
|
| 38 |
+
}
|
| 39 |
+
}
|
syncSession.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const { execSync } = require('child_process');
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Script ini mendeteksi jika ada data sesi di Environment Variable
|
| 7 |
+
* dan mengembalikannya ke folder ./session sebelum bot dijalankan.
|
| 8 |
+
*/
|
| 9 |
+
async function syncSession() {
|
| 10 |
+
const sessionB64 = process.env.SESSION_DATA;
|
| 11 |
+
const sessionPath = path.join(__dirname, 'session');
|
| 12 |
+
|
| 13 |
+
if (sessionB64 && sessionB64.length > 100) {
|
| 14 |
+
console.log("Mendeteksi SESSION_DATA dari Secret... Mengekstrak...");
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
const zipPath = path.join(__dirname, 'session.zip');
|
| 18 |
+
fs.writeFileSync(zipPath, Buffer.from(sessionB64, 'base64'));
|
| 19 |
+
|
| 20 |
+
// Ekstrak menggunakan unzip (tersedia di Linux HF)
|
| 21 |
+
fs.ensureDirSync(sessionPath);
|
| 22 |
+
execSync(`unzip -o ${zipPath} -d .`);
|
| 23 |
+
fs.unlinkSync(zipPath);
|
| 24 |
+
|
| 25 |
+
console.log("Sesi berhasil dipulihkan!");
|
| 26 |
+
} catch (error) {
|
| 27 |
+
console.error("Gagal mengekstrak sesi:", error.message);
|
| 28 |
+
}
|
| 29 |
+
} else {
|
| 30 |
+
console.log("Tidak ada SESSION_DATA ditemukan. Memulai sesi baru.");
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
syncSession();
|
tools/fileConverter.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const path = require('path');
|
| 2 |
+
const fs = require('fs-extra');
|
| 3 |
+
const { exec } = require('child_process');
|
| 4 |
+
const ffmpeg = require('fluent-ffmpeg');
|
| 5 |
+
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
|
| 6 |
+
const sharp = require('sharp');
|
| 7 |
+
|
| 8 |
+
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
| 9 |
+
|
| 10 |
+
module.exports = {
|
| 11 |
+
/**
|
| 12 |
+
* Konverter File Lokal yang Kuat (FFmpeg + Sharp + LibreOffice)
|
| 13 |
+
*/
|
| 14 |
+
execute: async (args, context) => {
|
| 15 |
+
const { outputFormat } = args;
|
| 16 |
+
const { filePath, mimeType } = context;
|
| 17 |
+
|
| 18 |
+
if (!outputFormat) return { error: 'Format output tidak ditentukan.' };
|
| 19 |
+
if (!filePath || !fs.existsSync(filePath)) return { error: 'File sumber tidak ditemukan.' };
|
| 20 |
+
|
| 21 |
+
const inputExt = path.extname(filePath).toLowerCase().replace('.', '');
|
| 22 |
+
const targetFormat = outputFormat.toLowerCase().replace('.', '');
|
| 23 |
+
const outputFileName = `${path.parse(filePath).name}_converted.${targetFormat}`;
|
| 24 |
+
const outputPath = path.join(__dirname, '../temp_files', outputFileName);
|
| 25 |
+
|
| 26 |
+
fs.ensureDirSync(path.join(__dirname, '../temp_files'));
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
// --- KATEGORI 1: GAMBAR (PNG, JPG, WEBP, TIFF, dll) ---
|
| 30 |
+
if (mimeType.startsWith('image/') && !mimeType.includes('photoshop')) {
|
| 31 |
+
await sharp(filePath)
|
| 32 |
+
.toFormat(targetFormat)
|
| 33 |
+
.toFile(outputPath);
|
| 34 |
+
return { success: true, filePath: outputPath };
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// --- KATEGORI 2: AUDIO & VIDEO (MP4, MP3, MKV, WAV, dll) ---
|
| 38 |
+
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/') || ['mp4', 'mkv', 'avi', 'mov', 'mp3', 'wav', 'flac', 'opus'].includes(inputExt)) {
|
| 39 |
+
return new Promise((resolve) => {
|
| 40 |
+
let command = ffmpeg(filePath).toFormat(targetFormat);
|
| 41 |
+
|
| 42 |
+
if (targetFormat === 'mp3') {
|
| 43 |
+
command.audioBitrate('192k').noVideo();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
command
|
| 47 |
+
.on('end', () => resolve({ success: true, filePath: outputPath }))
|
| 48 |
+
.on('error', (err) => resolve({ error: `FFmpeg Error: ${err.message}` }))
|
| 49 |
+
.save(outputPath);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// --- KATEGORI 3: DOKUMEN (DOCX, PDF, XLSX, PPTX) ---
|
| 54 |
+
// Menggunakan LibreOffice Headless (Harus terinstall di OS)
|
| 55 |
+
const docFormats = ['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'txt', 'rtf', 'html'];
|
| 56 |
+
if (docFormats.includes(inputExt) || docFormats.includes(targetFormat)) {
|
| 57 |
+
return new Promise((resolve) => {
|
| 58 |
+
// LibreOffice command: libreoffice --headless --convert-to [format] [file] --outdir [dir]
|
| 59 |
+
const cmd = `libreoffice --headless --convert-to ${targetFormat} "${filePath}" --outdir "${path.join(__dirname, '../temp_files')}"`;
|
| 60 |
+
|
| 61 |
+
exec(cmd, (error, stdout, stderr) => {
|
| 62 |
+
if (error) {
|
| 63 |
+
return resolve({ error: `LibreOffice Error: ${error.message}. Pastikan LibreOffice terinstall di server.` });
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// LibreOffice terkadang menggunakan nama file asli, kita perlu mendeteksinya
|
| 67 |
+
const expectedPath = path.join(__dirname, '../temp_files', `${path.parse(filePath).name}.${targetFormat}`);
|
| 68 |
+
if (fs.existsSync(expectedPath)) {
|
| 69 |
+
// Rename agar sesuai dengan outputFileName kita (opsional)
|
| 70 |
+
fs.renameSync(expectedPath, outputPath);
|
| 71 |
+
resolve({ success: true, filePath: outputPath });
|
| 72 |
+
} else {
|
| 73 |
+
resolve({ error: "Gagal menemukan file hasil konversi LibreOffice." });
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return { error: `Format konversi dari ${inputExt} ke ${targetFormat} belum didukung oleh engine lokal.` };
|
| 80 |
+
|
| 81 |
+
} catch (error) {
|
| 82 |
+
console.error('Local Conversion Error:', error);
|
| 83 |
+
return { error: `Konversi gagal: ${error.message}` };
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
};
|
tools/fileGenerator.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const PDFDocument = require('pdfkit');
|
| 4 |
+
const { Document, Packer, Paragraph, TextRun } = require('docx');
|
| 5 |
+
|
| 6 |
+
const TEMP_DIR = path.join(__dirname, '../temp_files');
|
| 7 |
+
fs.ensureDirSync(TEMP_DIR);
|
| 8 |
+
|
| 9 |
+
module.exports = {
|
| 10 |
+
execute: async ({ filename, content, format }) => {
|
| 11 |
+
const filePath = path.join(TEMP_DIR, filename);
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
if (format === 'pdf' || filename.endsWith('.pdf')) {
|
| 15 |
+
const doc = new PDFDocument();
|
| 16 |
+
const stream = fs.createWriteStream(filePath);
|
| 17 |
+
doc.pipe(stream);
|
| 18 |
+
doc.fontSize(12).text(content, 100, 100);
|
| 19 |
+
doc.end();
|
| 20 |
+
|
| 21 |
+
await new Promise((resolve) => stream.on('finish', resolve));
|
| 22 |
+
} else if (format === 'docx' || filename.endsWith('.docx')) {
|
| 23 |
+
const doc = new Document({
|
| 24 |
+
sections: [{
|
| 25 |
+
properties: {},
|
| 26 |
+
children: content.split('\n').map(line =>
|
| 27 |
+
new Paragraph({
|
| 28 |
+
children: [new TextRun(line)],
|
| 29 |
+
})
|
| 30 |
+
),
|
| 31 |
+
}],
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
const buffer = await Packer.toBuffer(doc);
|
| 35 |
+
fs.writeFileSync(filePath, buffer);
|
| 36 |
+
} else {
|
| 37 |
+
// txt, code (py, js, html)
|
| 38 |
+
fs.writeFileSync(filePath, content);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
success: true,
|
| 43 |
+
filePath: filePath,
|
| 44 |
+
message: `File ${filename} created successfully.`
|
| 45 |
+
};
|
| 46 |
+
} catch (error) {
|
| 47 |
+
console.error('File Generation Error:', error);
|
| 48 |
+
return { error: 'Failed to generate file.' };
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
};
|
tools/imageGenerator.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios');
|
| 2 |
+
const config = require('../config');
|
| 3 |
+
const fs = require('fs-extra');
|
| 4 |
+
const path = require('path');
|
| 5 |
+
|
| 6 |
+
const MODELS = [
|
| 7 |
+
"black-forest-labs/flux.2-klein-4b",
|
| 8 |
+
"bytedance-seed/seedream-4.5",
|
| 9 |
+
"black-forest-labs/flux.2-max"
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
module.exports = {
|
| 13 |
+
/**
|
| 14 |
+
* @param {object} args - { prompt }
|
| 15 |
+
* @param {object} context - { filePath, mimeType } for Image-to-Image
|
| 16 |
+
*/
|
| 17 |
+
execute: async (args, context) => {
|
| 18 |
+
const { prompt } = args;
|
| 19 |
+
const { filePath, mimeType } = context;
|
| 20 |
+
|
| 21 |
+
if (!config.ai.openRouter.apiKey) {
|
| 22 |
+
return { error: "OpenRouter API Key is missing." };
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Prepare image data if exists (Image-to-Image / Reference)
|
| 26 |
+
let imageData = null;
|
| 27 |
+
if (filePath && fs.existsSync(filePath) && mimeType.startsWith('image/')) {
|
| 28 |
+
imageData = fs.readFileSync(filePath).toString('base64');
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
for (const model of MODELS) {
|
| 32 |
+
try {
|
| 33 |
+
console.log(`Attempting image generation with model: ${model}...`);
|
| 34 |
+
|
| 35 |
+
const payload = {
|
| 36 |
+
model: model,
|
| 37 |
+
prompt: prompt,
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// Add reference image for multimodal models if available
|
| 41 |
+
if (imageData) {
|
| 42 |
+
payload.images = [imageData];
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const response = await axios.post('https://openrouter.ai/api/v1/images/generations', payload, {
|
| 46 |
+
headers: {
|
| 47 |
+
'Authorization': `Bearer ${config.ai.openRouter.apiKey}`,
|
| 48 |
+
'Content-Type': 'application/json'
|
| 49 |
+
},
|
| 50 |
+
timeout: 60000 // Image gen can be slow
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
if (response.data && response.data.data && response.data.data[0]) {
|
| 54 |
+
const imageUrl = response.data.data[0].url;
|
| 55 |
+
return {
|
| 56 |
+
success: true,
|
| 57 |
+
imageUrl: imageUrl,
|
| 58 |
+
modelUsed: model,
|
| 59 |
+
message: `Image generated using ${model}`
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
} catch (error) {
|
| 63 |
+
console.warn(`Model ${model} failed:`, error.response?.data || error.message);
|
| 64 |
+
continue; // Try next model
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return { error: "All image generation models failed. Please try again later." };
|
| 69 |
+
}
|
| 70 |
+
};
|
tools/manageReminder.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs-extra');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
|
| 4 |
+
const REMINDERS_FILE = path.join(__dirname, '../session/reminders.json');
|
| 5 |
+
|
| 6 |
+
module.exports = {
|
| 7 |
+
/**
|
| 8 |
+
* @param {object} args - { task, scheduledTime, action }
|
| 9 |
+
* @param {object} context - { remoteJid }
|
| 10 |
+
*/
|
| 11 |
+
execute: async (args, context) => {
|
| 12 |
+
const { task, scheduledTime, action } = args;
|
| 13 |
+
const { remoteJid } = context;
|
| 14 |
+
|
| 15 |
+
if (!remoteJid) return { error: "Context missing remoteJid" };
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
fs.ensureFileSync(REMINDERS_FILE);
|
| 19 |
+
let reminders = [];
|
| 20 |
+
try {
|
| 21 |
+
reminders = fs.readJSONSync(REMINDERS_FILE);
|
| 22 |
+
} catch (e) { reminders = []; }
|
| 23 |
+
|
| 24 |
+
if (action === 'add') {
|
| 25 |
+
const newReminder = {
|
| 26 |
+
id: Date.now().toString(),
|
| 27 |
+
jid: remoteJid,
|
| 28 |
+
task,
|
| 29 |
+
time: scheduledTime,
|
| 30 |
+
status: 'pending'
|
| 31 |
+
};
|
| 32 |
+
reminders.push(newReminder);
|
| 33 |
+
fs.writeJSONSync(REMINDERS_FILE, reminders, { spaces: 2 });
|
| 34 |
+
|
| 35 |
+
// Note: The actual scheduling happens in the background service
|
| 36 |
+
// which should watch this file or be notified.
|
| 37 |
+
return { success: true, message: `Reminder terdaftar untuk ${newReminder.time}: "${task}"` };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (action === 'list') {
|
| 41 |
+
const userReminders = reminders.filter(r => r.jid === remoteJid && r.status === 'pending');
|
| 42 |
+
return { success: true, reminders: userReminders };
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return { error: "Action not supported yet." };
|
| 46 |
+
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error("Reminder Tool Error:", error);
|
| 49 |
+
return { error: error.message };
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
};
|
tools/schemas/fileConverter.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "fileConverter",
|
| 3 |
+
"description": "Converts any file format to another format (e.g., PDF to DOCX, JPG to PNG, MP4 to MP3, etc.). Use this when the user asks to change the format of a file they sent or quoted.",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"outputFormat": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "The target format extension (e.g., 'pdf', 'docx', 'mp3', 'png')."
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
"required": ["outputFormat"]
|
| 13 |
+
}
|
| 14 |
+
}
|
tools/schemas/fileGenerator.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "fileGenerator",
|
| 3 |
+
"description": "Generates a file (PDF, DOCX, TXT, Python, JS, HTML) with the provided content. Use this when the user asks to create a file or download code.",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"filename": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "The name of the file to create (e.g., 'report.pdf', 'script.py')."
|
| 10 |
+
},
|
| 11 |
+
"content": {
|
| 12 |
+
"type": "STRING",
|
| 13 |
+
"description": "The text content or code to put inside the file."
|
| 14 |
+
},
|
| 15 |
+
"format": {
|
| 16 |
+
"type": "STRING",
|
| 17 |
+
"enum": ["pdf", "docx", "txt", "code"],
|
| 18 |
+
"description": "The format of the file."
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
"required": ["filename", "content", "format"]
|
| 22 |
+
}
|
| 23 |
+
}
|
tools/schemas/imageGenerator.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "imageGenerator",
|
| 3 |
+
"description": "Generates or edits an image based on a prompt. Can use an existing image as reference if provided. Use this when the user wants to create, visualize, or edit something visually.",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"prompt": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "The creative and detailed visual description of the image to generate, expanded from the user's request."
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
"required": ["prompt"]
|
| 13 |
+
}
|
| 14 |
+
}
|
tools/schemas/manageReminder.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "manageReminder",
|
| 3 |
+
"description": "Registers a new reminder or to-do list item for the user. AI should determine the specific date and time based on user input. For 'malam', assume 18:00 or 19:00 unless specified otherwise.",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"task": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "The description of the task or note to be reminded of."
|
| 10 |
+
},
|
| 11 |
+
"scheduledTime": {
|
| 12 |
+
"type": "STRING",
|
| 13 |
+
"description": "The ISO 8601 formatted date and time for the reminder (e.g., '2026-02-02T18:00:00Z'). AI must calculate this relative to the current time."
|
| 14 |
+
},
|
| 15 |
+
"action": {
|
| 16 |
+
"type": "STRING",
|
| 17 |
+
"enum": ["add", "list", "delete"],
|
| 18 |
+
"description": "The action to perform."
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
"required": ["task", "scheduledTime", "action"]
|
| 22 |
+
}
|
| 23 |
+
}
|
tools/schemas/stickerMaker.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "stickerMaker",
|
| 3 |
+
"description": "Converts an image or video into a WhatsApp sticker. Use this when the user explicitly asks to create a sticker from media they sent or quoted.",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"target": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "Set to 'auto' to indicate using the attached or quoted media.",
|
| 10 |
+
"enum": ["auto"]
|
| 11 |
+
}
|
| 12 |
+
},
|
| 13 |
+
"required": ["target"]
|
| 14 |
+
}
|
| 15 |
+
}
|
tools/schemas/webSearch.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "webSearch",
|
| 3 |
+
"description": "Tools untuk mencari informasi realtime atau terbaru",
|
| 4 |
+
"parameters": {
|
| 5 |
+
"type": "OBJECT",
|
| 6 |
+
"properties": {
|
| 7 |
+
"query": {
|
| 8 |
+
"type": "STRING",
|
| 9 |
+
"description": "The search query."
|
| 10 |
+
}
|
| 11 |
+
},
|
| 12 |
+
"required": ["query"]
|
| 13 |
+
}
|
| 14 |
+
}
|
tools/stickerMaker.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const ffmpeg = require('fluent-ffmpeg');
|
| 2 |
+
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
const fs = require('fs-extra');
|
| 5 |
+
|
| 6 |
+
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
| 7 |
+
|
| 8 |
+
module.exports = {
|
| 9 |
+
/**
|
| 10 |
+
* @param {object} args
|
| 11 |
+
* @param {object} context - Contains filePath and mimeType of the media
|
| 12 |
+
*/
|
| 13 |
+
execute: async (args, context) => {
|
| 14 |
+
const { filePath, mimeType } = context;
|
| 15 |
+
if (!filePath) return { error: 'No media found to convert.' };
|
| 16 |
+
|
| 17 |
+
const outputName = `sticker_${Date.now()}.webp`;
|
| 18 |
+
const outputPath = path.join(__dirname, '../temp_files', outputName);
|
| 19 |
+
fs.ensureDirSync(path.join(__dirname, '../temp_files'));
|
| 20 |
+
|
| 21 |
+
return new Promise((resolve, reject) => {
|
| 22 |
+
let command = ffmpeg(filePath);
|
| 23 |
+
|
| 24 |
+
if (mimeType.includes('video')) {
|
| 25 |
+
// Video to Animated Sticker (max 6 seconds)
|
| 26 |
+
command
|
| 27 |
+
.setStartTime(0)
|
| 28 |
+
.setDuration(6)
|
| 29 |
+
.on('error', (err) => resolve({ error: `FFMPEG Error: ${err.message}` }))
|
| 30 |
+
.on('end', () => resolve({ success: true, stickerPath: outputPath }))
|
| 31 |
+
.addOutputOptions([
|
| 32 |
+
'-vcodec', 'libwebp',
|
| 33 |
+
'-vf', 'scale=512:512:force_original_aspect_ratio=increase,fps=15,crop=512:512',
|
| 34 |
+
'-loop', '0',
|
| 35 |
+
'-preset', 'default',
|
| 36 |
+
'-an',
|
| 37 |
+
'-vsync', '0',
|
| 38 |
+
'-s', '512:512'
|
| 39 |
+
])
|
| 40 |
+
.toFormat('webp')
|
| 41 |
+
.save(outputPath);
|
| 42 |
+
} else {
|
| 43 |
+
// Image to Static Sticker
|
| 44 |
+
command
|
| 45 |
+
.on('error', (err) => resolve({ error: `FFMPEG Error: ${err.message}` }))
|
| 46 |
+
.on('end', () => resolve({ success: true, stickerPath: outputPath }))
|
| 47 |
+
.addOutputOptions([
|
| 48 |
+
'-vcodec', 'libwebp',
|
| 49 |
+
'-vf', 'scale=512:512:force_original_aspect_ratio=increase,fps=15,crop=512:512'
|
| 50 |
+
])
|
| 51 |
+
.toFormat('webp')
|
| 52 |
+
.save(outputPath);
|
| 53 |
+
}
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
};
|
tools/webSearch.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = require('../config');
|
| 2 |
+
const axios = require('axios');
|
| 3 |
+
// Lazy load puppeteer to save RAM on startup
|
| 4 |
+
// const puppeteer = require('puppeteer-extra');
|
| 5 |
+
|
| 6 |
+
async function searchWithApi(query) {
|
| 7 |
+
if (!config.search.apiKey || !config.search.cseId) return null;
|
| 8 |
+
|
| 9 |
+
try {
|
| 10 |
+
const url = `https://www.googleapis.com/customsearch/v1?key=${config.search.apiKey}&cx=${config.search.cseId}&q=${encodeURIComponent(query)}`;
|
| 11 |
+
const response = await axios.get(url);
|
| 12 |
+
const items = response.data.items || [];
|
| 13 |
+
return items.map(item => `Title: ${item.title}\nLink: ${item.link}\nSnippet: ${item.snippet}`).join('\n\n');
|
| 14 |
+
} catch (error) {
|
| 15 |
+
console.error('API Search Error:', error.message);
|
| 16 |
+
return null;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
async function searchWithPuppeteer(query) {
|
| 21 |
+
const puppeteer = require('puppeteer-extra');
|
| 22 |
+
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
| 23 |
+
puppeteer.use(StealthPlugin());
|
| 24 |
+
|
| 25 |
+
let browser;
|
| 26 |
+
try {
|
| 27 |
+
browser = await puppeteer.launch({
|
| 28 |
+
headless: 'new',
|
| 29 |
+
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
| 30 |
+
});
|
| 31 |
+
const page = await browser.newPage();
|
| 32 |
+
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}`);
|
| 33 |
+
|
| 34 |
+
// Wait for results
|
| 35 |
+
await page.waitForSelector('#search');
|
| 36 |
+
|
| 37 |
+
const results = await page.evaluate(() => {
|
| 38 |
+
const items = document.querySelectorAll('.tF2Cxc'); // Common selector for Google results
|
| 39 |
+
let data = [];
|
| 40 |
+
items.forEach(item => {
|
| 41 |
+
const title = item.querySelector('h3')?.innerText;
|
| 42 |
+
const link = item.querySelector('a')?.href;
|
| 43 |
+
const snippet = item.querySelector('.VwiC3b')?.innerText;
|
| 44 |
+
if (title && link) {
|
| 45 |
+
data.push(`Title: ${title}\nLink: ${link}\nSnippet: ${snippet || ''}`);
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
return data.slice(0, 5).join('\n\n');
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
return results;
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.error('Puppeteer Search Error:', error.message);
|
| 54 |
+
return 'Failed to perform search.';
|
| 55 |
+
} finally {
|
| 56 |
+
if (browser) await browser.close();
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
module.exports = {
|
| 61 |
+
execute: async ({ query }) => {
|
| 62 |
+
let results = await searchWithApi(query);
|
| 63 |
+
if (!results) {
|
| 64 |
+
console.log('Falling back to Puppeteer search...');
|
| 65 |
+
results = await searchWithPuppeteer(query);
|
| 66 |
+
}
|
| 67 |
+
return { result: results || 'No results found.' };
|
| 68 |
+
}
|
| 69 |
+
};
|