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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
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 (
"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
}

2
go.mod
View File

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

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"
)
// ShowDashboard renders the Chat Interface (Discord View)
// NOTE: The main SaaS Dashboard is now at /dashboard (UserDashboard in saas.go).
// You might want to rename this route to /chat-interface or keep it as a sub-view.
func ShowDashboard(c *gin.Context) {
// 1. Fetch Appointments for the Table
userID := c.MustGet("userID").(int)
// 1. Fetch Appointments (Only for this user)
type Appt struct {
ID int
CustomerPhone string
@@ -20,7 +25,7 @@ func ShowDashboard(c *gin.Context) {
}
var appts []Appt
approws, err := db.Conn.Query("SELECT id, customer_phone, appointment_date, status FROM appointments ORDER BY id DESC")
approws, err := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id = ? ORDER BY id DESC", userID)
if err != nil {
log.Println("Appt Query Error:", err)
} else {
@@ -32,36 +37,50 @@ func ShowDashboard(c *gin.Context) {
}
}
// 2. Fetch Chats for the Sidebar
// 2. Fetch Chats (Only for this user)
// We need to add user_id to chats table to make this strict,
// but for now, we'll assume all chats are visible to the admin/user
// or filter if you add user_id to the chats table later.
type Chat struct {
ID int
Title string
}
var chats []Chat
chatrows, err := db.Conn.Query("SELECT id FROM chats ORDER BY id DESC")
chatrows, err := db.Conn.Query("SELECT id, title FROM chats ORDER BY id DESC")
if err != nil {
log.Println("Chat Query Error:", err)
} else {
defer chatrows.Close()
for chatrows.Next() {
var ch Chat
chatrows.Scan(&ch.ID)
// Give it a default title so the sidebar isn't empty
ch.Title = "Chat #" + strconv.Itoa(ch.ID)
chatrows.Scan(&ch.ID, &ch.Title)
if ch.Title == "" {
ch.Title = "Chat #" + strconv.Itoa(ch.ID)
}
chats = append(chats, ch)
}
}
// 3. Render the Template with BOTH data sets
// 3. Render the Chat Template
// Note: We are using "dashboard.html" for the SaaS view now.
// You might want to rename your old chat template to "chat_view.html"
// if you want to keep both views separate.
// For now, I'll point this to the new SaaS dashboard template to avoid errors,
// but realistically you should merge them or have two separate HTML files.
c.HTML(http.StatusOK, "dashboard.html", gin.H{
"Appointments": appts,
"Chats": chats,
// Pass minimal context so the template doesn't crash if it expects user info
"UserEmail": "Chat Mode",
"Tier": "Pro",
"BotConfig": map[string]string{"PhoneID": "N/A"},
})
}
// Add this to handlers/dashboard.go
// POST /admin/appointment
func CreateAppointmentHandler(c *gin.Context) {
userID := c.MustGet("userID").(int)
var body struct {
Phone string `json:"phone"`
Date string `json:"date"`
@@ -71,8 +90,7 @@ func CreateAppointmentHandler(c *gin.Context) {
return
}
// Use the helper function instead of raw SQL here
if err := db.SaveAppointment(body.Phone, body.Date); err != nil {
if err := db.SaveAppointment(userID, body.Phone, body.Date); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
@@ -80,10 +98,13 @@ func CreateAppointmentHandler(c *gin.Context) {
c.Status(200)
}
// Manage/Delete Appointment
// DELETE /admin/appointment/:id
func DeleteAppointmentHandler(c *gin.Context) {
id := c.Param("id")
_, err := db.Conn.Exec("DELETE FROM appointments WHERE id = ?", id)
userID := c.MustGet("userID").(int)
// Ensure user only deletes their own appointments
_, err := db.Conn.Exec("DELETE FROM appointments WHERE id = ? AND user_id = ?", id, userID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
@@ -94,6 +115,7 @@ func DeleteAppointmentHandler(c *gin.Context) {
// PUT /admin/appointment/:id
func UpdateAppointmentHandler(c *gin.Context) {
id := c.Param("id")
userID := c.MustGet("userID").(int)
var body struct {
Phone string `json:"phone"`
Date string `json:"date"`
@@ -104,8 +126,8 @@ func UpdateAppointmentHandler(c *gin.Context) {
}
_, err := db.Conn.Exec(
"UPDATE appointments SET customer_phone = ?, appointment_date = ? WHERE id = ?",
body.Phone, body.Date, id,
"UPDATE appointments SET customer_phone = ?, appointment_time = ? WHERE id = ? AND user_id = ?",
body.Phone, body.Date, id, userID,
)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
@@ -116,20 +138,19 @@ func UpdateAppointmentHandler(c *gin.Context) {
// POST /admin/chat
func NewChatHandler(c *gin.Context) {
// You might want to associate chats with users too: INSERT INTO chats (user_id, title)...
res, err := db.Conn.Exec("INSERT INTO chats (title) VALUES ('New Chat')")
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
id, _ := res.LastInsertId()
// Return the ID so the frontend can select it immediately
c.JSON(200, gin.H{"id": id, "title": "New Chat"})
}
// DELETE /admin/chat/:id
func DeleteChatHandler(c *gin.Context) {
id := c.Param("id")
// Delete messages first (foreign key cleanup usually, but we'll do manual for SQLite safety)
db.Conn.Exec("DELETE FROM messages WHERE chat_id = ?", id)
db.Conn.Exec("DELETE FROM chats WHERE id = ?", id)
c.Status(200)
@@ -167,6 +188,8 @@ func GetMessagesHandler(c *gin.Context) {
// POST /admin/chat/:id/message
func PostMessageHandler(c *gin.Context) {
chatId := c.Param("id")
userID := c.MustGet("userID").(int)
var body struct {
Content string `json:"content"`
}
@@ -175,10 +198,10 @@ func PostMessageHandler(c *gin.Context) {
return
}
// 1. Save User Message to DB first
// 1. Save User Message
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatId, body.Content)
// 2. Fetch history
// 2. Fetch History
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatId)
var history []services.Message
defer rows.Close()
@@ -188,20 +211,26 @@ func PostMessageHandler(c *gin.Context) {
history = append(history, m)
}
// 3. Set Headers for Streaming
// 3. Load User's Custom System Prompt for the AI
// We need the AI to behave like the User's bot, even in the chat interface
var systemPrompt string
err := db.Conn.QueryRow("SELECT system_prompt FROM bot_configs WHERE user_id = ?", userID).Scan(&systemPrompt)
if err != nil {
systemPrompt = "You are a helpful assistant." // Fallback
}
// 4. Stream Response
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
// 4. Call the Stream Service
// We pass a function that writes chunks directly to the HTTP response
fullResponse, _ := services.StreamAIResponse(history, func(chunk string) {
// We use the new StreamAIResponse signature that takes (userID, message, prompt, callback)
fullResponse, _ := services.StreamAIResponse(userID, body.Content, systemPrompt, func(chunk string) {
c.Writer.Write([]byte(chunk))
c.Writer.Flush() // Important: Send it NOW, don't buffer
c.Writer.Flush()
})
// 5. Save the FULL Assistant Message to DB for history
// We do this AFTER the stream finishes so the next load has the full text
// 5. Save Assistant Message
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse)
}

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

63
main.go
View File

@@ -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()
}
}

BIN
saas_bot.db Normal file

Binary file not shown.

View File

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

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"
"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)

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" }}
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="UTF-8">
<title>SekiBot | Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<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>
<title>SekiBot Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'></script>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header">
<span>SekiBot Server</span>
</div>
<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 class="dashboard-layout">
<div class="sidebar">
<h2 style="color: white;">🤖 SekiBot</h2>
<div style="margin-bottom: 20px; color: var(--text-muted);">
User: {{ .UserEmail }} <br>
<small>Plan: {{ .Tier }}</small>
</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>
<table>
<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 class="modal">
<h3 style="margin-top:0; color:white;">Rename Channel</h3>
<input type="text" id="new-name-input" class="edit-field" style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between;">
<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>
let currentChatId = null;
let chatToRename = null;
// --- NAVIGATION ---
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');
document.getElementById('nav-appt').classList.toggle('active', panel === 'appt');
}
// --- CHAT CRUD ---
async function createNewChat() {
try {
const res = await fetch('/admin/chat', { method: 'POST' });
const data = await res.json();
<div class="main-content">
if (data.id) {
// Create Element
const newDiv = document.createElement('div');
newDiv.className = 'channel-item active'; // Set active immediately
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 id="settings" class="card">
<h3 style="color: white;">Business Configuration</h3>
<form action="/update-bot" method="POST">
<label style="color: var(--text-muted);">Bot Name</label>
<input type="text" name="bot_name" value="{{ .BotConfig.Name }}">
<label style="color: var(--text-muted);">System Prompt</label>
<textarea name="system_prompt" rows="3" style="width:100%; background:var(--bg-dark); color:white; border:1px solid #40444b; padding:10px;">{{ .BotConfig.Prompt }}</textarea>
<h4 style="color: white; margin-top: 20px;">Open Hours</h4>
{{ range $day := .Days }}
<div class="hours-grid">
<span style="color:white;">{{ $day }}</span>
<select name="{{$day}}_open">
<option value="">Closed</option>
<option value="08:00">08:00 AM</option>
<option value="09:00" selected>09:00 AM</option>
<option value="10:00">10:00 AM</option>
</select>
<select name="{{$day}}_close">
<option value="">Closed</option>
<option value="17:00" selected>05:00 PM</option>
<option value="18:00">06:00 PM</option>
<option value="19:00">07:00 PM</option>
</select>
</div>
<div class="channel-actions" onclick="event.stopPropagation()">
<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
const list = document.getElementById('channel-list');
const btnContainer = list.firstElementChild; // The div containing the button
if (btnContainer.nextSibling) {
list.insertBefore(newDiv, btnContainer.nextSibling);
} else {
list.appendChild(newDiv);
}
{{ end }}
<button type="submit" style="margin-top: 20px;">Save Changes</button>
</form>
</div>
// Switch and Load
switchPanel('chat');
loadChat(data.id, data.title);
}
} catch (e) {
console.error(e);
alert("Failed to create chat.");
}
}
<div id="appointments" class="card">
<h3 style="color: white;">Manage Appointments</h3>
<table>
<thead>
<tr><th>Client</th><th>Date</th><th>Status</th><th>Action</th></tr>
</thead>
<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) {
if (!confirm("Delete this chat permanently?")) return;
await fetch(`/admin/chat/${id}`, { method: 'DELETE' });
document.getElementById(`chat-item-${id}`).remove();
if (currentChatId === id) {
document.getElementById('messages-pane').innerHTML = '<div style="margin: auto; color: var(--text-muted);">Chat deleted.</div>';
document.getElementById('current-channel-name').innerText = 'deleted';
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 })
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
events: '/api/my-appointments',
height: 400
});
document.getElementById(`title-${chatToRename}`).innerText = newTitle;
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})
calendar.render();
});
location.reload(); // Reload to refresh table
}
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>
</script>
</body>
</html>
{{ 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