Compare commits

...

14 Commits

Author SHA1 Message Date
3cffa66144 updated docker file for static files 2026-03-02 00:47:45 -03:00
e505ddbdfc keep folder 2026-03-02 00:43:51 -03:00
7f670e31ff untrack db 2026-03-02 00:43:25 -03:00
584a2413ab untrack files lol 2026-03-02 00:42:38 -03:00
0b08960575 renamed: saas_bot.db -> data/saas_bot.db
modified:   db/db.go
2026-03-02 00:40:30 -03:00
cab2c8e99a Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/whatsapp-bot 2026-03-02 00:38:38 -03:00
e256fcb073 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
2026-03-02 00:38:05 -03:00
63654c4c1b dockerfile + db relocation 2026-03-02 00:17:32 -03:00
9ff021879f modified: .env
modified:   bot.db
	modified:   db/db.go
	modified:   handlers/webhook.go
	modified:   services/whatsapp.go
2026-03-01 08:40:30 -03:00
e47b12b0d9 modified: bot.db
modified:   handlers/dashboard.go
	modified:   main.go
	modified:   services/openrouter.go
	modified:   templates/dashboard.html
2026-03-01 07:39:40 -03:00
6f7987c3fe modified: bot.db
modified:   handlers/dashboard.go
	modified:   main.go
	modified:   services/openrouter.go
	modified:   templates/dashboard.html
2026-03-01 07:10:01 -03:00
d4d395356b modified: bot.db
modified:   db/db.go
	modified:   handlers/dashboard.go
	modified:   main.go
	modified:   services/openrouter.go
	modified:   templates/dashboard.html
2026-03-01 06:34:23 -03:00
39cc33a2ec Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/whatsapp-bot 2026-03-01 05:40:15 -03:00
9a341fed76 modified: .env
modified:   bot.db
	modified:   db/db.go
	modified:   go.mod
	modified:   go.sum
	modified:   handlers/dashboard.go
	modified:   main.go
	modified:   services/openrouter.go
	modified:   templates/dashboard.html
2026-03-01 05:39:31 -03:00
23 changed files with 1005 additions and 119 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.vscode
.git
data/
bot.db

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
bot.db
.env
data/saas_bot.db

15
.vscode/launch.json vendored Normal file
View 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
View 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"]

View File

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

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"log"
_ "modernc.org/sqlite"
)
@@ -10,24 +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()
}
// 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
}

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/joho/godotenv v1.5.1
modernc.org/sqlite v1.46.1
)

2
go.sum
View File

@@ -38,6 +38,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

61
handlers/auth.go Normal file
View File

@@ -0,0 +1,61 @@
package handlers
import (
"fmt"
"whatsapp-bot/db"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// Show Pages
func ShowLogin(c *gin.Context) { c.HTML(200, "login.html", nil) }
func ShowRegister(c *gin.Context) { c.HTML(200, "register.html", nil) }
func ShowLanding(c *gin.Context) { c.HTML(200, "landing.html", nil) }
// REGISTER
func RegisterHandler(c *gin.Context) {
email := c.PostForm("email")
pass := c.PostForm("password")
// Hash Password
hashed, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
// Create User in DB
res, err := db.Conn.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, string(hashed))
if err != nil {
c.HTML(400, "register.html", gin.H{"Error": "Email already taken"})
return
}
// Create Default Bot Config for new user
userID, _ := res.LastInsertId()
db.Conn.Exec("INSERT INTO bot_configs (user_id) VALUES (?)", userID)
c.Redirect(302, "/login")
}
// LOGIN
func LoginHandler(c *gin.Context) {
email := c.PostForm("email")
pass := c.PostForm("password")
var id int
var hash string
err := db.Conn.QueryRow("SELECT id, password_hash FROM users WHERE email=?", email).Scan(&id, &hash)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
c.HTML(401, "login.html", gin.H{"Error": "Invalid credentials"})
return
}
// Set simple cookie for session (In production use a secure session library)
c.SetCookie("user_id", fmt.Sprintf("%d", id), 3600*24, "/", "", false, true)
c.Redirect(302, "/dashboard")
}
// LOGOUT
func LogoutHandler(c *gin.Context) {
c.SetCookie("user_id", "", -1, "/", "", false, true)
c.Redirect(302, "/")
}

View File

@@ -1,61 +1,236 @@
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) {
// POST /admin/appointment
func CreateAppointmentHandler(c *gin.Context) {
userID := c.MustGet("userID").(int)
var body struct {
Prompt string `json:"prompt"`
Phone string `json:"phone"`
Date string `json:"date"`
}
if err := c.BindJSON(&body); err != nil {
c.JSON(400, gin.H{"response": "Invalid request, dummy."})
c.Status(400)
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()})
if err := db.SaveAppointment(userID, body.Phone, body.Date); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"response": response})
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
View File

@@ -0,0 +1,108 @@
package handlers
import (
"encoding/json"
"whatsapp-bot/db"
"github.com/gin-gonic/gin"
)
// GET /dashboard
func UserDashboard(c *gin.Context) {
userID := c.MustGet("userID").(int)
// 1. Fetch User Data
var email, tier string
db.Conn.QueryRow("SELECT email, subscription_tier FROM users WHERE id=?", userID).Scan(&email, &tier)
// 2. Fetch Bot Settings
var botName, prompt, availJSON string
db.Conn.QueryRow("SELECT bot_name, system_prompt, availability_hours FROM bot_configs WHERE user_id=?", userID).Scan(&botName, &prompt, &availJSON)
// 3. Fetch Appointments (¡Esto faltaba!)
type Appt struct {
ID int
Phone string
Date string
Status string
}
var appts []Appt
rows, _ := db.Conn.Query("SELECT id, customer_phone, appointment_time, status FROM appointments WHERE user_id=? ORDER BY id DESC", userID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var a Appt
rows.Scan(&a.ID, &a.Phone, &a.Date, &a.Status)
appts = append(appts, a)
}
}
// 4. Render Template
c.HTML(200, "dashboard.html", gin.H{
"UserEmail": email,
"Tier": tier,
"Days": []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, // Enviamos la lista desde aquí
"Appointments": appts,
"BotConfig": map[string]string{
"Name": botName,
"Prompt": prompt,
"Hours": availJSON, // Raw JSON if you want to parse it later
},
})
}
// POST /update-bot
func UpdateBotSettings(c *gin.Context) {
userID := c.MustGet("userID").(int)
botName := c.PostForm("bot_name")
prompt := c.PostForm("system_prompt")
// Recolectamos las horas en un Mapa
hours := make(map[string]string)
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
for _, d := range days {
open := c.PostForm(d + "_open")
close := c.PostForm(d + "_close")
if open != "" && close != "" {
hours[d] = open + "-" + close
} else {
hours[d] = "Closed"
}
}
hoursJSON, _ := json.Marshal(hours)
db.Conn.Exec("UPDATE bot_configs SET bot_name=?, system_prompt=?, availability_hours=? WHERE user_id=?",
botName, prompt, string(hoursJSON), userID)
c.Redirect(302, "/dashboard")
}
// POST /admin/appointment/:id/cancel
func CancelAppointmentHandler(c *gin.Context) {
id := c.Param("id")
userID := c.MustGet("userID").(int)
db.Conn.Exec("UPDATE appointments SET status='cancelled' WHERE id=? AND user_id=?", id, userID)
c.Redirect(302, "/dashboard")
}
// GET /api/my-appointments (Keep existing one or ensure it matches DB struct)
func MyAppointmentsAPI(c *gin.Context) {
userID := c.MustGet("userID").(int)
rows, _ := db.Conn.Query("SELECT customer_phone, appointment_time FROM appointments WHERE user_id=?", userID)
if rows != nil {
defer rows.Close()
var events []map[string]string
for rows.Next() {
var phone, timeStr string
rows.Scan(&phone, &timeStr)
events = append(events, map[string]string{
"title": "📞 " + phone,
"start": timeStr,
})
}
c.JSON(200, events)
} else {
c.JSON(200, []string{})
}
}

View File

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

48
main.go
View File

@@ -1,31 +1,57 @@
package main
import (
"strconv"
"whatsapp-bot/db"
"whatsapp-bot/handlers"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
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)
// 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()
}
}

View File

@@ -1,34 +1,132 @@
package services
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"whatsapp-bot/db"
)
func GetAIResponse(input string) (string, error) {
// NOTA: struct Message ya NO está aquí, está en types.go
// 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": "stepfun/step-3.5-flash:free",
"messages": []map[string]string{
{"role": "system", "content": "You are a business assistant in Chile. Book appointments or answer FAQs."},
{"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. 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"},
},
"required": []string{"customer_phone", "date"},
},
},
},
},
}
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()
// Simplification: You need to parse the JSON response here, seki.
// I assume you know how to decode a nested map. Big assumption, I know.
return "AI Response Placeholder", nil
scanner := bufio.NewScanner(resp.Body)
var fullContentBuffer strings.Builder
var toolCallBuffer strings.Builder
var toolName string
isToolCall := false
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)
}
}
}
// 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)
}
}
}
return fullContentBuffer.String(), nil
}

27
services/types.go Normal file
View File

@@ -0,0 +1,27 @@
package services
// Message: Usado para el historial del chat (OpenRouter) y dashboard
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// WebhookPayload: Usado para leer el JSON que envía WhatsApp
type WebhookPayload struct {
Entry []struct {
Changes []struct {
Value struct {
Metadata struct {
PhoneNumberID string `json:"phone_number_id"`
} `json:"metadata"`
Messages []struct {
From string `json:"from"`
Text struct {
Body string `json:"body"`
} `json:"text"`
Type string `json:"type"`
} `json:"messages"`
} `json:"value"`
} `json:"changes"`
} `json:"entry"`
}

View File

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

@@ -0,0 +1,43 @@
/* static/style.css */
:root {
--bg-dark: #2f3136;
--bg-darker: #202225;
--blurple: #5865F2;
--text-main: #dcddde;
--text-muted: #72767d;
--success: #3ba55c;
--danger: #ed4245;
}
body { margin: 0; font-family: 'Segoe UI', sans-serif; background: var(--bg-dark); color: var(--text-main); }
a { text-decoration: none; color: var(--blurple); }
a:hover { text-decoration: underline; }
/* AUTH & LANDING CONTAINERS */
.center-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; text-align: center; }
.auth-box { background: var(--bg-darker); padding: 40px; border-radius: 8px; width: 350px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.hero-title { font-size: 3rem; margin-bottom: 10px; color: white; }
.hero-sub { color: var(--text-muted); margin-bottom: 30px; font-size: 1.2rem; }
/* FORMS */
input, select { width: 100%; padding: 12px; margin: 8px 0 20px; background: var(--bg-dark); border: 1px solid #40444b; color: white; border-radius: 4px; box-sizing: border-box;}
button { width: 100%; padding: 12px; background: var(--blurple); color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; transition: 0.2s; }
button:hover { background: #4752c4; }
.btn-outline { background: transparent; border: 2px solid var(--blurple); color: var(--blurple); margin-top: 10px; }
.btn-outline:hover { background: rgba(88, 101, 242, 0.1); }
/* DASHBOARD LAYOUT */
.dashboard-layout { display: flex; height: 100vh; }
.sidebar { width: 260px; background: var(--bg-darker); padding: 20px; display: flex; flex-direction: column; }
.main-content { flex: 1; padding: 40px; overflow-y: auto; background: var(--bg-dark); }
/* CARDS & TABLES */
.card { background: var(--bg-darker); padding: 25px; border-radius: 8px; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
th { text-align: left; color: var(--text-muted); padding: 10px; border-bottom: 1px solid #40444b; }
td { padding: 10px; border-bottom: 1px solid #40444b; }
.status-confirmed { color: var(--success); font-weight: bold; }
.status-cancelled { color: var(--danger); font-weight: bold; }
/* HOURS GRID */
.hours-grid { display: grid; grid-template-columns: 100px 1fr 1fr; gap: 10px; align-items: center; margin-bottom: 10px; }

View File

@@ -2,69 +2,101 @@
<!DOCTYPE html>
<html>
<head>
<title>SekiBot Admin</title>
<style>
body { font-family: 'Segoe UI', sans-serif; background: #121212; color: white; padding: 40px; }
.card { background: #1e1e1e; padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #333; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #333; }
button { background: #3498db; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; }
button.delete { background: #e74c3c; }
input { padding: 8px; border-radius: 4px; border: 1px solid #444; background: #222; color: white; width: 70%; }
#ai-response { margin-top: 10px; padding: 10px; background: #2c3e50; border-radius: 4px; display: none; }
</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>
<h1>SekiBot Business Analytics</h1>
<div class="dashboard-layout">
<div class="sidebar">
<h2 style="color: white;">🤖 SekiBot</h2>
<div style="margin-bottom: 20px; color: var(--text-muted);">
User: {{ .UserEmail }} <br>
<small>Plan: {{ .Tier }}</small>
</div>
<a href="#settings" class="btn-outline" style="text-align:center; border:none; text-align: left;">⚙️ Settings</a>
<a href="#appointments" class="btn-outline" style="text-align:center; border:none; text-align: left;">📅 Appointments</a>
<a href="/logout" style="margin-top: auto; color: var(--danger);">🚪 Logout</a>
</div>
<!-- AI Test Section -->
<div class="card">
<h2>Test OpenRouter AI</h2>
<input type="text" id="ai-input" placeholder="Ask the bot something...">
<button onclick="testAI()">Send</button>
<div id="ai-response"></div>
</div>
<div class="main-content">
<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>
<!-- Appointments Management -->
<div class="card">
<h2>Manage Appointments</h2>
<table>
<tr><th>ID</th><th>Customer</th><th>Date</th><th>Status</th><th>Actions</th></tr>
{{range .Appointments}}
<tr id="appt-{{.ID}}">
<td>{{.ID}}</td>
<td>{{.CustomerPhone}}</td>
<td>{{.Date}}</td>
<td>{{.Status}}</td>
<td>
<button class="delete" onclick="deleteAppt({{.ID}})">Cancel</button>
</td>
</tr>
{{end}}
</table>
<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 resDiv = document.getElementById('ai-response');
resDiv.style.display = 'block';
resDiv.innerText = 'Thinking... (Wait for it, seki)';
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();
resDiv.innerText = data.response;
}
async function deleteAppt(id) {
if(!confirm('Really cancel this?')) return;
await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
document.getElementById(`appt-${id}`).remove();
}
calendar.render();
});
</script>
</body>
</html>

31
templates/landing.html Normal file
View File

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

20
templates/login.html Normal file
View File

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

20
templates/register.html Normal file
View File

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

View File

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