Compare commits

...

12 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
24 changed files with 971 additions and 310 deletions

4
.dockerignore Normal file
View File

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

2
.env
View File

@@ -1,2 +0,0 @@
OPENROUTER_API_KEY=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

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

BIN
bot.db

Binary file not shown.

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

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

61
handlers/auth.go Normal file
View File

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

View File

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

56
main.go
View File

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

View File

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

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