diff --git a/.env b/.env index 770653f..3f1c4a9 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ OPENROUTER_API_KEY=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 WHATSAPP_PHONE_ID=986583417873961 -WHATSAPP_TOKEN=EAATqIU03y9YBQ5EBDxAFANmJIokKqjceliErZA1rYERpTzZBpRZAIKDVqlE2UWD0YUztSRwvsqjEdX2Uzt92Lsst5CEwcBiLZBbjiK8aAqpDclh2r2KyW5YvZCo7jXZAQf6dNYZABksjuxi5jUdLgfNQbhOhvSfv1z1qoWdZCUh9dUyzEcH3xmtCZBk9VG1qdtuLZBIlT335DSrSKgDpaLTBaWoe54aZCLwTxB89YZA78DkiIFmLq6dZBz3wSuUyMEfKokrRvtz7lXE6VkienXNucslgihZCkAMgZDZD \ No newline at end of file +WHATSAPP_TOKEN=EAATqIU03y9YBQ1DnscXkt0QQ8lfhWQbI8TT0wRNdB9ZAGLWEdPhN3761E0XBXBdzJiZA3uiPEugjhIS1TjrUZCu979aiiSYFvjbDjFRFYGVsGfqIZCB13H6AaviQHlBNksil9JlkefZAy4ZBFqZCkAcYGjGNtZBWHaXZCaMYTMmfn7rOAx4IUt6eHjfiVkXVquOoqDQY8oVOs5HAekLWNZBqsxm2w2J34AacAzsUwzem6kmsYcKs9CDQ9wIJBRw9FDaKkbV64waI1FdEI7ZALkZCKZBEWUFeA \ No newline at end of file diff --git a/__debug_bin.exe b/__debug_bin.exe new file mode 100644 index 0000000..1e44444 Binary files /dev/null and b/__debug_bin.exe differ diff --git a/bot.db b/bot.db index 514ac89..28ad520 100644 Binary files a/bot.db and b/bot.db differ diff --git a/db/db.go b/db/db.go index 42ffff1..1cd68a6 100644 --- a/db/db.go +++ b/db/db.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "log" _ "modernc.org/sqlite" ) @@ -10,64 +11,93 @@ var Conn *sql.DB func Init() { var err error - Conn, err = sql.Open("sqlite", "./bot.db") + Conn, err = sql.Open("sqlite", "./saas_bot.db") if err != nil { panic(err) } schema := ` - CREATE TABLE IF NOT EXISTS clients ( - id TEXT PRIMARY KEY, - name TEXT, - tier TEXT, - msg_count INTEGER DEFAULT 0 + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE, + password_hash TEXT, + subscription_tier TEXT DEFAULT 'free', + stripe_customer_id TEXT ); + + CREATE TABLE IF NOT EXISTS bot_configs ( + user_id INTEGER PRIMARY KEY, + whatsapp_phone_id TEXT UNIQUE, + whatsapp_token TEXT, + bot_name TEXT DEFAULT 'My Assistant', + system_prompt TEXT DEFAULT 'You are a helpful assistant.', + availability_hours TEXT DEFAULT 'Mon-Fri 09:00-17:00', + FOREIGN KEY(user_id) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS appointments ( id INTEGER PRIMARY KEY AUTOINCREMENT, - client_id TEXT, + user_id INTEGER, customer_phone TEXT, - appointment_date TEXT, - status TEXT DEFAULT 'confirmed' - ); - CREATE TABLE IF NOT EXISTS chats ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT DEFAULT 'New Chat', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + appointment_time DATETIME, + status TEXT DEFAULT 'confirmed', + FOREIGN KEY(user_id) REFERENCES users(id) ); + ` + _, err = Conn.Exec(schema) + if err != nil { + log.Fatal("Migration Error:", err) + } - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER, - role TEXT, -- 'user' or 'assistant' - content TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(chat_id) REFERENCES chats(id) - );` - Conn.Exec(schema) + seedUser() } -func SaveAppointment(phone string, date string) error { - _, err := Conn.Exec( - "INSERT INTO appointments (customer_phone, appointment_date, status) VALUES (?, ?, ?)", - phone, date, "confirmed", - ) +// seedUser creates a default user so you can test the dashboard immediately +func seedUser() { + var count int + Conn.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count == 0 { + log.Println("🌱 Seeding default user (ID: 1)...") + _, _ = Conn.Exec("INSERT INTO users (email, subscription_tier) VALUES ('admin@sekibot.com', 'pro')") + // Insert default bot config for User 1 + // NOTE: You must update these values in the Dashboard or DB to match your real Meta credentials! + _, _ = Conn.Exec(`INSERT INTO bot_configs (user_id, whatsapp_phone_id, whatsapp_token, bot_name) + VALUES (1, '986583417873961', 'EAATqIU03y9YBQ1DnscXkt0QQ8lfhWQbI8TT0wRNdB9ZAGLWEdPhN3761E0XBXBdzJiZA3uiPEugjhIS1TjrUZCu979aiiSYFvjbDjFRFYGVsGfqIZCB13H6AaviQHlBNksil9JlkefZAy4ZBFqZCkAcYGjGNtZBWHaXZCaMYTMmfn7rOAx4IUt6eHjfiVkXVquOoqDQY8oVOs5HAekLWNZBqsxm2w2J34AacAzsUwzem6kmsYcKs9CDQ9wIJBRw9FDaKkbV64waI1FdEI7ZALkZCKZBEWUFeA', 'Seki Bot')`) + } +} + +// --- HELPERS --- + +type BotContext struct { + UserID int + SystemPrompt string + Availability string + Token string + PhoneID string +} + +func GetBotByPhoneID(phoneID string) (*BotContext, error) { + var b BotContext + err := Conn.QueryRow(` + SELECT user_id, system_prompt, availability_hours, whatsapp_token, whatsapp_phone_id + FROM bot_configs WHERE whatsapp_phone_id = ?`, phoneID).Scan(&b.UserID, &b.SystemPrompt, &b.Availability, &b.Token, &b.PhoneID) + if err != nil { + return nil, err + } + return &b, nil +} + +// UpdateBotConfig saves settings from the dashboard +func UpdateBotConfig(userID int, name, prompt, avail string) error { + _, err := Conn.Exec(` + UPDATE bot_configs + SET bot_name=?, system_prompt=?, availability_hours=? + WHERE user_id=?`, name, prompt, avail, userID) return err } -// GetOrCreateChatByPhone finds a chat for this phone number or creates one -func GetOrCreateChatByPhone(phone string) int { - // 1. Check if we already have a chat for this phone - // (Note: You might want to add a 'phone' column to 'chats' table if you haven't yet. - // For now, I'll cheat and put the phone in the title if it's new) - var id int - err := Conn.QueryRow("SELECT id FROM chats WHERE title = ?", phone).Scan(&id) - - if err == nil { - return id - } - - // 2. If not found, create one - res, _ := Conn.Exec("INSERT INTO chats (title) VALUES (?)", phone) - newId, _ := res.LastInsertId() - return int(newId) +// SaveAppointment now requires a userID to know WHO the appointment is for +func SaveAppointment(userID int, phone, date string) error { + _, err := Conn.Exec("INSERT INTO appointments (user_id, customer_phone, appointment_time) VALUES (?, ?, ?)", userID, phone, date) + return err } diff --git a/go.mod b/go.mod index d20bad1..2ed9a8e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 + github.com/joho/godotenv v1.5.1 modernc.org/sqlite v1.46.1 ) @@ -21,7 +22,6 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..d4eb060 --- /dev/null +++ b/handlers/auth.go @@ -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, "/") +} diff --git a/handlers/dashboard.go b/handlers/dashboard.go index 4bb00a6..0ef018f 100644 --- a/handlers/dashboard.go +++ b/handlers/dashboard.go @@ -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) } diff --git a/handlers/saas.go b/handlers/saas.go new file mode 100644 index 0000000..ee9cea1 --- /dev/null +++ b/handlers/saas.go @@ -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{}) + } +} diff --git a/handlers/webhook.go b/handlers/webhook.go index 02b4037..f77856d 100644 --- a/handlers/webhook.go +++ b/handlers/webhook.go @@ -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) } diff --git a/main.go b/main.go index 573754a..02bf029 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "log" + "strconv" "whatsapp-bot/db" "whatsapp-bot/handlers" @@ -10,39 +10,48 @@ import ( ) func main() { - - // 1. Load the .env file - err := godotenv.Load() - if err != nil { - log.Println("Warning: No .env file found. Hope you set your vars manually, seki.") - } - + godotenv.Load() db.Init() r := gin.Default() - - // Load templates so Gin knows where to find your HTML r.LoadHTMLGlob("templates/*") + r.Static("/static", "./static") // Serve the CSS + + // PUBLIC ROUTES + r.GET("/", handlers.ShowLanding) + r.GET("/login", handlers.ShowLogin) + r.POST("/login", handlers.LoginHandler) + r.GET("/register", handlers.ShowRegister) + r.POST("/register", handlers.RegisterHandler) + r.GET("/logout", handlers.LogoutHandler) - // Routes r.GET("/webhook", handlers.VerifyWebhook) r.POST("/webhook", handlers.HandleMessage) - r.GET("/dashboard", handlers.ShowDashboard) - r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler) - r.POST("/admin/appointment", handlers.CreateAppointmentHandler) - r.PUT("/admin/appointment/:id", handlers.UpdateAppointmentHandler) + // PRIVATE ROUTES (Middleware) + auth := r.Group("/") + auth.Use(AuthMiddleware()) + { + auth.GET("/dashboard", handlers.UserDashboard) + auth.POST("/update-bot", handlers.UpdateBotSettings) + auth.GET("/api/my-appointments", handlers.MyAppointmentsAPI) + auth.POST("/admin/appointment/:id/cancel", handlers.CancelAppointmentHandler) + } - r.POST("/admin/chat", handlers.NewChatHandler) // THE BUTTON HITS THIS - r.DELETE("/admin/chat/:id", handlers.DeleteChatHandler) // <--- ADD THIS - r.PUT("/admin/chat/:id/rename", handlers.RenameChatHandler) // <--- ADD THIS - r.GET("/admin/chat/:id/messages", handlers.GetMessagesHandler) - r.POST("/admin/chat/:id/message", handlers.PostMessageHandler) - - // A little something for the root so you don't get a 404 again, seki - r.GET("/", func(c *gin.Context) { - c.JSON(200, gin.H{"message": "Bot is running. Go to /dashboard"}) - }) - - r.Run(":9090") // Using your port 9090 from the screenshot + r.Run(":9090") +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + cookie, err := c.Cookie("user_id") + if err != nil { + c.Redirect(302, "/login") + c.Abort() + return + } + // Convert cookie to Int + uid, _ := strconv.Atoi(cookie) + c.Set("userID", uid) + c.Next() + } } diff --git a/saas_bot.db b/saas_bot.db new file mode 100644 index 0000000..369e108 Binary files /dev/null and b/saas_bot.db differ diff --git a/services/openrouter.go b/services/openrouter.go index 001eb01..6ed2a6b 100644 --- a/services/openrouter.go +++ b/services/openrouter.go @@ -8,84 +8,36 @@ import ( "net/http" "os" "strings" - "time" "whatsapp-bot/db" ) -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// Structs to parse the incoming stream from OpenRouter -type StreamResponse struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` -} +// NOTA: struct Message ya NO está aquí, está en types.go // StreamAIResponse handles the streaming connection -// onToken: a function that gets called every time we get text (we use this to push to the browser) -// Returns: the full final string (so we can save it to the DB) -func StreamAIResponse(chatHistory []Message, onToken func(string)) (string, error) { +func StreamAIResponse(userID int, userMessage string, systemPrompt string, onToken func(string)) (string, error) { apiKey := os.Getenv("OPENROUTER_API_KEY") url := "https://openrouter.ai/api/v1/chat/completions" - // 1. Get Current Time for the LLM - currentTime := time.Now().Format("Monday, 2006-01-02 15:04") - - // 2. Strict System Prompt - systemPrompt := fmt.Sprintf(` -You are a helpful scheduler assistant for a business in Chile. -Current Date/Time: %s - -RULES FOR BOOKING: -1. You MUST get three things from the user before booking: - - The Date (day/month) - - The Time (hour) - - The Phone Number -2. If any of these are missing, ASK for them. Do NOT assume or guess. -3. If the user says "tomorrow" or "next Friday", calculate the date based on the Current Date/Time above. -4. Only when you have all details, use the 'create_appointment' tool. -`, currentTime) - - // 3. Prepend System Prompt to History - fullMessages := append([]Message{ - {Role: "system", Content: systemPrompt}, - }, chatHistory...) + messages := []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userMessage}, + } payload := map[string]interface{}{ - "model": "arcee-ai/trinity-large-preview:free", // stepfun/step-3.5-flash:free, arcee-ai/trinity-large-preview:free - "messages": fullMessages, + "model": "arcee-ai/trinity-large-preview:free", //stepfun/step-3.5-flash:free + "messages": messages, "stream": true, "tools": []map[string]interface{}{ { "type": "function", "function": map[string]interface{}{ "name": "create_appointment", - "description": "Schedules a new appointment. ONLY use this when you have a confirm date, time, and phone number.", + "description": "Schedules a new appointment. ONLY use when you have date, time, and phone.", "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "customer_phone": map[string]string{ - "type": "string", - "description": "The user's phone number (e.g., +569...)", - }, - "date": map[string]string{ - "type": "string", - "description": "The full date and time in YYYY-MM-DD HH:MM format", - }, + "customer_phone": map[string]string{"type": "string"}, + "date": map[string]string{"type": "string"}, }, "required": []string{"customer_phone", "date"}, }, @@ -106,9 +58,7 @@ RULES FOR BOOKING: } defer resp.Body.Close() - // Prepare to read the stream line by line scanner := bufio.NewScanner(resp.Body) - var fullContentBuffer strings.Builder var toolCallBuffer strings.Builder var toolName string @@ -116,35 +66,37 @@ RULES FOR BOOKING: for scanner.Scan() { line := scanner.Text() - - // OpenRouter sends "data: {JSON}" lines. - // Use specific string trimming to handle the format. if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") - - // The stream ends with "data: [DONE]" if jsonStr == "[DONE]" { break } - var chunk StreamResponse - if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil { - continue + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` } + json.Unmarshal([]byte(jsonStr), &chunk) if len(chunk.Choices) > 0 { delta := chunk.Choices[0].Delta - - // 1. Handle Text Content if delta.Content != "" { fullContentBuffer.WriteString(delta.Content) - // Send this chunk to the frontend immediately - onToken(delta.Content) + if onToken != nil { + onToken(delta.Content) + } } - - // 2. Handle Tool Calls (Accumulate them, don't stream execution yet) if len(delta.ToolCalls) > 0 { isToolCall = true if delta.ToolCalls[0].Function.Name != "" { @@ -155,24 +107,24 @@ RULES FOR BOOKING: } } - // If it was a tool call, execute it now that the stream is finished + // EXECUTE TOOL if isToolCall && toolName == "create_appointment" { var args struct { Phone string `json:"customer_phone"` Date string `json:"date"` } - // Try to parse the accumulated JSON arguments if err := json.Unmarshal([]byte(toolCallBuffer.String()), &args); err == nil { - err := db.SaveAppointment(args.Phone, args.Date) + err := db.SaveAppointment(userID, args.Phone, args.Date) resultMsg := "" if err != nil { - resultMsg = "\n[System: Failed to book appointment.]" + resultMsg = "\n(System: Failed to book appointment)" } else { - resultMsg = fmt.Sprintf("\n✅ Booked for %s at %s", args.Phone, args.Date) + resultMsg = fmt.Sprintf("\n✅ Appointment Confirmed: %s", args.Date) } - // Send the tool result to the frontend - onToken(resultMsg) fullContentBuffer.WriteString(resultMsg) + if onToken != nil { + onToken(resultMsg) + } } } diff --git a/services/types.go b/services/types.go new file mode 100644 index 0000000..70ff326 --- /dev/null +++ b/services/types.go @@ -0,0 +1,27 @@ +package services + +// Message: Usado para el historial del chat (OpenRouter) y dashboard +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// WebhookPayload: Usado para leer el JSON que envía WhatsApp +type WebhookPayload struct { + Entry []struct { + Changes []struct { + Value struct { + Metadata struct { + PhoneNumberID string `json:"phone_number_id"` + } `json:"metadata"` + 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"` +} diff --git a/services/whatsapp.go b/services/whatsapp.go index 39b0be2..c9014b6 100644 --- a/services/whatsapp.go +++ b/services/whatsapp.go @@ -5,13 +5,10 @@ import ( "encoding/json" "fmt" "net/http" - "os" ) -// SendWhatsAppMessage sends a text reply to a user -func SendWhatsAppMessage(toPhone string, messageBody string) error { - token := os.Getenv("WHATSAPP_TOKEN") // "EAA..." - phoneID := os.Getenv("WHATSAPP_PHONE_ID") // "100..." +// SendWhatsAppMessage sends a text reply using the specific Client's credentials +func SendWhatsAppMessage(token, phoneID, toPhone, messageBody string) error { version := "v17.0" url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", version, phoneID) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f5280bc --- /dev/null +++ b/static/style.css @@ -0,0 +1,43 @@ +/* static/style.css */ +:root { + --bg-dark: #2f3136; + --bg-darker: #202225; + --blurple: #5865F2; + --text-main: #dcddde; + --text-muted: #72767d; + --success: #3ba55c; + --danger: #ed4245; +} + +body { margin: 0; font-family: 'Segoe UI', sans-serif; background: var(--bg-dark); color: var(--text-main); } +a { text-decoration: none; color: var(--blurple); } +a:hover { text-decoration: underline; } + +/* AUTH & LANDING CONTAINERS */ +.center-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; text-align: center; } +.auth-box { background: var(--bg-darker); padding: 40px; border-radius: 8px; width: 350px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); } +.hero-title { font-size: 3rem; margin-bottom: 10px; color: white; } +.hero-sub { color: var(--text-muted); margin-bottom: 30px; font-size: 1.2rem; } + +/* FORMS */ +input, select { width: 100%; padding: 12px; margin: 8px 0 20px; background: var(--bg-dark); border: 1px solid #40444b; color: white; border-radius: 4px; box-sizing: border-box;} +button { width: 100%; padding: 12px; background: var(--blurple); color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; transition: 0.2s; } +button:hover { background: #4752c4; } +.btn-outline { background: transparent; border: 2px solid var(--blurple); color: var(--blurple); margin-top: 10px; } +.btn-outline:hover { background: rgba(88, 101, 242, 0.1); } + +/* DASHBOARD LAYOUT */ +.dashboard-layout { display: flex; height: 100vh; } +.sidebar { width: 260px; background: var(--bg-darker); padding: 20px; display: flex; flex-direction: column; } +.main-content { flex: 1; padding: 40px; overflow-y: auto; background: var(--bg-dark); } + +/* CARDS & TABLES */ +.card { background: var(--bg-darker); padding: 25px; border-radius: 8px; margin-bottom: 20px; } +table { width: 100%; border-collapse: collapse; margin-top: 15px; } +th { text-align: left; color: var(--text-muted); padding: 10px; border-bottom: 1px solid #40444b; } +td { padding: 10px; border-bottom: 1px solid #40444b; } +.status-confirmed { color: var(--success); font-weight: bold; } +.status-cancelled { color: var(--danger); font-weight: bold; } + +/* HOURS GRID */ +.hours-grid { display: grid; grid-template-columns: 100px 1fr 1fr; gap: 10px; align-items: center; margin-bottom: 10px; } \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index e28a0bd..23e9bec 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,434 +1,103 @@ {{ define "dashboard.html" }} - +
- -| ID | -Phone | -Date (YYYY-MM-DD HH:MM) | -Status | -Actions | -
|---|---|---|---|---|
| #{{.ID}} | -- | - | {{.Status}} | -- - - | -
The AI receptionist that lives in WhatsApp.
+ + + +Never miss a client.
+No apps to install.
+