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
This commit is contained in:
530
apps/whatsme/main.go
Normal file
530
apps/whatsme/main.go
Normal file
@@ -0,0 +1,530 @@
|
||||
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
|
||||
}
|
||||
23
apps/whatsme/qr_render.go
Normal file
23
apps/whatsme/qr_render.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
println("usage: go run qr_render.go <code>")
|
||||
os.Exit(1)
|
||||
}
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.M,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 1,
|
||||
}
|
||||
qrterminal.GenerateWithConfig(os.Args[1], config)
|
||||
}
|
||||
23
apps/whatsme/qr_render2.go
Normal file
23
apps/whatsme/qr_render2.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
println("usage: go run qr_render2.go <code>")
|
||||
os.Exit(1)
|
||||
}
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.M,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: "##",
|
||||
WhiteChar: " ",
|
||||
QuietZone: 1,
|
||||
}
|
||||
qrterminal.GenerateWithConfig(os.Args[1], config)
|
||||
}
|
||||
244
apps/whatsme/templates/index.gohtml
Normal file
244
apps/whatsme/templates/index.gohtml
Normal file
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>whatsme</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 14px; }
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #0a0a0a;
|
||||
color: #a0a0a0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.status-waiting_qr { color: #d4a017; border-color: #d4a017; }
|
||||
.status-connected { color: #4ade80; border-color: #4ade80; }
|
||||
.status-disconnected { color: #f87171; border-color: #f87171; }
|
||||
.status-failed { color: #f87171; border-color: #f87171; }
|
||||
.status-connecting { color: #60a5fa; border-color: #60a5fa; }
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.panel {
|
||||
background: #0a0a0a;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: #505050;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
#qr-panel { grid-column: 1; grid-row: 1; }
|
||||
#messages-panel { grid-column: 1; grid-row: 2; }
|
||||
#log-panel { grid-column: 2; grid-row: 1 / span 2; }
|
||||
#qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
padding: 1rem;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
#qr-container canvas {
|
||||
image-rendering: pixelated;
|
||||
border: 4px solid #e0e0e0;
|
||||
}
|
||||
#messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 2px solid #2a2a2a;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
.message:hover { border-left-color: #d4a017; }
|
||||
.message-phone { color: #d4a017; font-size: 0.8rem; }
|
||||
.message-name { color: #606060; font-size: 0.7rem; margin-left: 0.5rem; }
|
||||
.message-text { color: #c0c0c0; margin-top: 0.25rem; font-size: 0.85rem; }
|
||||
.message-time { color: #404040; font-size: 0.65rem; margin-top: 0.25rem; }
|
||||
#log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.log-entry { color: #505050; }
|
||||
.log-entry:last-child { color: #a0a0a0; }
|
||||
.prompt { color: #4ade80; }
|
||||
.prompt::before { content: "$ "; }
|
||||
.empty-state {
|
||||
color: #303030;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>whatsme</h1>
|
||||
<span class="status-badge status-{{.State}}">{{.State}}</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="panel" id="qr-panel">
|
||||
<div class="panel-header">QR Code</div>
|
||||
{{if eq .State "waiting_qr"}}
|
||||
{{if .QR}}
|
||||
<div id="qr-container"></div>
|
||||
<input type="hidden" id="qr-code" value="{{.QR}}">
|
||||
{{else}}
|
||||
<div class="empty-state">generating qr code...</div>
|
||||
{{end}}
|
||||
{{else if eq .State "connected"}}
|
||||
<div class="empty-state"><span class="prompt">device connected</span></div>
|
||||
{{else if eq .State "disconnected"}}
|
||||
<div class="empty-state"><span class="prompt">reconnecting...</span></div>
|
||||
{{else if eq .State "failed"}}
|
||||
<div class="empty-state"><span class="prompt">connection failed</span></div>
|
||||
{{else}}
|
||||
<div class="empty-state"><span class="prompt">waiting for connection...</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="panel" id="messages-panel">
|
||||
<div class="panel-header">Messages</div>
|
||||
<div id="messages-list">
|
||||
{{if .Messages}}
|
||||
{{range .Messages}}
|
||||
<div class="message">
|
||||
<span class="message-phone">{{.Phone}}</span>
|
||||
<span class="message-name">{{.Name}}</span>
|
||||
<div class="message-text">{{.Message}}</div>
|
||||
<div class="message-time">{{.Timestamp}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state">no messages yet</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" id="log-panel">
|
||||
<div class="panel-header">Activity Log</div>
|
||||
<div id="log-list">
|
||||
<div class="log-entry"><span class="prompt">whatsme started</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const qrContainer = document.getElementById('qr-container');
|
||||
const qrCodeInput = document.getElementById('qr-code');
|
||||
|
||||
function renderQR(code) {
|
||||
if (!qrContainer) return;
|
||||
qrContainer.innerHTML = '';
|
||||
QRCode.toCanvas(qrContainer, code, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: '#e0e0e0', light: '#0a0a0a' }
|
||||
}, function (err) {
|
||||
if (err) console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (qrCodeInput && qrCodeInput.value) {
|
||||
renderQR(qrCodeInput.value);
|
||||
}
|
||||
|
||||
const evtSource = new EventSource('/events');
|
||||
const messagesList = document.getElementById('messages-list');
|
||||
const logList = document.getElementById('log-list');
|
||||
const statusBadge = document.querySelector('.status-badge');
|
||||
|
||||
evtSource.addEventListener('state', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.qr) {
|
||||
renderQR(data.qr);
|
||||
}
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = data.state;
|
||||
statusBadge.className = 'status-badge status-' + data.state;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener('message', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const msg = data.message;
|
||||
const emptyState = messagesList.querySelector('.empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message';
|
||||
div.innerHTML = `
|
||||
<span class="message-phone">${msg.Phone}</span>
|
||||
<span class="message-name">${msg.Name}</span>
|
||||
<div class="message-text">${msg.Message}</div>
|
||||
<div class="message-time">${new Date(msg.Timestamp * 1000).toLocaleTimeString()}</div>
|
||||
`;
|
||||
messagesList.insertBefore(div, messagesList.firstChild);
|
||||
});
|
||||
|
||||
evtSource.addEventListener('log', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.innerHTML = `<span class="prompt">${data.log}</span>`;
|
||||
logList.appendChild(div);
|
||||
logList.scrollTop = logList.scrollHeight;
|
||||
});
|
||||
|
||||
evtSource.onerror = () => {
|
||||
setTimeout(() => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.innerHTML = `<span class="prompt">[sse reconnecting...]</span>`;
|
||||
logList.appendChild(div);
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user