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:
2026-03-02 00:38:05 -03:00
parent 9ff021879f
commit e256fcb073
20 changed files with 627 additions and 659 deletions

2
.env
View File

@@ -1,4 +1,4 @@
OPENROUTER_API_KEY=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 OPENROUTER_API_KEY=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
WHATSAPP_PHONE_ID=986583417873961 WHATSAPP_PHONE_ID=986583417873961
WHATSAPP_TOKEN=EAATqIU03y9YBQ5EBDxAFANmJIokKqjceliErZA1rYERpTzZBpRZAIKDVqlE2UWD0YUztSRwvsqjEdX2Uzt92Lsst5CEwcBiLZBbjiK8aAqpDclh2r2KyW5YvZCo7jXZAQf6dNYZABksjuxi5jUdLgfNQbhOhvSfv1z1qoWdZCUh9dUyzEcH3xmtCZBk9VG1qdtuLZBIlT335DSrSKgDpaLTBaWoe54aZCLwTxB89YZA78DkiIFmLq6dZBz3wSuUyMEfKokrRvtz7lXE6VkienXNucslgihZCkAMgZDZD WHATSAPP_TOKEN=EAATqIU03y9YBQ1DnscXkt0QQ8lfhWQbI8TT0wRNdB9ZAGLWEdPhN3761E0XBXBdzJiZA3uiPEugjhIS1TjrUZCu979aiiSYFvjbDjFRFYGVsGfqIZCB13H6AaviQHlBNksil9JlkefZAy4ZBFqZCkAcYGjGNtZBWHaXZCaMYTMmfn7rOAx4IUt6eHjfiVkXVquOoqDQY8oVOs5HAekLWNZBqsxm2w2J34AacAzsUwzem6kmsYcKs9CDQ9wIJBRw9FDaKkbV64waI1FdEI7ZALkZCKZBEWUFeA

BIN
__debug_bin.exe Normal file

Binary file not shown.

BIN
bot.db

Binary file not shown.

118
db/db.go
View File

@@ -2,6 +2,7 @@ package db
import ( import (
"database/sql" "database/sql"
"log"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -10,64 +11,93 @@ var Conn *sql.DB
func Init() { func Init() {
var err error var err error
Conn, err = sql.Open("sqlite", "./bot.db") Conn, err = sql.Open("sqlite", "./saas_bot.db")
if err != nil { if err != nil {
panic(err) panic(err)
} }
schema := ` schema := `
CREATE TABLE IF NOT EXISTS clients ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, email TEXT UNIQUE,
tier TEXT, password_hash TEXT,
msg_count INTEGER DEFAULT 0 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 ( CREATE TABLE IF NOT EXISTS appointments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT, user_id INTEGER,
customer_phone TEXT, customer_phone TEXT,
appointment_date TEXT, appointment_time DATETIME,
status TEXT DEFAULT 'confirmed' status TEXT DEFAULT 'confirmed',
); FOREIGN KEY(user_id) REFERENCES users(id)
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT DEFAULT 'New Chat',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
`
_, err = Conn.Exec(schema)
if err != nil {
log.Fatal("Migration Error:", err)
}
CREATE TABLE IF NOT EXISTS messages ( seedUser()
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)
} }
func SaveAppointment(phone string, date string) error { // seedUser creates a default user so you can test the dashboard immediately
_, err := Conn.Exec( func seedUser() {
"INSERT INTO appointments (customer_phone, appointment_date, status) VALUES (?, ?, ?)", var count int
phone, date, "confirmed", 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 return err
} }
// GetOrCreateChatByPhone finds a chat for this phone number or creates one // SaveAppointment now requires a userID to know WHO the appointment is for
func GetOrCreateChatByPhone(phone string) int { func SaveAppointment(userID int, phone, date string) error {
// 1. Check if we already have a chat for this phone _, err := Conn.Exec("INSERT INTO appointments (user_id, customer_phone, appointment_time) VALUES (?, ?, ?)", userID, phone, date)
// (Note: You might want to add a 'phone' column to 'chats' table if you haven't yet. return err
// 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)
} }

2
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.0
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/joho/godotenv v1.5.1
modernc.org/sqlite v1.46.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect

61
handlers/auth.go Normal file
View 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, "/")
}

View File

@@ -10,8 +10,13 @@ import (
"github.com/gin-gonic/gin" "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) { 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 { type Appt struct {
ID int ID int
CustomerPhone string CustomerPhone string
@@ -20,7 +25,7 @@ func ShowDashboard(c *gin.Context) {
} }
var appts []Appt 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 { if err != nil {
log.Println("Appt Query Error:", err) log.Println("Appt Query Error:", err)
} else { } 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 { type Chat struct {
ID int ID int
Title string Title string
} }
var chats []Chat 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 { if err != nil {
log.Println("Chat Query Error:", err) log.Println("Chat Query Error:", err)
} else { } else {
defer chatrows.Close() defer chatrows.Close()
for chatrows.Next() { for chatrows.Next() {
var ch Chat var ch Chat
chatrows.Scan(&ch.ID) chatrows.Scan(&ch.ID, &ch.Title)
// Give it a default title so the sidebar isn't empty if ch.Title == "" {
ch.Title = "Chat #" + strconv.Itoa(ch.ID) ch.Title = "Chat #" + strconv.Itoa(ch.ID)
}
chats = append(chats, ch) 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{ c.HTML(http.StatusOK, "dashboard.html", gin.H{
"Appointments": appts, "Appointments": appts,
"Chats": chats, "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) { func CreateAppointmentHandler(c *gin.Context) {
userID := c.MustGet("userID").(int)
var body struct { var body struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Date string `json:"date"` Date string `json:"date"`
@@ -71,8 +90,7 @@ func CreateAppointmentHandler(c *gin.Context) {
return return
} }
// Use the helper function instead of raw SQL here if err := db.SaveAppointment(userID, body.Phone, body.Date); err != nil {
if err := db.SaveAppointment(body.Phone, body.Date); err != nil {
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
return return
} }
@@ -80,10 +98,13 @@ func CreateAppointmentHandler(c *gin.Context) {
c.Status(200) c.Status(200)
} }
// Manage/Delete Appointment // DELETE /admin/appointment/:id
func DeleteAppointmentHandler(c *gin.Context) { func DeleteAppointmentHandler(c *gin.Context) {
id := c.Param("id") 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 { if err != nil {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
return return
@@ -94,6 +115,7 @@ func DeleteAppointmentHandler(c *gin.Context) {
// PUT /admin/appointment/:id // PUT /admin/appointment/:id
func UpdateAppointmentHandler(c *gin.Context) { func UpdateAppointmentHandler(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
userID := c.MustGet("userID").(int)
var body struct { var body struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Date string `json:"date"` Date string `json:"date"`
@@ -104,8 +126,8 @@ func UpdateAppointmentHandler(c *gin.Context) {
} }
_, err := db.Conn.Exec( _, err := db.Conn.Exec(
"UPDATE appointments SET customer_phone = ?, appointment_date = ? WHERE id = ?", "UPDATE appointments SET customer_phone = ?, appointment_time = ? WHERE id = ? AND user_id = ?",
body.Phone, body.Date, id, body.Phone, body.Date, id, userID,
) )
if err != nil { if err != nil {
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
@@ -116,20 +138,19 @@ func UpdateAppointmentHandler(c *gin.Context) {
// POST /admin/chat // POST /admin/chat
func NewChatHandler(c *gin.Context) { 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')") res, err := db.Conn.Exec("INSERT INTO chats (title) VALUES ('New Chat')")
if err != nil { if err != nil {
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
return return
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
// Return the ID so the frontend can select it immediately
c.JSON(200, gin.H{"id": id, "title": "New Chat"}) c.JSON(200, gin.H{"id": id, "title": "New Chat"})
} }
// DELETE /admin/chat/:id // DELETE /admin/chat/:id
func DeleteChatHandler(c *gin.Context) { func DeleteChatHandler(c *gin.Context) {
id := c.Param("id") 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 messages WHERE chat_id = ?", id)
db.Conn.Exec("DELETE FROM chats WHERE id = ?", id) db.Conn.Exec("DELETE FROM chats WHERE id = ?", id)
c.Status(200) c.Status(200)
@@ -167,6 +188,8 @@ func GetMessagesHandler(c *gin.Context) {
// POST /admin/chat/:id/message // POST /admin/chat/:id/message
func PostMessageHandler(c *gin.Context) { func PostMessageHandler(c *gin.Context) {
chatId := c.Param("id") chatId := c.Param("id")
userID := c.MustGet("userID").(int)
var body struct { var body struct {
Content string `json:"content"` Content string `json:"content"`
} }
@@ -175,10 +198,10 @@ func PostMessageHandler(c *gin.Context) {
return 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) 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) rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatId)
var history []services.Message var history []services.Message
defer rows.Close() defer rows.Close()
@@ -188,20 +211,26 @@ func PostMessageHandler(c *gin.Context) {
history = append(history, m) 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("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked") c.Writer.Header().Set("Transfer-Encoding", "chunked")
// 4. Call the Stream Service // We use the new StreamAIResponse signature that takes (userID, message, prompt, callback)
// We pass a function that writes chunks directly to the HTTP response fullResponse, _ := services.StreamAIResponse(userID, body.Content, systemPrompt, func(chunk string) {
fullResponse, _ := services.StreamAIResponse(history, func(chunk string) {
c.Writer.Write([]byte(chunk)) 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 // 5. Save Assistant Message
// We do this AFTER the stream finishes so the next load has the full text
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse) db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse)
} }

108
handlers/saas.go Normal file
View 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{})
}
}

View File

@@ -3,36 +3,21 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"time"
"whatsapp-bot/db" "whatsapp-bot/db"
"whatsapp-bot/services" "whatsapp-bot/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Structs to parse incoming WhatsApp Webhook JSON // NOTA: Borramos la definición local de WebhookPayload porque ahora la importamos de services
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"`
}
// VerifyWebhook (Keep this as is)
func VerifyWebhook(c *gin.Context) { func VerifyWebhook(c *gin.Context) {
mode := c.Query("hub.mode") mode := c.Query("hub.mode")
token := c.Query("hub.verify_token") token := c.Query("hub.verify_token")
challenge := c.Query("hub.challenge") 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) c.String(http.StatusOK, challenge)
} else { } else {
c.Status(http.StatusForbidden) c.Status(http.StatusForbidden)
@@ -40,61 +25,49 @@ func VerifyWebhook(c *gin.Context) {
} }
func HandleMessage(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 { if err := c.BindJSON(&payload); err != nil {
// WhatsApp sends other events (statuses) that might not match. Ignore errors.
c.Status(200) c.Status(200)
return return
} }
// 1. Loop through messages (usually just one)
for _, entry := range payload.Entry { for _, entry := range payload.Entry {
for _, change := range entry.Changes { 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" { if msg.Type != "text" {
continue continue
} }
userPhone := msg.From fmt.Printf("📩 Msg for User %d (%s): %s\n", botConfig.UserID, botConfig.PhoneID, msg.Text.Body)
userText := 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 // 3. LLAMAR A LA IA
chatID := db.GetOrCreateChatByPhone(userPhone) aiResp, _ := services.StreamAIResponse(botConfig.UserID, msg.Text.Body, finalPrompt, nil)
// 3. Save User Message // 4. RESPONDER
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatID, userText) if aiResp != "" {
services.SendWhatsAppMessage(botConfig.Token, botConfig.PhoneID, msg.From, aiResp)
// 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)
}
} }
} }
} }
} }
c.Status(200) c.Status(200)
} }

63
main.go
View File

@@ -1,7 +1,7 @@
package main package main
import ( import (
"log" "strconv"
"whatsapp-bot/db" "whatsapp-bot/db"
"whatsapp-bot/handlers" "whatsapp-bot/handlers"
@@ -10,39 +10,48 @@ import (
) )
func main() { func main() {
godotenv.Load()
// 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.")
}
db.Init() db.Init()
r := gin.Default() r := gin.Default()
// Load templates so Gin knows where to find your HTML
r.LoadHTMLGlob("templates/*") 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.GET("/webhook", handlers.VerifyWebhook)
r.POST("/webhook", handlers.HandleMessage) r.POST("/webhook", handlers.HandleMessage)
r.GET("/dashboard", handlers.ShowDashboard)
r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler) // PRIVATE ROUTES (Middleware)
r.POST("/admin/appointment", handlers.CreateAppointmentHandler) auth := r.Group("/")
r.PUT("/admin/appointment/:id", handlers.UpdateAppointmentHandler) 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.Run(":9090")
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) func AuthMiddleware() gin.HandlerFunc {
r.POST("/admin/chat/:id/message", handlers.PostMessageHandler) return func(c *gin.Context) {
cookie, err := c.Cookie("user_id")
// A little something for the root so you don't get a 404 again, seki if err != nil {
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/login")
c.JSON(200, gin.H{"message": "Bot is running. Go to /dashboard"}) c.Abort()
}) return
}
r.Run(":9090") // Using your port 9090 from the screenshot // Convert cookie to Int
uid, _ := strconv.Atoi(cookie)
c.Set("userID", uid)
c.Next()
}
} }

BIN
saas_bot.db Normal file

Binary file not shown.

View File

@@ -8,84 +8,36 @@ import (
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time"
"whatsapp-bot/db" "whatsapp-bot/db"
) )
type Message struct { // NOTA: struct Message ya NO está aquí, está en types.go
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"`
}
// StreamAIResponse handles the streaming connection // StreamAIResponse handles the streaming connection
// onToken: a function that gets called every time we get text (we use this to push to the browser) func StreamAIResponse(userID int, userMessage string, systemPrompt string, onToken func(string)) (string, error) {
// Returns: the full final string (so we can save it to the DB)
func StreamAIResponse(chatHistory []Message, onToken func(string)) (string, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY") apiKey := os.Getenv("OPENROUTER_API_KEY")
url := "https://openrouter.ai/api/v1/chat/completions" url := "https://openrouter.ai/api/v1/chat/completions"
// 1. Get Current Time for the LLM messages := []map[string]string{
currentTime := time.Now().Format("Monday, 2006-01-02 15:04") {"role": "system", "content": systemPrompt},
{"role": "user", "content": userMessage},
// 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...)
payload := map[string]interface{}{ payload := map[string]interface{}{
"model": "arcee-ai/trinity-large-preview:free", // stepfun/step-3.5-flash:free, arcee-ai/trinity-large-preview:free "model": "arcee-ai/trinity-large-preview:free", //stepfun/step-3.5-flash:free
"messages": fullMessages, "messages": messages,
"stream": true, "stream": true,
"tools": []map[string]interface{}{ "tools": []map[string]interface{}{
{ {
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": "create_appointment", "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{}{ "parameters": map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"customer_phone": map[string]string{ "customer_phone": map[string]string{"type": "string"},
"type": "string", "date": 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",
},
}, },
"required": []string{"customer_phone", "date"}, "required": []string{"customer_phone", "date"},
}, },
@@ -106,9 +58,7 @@ RULES FOR BOOKING:
} }
defer resp.Body.Close() defer resp.Body.Close()
// Prepare to read the stream line by line
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
var fullContentBuffer strings.Builder var fullContentBuffer strings.Builder
var toolCallBuffer strings.Builder var toolCallBuffer strings.Builder
var toolName string var toolName string
@@ -116,35 +66,37 @@ RULES FOR BOOKING:
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// OpenRouter sends "data: {JSON}" lines.
// Use specific string trimming to handle the format.
if !strings.HasPrefix(line, "data: ") { if !strings.HasPrefix(line, "data: ") {
continue continue
} }
jsonStr := strings.TrimPrefix(line, "data: ") jsonStr := strings.TrimPrefix(line, "data: ")
// The stream ends with "data: [DONE]"
if jsonStr == "[DONE]" { if jsonStr == "[DONE]" {
break break
} }
var chunk StreamResponse var chunk struct {
if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil { Choices []struct {
continue 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 { if len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta delta := chunk.Choices[0].Delta
// 1. Handle Text Content
if delta.Content != "" { if delta.Content != "" {
fullContentBuffer.WriteString(delta.Content) fullContentBuffer.WriteString(delta.Content)
// Send this chunk to the frontend immediately if onToken != nil {
onToken(delta.Content) onToken(delta.Content)
}
} }
// 2. Handle Tool Calls (Accumulate them, don't stream execution yet)
if len(delta.ToolCalls) > 0 { if len(delta.ToolCalls) > 0 {
isToolCall = true isToolCall = true
if delta.ToolCalls[0].Function.Name != "" { 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" { if isToolCall && toolName == "create_appointment" {
var args struct { var args struct {
Phone string `json:"customer_phone"` Phone string `json:"customer_phone"`
Date string `json:"date"` Date string `json:"date"`
} }
// Try to parse the accumulated JSON arguments
if err := json.Unmarshal([]byte(toolCallBuffer.String()), &args); err == nil { 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 := "" resultMsg := ""
if err != nil { if err != nil {
resultMsg = "\n[System: Failed to book appointment.]" resultMsg = "\n(System: Failed to book appointment)"
} else { } 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) fullContentBuffer.WriteString(resultMsg)
if onToken != nil {
onToken(resultMsg)
}
} }
} }

27
services/types.go Normal file
View File

@@ -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"`
}

View File

@@ -5,13 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
) )
// SendWhatsAppMessage sends a text reply to a user // SendWhatsAppMessage sends a text reply using the specific Client's credentials
func SendWhatsAppMessage(toPhone string, messageBody string) error { func SendWhatsAppMessage(token, phoneID, toPhone, messageBody string) error {
token := os.Getenv("WHATSAPP_TOKEN") // "EAA..."
phoneID := os.Getenv("WHATSAPP_PHONE_ID") // "100..."
version := "v17.0" version := "v17.0"
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", version, phoneID) url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", version, phoneID)

43
static/style.css Normal file
View File

@@ -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; }

View File

@@ -1,434 +1,103 @@
{{ define "dashboard.html" }} {{ define "dashboard.html" }}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="UTF-8"> <title>SekiBot Dashboard</title>
<title>SekiBot | Dashboard</title> <link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'></script>
<style>
:root {
--bg-tertiary: #202225;
--bg-secondary: #2f3136;
--bg-primary: #36393f;
--bg-input: #40444b;
--text-normal: #dcddde;
--text-muted: #72767d;
--blurple: #5865F2;
--blurple-hover: #4752c4;
--danger: #ed4245;
--success: #3ba55c;
--interactive-hover: #3b3d42;
}
body { margin: 0; font-family: 'Inter', sans-serif; background: var(--bg-primary); color: var(--text-normal); overflow: hidden; display: flex; height: 100vh; }
/* SCROLLBARS */
::-webkit-scrollbar { width: 8px; height: 8px; background-color: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background-color: #202225; border-radius: 4px; }
/* SIDEBAR */
.sidebar { width: 240px; background: var(--bg-secondary); display: flex; flex-direction: column; flex-shrink: 0; }
.sidebar-header { padding: 16px; border-bottom: 1px solid #202225; font-weight: 600; color: white; display: flex; align-items: center; justify-content: space-between; }
.sidebar-nav { padding: 10px; border-bottom: 1px solid #202225; }
.sidebar-scroll { flex: 1; overflow-y: auto; padding: 8px; }
.nav-item { padding: 10px; border-radius: 4px; cursor: pointer; color: #8e9297; margin-bottom: 2px; font-weight: 500; }
.nav-item:hover { background: var(--interactive-hover); color: var(--text-normal); }
.nav-item.active { background: rgba(79,84,92,0.6); color: white; }
.channel-item { display: flex; align-items: center; justify-content: space-between; padding: 8px; border-radius: 4px; cursor: pointer; color: #8e9297; margin-bottom: 2px; }
.channel-item:hover { background: var(--interactive-hover); color: var(--text-normal); }
.channel-item.active { background: rgba(79,84,92,0.6); color: white; }
.channel-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-actions { display: none; gap: 4px; }
.channel-item:hover .channel-actions { display: flex; }
/* ICONS */
.icon-btn { color: var(--text-muted); padding: 2px; cursor: pointer; border-radius: 3px; font-size: 0.8rem; }
.icon-btn:hover { color: white; background: var(--bg-tertiary); }
.icon-btn.del:hover { color: var(--danger); }
/* PANELS */
.main-panel { flex: 1; display: none; flex-direction: column; background: var(--bg-primary); position: relative; height: 100vh; }
.main-panel.show { display: flex; }
/* CHAT STYLES */
.chat-header { height: 48px; border-bottom: 1px solid #26272d; display: flex; align-items: center; padding: 0 16px; font-weight: 600; color: white; box-shadow: 0 1px 0 rgba(4,4,5,0.02); }
.messages-wrapper { flex: 1; overflow-y: scroll; display: flex; flex-direction: column; padding: 0 16px; }
.message-group { margin-top: 16px; display: flex; gap: 16px; }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--blurple); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: bold; color: white; font-size: 18px; }
.avatar.bot { background: var(--success); }
.msg-content { flex: 1; min-width: 0; }
.msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.username { font-weight: 500; color: white; }
.timestamp { font-size: 0.75rem; color: var(--text-muted); }
.msg-text { white-space: pre-wrap; line-height: 1.375rem; color: var(--text-normal); }
.input-wrapper { padding: 0 16px 24px; flex-shrink: 0; margin-top: 10px; }
.input-bg { background: var(--bg-input); border-radius: 8px; padding: 12px; display: flex; align-items: center; }
.chat-input { background: transparent; border: none; color: white; flex: 1; font-size: 1rem; outline: none; }
/* APPOINTMENTS TABLE STYLES */
.appt-container { padding: 40px; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; background: var(--bg-secondary); border-radius: 8px; overflow: hidden; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--bg-input); }
th { background: #202225; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 1px; color: var(--text-muted); }
input.edit-field { background: var(--bg-input); border: 1px solid #202225; color: white; padding: 8px; border-radius: 4px; width: 100%; box-sizing: border-box; }
.status-pill { background: #faa61a; color: black; padding: 4px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; }
/* BUTTONS */
.btn-blurple { background: var(--blurple); color: white; border: none; padding: 8px 16px; border-radius: 4px; font-weight: 500; cursor: pointer; transition: 0.2s; }
.btn-blurple:hover { background: var(--blurple-hover); }
.btn-icon { background: transparent; border: none; cursor: pointer; padding: 5px; color: var(--text-muted); border-radius: 4px; }
.btn-icon:hover { background: var(--bg-tertiary); color: white; }
/* MODAL */
.modal-overlay { display: none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center;}
.modal { background: var(--bg-primary); padding: 20px; border-radius: 8px; width: 300px; text-align: center; }
</style>
</head> </head>
<body> <body>
<div class="dashboard-layout">
<nav class="sidebar"> <div class="sidebar">
<div class="sidebar-header"> <h2 style="color: white;">🤖 SekiBot</h2>
<span>SekiBot Server</span> <div style="margin-bottom: 20px; color: var(--text-muted);">
</div> User: {{ .UserEmail }} <br>
<small>Plan: {{ .Tier }}</small>
<div class="sidebar-nav">
<div class="nav-item active" onclick="switchPanel('chat')" id="nav-chat">💬 Conversations</div>
<div class="nav-item" onclick="switchPanel('appt')" id="nav-appt">📅 Appointments</div>
</div>
<div class="sidebar-scroll" id="channel-list">
<div style="padding: 10px;">
<button class="btn-blurple" style="width: 100%;" onclick="createNewChat()">+ New Chat</button>
</div>
{{range .Chats}}
<div class="channel-item" id="chat-item-{{.ID}}" onclick="loadChat({{.ID}}, '{{.Title}}')">
<div style="display: flex; align-items: center; overflow: hidden;">
<span style="color: #72767d; margin-right: 6px;">#</span>
<span class="channel-name" id="title-{{.ID}}">{{.Title}}</span>
</div>
<div class="channel-actions" onclick="event.stopPropagation()">
<span class="icon-btn" onclick="openRenameModal({{.ID}})"></span>
<span class="icon-btn del" onclick="deleteChat({{.ID}})"></span>
</div>
</div>
{{end}}
</div>
</nav>
<main class="main-panel show" id="panel-chat">
<div class="chat-header">
<span style="color: #72767d; margin-right: 6px; font-size: 1.2em;">#</span>
<span id="current-channel-name">general</span>
</div>
<div class="messages-wrapper" id="messages-pane">
<div style="margin: auto; text-align: center; color: var(--text-muted);">
<h3>Welcome back, Seki.</h3>
<p>Select a chat on the left to start.</p>
</div>
</div>
<div class="input-wrapper">
<div class="input-bg">
<input type="text" class="chat-input" id="user-input" placeholder="Message #general" onkeypress="handleEnter(event)">
</div>
</div>
</main>
<main class="main-panel" id="panel-appt">
<div class="appt-container">
<h1>Appointments Manager</h1>
<div style="background: var(--bg-secondary); padding: 20px; border-radius: 8px; margin-bottom: 30px;">
<h3 style="margin-top: 0; margin-bottom: 15px;">Add Manual Appointment</h3>
<div style="display: flex; gap: 10px;">
<input type="text" class="edit-field" id="m-phone" placeholder="+56 9 1234 5678" style="flex: 1;">
<input type="datetime-local" class="edit-field" id="m-date" style="flex: 1;">
<button class="btn-blurple" style="background: var(--success);" onclick="createMockAppt()">Add Booking</button>
</div> </div>
<a href="#settings" class="btn-outline" style="text-align:center; border:none; text-align: left;">⚙️ Settings</a>
<a href="#appointments" class="btn-outline" style="text-align:center; border:none; text-align: left;">📅 Appointments</a>
<a href="/logout" style="margin-top: auto; color: var(--danger);">🚪 Logout</a>
</div> </div>
<table> <div class="main-content">
<thead>
<tr>
<th style="width: 50px;">ID</th>
<th>Phone</th>
<th>Date (YYYY-MM-DD HH:MM)</th>
<th>Status</th>
<th style="width: 100px; text-align: right;">Actions</th>
</tr>
</thead>
<tbody>
{{range .Appointments}}
<tr id="appt-{{.ID}}">
<td>#{{.ID}}</td>
<td><input type="text" class="edit-field" value="{{.CustomerPhone}}" id="edit-phone-{{.ID}}"></td>
<td><input type="text" class="edit-field" value="{{.Date}}" id="edit-date-{{.ID}}"></td>
<td><span class="status-pill">{{.Status}}</span></td>
<td style="text-align: right;">
<button class="btn-icon" title="Save" onclick="updateAppt({{.ID}})">💾</button>
<button class="btn-icon" title="Delete" style="color: var(--danger);" onclick="deleteAppt({{.ID}})">🗑️</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</main>
<div class="modal-overlay" id="rename-modal"> <div id="settings" class="card">
<div class="modal"> <h3 style="color: white;">Business Configuration</h3>
<h3 style="margin-top:0; color:white;">Rename Channel</h3> <form action="/update-bot" method="POST">
<input type="text" id="new-name-input" class="edit-field" style="margin-bottom: 15px;"> <label style="color: var(--text-muted);">Bot Name</label>
<div style="display: flex; justify-content: space-between;"> <input type="text" name="bot_name" value="{{ .BotConfig.Name }}">
<button onclick="closeModal()" style="background: transparent; color: white; border: none; cursor: pointer;">Cancel</button>
<button class="btn-blurple" onclick="submitRename()">Save</button>
</div>
</div>
</div>
<script> <label style="color: var(--text-muted);">System Prompt</label>
let currentChatId = null; <textarea name="system_prompt" rows="3" style="width:100%; background:var(--bg-dark); color:white; border:1px solid #40444b; padding:10px;">{{ .BotConfig.Prompt }}</textarea>
let chatToRename = null;
// --- NAVIGATION --- <h4 style="color: white; margin-top: 20px;">Open Hours</h4>
function switchPanel(panel) {
document.getElementById('panel-chat').classList.toggle('show', panel === 'chat');
document.getElementById('panel-appt').classList.toggle('show', panel === 'appt');
document.getElementById('nav-chat').classList.toggle('active', panel === 'chat'); {{ range $day := .Days }}
document.getElementById('nav-appt').classList.toggle('active', panel === 'appt'); <div class="hours-grid">
} <span style="color:white;">{{ $day }}</span>
<select name="{{$day}}_open">
// --- CHAT CRUD --- <option value="">Closed</option>
<option value="08:00">08:00 AM</option>
async function createNewChat() { <option value="09:00" selected>09:00 AM</option>
try { <option value="10:00">10:00 AM</option>
const res = await fetch('/admin/chat', { method: 'POST' }); </select>
const data = await res.json(); <select name="{{$day}}_close">
<option value="">Closed</option>
if (data.id) { <option value="17:00" selected>05:00 PM</option>
// Create Element <option value="18:00">06:00 PM</option>
const newDiv = document.createElement('div'); <option value="19:00">07:00 PM</option>
newDiv.className = 'channel-item active'; // Set active immediately </select>
newDiv.id = `chat-item-${data.id}`;
newDiv.onclick = () => loadChat(data.id, data.title);
newDiv.innerHTML = `
<div style="display: flex; align-items: center; overflow: hidden;">
<span style="color: #72767d; margin-right: 6px;">#</span>
<span class="channel-name" id="title-${data.id}">${data.title}</span>
</div> </div>
<div class="channel-actions" onclick="event.stopPropagation()"> {{ end }}
<span class="icon-btn" onclick="openRenameModal(${data.id})">✎</span>
<span class="icon-btn del" onclick="deleteChat(${data.id})">✖</span>
</div>
`;
// Insert after the button container <button type="submit" style="margin-top: 20px;">Save Changes</button>
const list = document.getElementById('channel-list'); </form>
const btnContainer = list.firstElementChild; // The div containing the button </div>
if (btnContainer.nextSibling) {
list.insertBefore(newDiv, btnContainer.nextSibling);
} else {
list.appendChild(newDiv);
}
// Switch and Load <div id="appointments" class="card">
switchPanel('chat'); <h3 style="color: white;">Manage Appointments</h3>
loadChat(data.id, data.title); <table>
} <thead>
} catch (e) { <tr><th>Client</th><th>Date</th><th>Status</th><th>Action</th></tr>
console.error(e); </thead>
alert("Failed to create chat."); <tbody>
} {{ range .Appointments }}
} <tr>
<td>{{ .Phone }}</td>
<td>{{ .Date }}</td>
<td class="{{ if eq .Status "cancelled" }}status-cancelled{{ else }}status-confirmed{{ end }}">
{{ .Status }}
</td>
<td>
{{ if ne .Status "cancelled" }}
<form action="/admin/appointment/{{.ID}}/cancel" method="POST" style="margin:0;">
<button style="background:var(--danger); padding: 5px; font-size: 0.8rem;">Cancel</button>
</form>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="4" style="text-align:center; color:gray;">No appointments yet.</td></tr>
{{ end }}
</tbody>
</table>
<br>
<div id="calendar" style="background: white; padding: 10px; border-radius: 8px;"></div>
</div>
</div>
</div>
async function deleteChat(id) { <script>
if (!confirm("Delete this chat permanently?")) return; document.addEventListener('DOMContentLoaded', function() {
await fetch(`/admin/chat/${id}`, { method: 'DELETE' }); var calendarEl = document.getElementById('calendar');
document.getElementById(`chat-item-${id}`).remove(); var calendar = new FullCalendar.Calendar(calendarEl, {
if (currentChatId === id) { initialView: 'dayGridMonth',
document.getElementById('messages-pane').innerHTML = '<div style="margin: auto; color: var(--text-muted);">Chat deleted.</div>'; events: '/api/my-appointments',
document.getElementById('current-channel-name').innerText = 'deleted'; height: 400
currentChatId = null;
}
}
function openRenameModal(id) {
chatToRename = id;
document.getElementById('rename-modal').style.display = 'flex';
document.getElementById('new-name-input').value = document.getElementById(`title-${id}`).innerText;
document.getElementById('new-name-input').focus();
}
function closeModal() {
document.getElementById('rename-modal').style.display = 'none';
chatToRename = null;
}
async function submitRename() {
const newTitle = document.getElementById('new-name-input').value;
if (newTitle && chatToRename) {
await fetch(`/admin/chat/${chatToRename}/rename`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
}); });
document.getElementById(`title-${chatToRename}`).innerText = newTitle; calendar.render();
if (currentChatId === chatToRename) {
document.getElementById('current-channel-name').innerText = newTitle;
}
closeModal();
}
}
// --- MESSAGING ---
async function loadChat(id, title) {
currentChatId = id;
// Highlight active channel
document.querySelectorAll('.channel-item').forEach(el => el.classList.remove('active'));
const activeItem = document.getElementById(`chat-item-${id}`);
if(activeItem) activeItem.classList.add('active');
document.getElementById('current-channel-name').innerText = title;
document.getElementById('user-input').placeholder = `Message #${title}`;
document.getElementById('user-input').focus();
const pane = document.getElementById('messages-pane');
pane.innerHTML = '<div style="margin: auto; color: var(--text-muted);">Loading history...</div>';
const res = await fetch(`/admin/chat/${id}/messages`);
const messages = await res.json();
pane.innerHTML = '';
if (messages) {
messages.forEach(msg => renderMessage(msg.role, msg.content));
} else {
pane.innerHTML = '<div style="margin: auto; color: var(--text-muted);">No messages yet. Say hello!</div>';
}
scrollToBottom();
}
function renderMessage(role, text) {
const pane = document.getElementById('messages-pane');
const group = document.createElement('div');
group.className = 'message-group';
const avatar = document.createElement('div');
avatar.className = `avatar ${role === 'assistant' ? 'bot' : ''}`;
avatar.innerText = role === 'user' ? 'U' : 'AI';
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
const header = document.createElement('div');
header.className = 'msg-header';
header.innerHTML = `<span class="username">${role === 'user' ? 'User' : 'SekiBot'}</span> <span class="timestamp">${new Date().toLocaleTimeString()}</span>`;
const body = document.createElement('div');
body.className = 'msg-text';
body.innerText = text;
contentDiv.appendChild(header);
contentDiv.appendChild(body);
group.appendChild(avatar);
group.appendChild(contentDiv);
pane.appendChild(group);
return body;
}
async function sendMessage() {
if (!currentChatId) return alert("Select a chat first!");
const input = document.getElementById('user-input');
const content = input.value.trim();
if (!content) return;
input.value = '';
renderMessage('user', content);
scrollToBottom();
const streamContainer = renderMessage('assistant', '');
scrollToBottom();
try {
const response = await fetch(`/admin/chat/${currentChatId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
streamContainer.innerText += chunk;
scrollToBottom();
}
} catch (e) {
streamContainer.innerText += "\n[Error: Connection Failed]";
}
}
function scrollToBottom() {
const pane = document.getElementById('messages-pane');
pane.scrollTop = pane.scrollHeight;
}
function handleEnter(e) {
if (e.key === 'Enter') sendMessage();
}
// --- APPOINTMENTS MANAGER ---
async function createMockAppt() {
const phone = document.getElementById('m-phone').value;
const date = document.getElementById('m-date').value;
if(!phone || !date) return alert("Fill in fields, seki.");
await fetch('/admin/appointment', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone, date})
}); });
location.reload(); // Reload to refresh table </script>
}
async function updateAppt(id) {
const phone = document.getElementById(`edit-phone-${id}`).value;
const date = document.getElementById(`edit-date-${id}`).value;
const res = await fetch(`/admin/appointment/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone, date})
});
if(res.ok) alert("Saved.");
else alert("Update failed.");
}
async function deleteAppt(id) {
if(!confirm("Are you sure?")) return;
const res = await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
if(res.ok) {
document.getElementById(`appt-${id}`).remove();
}
}
</script>
</body> </body>
</html> </html>
{{ end }} {{ end }}

31
templates/landing.html Normal file
View File

@@ -0,0 +1,31 @@
{{ define "landing.html" }}
<!DOCTYPE html>
<html>
<head>
<title>SekiBot - AI Receptionist</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="center-container">
<h1 class="hero-title">Automate Your Appointments</h1>
<p class="hero-sub">The AI receptionist that lives in WhatsApp.</p>
<div style="display: flex; gap: 20px;">
<a href="/login"><button style="width: 150px;">Login</button></a>
<a href="/register"><button class="btn-outline" style="width: 150px;">Get Started</button></a>
</div>
<div style="margin-top: 50px; display: flex; gap: 20px;">
<div class="auth-box" style="width: 200px;">
<h3>24/7 Booking</h3>
<p style="color: grey;">Never miss a client.</p>
</div>
<div class="auth-box" style="width: 200px;">
<h3>WhatsApp Native</h3>
<p style="color: grey;">No apps to install.</p>
</div>
</div>
</div>
</body>
</html>
{{ end }}

20
templates/login.html Normal file
View File

@@ -0,0 +1,20 @@
{{ define "login.html" }}
<!DOCTYPE html>
<html>
<head><title>Login</title><link rel="stylesheet" href="/static/style.css"></head>
<body>
<div class="center-container">
<div class="auth-box">
<h2 style="color: white;">Welcome Back</h2>
{{ if .Error }}<div style="color: red; margin-bottom: 10px;">{{ .Error }}</div>{{ end }}
<form action="/login" method="POST">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<p style="margin-top: 15px;"><a href="/register">Create an account</a></p>
</div>
</div>
</body>
</html>
{{ end }}

20
templates/register.html Normal file
View File

@@ -0,0 +1,20 @@
{{ define "register.html" }}
<!DOCTYPE html>
<html>
<head><title>Register</title><link rel="stylesheet" href="/static/style.css"></head>
<body>
<div class="center-container">
<div class="auth-box">
<h2 style="color: white;">Start Free Trial</h2>
{{ if .Error }}<div style="color: red; margin-bottom: 10px;">{{ .Error }}</div>{{ end }}
<form action="/register" method="POST">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Create Account</button>
</form>
<p style="margin-top: 15px;"><a href="/login">Already have an account?</a></p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@@ -1 +0,0 @@
package main