modified: .env
new file: __debug_bin.exe modified: bot.db modified: db/db.go modified: go.mod new file: handlers/auth.go modified: handlers/dashboard.go new file: handlers/saas.go modified: handlers/webhook.go modified: main.go new file: saas_bot.db modified: services/openrouter.go new file: services/types.go modified: services/whatsapp.go new file: static/style.css modified: templates/dashboard.html new file: templates/landing.html new file: templates/login.html new file: templates/register.html deleted: types/types.go
This commit is contained in:
61
handlers/auth.go
Normal file
61
handlers/auth.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"whatsapp-bot/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Show Pages
|
||||
func ShowLogin(c *gin.Context) { c.HTML(200, "login.html", nil) }
|
||||
func ShowRegister(c *gin.Context) { c.HTML(200, "register.html", nil) }
|
||||
func ShowLanding(c *gin.Context) { c.HTML(200, "landing.html", nil) }
|
||||
|
||||
// REGISTER
|
||||
func RegisterHandler(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
pass := c.PostForm("password")
|
||||
|
||||
// Hash Password
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||
|
||||
// Create User in DB
|
||||
res, err := db.Conn.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, string(hashed))
|
||||
if err != nil {
|
||||
c.HTML(400, "register.html", gin.H{"Error": "Email already taken"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create Default Bot Config for new user
|
||||
userID, _ := res.LastInsertId()
|
||||
db.Conn.Exec("INSERT INTO bot_configs (user_id) VALUES (?)", userID)
|
||||
|
||||
c.Redirect(302, "/login")
|
||||
}
|
||||
|
||||
// LOGIN
|
||||
func LoginHandler(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
pass := c.PostForm("password")
|
||||
|
||||
var id int
|
||||
var hash string
|
||||
err := db.Conn.QueryRow("SELECT id, password_hash FROM users WHERE email=?", email).Scan(&id, &hash)
|
||||
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
|
||||
c.HTML(401, "login.html", gin.H{"Error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set simple cookie for session (In production use a secure session library)
|
||||
c.SetCookie("user_id", fmt.Sprintf("%d", id), 3600*24, "/", "", false, true)
|
||||
c.Redirect(302, "/dashboard")
|
||||
}
|
||||
|
||||
// LOGOUT
|
||||
func LogoutHandler(c *gin.Context) {
|
||||
c.SetCookie("user_id", "", -1, "/", "", false, true)
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -10,8 +10,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ShowDashboard renders the Chat Interface (Discord View)
|
||||
// NOTE: The main SaaS Dashboard is now at /dashboard (UserDashboard in saas.go).
|
||||
// You might want to rename this route to /chat-interface or keep it as a sub-view.
|
||||
func ShowDashboard(c *gin.Context) {
|
||||
// 1. Fetch Appointments for the Table
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
// 1. Fetch Appointments (Only for this user)
|
||||
type Appt struct {
|
||||
ID int
|
||||
CustomerPhone string
|
||||
@@ -20,7 +25,7 @@ func ShowDashboard(c *gin.Context) {
|
||||
}
|
||||
var appts []Appt
|
||||
|
||||
approws, err := db.Conn.Query("SELECT id, customer_phone, appointment_date, status FROM appointments ORDER BY id DESC")
|
||||
approws, err := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id = ? ORDER BY id DESC", userID)
|
||||
if err != nil {
|
||||
log.Println("Appt Query Error:", err)
|
||||
} else {
|
||||
@@ -32,36 +37,50 @@ func ShowDashboard(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Chats for the Sidebar
|
||||
// 2. Fetch Chats (Only for this user)
|
||||
// We need to add user_id to chats table to make this strict,
|
||||
// but for now, we'll assume all chats are visible to the admin/user
|
||||
// or filter if you add user_id to the chats table later.
|
||||
type Chat struct {
|
||||
ID int
|
||||
Title string
|
||||
}
|
||||
var chats []Chat
|
||||
|
||||
chatrows, err := db.Conn.Query("SELECT id FROM chats ORDER BY id DESC")
|
||||
chatrows, err := db.Conn.Query("SELECT id, title FROM chats ORDER BY id DESC")
|
||||
if err != nil {
|
||||
log.Println("Chat Query Error:", err)
|
||||
} else {
|
||||
defer chatrows.Close()
|
||||
for chatrows.Next() {
|
||||
var ch Chat
|
||||
chatrows.Scan(&ch.ID)
|
||||
// Give it a default title so the sidebar isn't empty
|
||||
ch.Title = "Chat #" + strconv.Itoa(ch.ID)
|
||||
chatrows.Scan(&ch.ID, &ch.Title)
|
||||
if ch.Title == "" {
|
||||
ch.Title = "Chat #" + strconv.Itoa(ch.ID)
|
||||
}
|
||||
chats = append(chats, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Render the Template with BOTH data sets
|
||||
// 3. Render the Chat Template
|
||||
// Note: We are using "dashboard.html" for the SaaS view now.
|
||||
// You might want to rename your old chat template to "chat_view.html"
|
||||
// if you want to keep both views separate.
|
||||
// For now, I'll point this to the new SaaS dashboard template to avoid errors,
|
||||
// but realistically you should merge them or have two separate HTML files.
|
||||
c.HTML(http.StatusOK, "dashboard.html", gin.H{
|
||||
"Appointments": appts,
|
||||
"Chats": chats,
|
||||
// Pass minimal context so the template doesn't crash if it expects user info
|
||||
"UserEmail": "Chat Mode",
|
||||
"Tier": "Pro",
|
||||
"BotConfig": map[string]string{"PhoneID": "N/A"},
|
||||
})
|
||||
}
|
||||
|
||||
// Add this to handlers/dashboard.go
|
||||
// POST /admin/appointment
|
||||
func CreateAppointmentHandler(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
var body struct {
|
||||
Phone string `json:"phone"`
|
||||
Date string `json:"date"`
|
||||
@@ -71,8 +90,7 @@ func CreateAppointmentHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the helper function instead of raw SQL here
|
||||
if err := db.SaveAppointment(body.Phone, body.Date); err != nil {
|
||||
if err := db.SaveAppointment(userID, body.Phone, body.Date); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -80,10 +98,13 @@ func CreateAppointmentHandler(c *gin.Context) {
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
// Manage/Delete Appointment
|
||||
// DELETE /admin/appointment/:id
|
||||
func DeleteAppointmentHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_, err := db.Conn.Exec("DELETE FROM appointments WHERE id = ?", id)
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
// Ensure user only deletes their own appointments
|
||||
_, err := db.Conn.Exec("DELETE FROM appointments WHERE id = ? AND user_id = ?", id, userID)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
@@ -94,6 +115,7 @@ func DeleteAppointmentHandler(c *gin.Context) {
|
||||
// PUT /admin/appointment/:id
|
||||
func UpdateAppointmentHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.MustGet("userID").(int)
|
||||
var body struct {
|
||||
Phone string `json:"phone"`
|
||||
Date string `json:"date"`
|
||||
@@ -104,8 +126,8 @@ func UpdateAppointmentHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
_, err := db.Conn.Exec(
|
||||
"UPDATE appointments SET customer_phone = ?, appointment_date = ? WHERE id = ?",
|
||||
body.Phone, body.Date, id,
|
||||
"UPDATE appointments SET customer_phone = ?, appointment_time = ? WHERE id = ? AND user_id = ?",
|
||||
body.Phone, body.Date, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
@@ -116,20 +138,19 @@ func UpdateAppointmentHandler(c *gin.Context) {
|
||||
|
||||
// POST /admin/chat
|
||||
func NewChatHandler(c *gin.Context) {
|
||||
// You might want to associate chats with users too: INSERT INTO chats (user_id, title)...
|
||||
res, err := db.Conn.Exec("INSERT INTO chats (title) VALUES ('New Chat')")
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
// Return the ID so the frontend can select it immediately
|
||||
c.JSON(200, gin.H{"id": id, "title": "New Chat"})
|
||||
}
|
||||
|
||||
// DELETE /admin/chat/:id
|
||||
func DeleteChatHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// Delete messages first (foreign key cleanup usually, but we'll do manual for SQLite safety)
|
||||
db.Conn.Exec("DELETE FROM messages WHERE chat_id = ?", id)
|
||||
db.Conn.Exec("DELETE FROM chats WHERE id = ?", id)
|
||||
c.Status(200)
|
||||
@@ -167,6 +188,8 @@ func GetMessagesHandler(c *gin.Context) {
|
||||
// POST /admin/chat/:id/message
|
||||
func PostMessageHandler(c *gin.Context) {
|
||||
chatId := c.Param("id")
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -175,10 +198,10 @@ func PostMessageHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Save User Message to DB first
|
||||
// 1. Save User Message
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatId, body.Content)
|
||||
|
||||
// 2. Fetch history
|
||||
// 2. Fetch History
|
||||
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatId)
|
||||
var history []services.Message
|
||||
defer rows.Close()
|
||||
@@ -188,20 +211,26 @@ func PostMessageHandler(c *gin.Context) {
|
||||
history = append(history, m)
|
||||
}
|
||||
|
||||
// 3. Set Headers for Streaming
|
||||
// 3. Load User's Custom System Prompt for the AI
|
||||
// We need the AI to behave like the User's bot, even in the chat interface
|
||||
var systemPrompt string
|
||||
err := db.Conn.QueryRow("SELECT system_prompt FROM bot_configs WHERE user_id = ?", userID).Scan(&systemPrompt)
|
||||
if err != nil {
|
||||
systemPrompt = "You are a helpful assistant." // Fallback
|
||||
}
|
||||
|
||||
// 4. Stream Response
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
||||
|
||||
// 4. Call the Stream Service
|
||||
// We pass a function that writes chunks directly to the HTTP response
|
||||
fullResponse, _ := services.StreamAIResponse(history, func(chunk string) {
|
||||
// We use the new StreamAIResponse signature that takes (userID, message, prompt, callback)
|
||||
fullResponse, _ := services.StreamAIResponse(userID, body.Content, systemPrompt, func(chunk string) {
|
||||
c.Writer.Write([]byte(chunk))
|
||||
c.Writer.Flush() // Important: Send it NOW, don't buffer
|
||||
c.Writer.Flush()
|
||||
})
|
||||
|
||||
// 5. Save the FULL Assistant Message to DB for history
|
||||
// We do this AFTER the stream finishes so the next load has the full text
|
||||
// 5. Save Assistant Message
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse)
|
||||
}
|
||||
|
||||
108
handlers/saas.go
Normal file
108
handlers/saas.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"whatsapp-bot/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /dashboard
|
||||
func UserDashboard(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
// 1. Fetch User Data
|
||||
var email, tier string
|
||||
db.Conn.QueryRow("SELECT email, subscription_tier FROM users WHERE id=?", userID).Scan(&email, &tier)
|
||||
|
||||
// 2. Fetch Bot Settings
|
||||
var botName, prompt, availJSON string
|
||||
db.Conn.QueryRow("SELECT bot_name, system_prompt, availability_hours FROM bot_configs WHERE user_id=?", userID).Scan(&botName, &prompt, &availJSON)
|
||||
|
||||
// 3. Fetch Appointments (¡Esto faltaba!)
|
||||
type Appt struct {
|
||||
ID int
|
||||
Phone string
|
||||
Date string
|
||||
Status string
|
||||
}
|
||||
var appts []Appt
|
||||
rows, _ := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id=? ORDER BY id DESC", userID)
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var a Appt
|
||||
rows.Scan(&a.ID, &a.Phone, &a.Date, &a.Status)
|
||||
appts = append(appts, a)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Render Template
|
||||
c.HTML(200, "dashboard.html", gin.H{
|
||||
"UserEmail": email,
|
||||
"Tier": tier,
|
||||
"Days": []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, // Enviamos la lista desde aquí
|
||||
"Appointments": appts,
|
||||
"BotConfig": map[string]string{
|
||||
"Name": botName,
|
||||
"Prompt": prompt,
|
||||
"Hours": availJSON, // Raw JSON if you want to parse it later
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /update-bot
|
||||
func UpdateBotSettings(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
botName := c.PostForm("bot_name")
|
||||
prompt := c.PostForm("system_prompt")
|
||||
|
||||
// Recolectamos las horas en un Mapa
|
||||
hours := make(map[string]string)
|
||||
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
for _, d := range days {
|
||||
open := c.PostForm(d + "_open")
|
||||
close := c.PostForm(d + "_close")
|
||||
if open != "" && close != "" {
|
||||
hours[d] = open + "-" + close
|
||||
} else {
|
||||
hours[d] = "Closed"
|
||||
}
|
||||
}
|
||||
hoursJSON, _ := json.Marshal(hours)
|
||||
|
||||
db.Conn.Exec("UPDATE bot_configs SET bot_name=?, system_prompt=?, availability_hours=? WHERE user_id=?",
|
||||
botName, prompt, string(hoursJSON), userID)
|
||||
|
||||
c.Redirect(302, "/dashboard")
|
||||
}
|
||||
|
||||
// POST /admin/appointment/:id/cancel
|
||||
func CancelAppointmentHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.MustGet("userID").(int)
|
||||
db.Conn.Exec("UPDATE appointments SET status='cancelled' WHERE id=? AND user_id=?", id, userID)
|
||||
c.Redirect(302, "/dashboard")
|
||||
}
|
||||
|
||||
// GET /api/my-appointments (Keep existing one or ensure it matches DB struct)
|
||||
func MyAppointmentsAPI(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
rows, _ := db.Conn.Query("SELECT customer_phone, appointment_time FROM appointments WHERE user_id=?", userID)
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
var events []map[string]string
|
||||
for rows.Next() {
|
||||
var phone, timeStr string
|
||||
rows.Scan(&phone, &timeStr)
|
||||
events = append(events, map[string]string{
|
||||
"title": "📞 " + phone,
|
||||
"start": timeStr,
|
||||
})
|
||||
}
|
||||
c.JSON(200, events)
|
||||
} else {
|
||||
c.JSON(200, []string{})
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,21 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"whatsapp-bot/db"
|
||||
"whatsapp-bot/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Structs to parse incoming WhatsApp Webhook JSON
|
||||
type WebhookPayload struct {
|
||||
Entry []struct {
|
||||
Changes []struct {
|
||||
Value struct {
|
||||
Messages []struct {
|
||||
From string `json:"from"`
|
||||
Text struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"text"`
|
||||
Type string `json:"type"`
|
||||
} `json:"messages"`
|
||||
} `json:"value"`
|
||||
} `json:"changes"`
|
||||
} `json:"entry"`
|
||||
}
|
||||
// NOTA: Borramos la definición local de WebhookPayload porque ahora la importamos de services
|
||||
|
||||
// VerifyWebhook (Keep this as is)
|
||||
func VerifyWebhook(c *gin.Context) {
|
||||
mode := c.Query("hub.mode")
|
||||
token := c.Query("hub.verify_token")
|
||||
challenge := c.Query("hub.challenge")
|
||||
|
||||
if mode == "subscribe" && token == "YOUR_SECRET_TOKEN" { // CHANGE THIS to match your Meta setup
|
||||
if mode == "subscribe" && token == "YOUR_SECRET_TOKEN" {
|
||||
c.String(http.StatusOK, challenge)
|
||||
} else {
|
||||
c.Status(http.StatusForbidden)
|
||||
@@ -40,61 +25,49 @@ func VerifyWebhook(c *gin.Context) {
|
||||
}
|
||||
|
||||
func HandleMessage(c *gin.Context) {
|
||||
var payload WebhookPayload
|
||||
// Ahora usamos services.WebhookPayload sin problemas
|
||||
var payload services.WebhookPayload
|
||||
if err := c.BindJSON(&payload); err != nil {
|
||||
// WhatsApp sends other events (statuses) that might not match. Ignore errors.
|
||||
c.Status(200)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Loop through messages (usually just one)
|
||||
for _, entry := range payload.Entry {
|
||||
for _, change := range entry.Changes {
|
||||
for _, msg := range change.Value.Messages {
|
||||
// 1. IDENTIFICAR AL USUARIO
|
||||
phoneID := change.Value.Metadata.PhoneNumberID
|
||||
|
||||
// We only handle text for now
|
||||
botConfig, err := db.GetBotByPhoneID(phoneID)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Unknown Phone ID: %s. Make sure this ID is in bot_configs table.\n", phoneID)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, msg := range change.Value.Messages {
|
||||
if msg.Type != "text" {
|
||||
continue
|
||||
}
|
||||
|
||||
userPhone := msg.From
|
||||
userText := msg.Text.Body
|
||||
fmt.Printf("📩 Msg for User %d (%s): %s\n", botConfig.UserID, botConfig.PhoneID, msg.Text.Body)
|
||||
|
||||
fmt.Printf("📩 Received from %s: %s\n", userPhone, userText)
|
||||
// 2. CONSTRUIR PROMPT
|
||||
currentTime := time.Now().Format("Monday, 2006-01-02 15:04")
|
||||
finalPrompt := fmt.Sprintf(
|
||||
"%s\n\nCONTEXT:\nCurrent Time: %s\nAvailability Rules: %s\n\nINSTRUCTIONS:\nIf booking, ask for Name, Date, Time. Use 'create_appointment' tool only when confirmed.",
|
||||
botConfig.SystemPrompt,
|
||||
currentTime,
|
||||
botConfig.Availability,
|
||||
)
|
||||
|
||||
// 2. Identify the Chat Logic
|
||||
chatID := db.GetOrCreateChatByPhone(userPhone)
|
||||
// 3. LLAMAR A LA IA
|
||||
aiResp, _ := services.StreamAIResponse(botConfig.UserID, msg.Text.Body, finalPrompt, nil)
|
||||
|
||||
// 3. Save User Message
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatID, userText)
|
||||
|
||||
// 4. Get AI Response
|
||||
// Fetch history
|
||||
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatID)
|
||||
var history []services.Message
|
||||
for rows.Next() {
|
||||
var m services.Message
|
||||
rows.Scan(&m.Role, &m.Content)
|
||||
history = append(history, m)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Call AI (We don't need the stream callback here, just the final string)
|
||||
aiResponse, _ := services.StreamAIResponse(history, func(chunk string) {
|
||||
// We can't stream to WhatsApp, so we do nothing here.
|
||||
})
|
||||
|
||||
// 5. Save & Send Response
|
||||
if aiResponse != "" {
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatID, aiResponse)
|
||||
err := services.SendWhatsAppMessage(userPhone, aiResponse)
|
||||
if err != nil {
|
||||
fmt.Println("❌ Error sending to WhatsApp:", err)
|
||||
}
|
||||
// 4. RESPONDER
|
||||
if aiResp != "" {
|
||||
services.SendWhatsAppMessage(botConfig.Token, botConfig.PhoneID, msg.From, aiResp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user