modified: .env

new file:   __debug_bin.exe
	modified:   bot.db
	modified:   db/db.go
	modified:   go.mod
	new file:   handlers/auth.go
	modified:   handlers/dashboard.go
	new file:   handlers/saas.go
	modified:   handlers/webhook.go
	modified:   main.go
	new file:   saas_bot.db
	modified:   services/openrouter.go
	new file:   services/types.go
	modified:   services/whatsapp.go
	new file:   static/style.css
	modified:   templates/dashboard.html
	new file:   templates/landing.html
	new file:   templates/login.html
	new file:   templates/register.html
	deleted:    types/types.go
This commit is contained in:
2026-03-02 00:38:05 -03:00
parent 9ff021879f
commit e256fcb073
20 changed files with 627 additions and 659 deletions

View File

@@ -8,84 +8,36 @@ import (
"net/http"
"os"
"strings"
"time"
"whatsapp-bot/db"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Structs to parse the incoming stream from OpenRouter
type StreamResponse struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
// NOTA: struct Message ya NO está aquí, está en types.go
// StreamAIResponse handles the streaming connection
// onToken: a function that gets called every time we get text (we use this to push to the browser)
// Returns: the full final string (so we can save it to the DB)
func StreamAIResponse(chatHistory []Message, onToken func(string)) (string, error) {
func StreamAIResponse(userID int, userMessage string, systemPrompt string, onToken func(string)) (string, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY")
url := "https://openrouter.ai/api/v1/chat/completions"
// 1. Get Current Time for the LLM
currentTime := time.Now().Format("Monday, 2006-01-02 15:04")
// 2. Strict System Prompt
systemPrompt := fmt.Sprintf(`
You are a helpful scheduler assistant for a business in Chile.
Current Date/Time: %s
RULES FOR BOOKING:
1. You MUST get three things from the user before booking:
- The Date (day/month)
- The Time (hour)
- The Phone Number
2. If any of these are missing, ASK for them. Do NOT assume or guess.
3. If the user says "tomorrow" or "next Friday", calculate the date based on the Current Date/Time above.
4. Only when you have all details, use the 'create_appointment' tool.
`, currentTime)
// 3. Prepend System Prompt to History
fullMessages := append([]Message{
{Role: "system", Content: systemPrompt},
}, chatHistory...)
messages := []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userMessage},
}
payload := map[string]interface{}{
"model": "arcee-ai/trinity-large-preview:free", // stepfun/step-3.5-flash:free, arcee-ai/trinity-large-preview:free
"messages": fullMessages,
"model": "arcee-ai/trinity-large-preview:free", //stepfun/step-3.5-flash:free
"messages": messages,
"stream": true,
"tools": []map[string]interface{}{
{
"type": "function",
"function": map[string]interface{}{
"name": "create_appointment",
"description": "Schedules a new appointment. ONLY use this when you have a confirm date, time, and phone number.",
"description": "Schedules a new appointment. ONLY use when you have date, time, and phone.",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"customer_phone": map[string]string{
"type": "string",
"description": "The user's phone number (e.g., +569...)",
},
"date": map[string]string{
"type": "string",
"description": "The full date and time in YYYY-MM-DD HH:MM format",
},
"customer_phone": map[string]string{"type": "string"},
"date": map[string]string{"type": "string"},
},
"required": []string{"customer_phone", "date"},
},
@@ -106,9 +58,7 @@ RULES FOR BOOKING:
}
defer resp.Body.Close()
// Prepare to read the stream line by line
scanner := bufio.NewScanner(resp.Body)
var fullContentBuffer strings.Builder
var toolCallBuffer strings.Builder
var toolName string
@@ -116,35 +66,37 @@ RULES FOR BOOKING:
for scanner.Scan() {
line := scanner.Text()
// OpenRouter sends "data: {JSON}" lines.
// Use specific string trimming to handle the format.
if !strings.HasPrefix(line, "data: ") {
continue
}
jsonStr := strings.TrimPrefix(line, "data: ")
// The stream ends with "data: [DONE]"
if jsonStr == "[DONE]" {
break
}
var chunk StreamResponse
if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil {
continue
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
}
json.Unmarshal([]byte(jsonStr), &chunk)
if len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta
// 1. Handle Text Content
if delta.Content != "" {
fullContentBuffer.WriteString(delta.Content)
// Send this chunk to the frontend immediately
onToken(delta.Content)
if onToken != nil {
onToken(delta.Content)
}
}
// 2. Handle Tool Calls (Accumulate them, don't stream execution yet)
if len(delta.ToolCalls) > 0 {
isToolCall = true
if delta.ToolCalls[0].Function.Name != "" {
@@ -155,24 +107,24 @@ RULES FOR BOOKING:
}
}
// If it was a tool call, execute it now that the stream is finished
// EXECUTE TOOL
if isToolCall && toolName == "create_appointment" {
var args struct {
Phone string `json:"customer_phone"`
Date string `json:"date"`
}
// Try to parse the accumulated JSON arguments
if err := json.Unmarshal([]byte(toolCallBuffer.String()), &args); err == nil {
err := db.SaveAppointment(args.Phone, args.Date)
err := db.SaveAppointment(userID, args.Phone, args.Date)
resultMsg := ""
if err != nil {
resultMsg = "\n[System: Failed to book appointment.]"
resultMsg = "\n(System: Failed to book appointment)"
} else {
resultMsg = fmt.Sprintf("\n✅ Booked for %s at %s", args.Phone, args.Date)
resultMsg = fmt.Sprintf("\n✅ Appointment Confirmed: %s", args.Date)
}
// Send the tool result to the frontend
onToken(resultMsg)
fullContentBuffer.WriteString(resultMsg)
if onToken != nil {
onToken(resultMsg)
}
}
}

27
services/types.go Normal file
View File

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

View File

@@ -5,13 +5,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
)
// SendWhatsAppMessage sends a text reply to a user
func SendWhatsAppMessage(toPhone string, messageBody string) error {
token := os.Getenv("WHATSAPP_TOKEN") // "EAA..."
phoneID := os.Getenv("WHATSAPP_PHONE_ID") // "100..."
// SendWhatsAppMessage sends a text reply using the specific Client's credentials
func SendWhatsAppMessage(token, phoneID, toPhone, messageBody string) error {
version := "v17.0"
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", version, phoneID)