Files
workspace/apps/whatsme/main.go
gabspereira 744868caa1 feat(go-crm): full auth, routing, middleware, and supporting infra
- Add auth handlers (signup, login, logout, account management) with bcrypt
- Add client, customer, service, scheduling, payment, question, answer handlers
- Add dashboard, monthly report, and lead pipeline pages
- Add UTF-8 middleware to force charset on HTML responses
- Add config package with env-based overrides for DB path, secrets, endpoints
- Add parser package for WhatsApp message ingestion
- Add clean-arch layers: pkg/domain, pkg/repo, pkg/usecase for leads
- Add cmd/migrate utility for DB migrations
- Add Makefile, README, run-tests.sh, and dev scripts
- Update docker-compose.yml with memory limits
- Update .air.toml to exclude DB files and stop on errors
- Update whatsapp-sync dependencies and add src/index.js entrypoint
- Add whatsme standalone WhatsApp reader app (source only)
- Untrack .opencode-sandbox/data/go-crm.db from git history
- Expand root .gitignore: ngrok, tmp dirs, sandbox DBs, compiled binaries
2026-05-23 16:55:55 -03:00

531 lines
12 KiB
Go

package main
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
_ "github.com/glebarez/sqlite"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
)
//go:embed templates/*
var templateFS embed.FS
type ConnectionState string
const (
StateDisconnected ConnectionState = "disconnected"
StateConnecting ConnectionState = "connecting"
StateWaitingQR ConnectionState = "waiting_qr"
StateConnected ConnectionState = "connected"
StateFailed ConnectionState = "failed"
)
type Message struct {
ID int64
Phone string
Name string
Message string
MessageID string
Timestamp int64
}
type Event struct {
Type string `json:"type"`
State string `json:"state,omitempty"`
QR string `json:"qr,omitempty"`
Error string `json:"error,omitempty"`
Message *Message `json:"message,omitempty"`
Log string `json:"log,omitempty"`
}
type App struct {
db *sql.DB
client *whatsmeow.Client
container *sqlstore.Container
mu sync.RWMutex
state ConnectionState
currentQR string
eventChan chan Event
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
dataDir string
}
func main() {
port := os.Getenv("WHATSME_PORT")
if port == "" {
port = "8080"
}
dataDir := os.Getenv("WHATSME_DATA_DIR")
if dataDir == "" {
dataDir = "./whatsme"
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
slog.Error("failed to create data dir", "error", err)
os.Exit(1)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx, cancel := context.WithCancel(context.Background())
app := &App{
eventChan: make(chan Event, 100),
logger: logger,
ctx: ctx,
cancel: cancel,
dataDir: dataDir,
state: StateDisconnected,
}
if err := app.initDB(); err != nil {
logger.Error("failed to init DB", "error", err)
os.Exit(1)
}
defer app.db.Close()
if err := app.initWhatsApp(); err != nil {
logger.Error("failed to init WhatsApp", "error", err)
os.Exit(1)
}
go app.autoResume()
r := chi.NewRouter()
r.Get("/", app.handleIndex)
r.Get("/events", app.handleEvents)
r.Get("/health", app.handleHealth)
srv := &http.Server{
Addr: ":" + port,
Handler: r,
}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
logger.Info("shutting down...")
cancel()
if app.client != nil {
app.client.Disconnect()
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
}()
logger.Info("starting", "port", port, "data_dir", dataDir)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
func (a *App) initDB() error {
dbPath := filepath.Join(a.dataDir, "messages.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
schema := `
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_id ON messages(message_id);
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("exec schema: %w", err)
}
a.db = db
return nil
}
func (a *App) initWhatsApp() error {
sessionPath := filepath.Join(a.dataDir, "session.db")
db, err := sql.Open("sqlite", sessionPath)
if err != nil {
return fmt.Errorf("open session db: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
a.container = sqlstore.NewWithDB(db, "sqlite", waLog.Stdout("SQL", "DEBUG", true))
if err := a.container.Upgrade(a.ctx); err != nil {
return fmt.Errorf("upgrade session db: %w", err)
}
return nil
}
func (a *App) autoResume() {
a.mu.Lock()
stores, err := a.container.GetAllDevices(a.ctx)
a.mu.Unlock()
if err != nil {
a.logger.Error("failed to get stores", "error", err)
return
}
if len(stores) == 0 {
a.logger.Info("no stored session, starting QR flow")
a.startQRFlow()
return
}
a.logger.Info("found stored session, resuming")
a.startQRFlow()
}
func (a *App) startQRFlow() {
device := a.container.NewDevice()
client := whatsmeow.NewClient(device, waLog.Stdout("Client", "DEBUG", true))
a.mu.Lock()
a.client = client
a.state = StateWaitingQR
a.mu.Unlock()
qrChan, err := client.GetQRChannel(a.ctx)
if err != nil {
a.logger.Error("failed to get QR channel", "error", err)
a.setState(StateFailed, "failed to get QR channel")
return
}
if err := client.Connect(); err != nil {
a.logger.Error("failed to connect", "error", err)
a.setState(StateFailed, "connection failed")
return
}
a.addMessageHandler(client)
go func() {
for evt := range qrChan {
switch evt.Event {
case whatsmeow.QRChannelSuccess.Event:
a.logger.Info("WhatsApp connected successfully")
a.setState(StateConnected, "")
return
case whatsmeow.QRChannelEventError:
a.logger.Error("QR error", "error", evt.Error)
a.setState(StateFailed, evt.Error.Error())
return
case whatsmeow.QRChannelTimeout.Event:
a.logger.Info("QR timeout, restarting")
a.setState(StateDisconnected, "QR timed out")
go a.startQRFlow()
return
default:
if evt.Code != "" {
a.mu.Lock()
a.currentQR = evt.Code
a.state = StateWaitingQR
a.mu.Unlock()
a.broadcastQR(evt.Code)
}
}
}
}()
}
func (a *App) setState(state ConnectionState, errMsg string) {
a.mu.Lock()
a.state = state
if errMsg != "" {
a.logger.Info("state changed", "state", state, "error", errMsg)
}
a.mu.Unlock()
a.eventChan <- Event{
Type: "state",
State: string(state),
Error: errMsg,
}
a.eventChan <- Event{
Type: "log",
Log: fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), state),
}
if state == StateDisconnected {
go a.startQRFlow()
}
}
func (a *App) broadcastQR(code string) {
a.eventChan <- Event{
Type: "state",
State: string(StateWaitingQR),
QR: code,
}
a.eventChan <- Event{
Type: "log",
Log: fmt.Sprintf("[%s] QR code generated", time.Now().Format("15:04:05")),
}
}
func (a *App) addMessageHandler(client *whatsmeow.Client) {
client.AddEventHandler(func(raw interface{}) {
evt, ok := raw.(*events.Message)
if !ok {
return
}
a.logger.Debug("raw event",
"type", fmt.Sprintf("%T", raw),
"is_from_me", evt.Info.IsFromMe,
"sender", evt.Info.Sender.String(),
"chat", evt.Info.Chat.String(),
"message_id", evt.Info.ID,
"timestamp", evt.Info.Timestamp,
"push_name", evt.Info.PushName,
"sender_server", evt.Info.Sender.Server,
"chat_server", evt.Info.Chat.Server,
"message_type", fmt.Sprintf("%T", evt.Message),
)
if evt.Info.IsFromMe {
a.logger.Info("skipping own message", "message_id", evt.Info.ID)
return
}
if evt.Info.Chat.Server == types.GroupServer {
a.logger.Info("skipping group message", "chat", evt.Info.Chat.String())
return
}
if evt.Info.ID == "" {
a.logger.Info("skipping message with empty ID")
return
}
sender := evt.Info.Sender
phone := sender.User
if phone == "" {
phone = sender.String()
}
if !strings.HasPrefix(phone, "+") {
phone = "+" + phone
}
if strings.HasSuffix(phone, "@s.whatsapp.net") {
phone = strings.TrimSuffix(phone, "@s.whatsapp.net")
phone = "+" + strings.TrimPrefix(phone, "+")
}
pushName := evt.Info.PushName
var text string
if evt.Message != nil {
if c := evt.Message.GetConversation(); c != "" {
text = c
} else if ext := evt.Message.GetExtendedTextMessage(); ext != nil {
text = ext.GetText()
}
}
if text == "" {
a.logger.Info("skipping non-text message", "message_id", evt.Info.ID)
return
}
ts := evt.Info.Timestamp.Unix()
if ts == 0 {
ts = time.Now().Unix()
}
msg := &Message{
Phone: phone,
Name: pushName,
Message: text,
MessageID: evt.Info.ID,
Timestamp: ts,
}
if err := a.saveMessage(msg); err != nil {
a.logger.Error("failed to save message", "error", err)
return
}
a.logger.Info("message saved",
"phone", phone,
"name", pushName,
"message", text,
"message_id", evt.Info.ID,
)
output := map[string]interface{}{
"phone": phone,
"name": pushName,
"message": text,
"message_id": evt.Info.ID,
"timestamp": ts,
}
if b, err := json.Marshal(output); err == nil {
fmt.Println(string(b))
}
a.eventChan <- Event{
Type: "message",
Message: msg,
}
a.eventChan <- Event{
Type: "log",
Log: fmt.Sprintf("[%s] MSG from %s (%s): %s", time.Now().Format("15:04:05"), phone, pushName, text),
}
})
}
func (a *App) saveMessage(msg *Message) error {
_, err := a.db.Exec(
`INSERT OR IGNORE INTO messages (phone, name, message, message_id, timestamp) VALUES (?, ?, ?, ?, ?)`,
msg.Phone, msg.Name, msg.Message, msg.MessageID, msg.Timestamp,
)
return err
}
func (a *App) getMessages() ([]Message, error) {
rows, err := a.db.Query(
`SELECT id, phone, name, message, message_id, timestamp FROM messages ORDER BY timestamp DESC LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []Message
for rows.Next() {
var m Message
if err := rows.Scan(&m.ID, &m.Phone, &m.Name, &m.Message, &m.MessageID, &m.Timestamp); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
return msgs, nil
}
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
a.mu.RLock()
state := a.state
currentQR := a.currentQR
a.mu.RUnlock()
msgs, err := a.getMessages()
if err != nil {
a.logger.Error("failed to get messages", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFS(templateFS, "templates/index.gohtml")
if err != nil {
a.logger.Error("failed to parse template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
data := struct {
State string
QR string
Messages []Message
}{
State: string(state),
QR: currentQR,
Messages: msgs,
}
if err := tmpl.Execute(w, data); err != nil {
a.logger.Error("failed to render template", "error", err)
}
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
a.mu.RLock()
state := a.state
currentQR := a.currentQR
a.mu.RUnlock()
if state == StateWaitingQR && currentQR != "" {
fmt.Fprintf(w, "event: state\ndata: {\"type\":\"state\",\"state\":\"%s\",\"qr\":\"%s\"}\n\n", state, escapeJSON(currentQR))
flusher.Flush()
} else {
fmt.Fprintf(w, "event: state\ndata: {\"type\":\"state\",\"state\":\"%s\"}\n\n", state)
flusher.Flush()
}
for evt := range a.eventChan {
switch evt.Type {
case "state":
if evt.QR != "" {
fmt.Fprintf(w, "event: state\ndata: {\"type\":\"state\",\"state\":\"%s\",\"qr\":\"%s\",\"error\":\"%s\"}\n\n", evt.State, escapeJSON(evt.QR), evt.Error)
} else {
fmt.Fprintf(w, "event: state\ndata: {\"type\":\"state\",\"state\":\"%s\",\"error\":\"%s\"}\n\n", evt.State, evt.Error)
}
case "message":
if evt.Message != nil {
msgJSON, _ := json.Marshal(evt.Message)
fmt.Fprintf(w, "event: message\ndata: {\"type\":\"message\",\"message\":%s}\n\n", msgJSON)
}
case "log":
fmt.Fprintf(w, "event: log\ndata: {\"type\":\"log\",\"log\":\"%s\"}\n\n", escapeJSON(evt.Log))
}
flusher.Flush()
}
}
func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
a.mu.RLock()
state := a.state
a.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"connected": state == StateConnected,
"state": state,
})
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}