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:
2
.env
2
.env
@@ -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
BIN
__debug_bin.exe
Normal file
Binary file not shown.
118
db/db.go
118
db/db.go
@@ -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
2
go.mod
@@ -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
61
handlers/auth.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"whatsapp-bot/db"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show Pages
|
||||||
|
func ShowLogin(c *gin.Context) { c.HTML(200, "login.html", nil) }
|
||||||
|
func ShowRegister(c *gin.Context) { c.HTML(200, "register.html", nil) }
|
||||||
|
func ShowLanding(c *gin.Context) { c.HTML(200, "landing.html", nil) }
|
||||||
|
|
||||||
|
// REGISTER
|
||||||
|
func RegisterHandler(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
pass := c.PostForm("password")
|
||||||
|
|
||||||
|
// Hash Password
|
||||||
|
hashed, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||||
|
|
||||||
|
// Create User in DB
|
||||||
|
res, err := db.Conn.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, string(hashed))
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(400, "register.html", gin.H{"Error": "Email already taken"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Default Bot Config for new user
|
||||||
|
userID, _ := res.LastInsertId()
|
||||||
|
db.Conn.Exec("INSERT INTO bot_configs (user_id) VALUES (?)", userID)
|
||||||
|
|
||||||
|
c.Redirect(302, "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGIN
|
||||||
|
func LoginHandler(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
pass := c.PostForm("password")
|
||||||
|
|
||||||
|
var id int
|
||||||
|
var hash string
|
||||||
|
err := db.Conn.QueryRow("SELECT id, password_hash FROM users WHERE email=?", email).Scan(&id, &hash)
|
||||||
|
|
||||||
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
|
||||||
|
c.HTML(401, "login.html", gin.H{"Error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set simple cookie for session (In production use a secure session library)
|
||||||
|
c.SetCookie("user_id", fmt.Sprintf("%d", id), 3600*24, "/", "", false, true)
|
||||||
|
c.Redirect(302, "/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGOUT
|
||||||
|
func LogoutHandler(c *gin.Context) {
|
||||||
|
c.SetCookie("user_id", "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(302, "/")
|
||||||
|
}
|
||||||
@@ -10,8 +10,13 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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
108
handlers/saas.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"whatsapp-bot/db"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /dashboard
|
||||||
|
func UserDashboard(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(int)
|
||||||
|
|
||||||
|
// 1. Fetch User Data
|
||||||
|
var email, tier string
|
||||||
|
db.Conn.QueryRow("SELECT email, subscription_tier FROM users WHERE id=?", userID).Scan(&email, &tier)
|
||||||
|
|
||||||
|
// 2. Fetch Bot Settings
|
||||||
|
var botName, prompt, availJSON string
|
||||||
|
db.Conn.QueryRow("SELECT bot_name, system_prompt, availability_hours FROM bot_configs WHERE user_id=?", userID).Scan(&botName, &prompt, &availJSON)
|
||||||
|
|
||||||
|
// 3. Fetch Appointments (¡Esto faltaba!)
|
||||||
|
type Appt struct {
|
||||||
|
ID int
|
||||||
|
Phone string
|
||||||
|
Date string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
var appts []Appt
|
||||||
|
rows, _ := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id=? ORDER BY id DESC", userID)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var a Appt
|
||||||
|
rows.Scan(&a.ID, &a.Phone, &a.Date, &a.Status)
|
||||||
|
appts = append(appts, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Render Template
|
||||||
|
c.HTML(200, "dashboard.html", gin.H{
|
||||||
|
"UserEmail": email,
|
||||||
|
"Tier": tier,
|
||||||
|
"Days": []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, // Enviamos la lista desde aquí
|
||||||
|
"Appointments": appts,
|
||||||
|
"BotConfig": map[string]string{
|
||||||
|
"Name": botName,
|
||||||
|
"Prompt": prompt,
|
||||||
|
"Hours": availJSON, // Raw JSON if you want to parse it later
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /update-bot
|
||||||
|
func UpdateBotSettings(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(int)
|
||||||
|
|
||||||
|
botName := c.PostForm("bot_name")
|
||||||
|
prompt := c.PostForm("system_prompt")
|
||||||
|
|
||||||
|
// Recolectamos las horas en un Mapa
|
||||||
|
hours := make(map[string]string)
|
||||||
|
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||||
|
for _, d := range days {
|
||||||
|
open := c.PostForm(d + "_open")
|
||||||
|
close := c.PostForm(d + "_close")
|
||||||
|
if open != "" && close != "" {
|
||||||
|
hours[d] = open + "-" + close
|
||||||
|
} else {
|
||||||
|
hours[d] = "Closed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hoursJSON, _ := json.Marshal(hours)
|
||||||
|
|
||||||
|
db.Conn.Exec("UPDATE bot_configs SET bot_name=?, system_prompt=?, availability_hours=? WHERE user_id=?",
|
||||||
|
botName, prompt, string(hoursJSON), userID)
|
||||||
|
|
||||||
|
c.Redirect(302, "/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/appointment/:id/cancel
|
||||||
|
func CancelAppointmentHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.MustGet("userID").(int)
|
||||||
|
db.Conn.Exec("UPDATE appointments SET status='cancelled' WHERE id=? AND user_id=?", id, userID)
|
||||||
|
c.Redirect(302, "/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/my-appointments (Keep existing one or ensure it matches DB struct)
|
||||||
|
func MyAppointmentsAPI(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(int)
|
||||||
|
rows, _ := db.Conn.Query("SELECT customer_phone, appointment_time FROM appointments WHERE user_id=?", userID)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
var events []map[string]string
|
||||||
|
for rows.Next() {
|
||||||
|
var phone, timeStr string
|
||||||
|
rows.Scan(&phone, &timeStr)
|
||||||
|
events = append(events, map[string]string{
|
||||||
|
"title": "📞 " + phone,
|
||||||
|
"start": timeStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(200, events)
|
||||||
|
} else {
|
||||||
|
c.JSON(200, []string{})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,36 +3,21 @@ package handlers
|
|||||||
import (
|
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
63
main.go
@@ -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
BIN
saas_bot.db
Normal file
Binary file not shown.
@@ -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
27
services/types.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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
43
static/style.css
Normal 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; }
|
||||||
@@ -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
31
templates/landing.html
Normal 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
20
templates/login.html
Normal 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
20
templates/register.html
Normal 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 }}
|
||||||
@@ -1 +0,0 @@
|
|||||||
package main
|
|
||||||
Reference in New Issue
Block a user