feat(ui): add message cache management page and dashboard enhancements
Some checks failed
CI / Test (1.23) (push) Failing after -30m37s
CI / Test (1.22) (push) Failing after -30m33s
CI / Build (push) Failing after -30m45s
CI / Lint (push) Failing after -30m39s

- Introduced MessageCachePage for browsing and managing cached webhook events.
- Enhanced DashboardPage to display runtime stats and message cache information.
- Added new API types for message cache events and system stats.
- Integrated SwaggerPage for API documentation and live request testing.
This commit is contained in:
2026-03-05 00:32:57 +02:00
parent 4b44340c58
commit 1490e0b596
47 changed files with 4430 additions and 611 deletions

2
.gitignore vendored
View File

@@ -51,3 +51,5 @@ Thumbs.db
server.log server.log
/data/* /data/*
cmd/server/__debug* cmd/server/__debug*
.gocache/
.pnpm-store/

View File

@@ -45,7 +45,7 @@ Add a new WhatsApp account to the system.
**Endpoint:** `POST /api/accounts/add` **Endpoint:** `POST /api/accounts/add`
**Request Body (WhatsApp Web/WhatsMe ow):** **Request Body (Whatsapp):**
```json ```json
{ {
@@ -420,7 +420,7 @@ INFO Skipping disabled account account_id=business
- Account will be reconnected automatically if enabled - Account will be reconnected automatically if enabled
3. **Session Management**: 3. **Session Management**:
- WhatsMe ow sessions are stored in `session_path` - Whatsapp sessions are stored in `session_path`
- Removing an account doesn't delete session files - Removing an account doesn't delete session files
- Clean up manually if needed - Clean up manually if needed

View File

@@ -554,6 +554,7 @@ Add to your `config.json`:
{ {
"message_cache": { "message_cache": {
"enabled": true, "enabled": true,
"storage": "database",
"data_path": "./data/message_cache", "data_path": "./data/message_cache",
"max_age_days": 7, "max_age_days": 7,
"max_events": 10000 "max_events": 10000
@@ -832,6 +833,7 @@ Here's a complete `config.json` with all Business API features:
}, },
"message_cache": { "message_cache": {
"enabled": true, "enabled": true,
"storage": "database",
"data_path": "./data/message_cache", "data_path": "./data/message_cache",
"max_age_days": 7, "max_age_days": 7,
"max_events": 10000 "max_events": 10000

View File

@@ -114,5 +114,12 @@
"file_dir": "./data/events", "file_dir": "./data/events",
"table_name": "event_logs" "table_name": "event_logs"
}, },
"message_cache": {
"enabled": true,
"storage": "database",
"data_path": "./data/message_cache",
"max_age_days": 7,
"max_events": 10000
},
"log_level": "info" "log_level": "info"
} }

View File

@@ -1,88 +0,0 @@
package main
import (
"context"
"fmt"
"git.warky.dev/wdevs/whatshooked/pkg/api"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/storage"
"github.com/rs/zerolog/log"
)
// Example: Initialize Phase 2 components and start the API server
func main() {
// Setup logging
logging.Init("info")
// Load configuration
cfg, err := config.Load("config.json")
if err != nil {
log.Fatal().Err(err).Msg("Failed to load configuration")
}
// Check if database configuration is provided
if cfg.Database.Type == "" {
log.Warn().Msg("No database configuration found, using SQLite default")
cfg.Database.Type = "sqlite"
cfg.Database.SQLitePath = "./data/whatshooked.db"
}
// Initialize database
log.Info().
Str("type", cfg.Database.Type).
Msg("Initializing database")
if err := storage.Initialize(&cfg.Database); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database")
}
defer storage.Close()
db := storage.GetDB()
// Create tables using BUN
log.Info().Msg("Creating database tables")
if err := storage.CreateTables(context.Background()); err != nil {
log.Fatal().Err(err).Msg("Failed to create tables")
}
// Seed default data (creates admin user if not exists)
log.Info().Msg("Seeding default data")
if err := storage.SeedData(context.Background()); err != nil {
log.Fatal().Err(err).Msg("Failed to seed data")
}
// Ensure API config is present
if !cfg.API.Enabled {
log.Warn().Msg("API server not enabled in config, enabling with defaults")
cfg.API.Enabled = true
}
// Create API server
log.Info().Msg("Creating API server with ResolveSpec")
server, err := api.NewServer(cfg, db)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create API server")
}
// Start server
addr := fmt.Sprintf("%s:%d", cfg.API.Host, cfg.API.Port)
log.Info().
Str("address", addr).
Msg("Starting API server")
log.Info().Msg("Default admin credentials: username=admin, password=admin123")
log.Info().Msg("⚠️ Please change the default password after first login!")
log.Info().Msg("")
log.Info().Msg("API Endpoints:")
log.Info().Msgf(" - POST %s/api/v1/auth/login - Login to get JWT token", addr)
log.Info().Msgf(" - POST %s/api/v1/auth/logout - Logout and invalidate token", addr)
log.Info().Msgf(" - GET %s/api/v1/users - List users (requires auth)", addr)
log.Info().Msgf(" - GET %s/api/v1/hooks - List hooks (requires auth)", addr)
log.Info().Msgf(" - GET %s/health - Health check", addr)
// Start the server (blocking call)
if err := server.Start(); err != nil {
log.Fatal().Err(err).Msg("API server error")
}
}

View File

@@ -1,12 +1,18 @@
package api package api
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"math"
"net/http" "net/http"
"os"
"runtime"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -41,6 +47,16 @@ type Server struct {
wh WhatsHookedInterface wh WhatsHookedInterface
} }
type systemStatsSampler struct {
mu sync.Mutex
lastCPUJiffies uint64
lastCPUTimestamp time.Time
lastNetTotal uint64
lastNetTimestamp time.Time
}
var runtimeStatsSampler = &systemStatsSampler{}
// NewServer creates a new API server with ResolveSpec integration // NewServer creates a new API server with ResolveSpec integration
func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) { func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) {
// Create model registry and register models // Create model registry and register models
@@ -85,6 +101,7 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
// Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD) // Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD)
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil) restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET")
// Add custom routes (login, logout, etc.) on main router // Add custom routes (login, logout, etc.) on main router
SetupCustomRoutes(router, secProvider, db) SetupCustomRoutes(router, secProvider, db)
@@ -126,6 +143,179 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
}, nil }, nil
} }
func handleSystemStats(w http.ResponseWriter, _ *http.Request) {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
cpuPercent := runtimeStatsSampler.sampleCPUPercent()
rxBytes, txBytes := readNetworkBytes()
netTotal := rxBytes + txBytes
netBytesPerSec := runtimeStatsSampler.sampleNetworkBytesPerSec(netTotal)
writeJSON(w, http.StatusOK, map[string]any{
"go_memory_bytes": mem.Alloc,
"go_memory_mb": float64(mem.Alloc) / (1024.0 * 1024.0),
"go_sys_memory_bytes": mem.Sys,
"go_sys_memory_mb": float64(mem.Sys) / (1024.0 * 1024.0),
"go_goroutines": runtime.NumGoroutine(),
"go_cpu_percent": cpuPercent,
"network_rx_bytes": rxBytes,
"network_tx_bytes": txBytes,
"network_total_bytes": netTotal,
"network_bytes_per_sec": netBytesPerSec,
})
}
func readProcessCPUJiffies() (uint64, bool) {
data, err := os.ReadFile("/proc/self/stat")
if err != nil {
return 0, false
}
parts := strings.Fields(string(data))
if len(parts) < 15 {
return 0, false
}
utime, err := strconv.ParseUint(parts[13], 10, 64)
if err != nil {
return 0, false
}
stime, err := strconv.ParseUint(parts[14], 10, 64)
if err != nil {
return 0, false
}
return utime + stime, true
}
func (s *systemStatsSampler) sampleCPUPercent() float64 {
const clockTicksPerSecond = 100.0 // Linux default USER_HZ
jiffies, ok := readProcessCPUJiffies()
if !ok {
return 0
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if s.lastCPUTimestamp.IsZero() || s.lastCPUJiffies == 0 {
s.lastCPUTimestamp = now
s.lastCPUJiffies = jiffies
return 0
}
elapsed := now.Sub(s.lastCPUTimestamp).Seconds()
deltaJiffies := jiffies - s.lastCPUJiffies
s.lastCPUTimestamp = now
s.lastCPUJiffies = jiffies
if elapsed <= 0 {
return 0
}
cpuSeconds := float64(deltaJiffies) / clockTicksPerSecond
cores := float64(runtime.NumCPU())
if cores < 1 {
cores = 1
}
percent := (cpuSeconds / elapsed) * 100.0 / cores
if percent < 0 {
return 0
}
if percent > 100 {
return 100
}
return math.Round(percent*100) / 100
}
func readNetworkBytes() (uint64, uint64) {
file, err := os.Open("/proc/net/dev")
if err != nil {
return 0, 0
}
defer file.Close()
var rxBytes uint64
var txBytes uint64
scanner := bufio.NewScanner(file)
lineNo := 0
for scanner.Scan() {
lineNo++
// Skip headers.
if lineNo <= 2 {
continue
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
iface := strings.TrimSpace(parts[0])
if iface == "lo" {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 16 {
continue
}
rx, err := strconv.ParseUint(fields[0], 10, 64)
if err == nil {
rxBytes += rx
}
tx, err := strconv.ParseUint(fields[8], 10, 64)
if err == nil {
txBytes += tx
}
}
return rxBytes, txBytes
}
func (s *systemStatsSampler) sampleNetworkBytesPerSec(totalBytes uint64) float64 {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if s.lastNetTimestamp.IsZero() {
s.lastNetTimestamp = now
s.lastNetTotal = totalBytes
return 0
}
elapsed := now.Sub(s.lastNetTimestamp).Seconds()
deltaBytes := totalBytes - s.lastNetTotal
s.lastNetTimestamp = now
s.lastNetTotal = totalBytes
if elapsed <= 0 {
return 0
}
bps := float64(deltaBytes) / elapsed
if bps < 0 {
return 0
}
return math.Round(bps*100) / 100
}
// Start starts the API server // Start starts the API server
func (s *Server) Start() error { func (s *Server) Start() error {
return s.serverMgr.ServeWithGracefulShutdown() return s.serverMgr.ServeWithGracefulShutdown()
@@ -171,6 +361,7 @@ func SetupWhatsAppRoutes(router *mux.Router, wh WhatsHookedInterface, distFS fs.
// Account management (with auth) // Account management (with auth)
router.HandleFunc("/api/accounts", h.Auth(h.Accounts)) router.HandleFunc("/api/accounts", h.Auth(h.Accounts))
router.HandleFunc("/api/accounts/status", h.Auth(h.AccountStatuses)).Methods("GET")
router.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)).Methods("POST") router.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)).Methods("POST")
router.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)).Methods("POST") router.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)).Methods("POST")
router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST") router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST")

View File

@@ -1,21 +1,34 @@
package cache package cache
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"time" "time"
"git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging" "git.warky.dev/wdevs/whatshooked/pkg/logging"
"github.com/uptrace/bun"
)
const (
storageDatabase = "database"
storageDisk = "disk"
) )
// CachedEvent represents an event stored in cache // CachedEvent represents an event stored in cache
type CachedEvent struct { type CachedEvent struct {
ID string `json:"id"` ID string `json:"id"`
Event events.Event `json:"event"` Event events.Event `json:"event"`
AccountID string `json:"account_id,omitempty"`
FromNumber string `json:"from_number,omitempty"`
ToNumber string `json:"to_number,omitempty"`
MessageID string `json:"message_id,omitempty"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Reason string `json:"reason"` Reason string `json:"reason"`
Attempts int `json:"attempts"` Attempts int `json:"attempts"`
@@ -28,6 +41,9 @@ type MessageCache struct {
mu sync.RWMutex mu sync.RWMutex
dataPath string dataPath string
enabled bool enabled bool
storage string
dbType string
db *bun.DB
maxAge time.Duration // Maximum age before events are purged maxAge time.Duration // Maximum age before events are purged
maxEvents int // Maximum number of events to keep maxEvents int // Maximum number of events to keep
} }
@@ -35,17 +51,47 @@ type MessageCache struct {
// Config holds cache configuration // Config holds cache configuration
type Config struct { type Config struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Storage string `json:"storage"`
DataPath string `json:"data_path"` DataPath string `json:"data_path"`
DBType string `json:"db_type"`
DB *bun.DB `json:"-"`
MaxAge time.Duration `json:"max_age"` // Default: 7 days MaxAge time.Duration `json:"max_age"` // Default: 7 days
MaxEvents int `json:"max_events"` // Default: 10000 MaxEvents int `json:"max_events"` // Default: 10000
} }
// cacheDBRow is the persistence representation for database-backed cache entries.
type cacheDBRow struct {
ID string `bun:"id"`
AccountID string `bun:"account_id"`
EventType string `bun:"event_type"`
EventData string `bun:"event_data"`
MessageID string `bun:"message_id"`
FromNumber string `bun:"from_number"`
ToNumber string `bun:"to_number"`
Reason string `bun:"reason"`
Attempts int `bun:"attempts"`
Timestamp time.Time `bun:"timestamp"`
LastAttempt *time.Time `bun:"last_attempt"`
}
// TableName tells bun which table to use for cacheDBRow.
func (cacheDBRow) TableName() string {
return "message_cache"
}
// NewMessageCache creates a new message cache // NewMessageCache creates a new message cache
func NewMessageCache(cfg Config) (*MessageCache, error) { func NewMessageCache(cfg Config) (*MessageCache, error) {
if !cfg.Enabled { if !cfg.Enabled {
return &MessageCache{ return &MessageCache{enabled: false}, nil
enabled: false, }
}, nil
if cfg.Storage == "" {
cfg.Storage = storageDatabase
}
cfg.Storage = strings.ToLower(cfg.Storage)
if cfg.Storage != storageDatabase && cfg.Storage != storageDisk {
logging.Warn("Unknown message cache storage backend, defaulting to disk", "storage", cfg.Storage)
cfg.Storage = storageDisk
} }
if cfg.DataPath == "" { if cfg.DataPath == "" {
@@ -58,22 +104,25 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
cfg.MaxEvents = 10000 cfg.MaxEvents = 10000
} }
// Create cache directory if cfg.Storage == storageDisk {
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil { if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err) return nil, fmt.Errorf("failed to create cache directory: %w", err)
} }
}
cache := &MessageCache{ cache := &MessageCache{
events: make(map[string]*CachedEvent), events: make(map[string]*CachedEvent),
dataPath: cfg.DataPath, dataPath: cfg.DataPath,
enabled: true, enabled: true,
storage: cfg.Storage,
dbType: strings.ToLower(cfg.DBType),
db: cfg.DB,
maxAge: cfg.MaxAge, maxAge: cfg.MaxAge,
maxEvents: cfg.MaxEvents, maxEvents: cfg.MaxEvents,
} }
// Load existing cached events if err := cache.loadPersistedEvents(); err != nil {
if err := cache.loadFromDisk(); err != nil { return nil, err
logging.Warn("Failed to load cached events from disk", "error", err)
} }
// Start cleanup goroutine // Start cleanup goroutine
@@ -81,6 +130,7 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
logging.Info("Message cache initialized", logging.Info("Message cache initialized",
"enabled", cfg.Enabled, "enabled", cfg.Enabled,
"storage", cache.storage,
"data_path", cfg.DataPath, "data_path", cfg.DataPath,
"max_age", cfg.MaxAge, "max_age", cfg.MaxAge,
"max_events", cfg.MaxEvents) "max_events", cfg.MaxEvents)
@@ -88,6 +138,22 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
return cache, nil return cache, nil
} }
// ConfigureDatabase attaches a database to the cache and loads persisted entries.
func (c *MessageCache) ConfigureDatabase(db *bun.DB) error {
if !c.enabled || c.storage != storageDatabase {
return nil
}
if db == nil {
return fmt.Errorf("database handle is nil")
}
c.mu.Lock()
c.db = db
c.mu.Unlock()
return c.loadPersistedEvents()
}
// Store adds an event to the cache // Store adds an event to the cache
func (c *MessageCache) Store(event events.Event, reason string) error { func (c *MessageCache) Store(event events.Event, reason string) error {
if !c.enabled { if !c.enabled {
@@ -109,6 +175,20 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
cached := &CachedEvent{ cached := &CachedEvent{
ID: id, ID: id,
Event: event, Event: event,
AccountID: stringFromEventData(event.Data, "account_id", "accountID"),
FromNumber: stringFromEventData(
event.Data,
"from",
"from_number",
"fromNumber",
),
ToNumber: stringFromEventData(
event.Data,
"to",
"to_number",
"toNumber",
),
MessageID: stringFromEventData(event.Data, "message_id", "messageId"),
Timestamp: time.Now(), Timestamp: time.Now(),
Reason: reason, Reason: reason,
Attempts: 0, Attempts: 0,
@@ -116,8 +196,8 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
c.events[id] = cached c.events[id] = cached
// Save to disk asynchronously // Persist asynchronously
go c.saveToDisk(cached) go c.persistCachedEvent(cached)
logging.Debug("Event cached", logging.Debug("Event cached",
"event_id", id, "event_id", id,
@@ -177,6 +257,53 @@ func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEven
return result return result
} }
// ListPaged returns cached events filtered by event type and paged by limit/offset.
// Events are sorted by cache timestamp (newest first).
// A limit <= 0 returns all events from offset onward.
func (c *MessageCache) ListPaged(eventType events.EventType, limit, offset int) ([]*CachedEvent, int) {
if !c.enabled {
return nil, 0
}
c.mu.RLock()
defer c.mu.RUnlock()
if offset < 0 {
offset = 0
}
filtered := make([]*CachedEvent, 0, len(c.events))
for _, cached := range c.events {
if eventType != "" && cached.Event.Type != eventType {
continue
}
filtered = append(filtered, cached)
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].Timestamp.Equal(filtered[j].Timestamp) {
return filtered[i].ID > filtered[j].ID
}
return filtered[i].Timestamp.After(filtered[j].Timestamp)
})
total := len(filtered)
if offset >= total {
return []*CachedEvent{}, total
}
if limit <= 0 {
return filtered[offset:], total
}
end := offset + limit
if end > total {
end = total
}
return filtered[offset:end], total
}
// Remove deletes an event from the cache // Remove deletes an event from the cache
func (c *MessageCache) Remove(id string) error { func (c *MessageCache) Remove(id string) error {
if !c.enabled { if !c.enabled {
@@ -192,8 +319,8 @@ func (c *MessageCache) Remove(id string) error {
delete(c.events, id) delete(c.events, id)
// Remove from disk // Remove persisted record asynchronously
go c.removeFromDisk(id) go c.removePersistedEvent(id)
logging.Debug("Event removed from cache", "event_id", id) logging.Debug("Event removed from cache", "event_id", id)
@@ -218,8 +345,8 @@ func (c *MessageCache) IncrementAttempts(id string) error {
cached.Attempts++ cached.Attempts++
cached.LastAttempt = &now cached.LastAttempt = &now
// Update on disk // Persist asynchronously
go c.saveToDisk(cached) go c.persistCachedEvent(cached)
return nil return nil
} }
@@ -235,8 +362,8 @@ func (c *MessageCache) Clear() error {
c.events = make(map[string]*CachedEvent) c.events = make(map[string]*CachedEvent)
// Clear disk cache // Clear persisted cache asynchronously
go c.clearDisk() go c.clearPersistedEvents()
logging.Info("Message cache cleared") logging.Info("Message cache cleared")
@@ -274,7 +401,7 @@ func (c *MessageCache) removeOldest() {
if oldestID != "" { if oldestID != "" {
delete(c.events, oldestID) delete(c.events, oldestID)
go c.removeFromDisk(oldestID) go c.removePersistedEvent(oldestID)
logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID) logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID)
} }
} }
@@ -309,7 +436,7 @@ func (c *MessageCache) cleanup() {
for _, id := range expiredIDs { for _, id := range expiredIDs {
delete(c.events, id) delete(c.events, id)
go c.removeFromDisk(id) go c.removePersistedEvent(id)
} }
if len(expiredIDs) > 0 { if len(expiredIDs) > 0 {
@@ -317,6 +444,332 @@ func (c *MessageCache) cleanup() {
} }
} }
// loadPersistedEvents loads events from the configured persistence backend.
func (c *MessageCache) loadPersistedEvents() error {
switch c.storage {
case storageDatabase:
if c.db == nil {
logging.Warn("Message cache database storage selected but database is not available yet")
return nil
}
if err := c.ensureDatabaseTable(); err != nil {
return err
}
if err := c.loadFromDatabase(); err != nil {
return err
}
case storageDisk:
if err := c.loadFromDisk(); err != nil {
return err
}
}
return nil
}
func (c *MessageCache) persistCachedEvent(cached *CachedEvent) {
switch c.storage {
case storageDatabase:
c.saveToDatabase(cached)
case storageDisk:
c.saveToDisk(cached)
}
}
func (c *MessageCache) removePersistedEvent(id string) {
switch c.storage {
case storageDatabase:
c.removeFromDatabase(id)
case storageDisk:
c.removeFromDisk(id)
}
}
func (c *MessageCache) clearPersistedEvents() {
switch c.storage {
case storageDatabase:
c.clearDatabase()
case storageDisk:
c.clearDisk()
}
}
// ensureDatabaseTable creates/updates the message_cache table shape for cache events.
func (c *MessageCache) ensureDatabaseTable() error {
if c.db == nil {
return fmt.Errorf("database handle not set for message cache")
}
ctx := context.Background()
switch c.dbType {
case "postgres", "postgresql":
queries := []string{
`CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
message_id VARCHAR(255) NOT NULL DEFAULT '',
from_number VARCHAR(64) NOT NULL DEFAULT '',
to_number VARCHAR(64) NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
timestamp TIMESTAMPTZ NOT NULL,
last_attempt TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS account_id VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_type VARCHAR(100) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_data JSONB NOT NULL DEFAULT '{}'::jsonb`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS message_id VARCHAR(255) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS from_number VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS to_number VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS last_attempt TIMESTAMPTZ`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
}
for _, q := range queries {
if _, err := c.db.ExecContext(ctx, q); err != nil {
return fmt.Errorf("failed to ensure message_cache table (postgres): %w", err)
}
}
default:
queries := []string{
`CREATE TABLE IF NOT EXISTS message_cache (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
event_data TEXT NOT NULL,
message_id TEXT NOT NULL DEFAULT '',
from_number TEXT NOT NULL DEFAULT '',
to_number TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
timestamp DATETIME NOT NULL,
last_attempt DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
}
for _, q := range queries {
if _, err := c.db.ExecContext(ctx, q); err != nil {
return fmt.Errorf("failed to ensure message_cache table (sqlite): %w", err)
}
}
if err := c.ensureSQLiteColumn("account_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("event_type", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("event_data", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("message_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("from_number", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("to_number", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("reason", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("attempts", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("timestamp", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("last_attempt", "DATETIME"); err != nil {
return err
}
if err := c.ensureSQLiteColumn("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
return err
}
}
return nil
}
func (c *MessageCache) ensureSQLiteColumn(name, definition string) error {
rows, err := c.db.QueryContext(context.Background(), `PRAGMA table_info(message_cache)`)
if err != nil {
return fmt.Errorf("failed to inspect sqlite message_cache table: %w", err)
}
defer rows.Close()
exists := false
for rows.Next() {
var cid int
var colName string
var colType string
var notNull int
var defaultValue any
var pk int
if err := rows.Scan(&cid, &colName, &colType, &notNull, &defaultValue, &pk); err != nil {
return fmt.Errorf("failed to scan sqlite table info: %w", err)
}
if strings.EqualFold(colName, name) {
exists = true
break
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("failed reading sqlite table info: %w", err)
}
if exists {
return nil
}
query := fmt.Sprintf("ALTER TABLE message_cache ADD COLUMN %s %s", name, definition)
if _, err := c.db.ExecContext(context.Background(), query); err != nil {
return fmt.Errorf("failed to add sqlite message_cache column %s: %w", name, err)
}
return nil
}
func (c *MessageCache) loadFromDatabase() error {
if c.db == nil {
return nil
}
ctx := context.Background()
rows := make([]cacheDBRow, 0)
if err := c.db.NewSelect().
Model(&rows).
Table("message_cache").
Column("id", "account_id", "event_type", "event_data", "message_id", "from_number", "to_number", "reason", "attempts", "timestamp", "last_attempt").
Scan(ctx); err != nil {
return fmt.Errorf("failed to load cached events from database: %w", err)
}
loaded := 0
now := time.Now()
expiredIDs := make([]string, 0)
c.mu.Lock()
for _, row := range rows {
var evt events.Event
if err := json.Unmarshal([]byte(row.EventData), &evt); err != nil {
logging.Warn("Failed to unmarshal cached event row", "event_id", row.ID, "error", err)
continue
}
if evt.Type == "" {
evt.Type = events.EventType(row.EventType)
}
if evt.Timestamp.IsZero() {
evt.Timestamp = row.Timestamp
}
cached := &CachedEvent{
ID: row.ID,
Event: evt,
AccountID: firstNonEmpty(row.AccountID, stringFromEventData(evt.Data, "account_id", "accountID")),
FromNumber: firstNonEmpty(row.FromNumber, stringFromEventData(evt.Data, "from", "from_number", "fromNumber")),
ToNumber: firstNonEmpty(row.ToNumber, stringFromEventData(evt.Data, "to", "to_number", "toNumber")),
MessageID: firstNonEmpty(row.MessageID, stringFromEventData(evt.Data, "message_id", "messageId")),
Timestamp: row.Timestamp,
Reason: row.Reason,
Attempts: row.Attempts,
LastAttempt: row.LastAttempt,
}
if now.Sub(cached.Timestamp) > c.maxAge {
expiredIDs = append(expiredIDs, row.ID)
continue
}
c.events[cached.ID] = cached
loaded++
}
c.mu.Unlock()
for _, id := range expiredIDs {
go c.removeFromDatabase(id)
}
if loaded > 0 {
logging.Info("Loaded cached events from database", "count", loaded)
}
return nil
}
func (c *MessageCache) saveToDatabase(cached *CachedEvent) {
if c.db == nil {
return
}
eventData, err := json.Marshal(cached.Event)
if err != nil {
logging.Error("Failed to marshal cached event", "event_id", cached.ID, "error", err)
return
}
row := cacheDBRow{
ID: cached.ID,
AccountID: cached.AccountID,
EventType: string(cached.Event.Type),
EventData: string(eventData),
MessageID: cached.MessageID,
FromNumber: cached.FromNumber,
ToNumber: cached.ToNumber,
Reason: cached.Reason,
Attempts: cached.Attempts,
Timestamp: cached.Timestamp,
LastAttempt: cached.LastAttempt,
}
_, err = c.db.NewInsert().
Model(&row).
Table("message_cache").
On("CONFLICT (id) DO UPDATE").
Set("account_id = EXCLUDED.account_id").
Set("event_type = EXCLUDED.event_type").
Set("event_data = EXCLUDED.event_data").
Set("message_id = EXCLUDED.message_id").
Set("from_number = EXCLUDED.from_number").
Set("to_number = EXCLUDED.to_number").
Set("reason = EXCLUDED.reason").
Set("attempts = EXCLUDED.attempts").
Set("timestamp = EXCLUDED.timestamp").
Set("last_attempt = EXCLUDED.last_attempt").
Exec(context.Background())
if err != nil {
logging.Error("Failed to persist cached event to database", "event_id", cached.ID, "error", err)
}
}
func (c *MessageCache) removeFromDatabase(id string) {
if c.db == nil {
return
}
if _, err := c.db.NewDelete().
Table("message_cache").
Where("id = ?", id).
Exec(context.Background()); err != nil {
logging.Error("Failed to remove cached event from database", "event_id", id, "error", err)
}
}
func (c *MessageCache) clearDatabase() {
if c.db == nil {
return
}
if _, err := c.db.NewDelete().
Table("message_cache").
Where("1 = 1").
Exec(context.Background()); err != nil {
logging.Error("Failed to clear cached events from database", "error", err)
}
}
// saveToDisk saves a cached event to disk // saveToDisk saves a cached event to disk
func (c *MessageCache) saveToDisk(cached *CachedEvent) { func (c *MessageCache) saveToDisk(cached *CachedEvent) {
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID)) filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID))
@@ -392,3 +845,33 @@ func (c *MessageCache) clearDisk() {
} }
} }
} }
func stringFromEventData(data map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := data[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
if typed != "" {
return typed
}
default:
asString := fmt.Sprintf("%v", typed)
if asString != "" && asString != "<nil>" {
return asString
}
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -131,6 +131,7 @@ type MQTTConfig struct {
// MessageCacheConfig holds message cache configuration // MessageCacheConfig holds message cache configuration
type MessageCacheConfig struct { type MessageCacheConfig struct {
Enabled bool `json:"enabled"` // Enable message caching Enabled bool `json:"enabled"` // Enable message caching
Storage string `json:"storage,omitempty"` // Storage backend: "database" (default) or "disk"
DataPath string `json:"data_path,omitempty"` // Directory to store cached events DataPath string `json:"data_path,omitempty"` // Directory to store cached events
MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7) MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7)
MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000) MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000)
@@ -207,6 +208,9 @@ func Load(path string) (*Config, error) {
} }
// Set message cache defaults // Set message cache defaults
if cfg.MessageCache.Storage == "" {
cfg.MessageCache.Storage = "database"
}
if cfg.MessageCache.DataPath == "" { if cfg.MessageCache.DataPath == "" {
cfg.MessageCache.DataPath = "./data/message_cache" cfg.MessageCache.DataPath = "./data/message_cache"
} }

View File

@@ -10,10 +10,123 @@ import (
"git.warky.dev/wdevs/whatshooked/pkg/storage" "git.warky.dev/wdevs/whatshooked/pkg/storage"
) )
type accountRuntimeStatus struct {
AccountID string `json:"account_id"`
Type string `json:"type"`
Status string `json:"status"`
Connected bool `json:"connected"`
QRAvailable bool `json:"qr_available"`
}
type accountConfigWithStatus struct {
config.WhatsAppConfig
Status string `json:"status"`
Connected bool `json:"connected"`
QRAvailable bool `json:"qr_available"`
}
func (h *Handlers) getAccountStatusMapFromDB() map[string]accountRuntimeStatus {
result := map[string]accountRuntimeStatus{}
db := storage.GetDB()
if db == nil {
return result
}
type statusRow struct {
ID string `bun:"id"`
AccountType string `bun:"account_type"`
Status string `bun:"status"`
}
rows := make([]statusRow, 0)
err := db.NewSelect().
Table("whatsapp_account").
Column("id", "account_type", "status").
Scan(context.Background(), &rows)
if err != nil {
logging.Warn("Failed to load account statuses from database", "error", err)
return result
}
for _, row := range rows {
accountID := row.ID
status := row.Status
if status == "" {
status = "disconnected"
}
result[accountID] = accountRuntimeStatus{
AccountID: accountID,
Type: row.AccountType,
Status: status,
Connected: status == "connected",
QRAvailable: status == "pairing",
}
}
return result
}
// Accounts returns the list of all configured WhatsApp accounts // Accounts returns the list of all configured WhatsApp accounts
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) { func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
writeJSON(w, h.config.WhatsApp)
statusByAccountID := h.getAccountStatusMapFromDB()
accounts := make([]accountConfigWithStatus, 0, len(h.config.WhatsApp))
for _, account := range h.config.WhatsApp {
status := accountRuntimeStatus{
AccountID: account.ID,
Type: account.Type,
Status: "disconnected",
}
if account.Disabled {
status.Status = "disconnected"
status.Connected = false
status.QRAvailable = false
} else if fromDB, exists := statusByAccountID[account.ID]; exists {
status = fromDB
}
accounts = append(accounts, accountConfigWithStatus{
WhatsAppConfig: account,
Status: status.Status,
Connected: status.Connected,
QRAvailable: status.QRAvailable,
})
}
writeJSON(w, accounts)
}
// AccountStatuses returns status values persisted in the database.
// GET /api/accounts/status
func (h *Handlers) AccountStatuses(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
statusByAccountID := h.getAccountStatusMapFromDB()
statuses := make([]accountRuntimeStatus, 0, len(h.config.WhatsApp))
for _, account := range h.config.WhatsApp {
status := accountRuntimeStatus{
AccountID: account.ID,
Type: account.Type,
Status: "disconnected",
}
if account.Disabled {
status.Status = "disconnected"
status.Connected = false
status.QRAvailable = false
} else if fromDB, exists := statusByAccountID[account.ID]; exists {
status = fromDB
}
statuses = append(statuses, status)
}
writeJSON(w, map[string]interface{}{
"statuses": statuses,
})
} }
// AddAccount adds a new WhatsApp account to the system // AddAccount adds a new WhatsApp account to the system
@@ -35,6 +148,13 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
return return
} }
if db := storage.GetDB(); db != nil {
repo := storage.NewWhatsAppAccountRepository(db)
if err := repo.UpdateStatus(context.Background(), account.ID, "connecting"); err != nil {
logging.Warn("Failed to set account status to connecting", "account_id", account.ID, "error", err)
}
}
// Update config // Update config
h.config.WhatsApp = append(h.config.WhatsApp, account) h.config.WhatsApp = append(h.config.WhatsApp, account)
if h.configPath != "" { if h.configPath != "" {
@@ -70,6 +190,13 @@ func (h *Handlers) RemoveAccount(w http.ResponseWriter, r *http.Request) {
// Continue with removal even if disconnect fails // Continue with removal even if disconnect fails
} }
if db := storage.GetDB(); db != nil {
repo := storage.NewWhatsAppAccountRepository(db)
if err := repo.UpdateStatus(context.Background(), req.ID, "disconnected"); err != nil {
logging.Warn("Failed to set account status to disconnected after removal", "account_id", req.ID, "error", err)
}
}
// Remove from config // Remove from config
found := false found := false
newAccounts := make([]config.WhatsAppConfig, 0) newAccounts := make([]config.WhatsAppConfig, 0)
@@ -137,6 +264,13 @@ func (h *Handlers) DisableAccount(w http.ResponseWriter, r *http.Request) {
// Mark as disabled // Mark as disabled
h.config.WhatsApp[i].Disabled = true h.config.WhatsApp[i].Disabled = true
logging.Info("Account disabled", "account_id", req.ID) logging.Info("Account disabled", "account_id", req.ID)
if db := storage.GetDB(); db != nil {
repo := storage.NewWhatsAppAccountRepository(db)
if err := repo.UpdateStatus(context.Background(), req.ID, "disconnected"); err != nil {
logging.Warn("Failed to set account status to disconnected", "account_id", req.ID, "error", err)
}
}
break break
} }
} }
@@ -207,6 +341,13 @@ func (h *Handlers) EnableAccount(w http.ResponseWriter, r *http.Request) {
return return
} }
if db := storage.GetDB(); db != nil {
repo := storage.NewWhatsAppAccountRepository(db)
if err := repo.UpdateStatus(context.Background(), req.ID, "connecting"); err != nil {
logging.Warn("Failed to set account status to connecting", "account_id", req.ID, "error", err)
}
}
logging.Info("Account enabled and connected", "account_id", req.ID) logging.Info("Account enabled and connected", "account_id", req.ID)
// Save config // Save config
@@ -277,6 +418,15 @@ func (h *Handlers) UpdateAccount(w http.ResponseWriter, r *http.Request) {
if err := accountRepo.UpdateConfig(context.Background(), updates.ID, updates.PhoneNumber, cfgJSON, !updates.Disabled); err != nil { if err := accountRepo.UpdateConfig(context.Background(), updates.ID, updates.PhoneNumber, cfgJSON, !updates.Disabled); err != nil {
logging.Warn("Failed to sync updated account config to database", "account_id", updates.ID, "error", err) logging.Warn("Failed to sync updated account config to database", "account_id", updates.ID, "error", err)
} }
if updates.Disabled {
if err := accountRepo.UpdateStatus(context.Background(), updates.ID, "disconnected"); err != nil {
logging.Warn("Failed to set account status to disconnected after update", "account_id", updates.ID, "error", err)
}
} else if oldConfig.Disabled {
if err := accountRepo.UpdateStatus(context.Background(), updates.ID, "connecting"); err != nil {
logging.Warn("Failed to set account status to connecting after update", "account_id", updates.ID, "error", err)
}
}
} }
// If the account was enabled and settings changed, reconnect it // If the account was enabled and settings changed, reconnect it

View File

@@ -25,17 +25,60 @@ func (h *Handlers) GetCachedEvents(w http.ResponseWriter, r *http.Request) {
// Optional event_type filter // Optional event_type filter
eventType := r.URL.Query().Get("event_type") eventType := r.URL.Query().Get("event_type")
limit := 0
offset := 0
limitProvided := false
offsetProvided := false
var cachedEvents interface{} limitParam := r.URL.Query().Get("limit")
if eventType != "" { if limitParam != "" {
cachedEvents = cache.ListByEventType(events.EventType(eventType)) parsedLimit, err := strconv.Atoi(limitParam)
} else { if err != nil {
cachedEvents = cache.List() http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
return
}
if parsedLimit <= 0 {
http.Error(w, "Limit must be greater than 0", http.StatusBadRequest)
return
}
limit = parsedLimit
limitProvided = true
}
offsetParam := r.URL.Query().Get("offset")
if offsetParam != "" {
parsedOffset, err := strconv.Atoi(offsetParam)
if err != nil {
http.Error(w, "Invalid offset parameter", http.StatusBadRequest)
return
}
if parsedOffset < 0 {
http.Error(w, "Offset must be greater than or equal to 0", http.StatusBadRequest)
return
}
offset = parsedOffset
offsetProvided = true
}
// If offset is provided without limit, use a sensible page size.
if offsetProvided && !limitProvided {
limit = 100
}
cachedEvents, filteredCount := cache.ListPaged(events.EventType(eventType), limit, offset)
if !limitProvided && !offsetProvided {
// Backward-compatible response values when pagination is not requested.
limit = len(cachedEvents)
offset = 0
} }
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"cached_events": cachedEvents, "cached_events": cachedEvents,
"count": cache.Count(), "count": cache.Count(),
"filtered_count": filteredCount,
"returned_count": len(cachedEvents),
"limit": limit,
"offset": offset,
}) })
} }

View File

@@ -9,15 +9,18 @@ import (
type ModelPublicMessageCache struct { type ModelPublicMessageCache struct {
bun.BaseModel `bun:"table:public.message_cache,alias:message_cache"` bun.BaseModel `bun:"table:public.message_cache,alias:message_cache"`
ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID ID resolvespec_common.SqlString `bun:"id,type:varchar(128),pk," json:"id"`
AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(36),notnull," json:"account_id"` AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(64),notnull," json:"account_id"`
ChatID resolvespec_common.SqlString `bun:"chat_id,type:varchar(255),notnull," json:"chat_id"` EventType resolvespec_common.SqlString `bun:"event_type,type:varchar(100),notnull," json:"event_type"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` // JSON encoded message content EventData resolvespec_common.SqlString `bun:"event_data,type:jsonb,notnull," json:"event_data"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`
FromMe bool `bun:"from_me,type:boolean,notnull," json:"from_me"`
MessageID resolvespec_common.SqlString `bun:"message_id,type:varchar(255),notnull," json:"message_id"` MessageID resolvespec_common.SqlString `bun:"message_id,type:varchar(255),notnull," json:"message_id"`
MessageType resolvespec_common.SqlString `bun:"message_type,type:varchar(50),notnull," json:"message_type"` // text FromNumber resolvespec_common.SqlString `bun:"from_number,type:varchar(64),notnull," json:"from_number"`
Timestamp resolvespec_common.SqlTimeStamp `bun:"timestamp,type:timestamp,notnull," json:"timestamp"` ToNumber resolvespec_common.SqlString `bun:"to_number,type:varchar(64),notnull," json:"to_number"`
Reason resolvespec_common.SqlString `bun:"reason,type:text,notnull," json:"reason"`
Attempts int `bun:"attempts,type:integer,default:0,notnull," json:"attempts"`
Timestamp resolvespec_common.SqlTimeStamp `bun:"timestamp,type:timestamp,default:now(),notnull," json:"timestamp"`
LastAttempt resolvespec_common.SqlTimeStamp `bun:"last_attempt,type:timestamp,nullzero," json:"last_attempt"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`
} }
// TableName returns the table name for ModelPublicMessageCache // TableName returns the table name for ModelPublicMessageCache

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
pkg/serverembed/dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -2,10 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>web</title>
<script type="module" crossorigin src="/ui/assets/index-_R1QOTag.js"></script> <script type="module" crossorigin src="/ui/assets/index-BKXFy3Jy.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
</head> </head>
<body> <body>

BIN
pkg/serverembed/dist/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

4
pkg/serverembed/dist/swagger-icon.svg vendored Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Swagger icon">
<circle cx="32" cy="32" r="30" fill="#85EA2D"/>
<path d="M17 24c0-4.97 4.03-9 9-9h12v8H26a1 1 0 0 0 0 2h12c4.97 0 9 4.03 9 9s-4.03 9-9 9H26v6h-8V40h20a1 1 0 0 0 0-2H26c-4.97 0-9-4.03-9-9 0-2.2.79-4.22 2.1-5.8A8.94 8.94 0 0 0 17 24z" fill="#173647"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsHooked API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "../api.json",
dom_id: "#swagger-ui",
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset,
],
layout: "BaseLayout",
deepLinking: true,
tryItOutEnabled: true,
});
</script>
</body>
</html>

View File

@@ -84,7 +84,6 @@ func CreateTables(ctx context.Context) error {
(*models.ModelPublicWhatsappAccount)(nil), (*models.ModelPublicWhatsappAccount)(nil),
(*models.ModelPublicEventLog)(nil), (*models.ModelPublicEventLog)(nil),
(*models.ModelPublicSession)(nil), (*models.ModelPublicSession)(nil),
(*models.ModelPublicMessageCache)(nil),
} }
for _, model := range models { for _, model := range models {
@@ -94,6 +93,10 @@ func CreateTables(ctx context.Context) error {
} }
} }
if err := ensureMessageCacheTable(ctx); err != nil {
return err
}
return nil return nil
} }
@@ -153,14 +156,16 @@ func createTablesSQLite(ctx context.Context) error {
)`, )`,
// WhatsApp Accounts table // WhatsApp Accounts table
`CREATE TABLE IF NOT EXISTS whatsapp_accounts ( `CREATE TABLE IF NOT EXISTS whatsapp_account (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL,
phone_number VARCHAR(20) NOT NULL UNIQUE, phone_number VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(255),
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow', account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
business_api_config TEXT, config TEXT,
active BOOLEAN NOT NULL DEFAULT 1, active BOOLEAN NOT NULL DEFAULT 1,
connected BOOLEAN NOT NULL DEFAULT 0, status VARCHAR(50) NOT NULL DEFAULT 'disconnected',
session_path TEXT,
last_connected_at TIMESTAMP, last_connected_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -181,7 +186,7 @@ func createTablesSQLite(ctx context.Context) error {
status VARCHAR(50), status VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET NULL FOREIGN KEY (account_id) REFERENCES whatsapp_account(id) ON DELETE SET NULL
)`, )`,
// Sessions table // Sessions table
@@ -198,18 +203,22 @@ func createTablesSQLite(ctx context.Context) error {
// Message Cache table // Message Cache table
`CREATE TABLE IF NOT EXISTS message_cache ( `CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(36), account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL, event_type VARCHAR(100) NOT NULL,
event_data TEXT NOT NULL, event_data TEXT NOT NULL,
message_id VARCHAR(255), message_id VARCHAR(255) NOT NULL DEFAULT '',
from_number VARCHAR(20), from_number VARCHAR(64) NOT NULL DEFAULT '',
to_number VARCHAR(20), to_number VARCHAR(64) NOT NULL DEFAULT '',
processed BOOLEAN NOT NULL DEFAULT 0, reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, attempts INTEGER NOT NULL DEFAULT 0,
processed_at TIMESTAMP, timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET NULL last_attempt TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type)`,
} }
for _, sql := range tables { for _, sql := range tables {
@@ -218,6 +227,236 @@ func createTablesSQLite(ctx context.Context) error {
} }
} }
if err := migrateLegacyWhatsAppAccountsTable(ctx); err != nil {
return err
}
if err := ensureWhatsAppAccountColumnsSQLite(ctx); err != nil {
return err
}
if err := ensureMessageCacheTable(ctx); err != nil {
return err
}
return nil
}
func migrateLegacyWhatsAppAccountsTable(ctx context.Context) error {
if DB == nil {
return fmt.Errorf("database not initialized")
}
var legacyCount int
if err := DB.NewSelect().
Table("sqlite_master").
ColumnExpr("COUNT(1)").
Where("type = 'table' AND name = 'whatsapp_accounts'").
Scan(ctx, &legacyCount); err != nil {
return fmt.Errorf("failed to inspect legacy whatsapp_accounts table: %w", err)
}
var currentCount int
if err := DB.NewSelect().
Table("sqlite_master").
ColumnExpr("COUNT(1)").
Where("type = 'table' AND name = 'whatsapp_account'").
Scan(ctx, &currentCount); err != nil {
return fmt.Errorf("failed to inspect whatsapp_account table: %w", err)
}
if legacyCount > 0 {
if currentCount == 0 {
if _, err := DB.ExecContext(ctx, `ALTER TABLE whatsapp_accounts RENAME TO whatsapp_account`); err != nil {
return fmt.Errorf("failed to migrate table whatsapp_accounts -> whatsapp_account: %w", err)
}
return nil
}
mergeSQL := `INSERT OR IGNORE INTO whatsapp_account
(id, user_id, phone_number, display_name, account_type, config, active, status, session_path, last_connected_at, created_at, updated_at, deleted_at)
SELECT
id,
user_id,
phone_number,
'' AS display_name,
COALESCE(account_type, 'whatsmeow') AS account_type,
COALESCE(business_api_config, '') AS config,
COALESCE(active, 1) AS active,
CASE
WHEN COALESCE(active, 1) = 0 THEN 'disconnected'
WHEN COALESCE(connected, 0) = 1 THEN 'connected'
ELSE 'disconnected'
END AS status,
'' AS session_path,
last_connected_at,
created_at,
updated_at,
deleted_at
FROM whatsapp_accounts`
if _, err := DB.ExecContext(ctx, mergeSQL); err != nil {
return fmt.Errorf("failed to merge legacy whatsapp_accounts data: %w", err)
}
if _, err := DB.ExecContext(ctx, `DROP TABLE whatsapp_accounts`); err != nil {
return fmt.Errorf("failed to drop legacy whatsapp_accounts table: %w", err)
}
}
return nil
}
func ensureWhatsAppAccountColumnsSQLite(ctx context.Context) error {
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "display_name", "VARCHAR(255)"); err != nil {
return err
}
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "config", "TEXT"); err != nil {
return err
}
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "status", "VARCHAR(50) NOT NULL DEFAULT 'disconnected'"); err != nil {
return err
}
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "session_path", "TEXT"); err != nil {
return err
}
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "updated_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
return err
}
// Backfill config/status from legacy columns if they still exist on this table.
if hasBusinessAPIConfig, err := sqliteColumnExists(ctx, "whatsapp_account", "business_api_config"); err != nil {
return err
} else if hasBusinessAPIConfig {
if _, err := DB.ExecContext(ctx, `UPDATE whatsapp_account SET config = COALESCE(config, business_api_config, '')`); err != nil {
return fmt.Errorf("failed to backfill config from business_api_config: %w", err)
}
}
if hasConnected, err := sqliteColumnExists(ctx, "whatsapp_account", "connected"); err != nil {
return err
} else if hasConnected {
if _, err := DB.ExecContext(ctx, `UPDATE whatsapp_account
SET status = CASE
WHEN COALESCE(active, 1) = 0 THEN 'disconnected'
WHEN COALESCE(connected, 0) = 1 THEN 'connected'
WHEN status = '' OR status IS NULL THEN 'disconnected'
ELSE status
END`); err != nil {
return fmt.Errorf("failed to backfill status from connected column: %w", err)
}
}
return nil
}
func sqliteColumnExists(ctx context.Context, table, column string) (bool, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, fmt.Errorf("failed to inspect sqlite table %s: %w", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var colName string
var colType string
var notNull int
var defaultValue any
var pk int
if err := rows.Scan(&cid, &colName, &colType, &notNull, &defaultValue, &pk); err != nil {
return false, fmt.Errorf("failed to scan sqlite table info for %s: %w", table, err)
}
if colName == column {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("failed reading sqlite table info for %s: %w", table, err)
}
return false, nil
}
func ensureSQLiteColumn(ctx context.Context, table, name, definition string) error {
exists, err := sqliteColumnExists(ctx, table, name)
if err != nil {
return err
}
if exists {
return nil
}
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, name, definition)
if _, err := DB.ExecContext(ctx, query); err != nil {
return fmt.Errorf("failed to add sqlite column %s.%s: %w", table, name, err)
}
return nil
}
func ensureMessageCacheTable(ctx context.Context) error {
if DB == nil {
return fmt.Errorf("database not initialized")
}
if dbType == "postgres" || dbType == "postgresql" {
queries := []string{
`CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
message_id VARCHAR(255) NOT NULL DEFAULT '',
from_number VARCHAR(64) NOT NULL DEFAULT '',
to_number VARCHAR(64) NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
timestamp TIMESTAMPTZ NOT NULL,
last_attempt TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS account_id VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_type VARCHAR(100) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_data JSONB NOT NULL DEFAULT '{}'::jsonb`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS message_id VARCHAR(255) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS from_number VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS to_number VARCHAR(64) NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS last_attempt TIMESTAMPTZ`,
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
}
for _, query := range queries {
if _, err := DB.ExecContext(ctx, query); err != nil {
return fmt.Errorf("failed to ensure postgres message_cache table: %w", err)
}
}
return nil
}
queries := []string{
`CREATE TABLE IF NOT EXISTS message_cache (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
event_data TEXT NOT NULL,
message_id TEXT NOT NULL DEFAULT '',
from_number TEXT NOT NULL DEFAULT '',
to_number TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_attempt DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type)`,
}
for _, query := range queries {
if _, err := DB.ExecContext(ctx, query); err != nil {
return fmt.Errorf("failed to ensure sqlite message_cache table: %w", err)
}
}
return nil return nil
} }

View File

@@ -2,6 +2,7 @@ package storage
import ( import (
"context" "context"
"fmt"
"time" "time"
"git.warky.dev/wdevs/whatshooked/pkg/models" "git.warky.dev/wdevs/whatshooked/pkg/models"
@@ -202,30 +203,64 @@ func (r *WhatsAppAccountRepository) GetByPhoneNumber(ctx context.Context, phoneN
// UpdateConfig updates the config JSON column and phone number for a WhatsApp account // UpdateConfig updates the config JSON column and phone number for a WhatsApp account
func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error { func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error {
_, err := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)). now := time.Now()
Set("config = ?", cfgJSON). updated, err := r.updateAccountTable(ctx, id, map[string]any{
Set("phone_number = ?", phoneNumber). "config": cfgJSON,
Set("active = ?", active). "phone_number": phoneNumber,
Set("updated_at = ?", time.Now()). "active": active,
Where("id = ?", id). "updated_at": now,
Exec(ctx) })
if err != nil {
return err return err
} }
if updated {
return nil
}
return fmt.Errorf("no whatsapp account row found for id=%s", id)
}
// UpdateStatus updates the status of a WhatsApp account // UpdateStatus updates the status of a WhatsApp account
func (r *WhatsAppAccountRepository) UpdateStatus(ctx context.Context, id string, status string) error { func (r *WhatsAppAccountRepository) UpdateStatus(ctx context.Context, id string, status string) error {
query := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
Set("status = ?", status).
Where("id = ?", id)
if status == "connected" {
now := time.Now() now := time.Now()
query = query.Set("last_connected_at = ?", now) fields := map[string]any{
"status": status,
"updated_at": now,
}
if status == "connected" {
fields["last_connected_at"] = now
} }
_, err := query.Exec(ctx) updated, err := r.updateAccountTable(ctx, id, fields)
if err != nil {
return err return err
} }
if updated {
return nil
}
return fmt.Errorf("no whatsapp account row found for id=%s", id)
}
func (r *WhatsAppAccountRepository) updateAccountTable(ctx context.Context, id string, fields map[string]any) (bool, error) {
query := r.db.NewUpdate().Table("whatsapp_account").Where("id = ?", id)
for column, value := range fields {
query = query.Set(column+" = ?", value)
}
result, err := query.Exec(ctx)
if err != nil {
return false, err
}
if result == nil {
return false, nil
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return false, err
}
return rowsAffected > 0, nil
}
// SessionRepository provides session-specific operations // SessionRepository provides session-specific operations
type SessionRepository struct { type SessionRepository struct {

View File

@@ -224,6 +224,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
// Account management (with auth) // Account management (with auth)
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts)) mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
mux.HandleFunc("/api/accounts/status", h.Auth(h.AccountStatuses))
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)) mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
mux.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)) mux.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount))
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)) mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))

View File

@@ -19,6 +19,7 @@ import (
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp" "git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/uptrace/bun"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
) )
@@ -116,7 +117,9 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
// Initialize message cache // Initialize message cache
cacheConfig := cache.Config{ cacheConfig := cache.Config{
Enabled: cfg.MessageCache.Enabled, Enabled: cfg.MessageCache.Enabled,
Storage: cfg.MessageCache.Storage,
DataPath: cfg.MessageCache.DataPath, DataPath: cfg.MessageCache.DataPath,
DBType: cfg.Database.Type,
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour, MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
MaxEvents: cfg.MessageCache.MaxEvents, MaxEvents: cfg.MessageCache.MaxEvents,
} }
@@ -212,6 +215,10 @@ func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil { if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err) logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// Continue connecting to other accounts even if one fails // Continue connecting to other accounts even if one fails
} else {
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "connecting"); err != nil {
logging.Warn("Failed to set account status to connecting", "account_id", waCfg.ID, "error", err)
}
} }
} }
return nil return nil
@@ -219,16 +226,30 @@ func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
// connectFromConfig loads and connects WhatsApp accounts from config file (legacy) // connectFromConfig loads and connects WhatsApp accounts from config file (legacy)
func (wh *WhatsHooked) connectFromConfig(ctx context.Context) error { func (wh *WhatsHooked) connectFromConfig(ctx context.Context) error {
var accountRepo *storage.WhatsAppAccountRepository
if db := storage.GetDB(); db != nil {
accountRepo = storage.NewWhatsAppAccountRepository(db)
}
for _, waCfg := range wh.config.WhatsApp { for _, waCfg := range wh.config.WhatsApp {
// Skip disabled accounts // Skip disabled accounts
if waCfg.Disabled { if waCfg.Disabled {
logging.Info("Skipping disabled account", "account_id", waCfg.ID) logging.Info("Skipping disabled account", "account_id", waCfg.ID)
if accountRepo != nil {
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "disconnected"); err != nil {
logging.Warn("Failed to set disabled account status", "account_id", waCfg.ID, "error", err)
}
}
continue continue
} }
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil { if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err) logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// Continue connecting to other accounts even if one fails // Continue connecting to other accounts even if one fails
} else if accountRepo != nil {
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "connecting"); err != nil {
logging.Warn("Failed to set account status to connecting", "account_id", waCfg.ID, "error", err)
}
} }
} }
return nil return nil
@@ -374,9 +395,6 @@ func (wh *WhatsHooked) syncConfigToDatabase(ctx context.Context) error {
// --- Sync WhatsApp accounts --- // --- Sync WhatsApp accounts ---
if len(wh.config.WhatsApp) > 0 { if len(wh.config.WhatsApp) > 0 {
accountRepo := storage.NewWhatsAppAccountRepository(db)
_ = accountRepo // used via db directly for upsert
for _, wa := range wh.config.WhatsApp { for _, wa := range wh.config.WhatsApp {
if wa.ID == "" { if wa.ID == "" {
logging.Warn("Skipping config WhatsApp account with no ID", "phone", wa.PhoneNumber) logging.Warn("Skipping config WhatsApp account with no ID", "phone", wa.PhoneNumber)
@@ -402,25 +420,27 @@ func (wh *WhatsHooked) syncConfigToDatabase(ctx context.Context) error {
SessionPath: resolvespec_common.NewSqlString(wa.SessionPath), SessionPath: resolvespec_common.NewSqlString(wa.SessionPath),
Config: resolvespec_common.NewSqlString(cfgJSON), Config: resolvespec_common.NewSqlString(cfgJSON),
Active: !wa.Disabled, Active: !wa.Disabled,
Status: resolvespec_common.NewSqlString("disconnected"),
UserID: resolvespec_common.NewSqlString(adminID), UserID: resolvespec_common.NewSqlString(adminID),
CreatedAt: resolvespec_common.NewSqlTimeStamp(now), CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now), UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
} }
_, err := db.NewInsert(). result, err := db.NewInsert().
Model(&row). Model(&row).
On("CONFLICT (id) DO UPDATE"). // Config should only prime missing accounts, never mutate existing DB rows.
Set("account_type = EXCLUDED.account_type"). On("CONFLICT (id) DO NOTHING").
Set("phone_number = EXCLUDED.phone_number").
Set("session_path = EXCLUDED.session_path").
Set("config = EXCLUDED.config").
Set("active = EXCLUDED.active").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
logging.Error("Failed to sync WhatsApp account from config", "account_id", wa.ID, "error", err) logging.Error("Failed to sync WhatsApp account from config", "account_id", wa.ID, "error", err)
} else { } else {
logging.Info("Synced WhatsApp account from config to database", "account_id", wa.ID, "phone", wa.PhoneNumber) if result != nil {
if rows, rowsErr := result.RowsAffected(); rowsErr == nil && rows == 0 {
logging.Debug("Skipped existing WhatsApp account during config prime", "account_id", wa.ID)
continue
}
}
logging.Info("Primed WhatsApp account from config into database", "account_id", wa.ID, "phone", wa.PhoneNumber)
} }
} }
} }
@@ -491,6 +511,13 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
return err return err
} }
// Bind message cache to database storage once DB is initialized.
if wh.messageCache != nil {
if err := wh.messageCache.ConfigureDatabase(db); err != nil {
logging.Error("Failed to configure message cache database storage", "error", err)
}
}
// Seed initial data (creates admin user if not exists) // Seed initial data (creates admin user if not exists)
logging.Info("Seeding initial data") logging.Info("Seeding initial data")
if err := storage.SeedData(ctx); err != nil { if err := storage.SeedData(ctx); err != nil {
@@ -500,6 +527,9 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
// Mark database as ready for account/hook loading // Mark database as ready for account/hook loading
wh.dbReady = true wh.dbReady = true
// Keep whatsapp_account.status synchronized with runtime events.
wh.subscribeAccountStatusEvents(db)
// Persist hook events to the event_log table // Persist hook events to the event_log table
eventLogRepo := storage.NewEventLogRepository(db) eventLogRepo := storage.NewEventLogRepository(db)
wh.eventBus.Subscribe(events.EventHookFailed, func(event events.Event) { wh.eventBus.Subscribe(events.EventHookFailed, func(event events.Event) {
@@ -542,6 +572,41 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
return nil return nil
} }
func (wh *WhatsHooked) subscribeAccountStatusEvents(db *bun.DB) {
accountRepo := storage.NewWhatsAppAccountRepository(db)
updateStatus := func(event events.Event, status string) {
accountID, _ := event.Data["account_id"].(string)
if accountID == "" {
return
}
if err := accountRepo.UpdateStatus(context.Background(), accountID, status); err != nil {
logging.Warn("Failed to sync account status from event", "account_id", accountID, "status", status, "error", err)
}
}
wh.eventBus.Subscribe(events.EventWhatsAppConnected, func(event events.Event) {
updateStatus(event, "connected")
})
wh.eventBus.Subscribe(events.EventWhatsAppDisconnected, func(event events.Event) {
updateStatus(event, "disconnected")
})
wh.eventBus.Subscribe(events.EventWhatsAppQRCode, func(event events.Event) {
updateStatus(event, "pairing")
})
wh.eventBus.Subscribe(events.EventWhatsAppQRTimeout, func(event events.Event) {
updateStatus(event, "connecting")
})
wh.eventBus.Subscribe(events.EventWhatsAppQRError, func(event events.Event) {
updateStatus(event, "disconnected")
})
wh.eventBus.Subscribe(events.EventWhatsAppPairFailed, func(event events.Event) {
updateStatus(event, "disconnected")
})
wh.eventBus.Subscribe(events.EventWhatsAppPairSuccess, func(event events.Event) {
updateStatus(event, "connected")
})
}
// StopAPIServer stops the ResolveSpec server // StopAPIServer stops the ResolveSpec server
func (wh *WhatsHooked) StopAPIServer(ctx context.Context) error { func (wh *WhatsHooked) StopAPIServer(ctx context.Context) error {
if wh.apiServer != nil { if wh.apiServer != nil {

View File

@@ -5,7 +5,7 @@
DROP TABLE IF EXISTS public.message_cache CASCADE; DROP TABLE IF EXISTS public.message_cache CASCADE;
DROP TABLE IF EXISTS public.sessions CASCADE; DROP TABLE IF EXISTS public.sessions CASCADE;
DROP TABLE IF EXISTS public.event_logs CASCADE; DROP TABLE IF EXISTS public.event_logs CASCADE;
DROP TABLE IF EXISTS public.whatsapp_accounts CASCADE; DROP TABLE IF EXISTS public.whatsapp_account CASCADE;
DROP TABLE IF EXISTS public.hooks CASCADE; DROP TABLE IF EXISTS public.hooks CASCADE;
DROP TABLE IF EXISTS public.api_keys CASCADE; DROP TABLE IF EXISTS public.api_keys CASCADE;
DROP TABLE IF EXISTS public.users CASCADE; DROP TABLE IF EXISTS public.users CASCADE;

View File

@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS public.hooks (
user_id varchar(36) NOT NULL user_id varchar(36) NOT NULL
); );
CREATE TABLE IF NOT EXISTS public.whatsapp_accounts ( CREATE TABLE IF NOT EXISTS public.whatsapp_account (
account_type varchar(50) NOT NULL, account_type varchar(50) NOT NULL,
active boolean NOT NULL DEFAULT true, active boolean NOT NULL DEFAULT true,
config text, config text,
@@ -94,15 +94,18 @@ CREATE TABLE IF NOT EXISTS public.sessions (
); );
CREATE TABLE IF NOT EXISTS public.message_cache ( CREATE TABLE IF NOT EXISTS public.message_cache (
account_id varchar(36) NOT NULL, id varchar(128) NOT NULL,
chat_id varchar(255) NOT NULL, account_id varchar(64) NOT NULL DEFAULT '',
content text NOT NULL, event_type varchar(100) NOT NULL,
created_at timestamp NOT NULL DEFAULT now(), event_data jsonb NOT NULL DEFAULT '{}'::jsonb,
from_me boolean NOT NULL, message_id varchar(255) NOT NULL DEFAULT '',
id varchar(36) NOT NULL, from_number varchar(64) NOT NULL DEFAULT '',
message_id varchar(255) NOT NULL, to_number varchar(64) NOT NULL DEFAULT '',
message_type varchar(50) NOT NULL, reason text NOT NULL DEFAULT '',
timestamp timestamp NOT NULL attempts integer NOT NULL DEFAULT 0,
timestamp timestamptz NOT NULL DEFAULT now(),
last_attempt timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
); );
-- Add missing columns for schema: public -- Add missing columns for schema: public
@@ -592,10 +595,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'account_type' AND column_name = 'account_type'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN account_type varchar(50) NOT NULL; ALTER TABLE public.whatsapp_account ADD COLUMN account_type varchar(50) NOT NULL;
END IF; END IF;
END; END;
$$; $$;
@@ -605,10 +608,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'active' AND column_name = 'active'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN active boolean NOT NULL DEFAULT true; ALTER TABLE public.whatsapp_account ADD COLUMN active boolean NOT NULL DEFAULT true;
END IF; END IF;
END; END;
$$; $$;
@@ -618,10 +621,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'config' AND column_name = 'config'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN config text; ALTER TABLE public.whatsapp_account ADD COLUMN config text;
END IF; END IF;
END; END;
$$; $$;
@@ -631,10 +634,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'created_at' AND column_name = 'created_at'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN created_at timestamp NOT NULL DEFAULT now(); ALTER TABLE public.whatsapp_account ADD COLUMN created_at timestamp NOT NULL DEFAULT now();
END IF; END IF;
END; END;
$$; $$;
@@ -644,10 +647,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'deleted_at' AND column_name = 'deleted_at'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN deleted_at timestamp; ALTER TABLE public.whatsapp_account ADD COLUMN deleted_at timestamp;
END IF; END IF;
END; END;
$$; $$;
@@ -657,10 +660,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'display_name' AND column_name = 'display_name'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN display_name varchar(255); ALTER TABLE public.whatsapp_account ADD COLUMN display_name varchar(255);
END IF; END IF;
END; END;
$$; $$;
@@ -670,10 +673,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'id' AND column_name = 'id'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN id varchar(36) NOT NULL; ALTER TABLE public.whatsapp_account ADD COLUMN id varchar(36) NOT NULL;
END IF; END IF;
END; END;
$$; $$;
@@ -683,10 +686,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'last_connected_at' AND column_name = 'last_connected_at'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN last_connected_at timestamp; ALTER TABLE public.whatsapp_account ADD COLUMN last_connected_at timestamp;
END IF; END IF;
END; END;
$$; $$;
@@ -696,10 +699,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'phone_number' AND column_name = 'phone_number'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN phone_number varchar(50) NOT NULL; ALTER TABLE public.whatsapp_account ADD COLUMN phone_number varchar(50) NOT NULL;
END IF; END IF;
END; END;
$$; $$;
@@ -709,10 +712,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'session_path' AND column_name = 'session_path'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN session_path text; ALTER TABLE public.whatsapp_account ADD COLUMN session_path text;
END IF; END IF;
END; END;
$$; $$;
@@ -722,10 +725,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'status' AND column_name = 'status'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN status varchar(50) NOT NULL DEFAULT 'disconnected'; ALTER TABLE public.whatsapp_account ADD COLUMN status varchar(50) NOT NULL DEFAULT 'disconnected';
END IF; END IF;
END; END;
$$; $$;
@@ -735,10 +738,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'updated_at' AND column_name = 'updated_at'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN updated_at timestamp NOT NULL DEFAULT now(); ALTER TABLE public.whatsapp_account ADD COLUMN updated_at timestamp NOT NULL DEFAULT now();
END IF; END IF;
END; END;
$$; $$;
@@ -748,10 +751,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND column_name = 'user_id' AND column_name = 'user_id'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN user_id varchar(36) NOT NULL; ALTER TABLE public.whatsapp_account ADD COLUMN user_id varchar(36) NOT NULL;
END IF; END IF;
END; END;
$$; $$;
@@ -1016,71 +1019,6 @@ BEGIN
END; END;
$$; $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'account_id'
) THEN
ALTER TABLE public.message_cache ADD COLUMN account_id varchar(36) NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'chat_id'
) THEN
ALTER TABLE public.message_cache ADD COLUMN chat_id varchar(255) NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'content'
) THEN
ALTER TABLE public.message_cache ADD COLUMN content text NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'created_at'
) THEN
ALTER TABLE public.message_cache ADD COLUMN created_at timestamp NOT NULL DEFAULT now();
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'from_me'
) THEN
ALTER TABLE public.message_cache ADD COLUMN from_me boolean NOT NULL;
END IF;
END;
$$;
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -1089,7 +1027,46 @@ BEGIN
AND table_name = 'message_cache' AND table_name = 'message_cache'
AND column_name = 'id' AND column_name = 'id'
) THEN ) THEN
ALTER TABLE public.message_cache ADD COLUMN id varchar(36) NOT NULL; ALTER TABLE public.message_cache ADD COLUMN id varchar(128) NOT NULL;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'account_id'
) THEN
ALTER TABLE public.message_cache ADD COLUMN account_id varchar(64) NOT NULL DEFAULT '';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'event_type'
) THEN
ALTER TABLE public.message_cache ADD COLUMN event_type varchar(100) NOT NULL DEFAULT '';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'event_data'
) THEN
ALTER TABLE public.message_cache ADD COLUMN event_data jsonb NOT NULL DEFAULT '{}'::jsonb;
END IF; END IF;
END; END;
$$; $$;
@@ -1102,7 +1079,7 @@ BEGIN
AND table_name = 'message_cache' AND table_name = 'message_cache'
AND column_name = 'message_id' AND column_name = 'message_id'
) THEN ) THEN
ALTER TABLE public.message_cache ADD COLUMN message_id varchar(255) NOT NULL; ALTER TABLE public.message_cache ADD COLUMN message_id varchar(255) NOT NULL DEFAULT '';
END IF; END IF;
END; END;
$$; $$;
@@ -1113,9 +1090,48 @@ BEGIN
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'message_cache' AND table_name = 'message_cache'
AND column_name = 'message_type' AND column_name = 'from_number'
) THEN ) THEN
ALTER TABLE public.message_cache ADD COLUMN message_type varchar(50) NOT NULL; ALTER TABLE public.message_cache ADD COLUMN from_number varchar(64) NOT NULL DEFAULT '';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'to_number'
) THEN
ALTER TABLE public.message_cache ADD COLUMN to_number varchar(64) NOT NULL DEFAULT '';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'reason'
) THEN
ALTER TABLE public.message_cache ADD COLUMN reason text NOT NULL DEFAULT '';
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'attempts'
) THEN
ALTER TABLE public.message_cache ADD COLUMN attempts integer NOT NULL DEFAULT 0;
END IF; END IF;
END; END;
$$; $$;
@@ -1128,7 +1144,33 @@ BEGIN
AND table_name = 'message_cache' AND table_name = 'message_cache'
AND column_name = 'timestamp' AND column_name = 'timestamp'
) THEN ) THEN
ALTER TABLE public.message_cache ADD COLUMN timestamp timestamp NOT NULL; ALTER TABLE public.message_cache ADD COLUMN timestamp timestamptz NOT NULL DEFAULT now();
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'last_attempt'
) THEN
ALTER TABLE public.message_cache ADD COLUMN last_attempt timestamptz;
END IF;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'created_at'
) THEN
ALTER TABLE public.message_cache ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();
END IF; END IF;
END; END;
$$; $$;
@@ -1226,22 +1268,22 @@ BEGIN
SELECT constraint_name INTO auto_pk_name SELECT constraint_name INTO auto_pk_name
FROM information_schema.table_constraints FROM information_schema.table_constraints
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND constraint_type = 'PRIMARY KEY' AND constraint_type = 'PRIMARY KEY'
AND constraint_name IN ('whatsapp_accounts_pkey', 'public_whatsapp_accounts_pkey'); AND constraint_name IN ('whatsapp_account_pkey', 'public_whatsapp_account_pkey');
IF auto_pk_name IS NOT NULL THEN IF auto_pk_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE public.whatsapp_accounts DROP CONSTRAINT ' || quote_ident(auto_pk_name); EXECUTE 'ALTER TABLE public.whatsapp_account DROP CONSTRAINT ' || quote_ident(auto_pk_name);
END IF; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND constraint_name = 'pk_public_whatsapp_accounts' AND constraint_name = 'pk_public_whatsapp_account'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD CONSTRAINT pk_public_whatsapp_accounts PRIMARY KEY (id); ALTER TABLE public.whatsapp_account ADD CONSTRAINT pk_public_whatsapp_account PRIMARY KEY (id);
END IF; END IF;
END; END;
$$; $$;
@@ -1346,11 +1388,11 @@ CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at
CREATE INDEX IF NOT EXISTS idx_hooks_user_id CREATE INDEX IF NOT EXISTS idx_hooks_user_id
ON public.hooks USING btree (user_id); ON public.hooks USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at
ON public.whatsapp_accounts USING btree (deleted_at); ON public.whatsapp_account USING btree (deleted_at);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id
ON public.whatsapp_accounts USING btree (user_id); ON public.whatsapp_account USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_event_logs_created_at CREATE INDEX IF NOT EXISTS idx_event_logs_created_at
ON public.event_logs USING btree (created_at); ON public.event_logs USING btree (created_at);
@@ -1373,17 +1415,11 @@ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
CREATE INDEX IF NOT EXISTS idx_sessions_user_id CREATE INDEX IF NOT EXISTS idx_sessions_user_id
ON public.sessions USING btree (user_id); ON public.sessions USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_message_cache_account_id
ON public.message_cache USING btree (account_id);
CREATE INDEX IF NOT EXISTS idx_message_cache_chat_id
ON public.message_cache USING btree (chat_id);
CREATE INDEX IF NOT EXISTS idx_message_cache_from_me
ON public.message_cache USING btree (from_me);
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp
ON public.message_cache USING btree (timestamp); ON public.message_cache USING btree (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_message_cache_event_type
ON public.message_cache USING btree (event_type);
-- Unique constraints for schema: public -- Unique constraints for schema: public
DO $$ DO $$
@@ -1430,10 +1466,10 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND constraint_name = 'ukey_whatsapp_accounts_phone_number' AND constraint_name = 'ukey_whatsapp_account_phone_number'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ADD CONSTRAINT ukey_whatsapp_accounts_phone_number UNIQUE (phone_number); ALTER TABLE public.whatsapp_account ADD CONSTRAINT ukey_whatsapp_account_phone_number UNIQUE (phone_number);
END IF; END IF;
END; END;
$$; $$;
@@ -1451,19 +1487,6 @@ BEGIN
END; END;
$$; $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND constraint_name = 'ukey_message_cache_message_id'
) THEN
ALTER TABLE public.message_cache ADD CONSTRAINT ukey_message_cache_message_id UNIQUE (message_id);
END IF;
END;
$$;
-- Check constraints for schema: public -- Check constraints for schema: public
-- Foreign keys for schema: public -- Foreign keys for schema: public
DO $$ DO $$
@@ -1503,11 +1526,11 @@ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts' AND table_name = 'whatsapp_account'
AND constraint_name = 'fk_whatsapp_accounts_user_id' AND constraint_name = 'fk_whatsapp_account_user_id'
) THEN ) THEN
ALTER TABLE public.whatsapp_accounts ALTER TABLE public.whatsapp_account
ADD CONSTRAINT fk_whatsapp_accounts_user_id ADD CONSTRAINT fk_whatsapp_account_user_id
FOREIGN KEY (user_id) FOREIGN KEY (user_id)
REFERENCES public.users (id) REFERENCES public.users (id)
ON DELETE NO ACTION ON DELETE NO ACTION
@@ -1554,4 +1577,3 @@ $$;-- Set sequence values for schema: public

View File

@@ -78,8 +78,8 @@ Table whatsapp_account {
deleted_at timestamp [null] deleted_at timestamp [null]
indexes { indexes {
(user_id) [name: 'idx_whatsapp_accounts_user_id'] (user_id) [name: 'idx_whatsapp_account_user_id']
(deleted_at) [name: 'idx_whatsapp_accounts_deleted_at'] (deleted_at) [name: 'idx_whatsapp_account_deleted_at']
} }
} }
@@ -123,26 +123,27 @@ Table session {
} }
Table message_cache { Table message_cache {
id varchar(36) [primary key, note: 'UUID'] id varchar(128) [primary key]
account_id varchar(36) [not null] account_id varchar(64) [not null, default: '']
message_id varchar(255) [unique, not null] event_type varchar(100) [not null]
chat_id varchar(255) [not null] event_data text [not null, note: 'JSON encoded event payload']
from_me boolean [not null] message_id varchar(255) [not null, default: '']
timestamp timestamp [not null] from_number varchar(64) [not null, default: '']
message_type varchar(50) [not null, note: 'text, image, video, etc.'] to_number varchar(64) [not null, default: '']
content text [not null, note: 'JSON encoded message content'] reason text [not null, default: '']
attempts integer [not null, default: 0]
timestamp timestamp [not null, default: `now()`]
last_attempt timestamp [null]
created_at timestamp [not null, default: `now()`] created_at timestamp [not null, default: `now()`]
indexes { indexes {
(account_id) [name: 'idx_message_cache_account_id']
(chat_id) [name: 'idx_message_cache_chat_id']
(from_me) [name: 'idx_message_cache_from_me']
(timestamp) [name: 'idx_message_cache_timestamp'] (timestamp) [name: 'idx_message_cache_timestamp']
(event_type) [name: 'idx_message_cache_event_type']
} }
} }
// Reference documentation // Reference documentation
Ref: api_keys.user_id > users.id [delete: cascade] Ref: api_keys.user_id > users.id [delete: cascade]
Ref: hooks.user_id > users.id [delete: cascade] Ref: hooks.user_id > users.id [delete: cascade]
Ref: whatsapp_accounts.user_id > users.id [delete: cascade] Ref: whatsapp_account.user_id > users.id [delete: cascade]
Ref: sessions.user_id > users.id [delete: cascade] Ref: sessions.user_id > users.id [delete: cascade]

View File

@@ -62,10 +62,9 @@ CREATE INDEX IF NOT EXISTS idx_hooks_user_id ON hooks(user_id);
CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at ON hooks(deleted_at); CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at ON hooks(deleted_at);
-- WhatsApp Accounts table -- WhatsApp Accounts table
CREATE TABLE IF NOT EXISTS whatsapp_accounts ( CREATE TABLE IF NOT EXISTS whatsapp_account (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL,
account_id VARCHAR(100) UNIQUE,
phone_number VARCHAR(50) NOT NULL UNIQUE, phone_number VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(255), display_name VARCHAR(255),
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow', account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
@@ -80,9 +79,8 @@ CREATE TABLE IF NOT EXISTS whatsapp_accounts (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
); );
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id ON whatsapp_accounts(user_id); CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id ON whatsapp_account(user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at ON whatsapp_accounts(deleted_at); CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at ON whatsapp_account(deleted_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_whatsapp_accounts_account_id ON whatsapp_accounts(account_id);
-- Event Logs table -- Event Logs table
CREATE TABLE IF NOT EXISTS event_logs ( CREATE TABLE IF NOT EXISTS event_logs (
@@ -125,18 +123,19 @@ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-- Message Cache table -- Message Cache table
CREATE TABLE IF NOT EXISTS message_cache ( CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(36) NOT NULL, account_id VARCHAR(64) NOT NULL DEFAULT '',
message_id VARCHAR(255) NOT NULL UNIQUE, event_type VARCHAR(100) NOT NULL,
chat_id VARCHAR(255) NOT NULL, event_data TEXT NOT NULL,
message_type VARCHAR(50) NOT NULL, message_id VARCHAR(255) NOT NULL DEFAULT '',
content TEXT NOT NULL, from_number VARCHAR(64) NOT NULL DEFAULT '',
from_me BOOLEAN NOT NULL, to_number VARCHAR(64) NOT NULL DEFAULT '',
timestamp TIMESTAMP NOT NULL, reason TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_attempt TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_message_cache_account_id ON message_cache(account_id); CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_message_cache_chat_id ON message_cache(chat_id); CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type);
CREATE INDEX IF NOT EXISTS idx_message_cache_from_me ON message_cache(from_me);
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp);

1
web/.gitignore vendored
View File

@@ -22,3 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.gocache/

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>web</title>
</head> </head>

View File

@@ -19,12 +19,13 @@
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@warkypublic/oranguru": "^0.0.49", "@warkypublic/oranguru": "^0.0.49",
"@warkypublic/resolvespec-js": "^1.0.1",
"axios": "^1.13.4", "axios": "^1.13.4",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"@warkypublic/resolvespec-js": "^1.0.1", "swagger-ui-react": "^5.32.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

1455
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Swagger icon">
<circle cx="32" cy="32" r="30" fill="#85EA2D"/>
<path d="M17 24c0-4.97 4.03-9 9-9h12v8H26a1 1 0 0 0 0 2h12c4.97 0 9 4.03 9 9s-4.03 9-9 9H26v6h-8V40h20a1 1 0 0 0 0-2H26c-4.97 0-9-4.03-9-9 0-2.2.79-4.22 2.1-5.8A8.94 8.94 0 0 0 17 24z" fill="#173647"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsHooked API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "../api.json",
dom_id: "#swagger-ui",
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset,
],
layout: "BaseLayout",
deepLinking: true,
tryItOutEnabled: true,
});
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
@@ -11,11 +11,13 @@ import UsersPage from './pages/UsersPage';
import HooksPage from './pages/HooksPage'; import HooksPage from './pages/HooksPage';
import AccountsPage from './pages/AccountsPage'; import AccountsPage from './pages/AccountsPage';
import EventLogsPage from './pages/EventLogsPage'; import EventLogsPage from './pages/EventLogsPage';
import MessageCachePage from './pages/MessageCachePage';
import SendMessagePage from './pages/SendMessagePage'; import SendMessagePage from './pages/SendMessagePage';
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage'; import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
import TemplateManagementPage from './pages/TemplateManagementPage'; import TemplateManagementPage from './pages/TemplateManagementPage';
import CatalogManagementPage from './pages/CatalogManagementPage'; import CatalogManagementPage from './pages/CatalogManagementPage';
import FlowManagementPage from './pages/FlowManagementPage'; import FlowManagementPage from './pages/FlowManagementPage';
const SwaggerPage = lazy(() => import('./pages/SwaggerPage'));
// Import Mantine styles // Import Mantine styles
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
@@ -55,6 +57,12 @@ function App() {
<Route path="flows" element={<FlowManagementPage />} /> <Route path="flows" element={<FlowManagementPage />} />
<Route path="send-message" element={<SendMessagePage />} /> <Route path="send-message" element={<SendMessagePage />} />
<Route path="event-logs" element={<EventLogsPage />} /> <Route path="event-logs" element={<EventLogsPage />} />
<Route path="message-cache" element={<MessageCachePage />} />
<Route path="sw" element={
<Suspense fallback={null}>
<SwaggerPage />
</Suspense>
} />
</Route> </Route>
{/* Catch all */} {/* Catch all */}

View File

@@ -8,7 +8,7 @@ import {
Button, Button,
Avatar, Avatar,
Stack, Stack,
Badge, Image,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
@@ -22,6 +22,7 @@ import {
IconCategory, IconCategory,
IconArrowsShuffle, IconArrowsShuffle,
IconFileText, IconFileText,
IconDatabase,
IconLogout, IconLogout,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
@@ -40,6 +41,14 @@ export default function DashboardLayout() {
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
const isAnyActive = (paths: string[]) => const isAnyActive = (paths: string[]) =>
paths.some((path) => location.pathname === path); paths.some((path) => location.pathname === path);
const displayName =
user?.username?.trim() ||
user?.full_name?.trim() ||
user?.email?.trim() ||
"User";
const displayInitial = displayName[0]?.toUpperCase() || "U";
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
const swaggerIconSrc = `${import.meta.env.BASE_URL}swagger-icon.svg`;
return ( return (
<AppShell <AppShell
@@ -60,19 +69,17 @@ export default function DashboardLayout() {
hiddenFrom="sm" hiddenFrom="sm"
size="sm" size="sm"
/> />
<Image src={logoSrc} alt="WhatsHooked logo" w={24} h={24} fit="contain" />
<Text size="xl" fw={700}> <Text size="xl" fw={700}>
WhatsHooked WhatsHooked
</Text> </Text>
<Badge color="blue" variant="light">
Admin
</Badge>
</Group> </Group>
<Group> <Group>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{user?.username || "User"} {displayName}
</Text> </Text>
<Avatar color="blue" radius="xl" size="sm"> <Avatar color="blue" radius="xl" size="sm">
{user?.username?.[0]?.toUpperCase() || "U"} {displayInitial}
</Avatar> </Avatar>
</Group> </Group>
</Group> </Group>
@@ -222,6 +229,32 @@ export default function DashboardLayout() {
if (opened) toggle(); if (opened) toggle();
}} }}
/> />
<NavLink
href="/message-cache"
label="Message Cache"
leftSection={
<IconDatabase size={20} stroke={1.5} color="indigo" />
}
active={isActive("/message-cache")}
onClick={(e) => {
e.preventDefault();
navigate("/message-cache");
if (opened) toggle();
}}
/>
<NavLink
href="/sw"
label="Swagger"
leftSection={
<Image src={swaggerIconSrc} alt="Swagger" w={18} h={18} fit="contain" />
}
active={isActive("/sw")}
onClick={(e) => {
e.preventDefault();
navigate("/sw");
if (opened) toggle();
}}
/>
</Stack> </Stack>
</AppShell.Section> </AppShell.Section>
@@ -230,7 +263,7 @@ export default function DashboardLayout() {
<Group justify="space-between" px="sm"> <Group justify="space-between" px="sm">
<div> <div>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{user?.username || "User"} {displayName}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{user?.role || "user"} {user?.role || "user"}

View File

@@ -15,6 +15,10 @@ import type {
APIKey, APIKey,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
MessageCacheListResponse,
MessageCacheStats,
SystemStats,
WhatsAppAccountRuntimeStatus,
} from "../types"; } from "../types";
function getApiBaseUrl(): string { function getApiBaseUrl(): string {
@@ -26,6 +30,47 @@ function getApiBaseUrl(): string {
const API_BASE_URL = getApiBaseUrl(); const API_BASE_URL = getApiBaseUrl();
function normalizeUser(raw: unknown): User | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
const resolvedUsername =
(typeof value.username === "string" && value.username) ||
(typeof value.user_name === "string" && value.user_name) ||
(typeof value.full_name === "string" && value.full_name) ||
(typeof value.email === "string" && value.email.split("@")[0]) ||
"User";
const resolvedRole =
(typeof value.role === "string" && value.role) ||
(Array.isArray(value.roles) && typeof value.roles[0] === "string" && value.roles[0]) ||
"user";
const claims =
value.claims && typeof value.claims === "object"
? (value.claims as Record<string, unknown>)
: null;
const resolvedID =
(typeof value.id === "string" && value.id) ||
(typeof value.user_id === "string" && value.user_id) ||
(claims && typeof claims.user_id === "string" && claims.user_id) ||
"0";
const normalized: User = {
id: resolvedID,
username: resolvedUsername,
email: typeof value.email === "string" ? value.email : "",
full_name: typeof value.full_name === "string" ? value.full_name : undefined,
role: resolvedRole === "admin" ? "admin" : "user",
active: typeof value.active === "boolean" ? value.active : true,
created_at: typeof value.created_at === "string" ? value.created_at : "",
updated_at: typeof value.updated_at === "string" ? value.updated_at : "",
};
return normalized;
}
class ApiClient { class ApiClient {
private client: AxiosInstance; private client: AxiosInstance;
@@ -82,7 +127,11 @@ class ApiClient {
); );
if (data.token) { if (data.token) {
this.setToken(data.token); this.setToken(data.token);
localStorage.setItem("user", JSON.stringify(data.user)); const normalizedUser = normalizeUser(data.user);
if (normalizedUser) {
localStorage.setItem("user", JSON.stringify(normalizedUser));
data.user = normalizedUser;
}
} }
return data; return data;
} }
@@ -97,7 +146,12 @@ class ApiClient {
getCurrentUser(): User | null { getCurrentUser(): User | null {
const userStr = localStorage.getItem("user"); const userStr = localStorage.getItem("user");
return userStr ? JSON.parse(userStr) : null; if (!userStr) return null;
try {
return normalizeUser(JSON.parse(userStr));
} catch {
return null;
}
} }
isAuthenticated(): boolean { isAuthenticated(): boolean {
@@ -213,6 +267,13 @@ class ApiClient {
return data; return data;
} }
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> {
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>(
"/api/accounts/status",
);
return data;
}
async addAccountConfig( async addAccountConfig(
account: WhatsAppAccountConfig, account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> { ): Promise<{ status: string; account_id: string }> {
@@ -377,6 +438,73 @@ class ApiClient {
return data; return data;
} }
// Message cache API
async getMessageCacheEvents(params?: {
limit?: number;
offset?: number;
eventType?: string;
}): Promise<MessageCacheListResponse> {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set("limit", String(params.limit));
if (params?.offset !== undefined) searchParams.set("offset", String(params.offset));
if (params?.eventType) searchParams.set("event_type", params.eventType);
const query = searchParams.toString();
const url = query ? `/api/cache?${query}` : "/api/cache";
const { data } = await this.client.get<MessageCacheListResponse>(url);
return data;
}
async getMessageCacheStats(): Promise<MessageCacheStats> {
const { data } = await this.client.get<MessageCacheStats>("/api/cache/stats");
return data;
}
async replayCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
const { data } = await this.client.post<{ success: boolean; event_id: string; message: string }>(
`/api/cache/event/replay?id=${encodeURIComponent(id)}`,
);
return data;
}
async deleteCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
const { data } = await this.client.delete<{ success: boolean; event_id: string; message: string }>(
`/api/cache/event/delete?id=${encodeURIComponent(id)}`,
);
return data;
}
async replayAllCachedEvents(): Promise<{
success: boolean;
replayed: number;
delivered: number;
failed: number;
remaining_cached: number;
}> {
const { data } = await this.client.post<{
success: boolean;
replayed: number;
delivered: number;
failed: number;
remaining_cached: number;
}>("/api/cache/replay");
return data;
}
async clearMessageCache(): Promise<{ success: boolean; cleared: number; message: string }> {
const { data } = await this.client.delete<{ success: boolean; cleared: number; message: string }>(
"/api/cache/clear?confirm=true",
);
return data;
}
async getQRCode(accountId: string): Promise<Blob> {
const { data } = await this.client.get<Blob>(`/api/qr/${encodeURIComponent(accountId)}`, {
responseType: "blob",
});
return data;
}
// API Keys API // API Keys API
async getAPIKeys(): Promise<APIKey[]> { async getAPIKeys(): Promise<APIKey[]> {
const { data } = await this.client.get<APIKey[]>("/api/v1/api_keys"); const { data } = await this.client.get<APIKey[]>("/api/v1/api_keys");
@@ -397,6 +525,11 @@ class ApiClient {
const { data } = await this.client.get<{ status: string }>("/health"); const { data } = await this.client.get<{ status: string }>("/health");
return data; return data;
} }
async getSystemStats(): Promise<SystemStats> {
const { data } = await this.client.get<SystemStats>("/api/v1/system/stats");
return data;
}
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Container, Container,
Title, Title,
@@ -10,74 +10,127 @@ import {
Modal, Modal,
TextInput, TextInput,
Select, Select,
Textarea,
Checkbox, Checkbox,
Stack, Stack,
Alert, Alert,
Loader, Loader,
Center, Center,
ActionIcon ActionIcon,
Code,
Anchor
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react'; import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp, IconQrcode } from '@tabler/icons-react';
import { apiClient } from '../lib/api'; import { apiClient } from '../lib/api';
import type { WhatsAppAccount, WhatsAppAccountConfig } from '../types'; import type { BusinessAPIConfig, WhatsAppAccount, WhatsAppAccountConfig } from '../types';
function buildSessionPath(accountId: string) { function buildSessionPath(accountId: string) {
return `./sessions/${accountId}`; return `./sessions/${accountId}`;
} }
function toPrettyJSON(value: unknown) {
return JSON.stringify(value, null, 2);
}
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] { function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
return [...accounts].sort((a, b) => return [...accounts].sort((a, b) =>
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }), (a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
); );
} }
function mergeAccounts( function mapConfigToAccount(configuredAccount: WhatsAppAccountConfig): WhatsAppAccount {
configuredAccounts: WhatsAppAccountConfig[],
databaseAccounts: WhatsAppAccount[],
): WhatsAppAccount[] {
const databaseAccountsById = new Map(
databaseAccounts.map((account) => [account.id, account]),
);
const mergedAccounts = configuredAccounts.map((configuredAccount) => {
const databaseAccount = databaseAccountsById.get(configuredAccount.id);
return { return {
...databaseAccount,
id: configuredAccount.id, id: configuredAccount.id,
account_id: configuredAccount.id, account_id: configuredAccount.id,
user_id: databaseAccount?.user_id || '', user_id: '',
phone_number: configuredAccount.phone_number || databaseAccount?.phone_number || '', phone_number: configuredAccount.phone_number || '',
display_name: databaseAccount?.display_name || '', display_name: '',
account_type: configuredAccount.type || databaseAccount?.account_type || 'whatsmeow', account_type: configuredAccount.type || 'whatsmeow',
status: databaseAccount?.status || 'disconnected', status: configuredAccount.status || 'disconnected',
config: configuredAccount.business_api show_qr: configuredAccount.show_qr,
? toPrettyJSON(configuredAccount.business_api) business_api: configuredAccount.business_api,
: (databaseAccount?.config || ''), config: configuredAccount.business_api ? JSON.stringify(configuredAccount.business_api, null, 2) : '',
session_path: configuredAccount.session_path || databaseAccount?.session_path || buildSessionPath(configuredAccount.id), session_path: configuredAccount.session_path || buildSessionPath(configuredAccount.id),
last_connected_at: databaseAccount?.last_connected_at, last_connected_at: undefined,
active: !configuredAccount.disabled, active: !configuredAccount.disabled,
created_at: databaseAccount?.created_at || '', created_at: '',
updated_at: databaseAccount?.updated_at || '', updated_at: '',
}; };
}); }
const configuredIds = new Set(configuredAccounts.map((account) => account.id)); type BusinessAPIFormData = {
const orphanedDatabaseAccounts = databaseAccounts phone_number_id: string;
.filter((account) => !configuredIds.has(account.id)) access_token: string;
.map((account) => ({ waba_id: string;
...account, business_account_id: string;
account_id: account.account_id || account.id, api_version: string;
})); webhook_path: string;
verify_token: string;
};
return sortAccountsAlphabetically([...mergedAccounts, ...orphanedDatabaseAccounts]); function emptyBusinessAPIFormData(): BusinessAPIFormData {
return {
phone_number_id: '',
access_token: '',
waba_id: '',
business_account_id: '',
api_version: 'v21.0',
webhook_path: '',
verify_token: '',
};
}
function toBusinessAPIFormData(config?: BusinessAPIConfig): BusinessAPIFormData {
return {
phone_number_id: typeof config?.phone_number_id === 'string' ? config.phone_number_id : '',
access_token: typeof config?.access_token === 'string' ? config.access_token : '',
waba_id: typeof config?.waba_id === 'string' ? config.waba_id : '',
business_account_id: typeof config?.business_account_id === 'string' ? config.business_account_id : '',
api_version: typeof config?.api_version === 'string' && config.api_version ? config.api_version : 'v21.0',
webhook_path: typeof config?.webhook_path === 'string' ? config.webhook_path : '',
verify_token: typeof config?.verify_token === 'string' ? config.verify_token : '',
};
}
function fromBusinessAPIFormData(form: BusinessAPIFormData): BusinessAPIConfig {
const payload: BusinessAPIConfig = {};
const phoneNumberID = form.phone_number_id.trim();
const accessToken = form.access_token.trim();
const wabaID = form.waba_id.trim();
const businessAccountID = form.business_account_id.trim();
const apiVersion = form.api_version.trim();
const webhookPath = form.webhook_path.trim();
const verifyToken = form.verify_token.trim();
if (phoneNumberID) payload.phone_number_id = phoneNumberID;
if (accessToken) payload.access_token = accessToken;
if (wabaID) payload.waba_id = wabaID;
if (businessAccountID) payload.business_account_id = businessAccountID;
if (apiVersion) payload.api_version = apiVersion;
if (webhookPath) payload.webhook_path = webhookPath;
if (verifyToken) payload.verify_token = verifyToken;
return payload;
}
function parseLegacyBusinessAPIConfig(configJSON?: string): BusinessAPIConfig | undefined {
if (!configJSON) {
return undefined;
}
try {
const parsed = JSON.parse(configJSON);
if (!parsed || typeof parsed !== 'object') {
return undefined;
}
return parsed as BusinessAPIConfig;
} catch {
return undefined;
}
}
function getConnectionStatus(
account: WhatsAppAccount,
): string {
return account.status;
} }
export default function AccountsPage() { export default function AccountsPage() {
@@ -85,36 +138,49 @@ export default function AccountsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [qrModalOpened, { open: openQRModal, close: closeQRModal }] = useDisclosure(false);
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null); const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
const [selectedQRAccount, setSelectedQRAccount] = useState<WhatsAppAccount | null>(null);
const [qrImageError, setQRImageError] = useState<string | null>(null);
const [qrRefreshTick, setQRRefreshTick] = useState(0);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
account_id: '', account_id: '',
phone_number: '', phone_number: '',
display_name: '', display_name: '',
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api', account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
config: '', business_api: emptyBusinessAPIFormData(),
active: true active: true
}); });
useEffect(() => { const loadAccounts = useCallback(async (showLoader = true) => {
loadAccounts();
}, []);
const loadAccounts = async () => {
try { try {
if (showLoader) {
setLoading(true); setLoading(true);
const [configuredAccounts, databaseAccounts] = await Promise.all([ }
apiClient.getAccountConfigs(), const configuredAccounts = await apiClient.getAccountConfigs();
apiClient.getAccounts(), setAccounts(sortAccountsAlphabetically((configuredAccounts || []).map(mapConfigToAccount)));
]);
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
setError(null); setError(null);
} catch (err) { } catch (err) {
setError('Failed to load accounts'); setError('Failed to load accounts');
console.error(err); console.error(err);
} finally { } finally {
if (showLoader) {
setLoading(false); setLoading(false);
} }
}; }
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
useEffect(() => {
const interval = setInterval(() => {
void loadAccounts(false);
}, 5000);
return () => clearInterval(interval);
}, [loadAccounts]);
const handleCreate = () => { const handleCreate = () => {
setEditingAccount(null); setEditingAccount(null);
@@ -123,7 +189,7 @@ export default function AccountsPage() {
phone_number: '', phone_number: '',
display_name: '', display_name: '',
account_type: 'whatsmeow', account_type: 'whatsmeow',
config: '', business_api: emptyBusinessAPIFormData(),
active: true active: true
}); });
open(); open();
@@ -131,12 +197,13 @@ export default function AccountsPage() {
const handleEdit = (account: WhatsAppAccount) => { const handleEdit = (account: WhatsAppAccount) => {
setEditingAccount(account); setEditingAccount(account);
const accountBusinessAPI = account.business_api || parseLegacyBusinessAPIConfig(account.config);
setFormData({ setFormData({
account_id: account.account_id || account.id || '', account_id: account.account_id || account.id || '',
phone_number: account.phone_number, phone_number: account.phone_number,
display_name: account.display_name || '', display_name: account.display_name || '',
account_type: account.account_type, account_type: account.account_type,
config: account.config || '', business_api: toBusinessAPIFormData(accountBusinessAPI),
active: account.active active: account.active
}); });
open(); open();
@@ -164,22 +231,50 @@ export default function AccountsPage() {
} }
}; };
const getQRCodePath = (accountId: string) => `/api/qr/${encodeURIComponent(accountId)}`;
const getQRCodeUrl = (accountId: string) => `${window.location.origin}${getQRCodePath(accountId)}`;
const handleOpenQRModal = (account: WhatsAppAccount) => {
setSelectedQRAccount(account);
setQRImageError(null);
setQRRefreshTick(0);
openQRModal();
};
const handleCloseQRModal = () => {
setSelectedQRAccount(null);
setQRImageError(null);
setQRRefreshTick(0);
closeQRModal();
};
useEffect(() => {
if (!qrModalOpened || !selectedQRAccount) {
return;
}
const refreshInterval = setInterval(() => {
setQRRefreshTick((previous) => previous + 1);
setQRImageError(null);
}, 4000);
return () => clearInterval(refreshInterval);
}, [qrModalOpened, selectedQRAccount]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const accountId = (editingAccount?.id || formData.account_id).trim(); const accountId = (editingAccount?.id || formData.account_id).trim();
const parsedConfig = formData.config ? (() => { const businessAPIPayload = fromBusinessAPIFormData(formData.business_api);
try {
return JSON.parse(formData.config);
} catch {
return null;
}
})() : null;
if (formData.config && parsedConfig === null) { if (
formData.account_type === 'business-api' &&
(!businessAPIPayload.phone_number_id || !businessAPIPayload.access_token)
) {
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: 'Config must be valid JSON', message: 'Phone Number ID and Access Token are required for Business API accounts',
color: 'red', color: 'red',
}); });
return; return;
@@ -194,8 +289,8 @@ export default function AccountsPage() {
disabled: !formData.active, disabled: !formData.active,
}; };
if (formData.account_type === 'business-api' && parsedConfig) { if (formData.account_type === 'business-api') {
payload.business_api = parsedConfig; payload.business_api = businessAPIPayload;
} }
if (editingAccount) { if (editingAccount) {
@@ -229,6 +324,7 @@ export default function AccountsPage() {
switch (status) { switch (status) {
case 'connected': return 'green'; case 'connected': return 'green';
case 'connecting': return 'yellow'; case 'connecting': return 'yellow';
case 'pairing': return 'yellow';
case 'disconnected': return 'red'; case 'disconnected': return 'red';
default: return 'gray'; default: return 'gray';
} }
@@ -250,7 +346,7 @@ export default function AccountsPage() {
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md"> <Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error} {error}
</Alert> </Alert>
<Button onClick={loadAccounts}>Retry</Button> <Button onClick={() => loadAccounts()}>Retry</Button>
</Container> </Container>
); );
} }
@@ -293,19 +389,21 @@ export default function AccountsPage() {
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
) : ( ) : (
accounts.map((account) => ( accounts.map((account) => {
const connectionStatus = getConnectionStatus(account);
return (
<Table.Tr key={account.id}> <Table.Tr key={account.id}>
<Table.Td fw={500}>{account.account_id || '-'}</Table.Td> <Table.Td fw={500}>{account.account_id || '-'}</Table.Td>
<Table.Td>{account.phone_number || '-'}</Table.Td> <Table.Td>{account.phone_number || '-'}</Table.Td>
<Table.Td>{account.display_name || '-'}</Table.Td> <Table.Td>{account.display_name || '-'}</Table.Td>
<Table.Td> <Table.Td>
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light"> <Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
{account.account_type === 'whatsmeow' ? 'WhatsApp' : 'Business API'} {account.account_type === 'whatsmeow' ? 'Whatsapp' : 'Meta Business API'}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge color={getStatusColor(account.status)} variant="light"> <Badge color={getStatusColor(connectionStatus)} variant="light">
{account.status} {connectionStatus}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
@@ -320,6 +418,16 @@ export default function AccountsPage() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
{account.account_type === 'whatsmeow' && (
<ActionIcon
variant="light"
color="teal"
onClick={() => handleOpenQRModal(account)}
title="View QR code"
>
<IconQrcode size={16} />
</ActionIcon>
)}
<ActionIcon <ActionIcon
variant="light" variant="light"
color="blue" color="blue"
@@ -337,7 +445,8 @@ export default function AccountsPage() {
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)) );
})
)} )}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
@@ -385,28 +494,106 @@ export default function AccountsPage() {
value={formData.account_type} value={formData.account_type}
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })} onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
data={[ data={[
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' }, { value: 'whatsmeow', label: 'Whatsapp' },
{ value: 'business-api', label: 'Business API' } { value: 'business-api', label: 'Meta Business API' }
]} ]}
required required
disabled={!!editingAccount} disabled={!!editingAccount}
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API" description="Whatsapp: Personal/WhatsApp Business app connection. Meta Business API: Official WhatsApp Business API"
/> />
{formData.account_type === 'business-api' && ( {formData.account_type === 'business-api' && (
<Textarea <>
label="Business API Config (JSON)" <TextInput
placeholder={`{ label="Phone Number ID"
"api_key": "your-api-key", placeholder="123456789012345"
"api_url": "https://api.whatsapp.com", value={formData.business_api.phone_number_id}
"phone_number_id": "123456" onChange={(e) =>
}`} setFormData({
value={formData.config} ...formData,
onChange={(e) => setFormData({ ...formData, config: e.target.value })} business_api: { ...formData.business_api, phone_number_id: e.target.value },
rows={6} })
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }} }
description="Business API credentials and configuration" required
description="Required Meta phone number identifier"
/> />
<TextInput
label="Access Token"
placeholder="EAAG..."
type="password"
value={formData.business_api.access_token}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, access_token: e.target.value },
})
}
required
description="Required WhatsApp Business API token"
/>
<TextInput
label="WABA ID"
placeholder="Optional (resolved automatically when omitted)"
value={formData.business_api.waba_id}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, waba_id: e.target.value },
})
}
/>
<TextInput
label="Business Account ID"
placeholder="Optional Facebook Business Manager ID"
value={formData.business_api.business_account_id}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, business_account_id: e.target.value },
})
}
/>
<TextInput
label="API Version"
placeholder="v21.0"
value={formData.business_api.api_version}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, api_version: e.target.value },
})
}
description="Defaults to v21.0 if empty"
/>
<TextInput
label="Webhook Path"
placeholder="/webhooks/whatsapp/{account}"
value={formData.business_api.webhook_path}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, webhook_path: e.target.value },
})
}
/>
<TextInput
label="Verify Token"
placeholder="Optional webhook verification token"
value={formData.business_api.verify_token}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, verify_token: e.target.value },
})
}
/>
</>
)} )}
<Checkbox <Checkbox
@@ -422,6 +609,43 @@ export default function AccountsPage() {
</Stack> </Stack>
</form> </form>
</Modal> </Modal>
<Modal
opened={qrModalOpened}
onClose={handleCloseQRModal}
title={`QR Code: ${selectedQRAccount?.account_id || selectedQRAccount?.id || ''}`}
size="lg"
>
<Stack>
{selectedQRAccount && (
<>
<Text size="sm" c="dimmed">QR image URL</Text>
<Code block>{getQRCodeUrl(selectedQRAccount.id)}</Code>
<Anchor
href={getQRCodePath(selectedQRAccount.id)}
target="_blank"
rel="noopener noreferrer"
>
Open QR image in new tab
</Anchor>
{qrImageError ? (
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="QR unavailable">
{qrImageError}
</Alert>
) : (
<img
src={`${getQRCodePath(selectedQRAccount.id)}?t=${qrRefreshTick}`}
alt={`QR code for account ${selectedQRAccount.account_id || selectedQRAccount.id}`}
style={{ width: '100%', maxWidth: 420, alignSelf: 'center', borderRadius: 8 }}
onError={() =>
setQRImageError('No QR code available. The account may already be connected or pairing has not started yet.')
}
/>
)}
</>
)}
</Stack>
</Modal>
</Container> </Container>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, memo } from 'react';
import { import {
Container, Container,
Title, Title,
@@ -9,33 +9,48 @@ import {
ThemeIcon, ThemeIcon,
Loader, Loader,
Center, Center,
Stack Stack,
Image,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconUsers, IconUsers,
IconWebhook, IconWebhook,
IconBrandWhatsapp, IconBrandWhatsapp,
IconFileText IconFileText,
IconDatabase,
IconCpu,
IconDeviceDesktopAnalytics,
IconNetwork,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { apiClient } from '../lib/api'; import { apiClient } from '../lib/api';
interface Stats { interface DashboardStats {
users: number; users: number;
hooks: number; hooks: number;
accounts: number; accounts: number;
eventLogs: number; eventLogs: number;
messageCacheEnabled: boolean;
messageCacheCount: number;
} }
function StatCard({ interface RuntimeStats {
goMemoryMB: number;
goCPUPercent: number;
networkBytesPerSec: number;
}
const StatCard = memo(function StatCard({
title, title,
value, value,
icon: Icon, icon: Icon,
color color,
valueColor,
}: { }: {
title: string; title: string;
value: number; value: number | string;
icon: any; icon: any;
color: string; color: string;
valueColor?: string;
}) { }) {
return ( return (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
@@ -44,8 +59,8 @@ function StatCard({
<Text c="dimmed" tt="uppercase" fw={700} fz="xs"> <Text c="dimmed" tt="uppercase" fw={700} fz="xs">
{title} {title}
</Text> </Text>
<Text fw={700} fz="xl" mt="md"> <Text fw={700} fz="xl" mt="md" c={valueColor}>
{value.toLocaleString()} {typeof value === 'number' ? value.toLocaleString() : value}
</Text> </Text>
</div> </div>
<ThemeIcon <ThemeIcon
@@ -59,51 +74,74 @@ function StatCard({
</Group> </Group>
</Paper> </Paper>
); );
} });
export default function DashboardPage() { export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({ const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
const [stats, setStats] = useState<DashboardStats>({
users: 0, users: 0,
hooks: 0, hooks: 0,
accounts: 0, accounts: 0,
eventLogs: 0 eventLogs: 0,
messageCacheEnabled: false,
messageCacheCount: 0,
});
const [runtimeStats, setRuntimeStats] = useState<RuntimeStats>({
goMemoryMB: 0,
goCPUPercent: 0,
networkBytesPerSec: 0,
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
loadStats(); loadDashboardStats();
const intervalID = window.setInterval(loadDashboardStats, 60000);
return () => window.clearInterval(intervalID);
}, []); }, []);
const loadStats = async () => { useEffect(() => {
loadRuntimeStats();
const intervalID = window.setInterval(loadRuntimeStats, 5000);
return () => window.clearInterval(intervalID);
}, []);
const loadDashboardStats = async () => {
try { try {
setLoading(true); setLoading(true);
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([ const [usersResult, hooksResult, accountsResult, eventLogsResult, messageCacheResult] = await Promise.allSettled([
apiClient.getUsers(), apiClient.getUsers(),
apiClient.getHooks(), apiClient.getHooks(),
apiClient.getAccounts(), apiClient.getAccounts(),
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' }) apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' }),
apiClient.getMessageCacheStats(),
]); ]);
const users = usersResult.status === 'fulfilled' ? usersResult.value : []; const users = usersResult.status === 'fulfilled' ? usersResult.value : [];
const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : []; const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : []; const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null; const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null;
const messageCache = messageCacheResult.status === 'fulfilled' ? messageCacheResult.value : null;
const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0; const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0;
const messageCacheEnabled = !!messageCache?.enabled;
const messageCacheCount = messageCache?.total_count ?? messageCache?.count ?? 0;
setStats({ setStats({
users: users?.length || 0, users: users?.length || 0,
hooks: hooks?.length || 0, hooks: hooks?.length || 0,
accounts: accounts?.length || 0, accounts: accounts?.length || 0,
eventLogs: eventLogCount eventLogs: eventLogCount,
messageCacheEnabled,
messageCacheCount,
}); });
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected') { if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected' || messageCacheResult.status === 'rejected') {
console.error('One or more dashboard stats failed to load', { console.error('One or more dashboard stats failed to load', {
users: usersResult.status === 'rejected' ? usersResult.reason : null, users: usersResult.status === 'rejected' ? usersResult.reason : null,
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null, hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null, accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null, eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
messageCache: messageCacheResult.status === 'rejected' ? messageCacheResult.reason : null,
}); });
} }
} catch (err) { } catch (err) {
@@ -113,6 +151,19 @@ export default function DashboardPage() {
} }
}; };
const loadRuntimeStats = async () => {
try {
const systemStats = await apiClient.getSystemStats();
setRuntimeStats({
goMemoryMB: Number(systemStats?.go_memory_mb ?? 0),
goCPUPercent: Number(systemStats?.go_cpu_percent ?? 0),
networkBytesPerSec: Number(systemStats?.network_bytes_per_sec ?? 0),
});
} catch (err) {
console.error('Failed to load runtime stats:', err);
}
};
if (loading) { if (loading) {
return ( return (
<Container size="xl" py="xl"> <Container size="xl" py="xl">
@@ -127,11 +178,12 @@ export default function DashboardPage() {
<Container size="xl" py="xl"> <Container size="xl" py="xl">
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Image src={logoSrc} alt="WhatsHooked logo" w={120} h={120} fit="contain" mb="sm" />
<Title order={2}>Dashboard</Title> <Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text> <Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
</div> </div>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }}>
<StatCard <StatCard
title="Total Users" title="Total Users"
value={stats.users} value={stats.users}
@@ -156,6 +208,31 @@ export default function DashboardPage() {
icon={IconFileText} icon={IconFileText}
color="violet" color="violet"
/> />
<StatCard
title="Message Cache"
value={stats.messageCacheEnabled ? stats.messageCacheCount : 'Disabled'}
icon={IconDatabase}
color={stats.messageCacheEnabled ? 'green' : 'gray'}
valueColor={stats.messageCacheEnabled ? undefined : 'dimmed'}
/>
<StatCard
title="Go Memory"
value={`${runtimeStats.goMemoryMB.toFixed(2)} MB`}
icon={IconDeviceDesktopAnalytics}
color="cyan"
/>
<StatCard
title="Go CPU"
value={`${runtimeStats.goCPUPercent.toFixed(2)}%`}
icon={IconCpu}
color="orange"
/>
<StatCard
title="Network Throughput"
value={`${(runtimeStats.networkBytesPerSec / 1024).toFixed(2)} KB/s`}
icon={IconNetwork}
color="indigo"
/>
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Container> </Container>

View File

@@ -0,0 +1,444 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Title,
Text,
Table,
Badge,
Group,
Alert,
Loader,
Center,
Stack,
TextInput,
Modal,
Code,
Button,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconAlertCircle,
IconDatabase,
IconSearch,
IconPlayerPlay,
IconTrash,
IconRefresh,
IconTrashX,
} from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { MessageCacheEvent, MessageCacheStats } from '../types';
import type { AxiosError } from 'axios';
const ITEMS_PER_PAGE = 50;
function getApiErrorMessage(err: unknown, fallback: string): string {
const axiosErr = err as AxiosError<unknown>;
const responseData = axiosErr?.response?.data;
if (typeof responseData === 'string' && responseData.trim() !== '') {
return responseData.trim();
}
if (responseData && typeof responseData === 'object') {
const maybeMessage = (responseData as { message?: unknown }).message;
const maybeError = (responseData as { error?: unknown }).error;
if (typeof maybeMessage === 'string' && maybeMessage.trim() !== '') {
return maybeMessage.trim();
}
if (typeof maybeError === 'string' && maybeError.trim() !== '') {
return maybeError.trim();
}
}
if (err instanceof Error && err.message.trim() !== '') {
return err.message;
}
return fallback;
}
export default function MessageCachePage() {
const [cachedEvents, setCachedEvents] = useState<MessageCacheEvent[]>([]);
const [stats, setStats] = useState<MessageCacheStats | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [totalFilteredCount, setTotalFilteredCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [eventTypeQuery, setEventTypeQuery] = useState('');
const [debouncedEventTypeQuery, setDebouncedEventTypeQuery] = useState('');
const [modalTitle, setModalTitle] = useState('Cached Event Data');
const [modalContent, setModalContent] = useState('');
const [dataModalOpened, { open: openDataModal, close: closeDataModal }] = useDisclosure(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timer = setTimeout(() => setDebouncedEventTypeQuery(eventTypeQuery.trim()), 350);
return () => clearTimeout(timer);
}, [eventTypeQuery]);
const loadStats = useCallback(async () => {
try {
const cacheStats = await apiClient.getMessageCacheStats();
setStats(cacheStats);
} catch (err) {
console.error(err);
}
}, []);
const loadCachePage = useCallback(async (targetOffset: number, reset: boolean) => {
try {
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
const result = await apiClient.getMessageCacheEvents({
limit: ITEMS_PER_PAGE,
offset: targetOffset,
eventType: debouncedEventTypeQuery || undefined,
});
const pageData = result.cached_events || [];
const nextOffset = targetOffset + pageData.length;
if (reset) {
setCachedEvents(pageData);
} else {
setCachedEvents((previousEvents) => [...previousEvents, ...pageData]);
}
setOffset(nextOffset);
setTotalFilteredCount(result.filtered_count);
setHasMore(nextOffset < result.filtered_count);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, 'Failed to load cached events'));
console.error(err);
} finally {
if (reset) {
setLoading(false);
} else {
setLoadingMore(false);
}
}
}, [debouncedEventTypeQuery]);
useEffect(() => {
setCachedEvents([]);
setOffset(0);
setHasMore(false);
setTotalFilteredCount(null);
loadCachePage(0, true);
loadStats();
}, [debouncedEventTypeQuery, loadCachePage, loadStats]);
useEffect(() => {
if (!sentinelRef.current || loading || loadingMore || !hasMore || error) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadCachePage(offset, false);
}
},
{ rootMargin: '250px' },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loading, loadingMore, hasMore, error, offset, loadCachePage]);
const refreshPage = async () => {
await Promise.all([loadCachePage(0, true), loadStats()]);
};
const openDetailsModal = (event: MessageCacheEvent) => {
setModalTitle(`Cached Event: ${event.event.type}`);
setModalContent(JSON.stringify(event, null, 2));
openDataModal();
};
const handleReplayEvent = async (id: string) => {
try {
setActionLoading(true);
await apiClient.replayCachedEvent(id);
notifications.show({ title: 'Success', message: 'Cached event replayed', color: 'green' });
await refreshPage();
} catch (err) {
notifications.show({ title: 'Error', message: 'Failed to replay cached event', color: 'red' });
console.error(err);
} finally {
setActionLoading(false);
}
};
const handleDeleteEvent = async (id: string) => {
if (!confirm('Delete this cached event?')) {
return;
}
try {
setActionLoading(true);
await apiClient.deleteCachedEvent(id);
notifications.show({ title: 'Success', message: 'Cached event deleted', color: 'green' });
await refreshPage();
} catch (err) {
notifications.show({ title: 'Error', message: 'Failed to delete cached event', color: 'red' });
console.error(err);
} finally {
setActionLoading(false);
}
};
const handleReplayAll = async () => {
if (!confirm('Replay all cached events now?')) {
return;
}
try {
setActionLoading(true);
const result = await apiClient.replayAllCachedEvents();
notifications.show({
title: 'Replay complete',
message: `Replayed ${result.replayed} events (${result.delivered} delivered, ${result.failed} failed)`,
color: 'green',
});
await refreshPage();
} catch (err) {
notifications.show({ title: 'Error', message: 'Failed to replay cached events', color: 'red' });
console.error(err);
} finally {
setActionLoading(false);
}
};
const handleClearAll = async () => {
if (!confirm('Clear all cached events? This cannot be undone.')) {
return;
}
try {
setActionLoading(true);
const result = await apiClient.clearMessageCache();
notifications.show({ title: 'Success', message: `Cleared ${result.cleared} cached events`, color: 'green' });
await refreshPage();
} catch (err) {
notifications.show({ title: 'Error', message: 'Failed to clear message cache', color: 'red' });
console.error(err);
} finally {
setActionLoading(false);
}
};
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
if (error) {
return (
<Container size="xl" py="xl">
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Button onClick={refreshPage}>Retry</Button>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Group justify="space-between" mb="xl" align="flex-start">
<div>
<Title order={2}>Message Cache</Title>
<Text c="dimmed" size="sm">Browse and manage cached webhook events with paged loading</Text>
<Text c="dimmed" size="sm" mt={4}>
Cache status: {stats?.enabled ? 'enabled' : 'disabled'}
{typeof stats?.total_count === 'number' ? ` • Total: ${stats.total_count}` : ''}
</Text>
</div>
<Group>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={refreshPage}
loading={actionLoading}
>
Refresh
</Button>
<Button
color="blue"
leftSection={<IconPlayerPlay size={16} />}
onClick={handleReplayAll}
loading={actionLoading}
disabled={cachedEvents.length === 0}
>
Replay All
</Button>
<Button
color="red"
variant="light"
leftSection={<IconTrashX size={16} />}
onClick={handleClearAll}
loading={actionLoading}
disabled={cachedEvents.length === 0}
>
Clear Cache
</Button>
</Group>
</Group>
<Group mb="md">
<TextInput
placeholder="Filter by event type (e.g. message.received)"
leftSection={<IconSearch size={16} />}
value={eventTypeQuery}
onChange={(e) => setEventTypeQuery(e.target.value)}
style={{ flex: 1 }}
/>
</Group>
<Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Cached At</Table.Th>
<Table.Th>Event Type</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th>Attempts</Table.Th>
<Table.Th>Last Attempt</Table.Th>
<Table.Th>Details</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{cachedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Center h={200}>
<Stack align="center">
<IconDatabase size={48} stroke={1.5} color="gray" />
<Text c="dimmed">
{debouncedEventTypeQuery ? 'No cached events match this filter' : 'No cached events'}
</Text>
</Stack>
</Center>
</Table.Td>
</Table.Tr>
) : (
cachedEvents.map((cachedEvent) => (
<Table.Tr key={cachedEvent.id}>
<Table.Td>
<Text size="sm">{new Date(cachedEvent.timestamp).toLocaleString()}</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{cachedEvent.event.type}</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{cachedEvent.reason || '-'}</Text>
</Table.Td>
<Table.Td>
<Badge color={cachedEvent.attempts > 0 ? 'yellow' : 'gray'} variant="light">
{cachedEvent.attempts}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{cachedEvent.last_attempt
? new Date(cachedEvent.last_attempt).toLocaleString()
: '-'}
</Text>
</Table.Td>
<Table.Td>
<Code
component="button"
onClick={() => openDetailsModal(cachedEvent)}
style={{ cursor: 'pointer', border: 'none' }}
>
View
</Code>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip label="Replay event">
<ActionIcon
variant="light"
color="blue"
onClick={() => handleReplayEvent(cachedEvent.id)}
loading={actionLoading}
>
<IconPlayerPlay size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete event">
<ActionIcon
variant="light"
color="red"
onClick={() => handleDeleteEvent(cachedEvent.id)}
loading={actionLoading}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
<div ref={sentinelRef} />
{loadingMore && (
<Center mt="lg">
<Loader size="sm" />
</Center>
)}
<Group justify="space-between" mt="md">
<Text size="sm" c="dimmed">
{totalFilteredCount !== null
? `Showing ${cachedEvents.length} of ${totalFilteredCount} cached events`
: `Showing ${cachedEvents.length} cached events`}
</Text>
{debouncedEventTypeQuery && (
<Text size="sm" c="dimmed">Filtered by: "{debouncedEventTypeQuery}"</Text>
)}
</Group>
<Modal
opened={dataModalOpened}
onClose={closeDataModal}
title={modalTitle}
fullScreen
>
<Code
component="pre"
block
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '90vh',
overflow: 'auto',
}}
>
{modalContent}
</Code>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,21 @@
import { Container, Stack, Title, Text } from "@mantine/core";
import SwaggerUI from "swagger-ui-react";
import "swagger-ui-react/swagger-ui.css";
export default function SwaggerPage() {
return (
<Container size="xl" py="xl">
<Stack gap="sm" mb="md">
<Title order={2}>Swagger</Title>
<Text c="dimmed" size="sm">
API documentation and live request testing.
</Text>
</Stack>
<SwaggerUI
url={`${import.meta.env.BASE_URL}openapi.json`}
deepLinking
tryItOutEnabled
/>
</Container>
);
}

View File

@@ -34,7 +34,9 @@ export interface WhatsAppAccount {
phone_number: string; phone_number: string;
display_name?: string; display_name?: string;
account_type: 'whatsmeow' | 'business-api'; account_type: 'whatsmeow' | 'business-api';
status: 'connected' | 'disconnected' | 'connecting'; status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
show_qr?: boolean;
business_api?: BusinessAPIConfig;
config?: string; // JSON string config?: string; // JSON string
session_path?: string; session_path?: string;
last_connected_at?: string; last_connected_at?: string;
@@ -197,9 +199,20 @@ export interface WhatsAppAccountConfig {
session_path?: string; session_path?: string;
show_qr?: boolean; show_qr?: boolean;
disabled?: boolean; disabled?: boolean;
status?: 'connected' | 'disconnected' | 'connecting' | 'pairing';
connected?: boolean;
qr_available?: boolean;
business_api?: BusinessAPIConfig; business_api?: BusinessAPIConfig;
} }
export interface WhatsAppAccountRuntimeStatus {
account_id: string;
type: string;
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
connected: boolean;
qr_available: boolean;
}
export interface EventLog { export interface EventLog {
id: string; id: string;
user_id?: string; user_id?: string;
@@ -215,6 +228,50 @@ export interface EventLog {
created_at: string; created_at: string;
} }
export interface MessageCacheEventPayload {
type: string;
timestamp: string;
data?: Record<string, unknown>;
}
export interface MessageCacheEvent {
id: string;
event: MessageCacheEventPayload;
timestamp: string;
reason: string;
attempts: number;
last_attempt?: string;
}
export interface MessageCacheListResponse {
cached_events: MessageCacheEvent[];
count: number;
filtered_count: number;
returned_count: number;
limit: number;
offset: number;
}
export interface MessageCacheStats {
enabled: boolean;
count?: number;
total_count?: number;
by_event_type?: Record<string, number>;
}
export interface SystemStats {
go_memory_bytes: number;
go_memory_mb: number;
go_sys_memory_bytes: number;
go_sys_memory_mb: number;
go_goroutines: number;
go_cpu_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
network_total_bytes: number;
network_bytes_per_sec: number;
}
export interface APIKey { export interface APIKey {
id: string; id: string;
user_id: string; user_id: string;

1
web/src/types/swagger-ui-react.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "swagger-ui-react";