Compare commits
12 Commits
39cc33a2ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cffa66144 | |||
| e505ddbdfc | |||
| 7f670e31ff | |||
| 584a2413ab | |||
| 0b08960575 | |||
| cab2c8e99a | |||
| e256fcb073 | |||
| 63654c4c1b | |||
| 9ff021879f | |||
| e47b12b0d9 | |||
| 6f7987c3fe | |||
| d4d395356b |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.vscode
|
||||
.git
|
||||
data/
|
||||
bot.db
|
||||
2
.env
2
.env
@@ -1,2 +0,0 @@
|
||||
|
||||
OPENROUTER_API_KEY=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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
bot.db
|
||||
.env
|
||||
data/saas_bot.db
|
||||
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bot .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN mkdir /data
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
COPY --from=builder /app/bot .
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/static ./static
|
||||
COPY .env .
|
||||
|
||||
ENV DB_PATH=/data/bot.db
|
||||
|
||||
CMD ["./bot"]
|
||||
38
README.md
38
README.md
@@ -1,2 +1,40 @@
|
||||
# whatsapp-bot
|
||||
|
||||
## 🐳 Docker Deployment (Server)
|
||||
|
||||
Build and run the central inventory server:
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t go-bot:latest .
|
||||
|
||||
# Run the container (Map port 9090 and persist the database/cache)
|
||||
docker run -d \
|
||||
--name whatsapp-bot \
|
||||
-p 9090:9090 \
|
||||
-e OPENROUTER_API_KEY=your_key \
|
||||
-e WHATSAPP_PHONE_ID=your_id \
|
||||
-e WHATSAPP_TOKEN=your_token \
|
||||
-v $(pwd)/whatsapp-bot/data:/root/data:Z \
|
||||
--restart unless-stopped \
|
||||
go-bot:latest
|
||||
```
|
||||
|
||||
Or use this stack:
|
||||
```yml
|
||||
services:
|
||||
bot:
|
||||
image: go-bot:latest
|
||||
container_name: whatsapp-bot
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- OPENROUTER_API_KEY=your_api_key_here
|
||||
- WHATSAPP_PHONE_ID=your_phone_id_here
|
||||
- WHATSAPP_TOKEN=your_token_here
|
||||
volumes:
|
||||
# Map host data folder to the app's data path
|
||||
# The :Z is required for SELinux (Fedora/RHEL)
|
||||
- YOUR_PATH/whatsapp-bot/data:/root/data:Z
|
||||
ports:
|
||||
- "8080:9090"
|
||||
```
|
||||
|
||||
2
data/.gitignore
vendored
Normal file
2
data/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
94
db/db.go
94
db/db.go
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -10,32 +11,93 @@ var Conn *sql.DB
|
||||
|
||||
func Init() {
|
||||
var err error
|
||||
Conn, err = sql.Open("sqlite", "./bot.db")
|
||||
Conn, err = sql.Open("sqlite", "./data/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'
|
||||
);`
|
||||
Conn.Exec(schema)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
2
go.mod
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
@@ -21,7 +22,6 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
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, "/")
|
||||
}
|
||||
@@ -1,56 +1,86 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"whatsapp-bot/db"
|
||||
"whatsapp-bot/services"
|
||||
|
||||
"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) {
|
||||
rows, err := db.Conn.Query("SELECT customer_phone, appointment_date, status FROM appointments")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "DB Error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
type Appt struct{ CustomerPhone, Date, Status string }
|
||||
// 1. Fetch Appointments (Only for this user)
|
||||
type Appt struct {
|
||||
ID int
|
||||
CustomerPhone string
|
||||
Date string
|
||||
Status string
|
||||
}
|
||||
var appts []Appt
|
||||
for rows.Next() {
|
||||
var a Appt
|
||||
rows.Scan(&a.CustomerPhone, &a.Date, &a.Status)
|
||||
appts = append(appts, a)
|
||||
|
||||
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 {
|
||||
defer approws.Close()
|
||||
for approws.Next() {
|
||||
var a Appt
|
||||
approws.Scan(&a.ID, &a.CustomerPhone, &a.Date, &a.Status)
|
||||
appts = append(appts, a)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 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, &ch.Title)
|
||||
if ch.Title == "" {
|
||||
ch.Title = "Chat #" + strconv.Itoa(ch.ID)
|
||||
}
|
||||
chats = append(chats, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
})
|
||||
}
|
||||
|
||||
// Test OpenRouter via the Dashboard
|
||||
func TestAIHandler(c *gin.Context) {
|
||||
var body struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := c.BindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"response": "Invalid request, dummy."})
|
||||
return
|
||||
}
|
||||
|
||||
// Calling the service we wrote earlier
|
||||
response, err := services.GetAIResponse(body.Prompt)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"response": "AI Error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"response": response})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -60,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
|
||||
}
|
||||
@@ -69,13 +98,139 @@ 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
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
if err := c.BindJSON(&body); err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.Conn.Exec(
|
||||
"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()})
|
||||
return
|
||||
}
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
// 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()
|
||||
c.JSON(200, gin.H{"id": id, "title": "New Chat"})
|
||||
}
|
||||
|
||||
// DELETE /admin/chat/:id
|
||||
func DeleteChatHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
db.Conn.Exec("DELETE FROM messages WHERE chat_id = ?", id)
|
||||
db.Conn.Exec("DELETE FROM chats WHERE id = ?", id)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
// PUT /admin/chat/:id/rename
|
||||
func RenameChatHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := c.BindJSON(&body); err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
db.Conn.Exec("UPDATE chats SET title = ? WHERE id = ?", body.Title, id)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
// GET /admin/chat/:id/messages
|
||||
func GetMessagesHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", id)
|
||||
defer rows.Close()
|
||||
|
||||
var msgs []services.Message
|
||||
for rows.Next() {
|
||||
var m services.Message
|
||||
rows.Scan(&m.Role, &m.Content)
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
c.JSON(200, msgs)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
if err := c.BindJSON(&body); err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Save User Message
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatId, body.Content)
|
||||
|
||||
// 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()
|
||||
for rows.Next() {
|
||||
var m services.Message
|
||||
rows.Scan(&m.Role, &m.Content)
|
||||
history = append(history, m)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
// 5. Save Assistant Message
|
||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse)
|
||||
}
|
||||
|
||||
108
handlers/saas.go
Normal file
108
handlers/saas.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"whatsapp-bot/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /dashboard
|
||||
func UserDashboard(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
// 1. Fetch User Data
|
||||
var email, tier string
|
||||
db.Conn.QueryRow("SELECT email, subscription_tier FROM users WHERE id=?", userID).Scan(&email, &tier)
|
||||
|
||||
// 2. Fetch Bot Settings
|
||||
var botName, prompt, availJSON string
|
||||
db.Conn.QueryRow("SELECT bot_name, system_prompt, availability_hours FROM bot_configs WHERE user_id=?", userID).Scan(&botName, &prompt, &availJSON)
|
||||
|
||||
// 3. Fetch Appointments (¡Esto faltaba!)
|
||||
type Appt struct {
|
||||
ID int
|
||||
Phone string
|
||||
Date string
|
||||
Status string
|
||||
}
|
||||
var appts []Appt
|
||||
rows, _ := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id=? ORDER BY id DESC", userID)
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var a Appt
|
||||
rows.Scan(&a.ID, &a.Phone, &a.Date, &a.Status)
|
||||
appts = append(appts, a)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Render Template
|
||||
c.HTML(200, "dashboard.html", gin.H{
|
||||
"UserEmail": email,
|
||||
"Tier": tier,
|
||||
"Days": []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, // Enviamos la lista desde aquí
|
||||
"Appointments": appts,
|
||||
"BotConfig": map[string]string{
|
||||
"Name": botName,
|
||||
"Prompt": prompt,
|
||||
"Hours": availJSON, // Raw JSON if you want to parse it later
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /update-bot
|
||||
func UpdateBotSettings(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
|
||||
botName := c.PostForm("bot_name")
|
||||
prompt := c.PostForm("system_prompt")
|
||||
|
||||
// Recolectamos las horas en un Mapa
|
||||
hours := make(map[string]string)
|
||||
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
for _, d := range days {
|
||||
open := c.PostForm(d + "_open")
|
||||
close := c.PostForm(d + "_close")
|
||||
if open != "" && close != "" {
|
||||
hours[d] = open + "-" + close
|
||||
} else {
|
||||
hours[d] = "Closed"
|
||||
}
|
||||
}
|
||||
hoursJSON, _ := json.Marshal(hours)
|
||||
|
||||
db.Conn.Exec("UPDATE bot_configs SET bot_name=?, system_prompt=?, availability_hours=? WHERE user_id=?",
|
||||
botName, prompt, string(hoursJSON), userID)
|
||||
|
||||
c.Redirect(302, "/dashboard")
|
||||
}
|
||||
|
||||
// POST /admin/appointment/:id/cancel
|
||||
func CancelAppointmentHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.MustGet("userID").(int)
|
||||
db.Conn.Exec("UPDATE appointments SET status='cancelled' WHERE id=? AND user_id=?", id, userID)
|
||||
c.Redirect(302, "/dashboard")
|
||||
}
|
||||
|
||||
// GET /api/my-appointments (Keep existing one or ensure it matches DB struct)
|
||||
func MyAppointmentsAPI(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(int)
|
||||
rows, _ := db.Conn.Query("SELECT customer_phone, appointment_time FROM appointments WHERE user_id=?", userID)
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
var events []map[string]string
|
||||
for rows.Next() {
|
||||
var phone, timeStr string
|
||||
rows.Scan(&phone, &timeStr)
|
||||
events = append(events, map[string]string{
|
||||
"title": "📞 " + phone,
|
||||
"start": timeStr,
|
||||
})
|
||||
}
|
||||
c.JSON(200, events)
|
||||
} else {
|
||||
c.JSON(200, []string{})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"whatsapp-bot/db"
|
||||
"whatsapp-bot/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Meta verification
|
||||
func VerifyWebhook(c *gin.Context) {
|
||||
challenge := c.Query("hub.challenge")
|
||||
token := c.Query("hub.verify_token")
|
||||
// NOTA: Borramos la definición local de WebhookPayload porque ahora la importamos de services
|
||||
|
||||
if token == "YOUR_SECRET_TOKEN" {
|
||||
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" {
|
||||
c.String(http.StatusOK, challenge)
|
||||
} else {
|
||||
c.Status(http.StatusForbidden)
|
||||
@@ -19,7 +25,49 @@ func VerifyWebhook(c *gin.Context) {
|
||||
}
|
||||
|
||||
func HandleMessage(c *gin.Context) {
|
||||
// You should bind your WhatsApp types here
|
||||
// go services.Process(...)
|
||||
c.Status(http.StatusOK)
|
||||
// Ahora usamos services.WebhookPayload sin problemas
|
||||
var payload services.WebhookPayload
|
||||
if err := c.BindJSON(&payload); err != nil {
|
||||
c.Status(200)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range payload.Entry {
|
||||
for _, change := range entry.Changes {
|
||||
// 1. IDENTIFICAR AL USUARIO
|
||||
phoneID := change.Value.Metadata.PhoneNumberID
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("📩 Msg for User %d (%s): %s\n", botConfig.UserID, botConfig.PhoneID, msg.Text.Body)
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
// 3. LLAMAR A LA IA
|
||||
aiResp, _ := services.StreamAIResponse(botConfig.UserID, msg.Text.Body, finalPrompt, nil)
|
||||
|
||||
// 4. RESPONDER
|
||||
if aiResp != "" {
|
||||
services.SendWhatsAppMessage(botConfig.Token, botConfig.PhoneID, msg.From, aiResp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
56
main.go
56
main.go
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"whatsapp-bot/db"
|
||||
"whatsapp-bot/handlers"
|
||||
|
||||
@@ -10,32 +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.POST("/admin/test-ai", handlers.TestAIHandler)
|
||||
r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler)
|
||||
r.POST("/admin/appointment", handlers.CreateAppointmentHandler)
|
||||
|
||||
// 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"})
|
||||
})
|
||||
// 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.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"whatsapp-bot/db"
|
||||
)
|
||||
|
||||
// The structs to map OpenRouter's response
|
||||
type OpenRouterResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
// NOTA: struct Message ya NO está aquí, está en types.go
|
||||
|
||||
func GetAIResponse(input string) (string, error) {
|
||||
// StreamAIResponse handles the streaming connection
|
||||
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"
|
||||
|
||||
messages := []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userMessage},
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": "google/gemini-2.0-flash-001",
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": "You are a Chilean business assistant. Be brief."},
|
||||
{"role": "user", "content": input},
|
||||
},
|
||||
"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 in the database",
|
||||
"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"},
|
||||
"date": map[string]string{"type": "string", "description": "ISO format date and time"},
|
||||
"date": map[string]string{"type": "string"},
|
||||
},
|
||||
"required": []string{"customer_phone", "date"},
|
||||
},
|
||||
@@ -44,32 +46,87 @@ func GetAIResponse(input string) (string, error) {
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(data))
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
var fullContentBuffer strings.Builder
|
||||
var toolCallBuffer strings.Builder
|
||||
var toolName string
|
||||
isToolCall := false
|
||||
|
||||
// If it's not a 200, return the error body so you can see why it failed
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "API Error: " + string(bodyBytes), nil
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
jsonStr := strings.TrimPrefix(line, "data: ")
|
||||
if jsonStr == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
if delta.Content != "" {
|
||||
fullContentBuffer.WriteString(delta.Content)
|
||||
if onToken != nil {
|
||||
onToken(delta.Content)
|
||||
}
|
||||
}
|
||||
if len(delta.ToolCalls) > 0 {
|
||||
isToolCall = true
|
||||
if delta.ToolCalls[0].Function.Name != "" {
|
||||
toolName = delta.ToolCalls[0].Function.Name
|
||||
}
|
||||
toolCallBuffer.WriteString(delta.ToolCalls[0].Function.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result OpenRouterResponse
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", err
|
||||
// EXECUTE TOOL
|
||||
if isToolCall && toolName == "create_appointment" {
|
||||
var args struct {
|
||||
Phone string `json:"customer_phone"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolCallBuffer.String()), &args); err == nil {
|
||||
err := db.SaveAppointment(userID, args.Phone, args.Date)
|
||||
resultMsg := ""
|
||||
if err != nil {
|
||||
resultMsg = "\n(System: Failed to book appointment)"
|
||||
} else {
|
||||
resultMsg = fmt.Sprintf("\n✅ Appointment Confirmed: %s", args.Date)
|
||||
}
|
||||
fullContentBuffer.WriteString(resultMsg)
|
||||
if onToken != nil {
|
||||
onToken(resultMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Choices) > 0 {
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
return "No response from AI.", nil
|
||||
return fullContentBuffer.String(), nil
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -1 +1,40 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"messaging_product": "whatsapp",
|
||||
"to": toPhone,
|
||||
"type": "text",
|
||||
"text": map[string]string{
|
||||
"body": messageBody,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("whatsapp api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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,211 +1,102 @@
|
||||
{{ define "dashboard.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SekiBot Admin | Discord Edition</title>
|
||||
<style>
|
||||
:root {
|
||||
--blurple: #5865F2;
|
||||
--background-dark: #36393f;
|
||||
--background-darker: #2f3136;
|
||||
--background-deep: #202225;
|
||||
--text-normal: #dcddde;
|
||||
--text-muted: #b9bbbe;
|
||||
--header-primary: #ffffff;
|
||||
--danger: #ed4245;
|
||||
--success: #3ba55c;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-deep);
|
||||
color: var(--text-normal);
|
||||
font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar Look */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--background-darker);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-right: 1px solid #202225;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
background-color: var(--background-dark);
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3 { color: var(--header-primary); margin-top: 0; }
|
||||
|
||||
.card {
|
||||
background-color: var(--background-darker);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Inputs & Buttons */
|
||||
input {
|
||||
background-color: var(--background-deep);
|
||||
border: 1px solid #202225;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
width: calc(100% - 22px);
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--blurple);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover { background-color: #4752c4; }
|
||||
button.delete { background-color: var(--danger); }
|
||||
button.delete:hover { background-color: #c03537; }
|
||||
|
||||
/* Table Styling */
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th { text-align: left; color: var(--text-muted); text-transform: uppercase; font-size: 12px; padding-bottom: 10px; }
|
||||
td { padding: 12px 0; border-top: 1px solid #42454a; vertical-align: middle; }
|
||||
|
||||
.status-pill {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#ai-response-box {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background-color: var(--background-deep);
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid var(--blurple);
|
||||
display: none;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</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>
|
||||
|
||||
<div class="sidebar">
|
||||
<h2>SekiBot</h2>
|
||||
<p style="font-size: 12px; color: var(--text-muted);">v1.0.0-beta</p>
|
||||
<hr style="border: 0.5px solid #42454a; width: 100%;">
|
||||
<nav>
|
||||
<p style="color: var(--blurple); font-weight: bold;"># dashboard</p>
|
||||
<p style="color: var(--text-muted);"># analytics</p>
|
||||
<p style="color: var(--text-muted);"># settings</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<h1>Welcome back, seki</h1>
|
||||
|
||||
<!-- AI Tester -->
|
||||
<div class="card">
|
||||
<h3>Test OpenRouter AI</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="ai-input" placeholder="Message the bot..." style="margin-bottom: 0;">
|
||||
<button onclick="testAI()">Send</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>
|
||||
<div id="ai-response-box"></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>
|
||||
|
||||
<!-- Manual Entry -->
|
||||
<div class="card">
|
||||
<h3>Quick Create Appointment</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;">
|
||||
<input type="text" id="phone" placeholder="Customer Phone (+569...)">
|
||||
<input type="datetime-local" id="date">
|
||||
<button onclick="createAppt()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
|
||||
<!-- The Table -->
|
||||
<div class="card">
|
||||
<h3>Scheduled Appointments</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Date & Time</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Appointments}}
|
||||
<tr id="row-{{.ID}}">
|
||||
<td>#{{.ID}}</td>
|
||||
<td style="font-weight: bold;">{{.CustomerPhone}}</td>
|
||||
<td>{{.Date}}</td>
|
||||
<td><span class="status-pill">{{.Status}}</span></td>
|
||||
<td>
|
||||
<button class="delete" onclick="deleteAppt({{.ID}})">Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
{{ end }}
|
||||
|
||||
<button type="submit" style="margin-top: 20px;">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
async function testAI() {
|
||||
const input = document.getElementById('ai-input').value;
|
||||
const box = document.getElementById('ai-response-box');
|
||||
box.style.display = 'block';
|
||||
box.innerText = "Processing message...";
|
||||
|
||||
const res = await fetch('/admin/test-ai', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({prompt: input})
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
events: '/api/my-appointments',
|
||||
height: 400
|
||||
});
|
||||
const data = await res.json();
|
||||
box.innerText = data.response;
|
||||
}
|
||||
|
||||
async function createAppt() {
|
||||
const phone = document.getElementById('phone').value;
|
||||
const date = document.getElementById('date').value;
|
||||
if(!phone || !date) return alert("Fill the fields, seki.");
|
||||
|
||||
await fetch('/admin/appointment', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({phone, date})
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteAppt(id) {
|
||||
if(!confirm("Terminate this appointment?")) return;
|
||||
await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
|
||||
document.getElementById(`row-${id}`).remove();
|
||||
}
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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