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
/data/*
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`
**Request Body (WhatsApp Web/WhatsMe ow):**
**Request Body (Whatsapp):**
```json
{
@@ -420,7 +420,7 @@ INFO Skipping disabled account account_id=business
- Account will be reconnected automatically if enabled
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
- Clean up manually if needed

View File

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

View File

@@ -114,5 +114,12 @@
"file_dir": "./data/events",
"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"
}

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
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io/fs"
"math"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -41,6 +47,16 @@ type Server struct {
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
func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) {
// 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)
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET")
// Add custom routes (login, logout, etc.) on main router
SetupCustomRoutes(router, secProvider, db)
@@ -126,6 +143,179 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
}, 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
func (s *Server) Start() error {
return s.serverMgr.ServeWithGracefulShutdown()
@@ -171,6 +361,7 @@ func SetupWhatsAppRoutes(router *mux.Router, wh WhatsHookedInterface, distFS fs.
// Account management (with auth)
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/update", h.Auth(h.UpdateAccount)).Methods("POST")
router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST")

View File

@@ -1,21 +1,34 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"github.com/uptrace/bun"
)
const (
storageDatabase = "database"
storageDisk = "disk"
)
// CachedEvent represents an event stored in cache
type CachedEvent struct {
ID string `json:"id"`
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"`
Reason string `json:"reason"`
Attempts int `json:"attempts"`
@@ -28,6 +41,9 @@ type MessageCache struct {
mu sync.RWMutex
dataPath string
enabled bool
storage string
dbType string
db *bun.DB
maxAge time.Duration // Maximum age before events are purged
maxEvents int // Maximum number of events to keep
}
@@ -35,17 +51,47 @@ type MessageCache struct {
// Config holds cache configuration
type Config struct {
Enabled bool `json:"enabled"`
Storage string `json:"storage"`
DataPath string `json:"data_path"`
DBType string `json:"db_type"`
DB *bun.DB `json:"-"`
MaxAge time.Duration `json:"max_age"` // Default: 7 days
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
func NewMessageCache(cfg Config) (*MessageCache, error) {
if !cfg.Enabled {
return &MessageCache{
enabled: false,
}, nil
return &MessageCache{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 == "" {
@@ -58,22 +104,25 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
cfg.MaxEvents = 10000
}
// Create cache directory
if cfg.Storage == storageDisk {
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
}
cache := &MessageCache{
events: make(map[string]*CachedEvent),
dataPath: cfg.DataPath,
enabled: true,
storage: cfg.Storage,
dbType: strings.ToLower(cfg.DBType),
db: cfg.DB,
maxAge: cfg.MaxAge,
maxEvents: cfg.MaxEvents,
}
// Load existing cached events
if err := cache.loadFromDisk(); err != nil {
logging.Warn("Failed to load cached events from disk", "error", err)
if err := cache.loadPersistedEvents(); err != nil {
return nil, err
}
// Start cleanup goroutine
@@ -81,6 +130,7 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
logging.Info("Message cache initialized",
"enabled", cfg.Enabled,
"storage", cache.storage,
"data_path", cfg.DataPath,
"max_age", cfg.MaxAge,
"max_events", cfg.MaxEvents)
@@ -88,6 +138,22 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
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
func (c *MessageCache) Store(event events.Event, reason string) error {
if !c.enabled {
@@ -109,6 +175,20 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
cached := &CachedEvent{
ID: id,
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(),
Reason: reason,
Attempts: 0,
@@ -116,8 +196,8 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
c.events[id] = cached
// Save to disk asynchronously
go c.saveToDisk(cached)
// Persist asynchronously
go c.persistCachedEvent(cached)
logging.Debug("Event cached",
"event_id", id,
@@ -177,6 +257,53 @@ func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEven
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
func (c *MessageCache) Remove(id string) error {
if !c.enabled {
@@ -192,8 +319,8 @@ func (c *MessageCache) Remove(id string) error {
delete(c.events, id)
// Remove from disk
go c.removeFromDisk(id)
// Remove persisted record asynchronously
go c.removePersistedEvent(id)
logging.Debug("Event removed from cache", "event_id", id)
@@ -218,8 +345,8 @@ func (c *MessageCache) IncrementAttempts(id string) error {
cached.Attempts++
cached.LastAttempt = &now
// Update on disk
go c.saveToDisk(cached)
// Persist asynchronously
go c.persistCachedEvent(cached)
return nil
}
@@ -235,8 +362,8 @@ func (c *MessageCache) Clear() error {
c.events = make(map[string]*CachedEvent)
// Clear disk cache
go c.clearDisk()
// Clear persisted cache asynchronously
go c.clearPersistedEvents()
logging.Info("Message cache cleared")
@@ -274,7 +401,7 @@ func (c *MessageCache) removeOldest() {
if 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)
}
}
@@ -309,7 +436,7 @@ func (c *MessageCache) cleanup() {
for _, id := range expiredIDs {
delete(c.events, id)
go c.removeFromDisk(id)
go c.removePersistedEvent(id)
}
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
func (c *MessageCache) saveToDisk(cached *CachedEvent) {
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
type MessageCacheConfig struct {
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
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)
@@ -207,6 +208,9 @@ func Load(path string) (*Config, error) {
}
// Set message cache defaults
if cfg.MessageCache.Storage == "" {
cfg.MessageCache.Storage = "database"
}
if cfg.MessageCache.DataPath == "" {
cfg.MessageCache.DataPath = "./data/message_cache"
}

View File

@@ -10,10 +10,123 @@ import (
"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
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
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
@@ -35,6 +148,13 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
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
h.config.WhatsApp = append(h.config.WhatsApp, account)
if h.configPath != "" {
@@ -70,6 +190,13 @@ func (h *Handlers) RemoveAccount(w http.ResponseWriter, r *http.Request) {
// 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
found := false
newAccounts := make([]config.WhatsAppConfig, 0)
@@ -137,6 +264,13 @@ func (h *Handlers) DisableAccount(w http.ResponseWriter, r *http.Request) {
// Mark as disabled
h.config.WhatsApp[i].Disabled = true
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
}
}
@@ -207,6 +341,13 @@ func (h *Handlers) EnableAccount(w http.ResponseWriter, r *http.Request) {
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)
// 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 {
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

View File

@@ -25,17 +25,60 @@ func (h *Handlers) GetCachedEvents(w http.ResponseWriter, r *http.Request) {
// Optional event_type filter
eventType := r.URL.Query().Get("event_type")
limit := 0
offset := 0
limitProvided := false
offsetProvided := false
var cachedEvents interface{}
if eventType != "" {
cachedEvents = cache.ListByEventType(events.EventType(eventType))
} else {
cachedEvents = cache.List()
limitParam := r.URL.Query().Get("limit")
if limitParam != "" {
parsedLimit, err := strconv.Atoi(limitParam)
if err != nil {
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{}{
"cached_events": cachedEvents,
"count": cache.Count(),
"filtered_count": filteredCount,
"returned_count": len(cachedEvents),
"limit": limit,
"offset": offset,
})
}

View File

@@ -9,15 +9,18 @@ import (
type ModelPublicMessageCache struct {
bun.BaseModel `bun:"table:public.message_cache,alias:message_cache"`
ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID
AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(36),notnull," json:"account_id"`
ChatID resolvespec_common.SqlString `bun:"chat_id,type:varchar(255),notnull," json:"chat_id"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` // JSON encoded message content
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"`
ID resolvespec_common.SqlString `bun:"id,type:varchar(128),pk," json:"id"`
AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(64),notnull," json:"account_id"`
EventType resolvespec_common.SqlString `bun:"event_type,type:varchar(100),notnull," json:"event_type"`
EventData resolvespec_common.SqlString `bun:"event_data,type:jsonb,notnull," json:"event_data"`
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
Timestamp resolvespec_common.SqlTimeStamp `bun:"timestamp,type:timestamp,notnull," json:"timestamp"`
FromNumber resolvespec_common.SqlString `bun:"from_number,type:varchar(64),notnull," json:"from_number"`
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

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">
<head>
<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" />
<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">
</head>
<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.ModelPublicEventLog)(nil),
(*models.ModelPublicSession)(nil),
(*models.ModelPublicMessageCache)(nil),
}
for _, model := range models {
@@ -94,6 +93,10 @@ func CreateTables(ctx context.Context) error {
}
}
if err := ensureMessageCacheTable(ctx); err != nil {
return err
}
return nil
}
@@ -153,14 +156,16 @@ func createTablesSQLite(ctx context.Context) error {
)`,
// WhatsApp Accounts table
`CREATE TABLE IF NOT EXISTS whatsapp_accounts (
`CREATE TABLE IF NOT EXISTS whatsapp_account (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
phone_number VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(255),
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
business_api_config TEXT,
config TEXT,
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,
created_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),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
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
@@ -198,18 +203,22 @@ func createTablesSQLite(ctx context.Context) error {
// Message Cache table
`CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(36) PRIMARY KEY,
account_id VARCHAR(36),
id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL,
event_data TEXT NOT NULL,
message_id VARCHAR(255),
from_number VARCHAR(20),
to_number VARCHAR(20),
processed BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 {
@@ -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
}

View File

@@ -2,6 +2,7 @@ package storage
import (
"context"
"fmt"
"time"
"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
func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error {
_, err := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
Set("config = ?", cfgJSON).
Set("phone_number = ?", phoneNumber).
Set("active = ?", active).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(ctx)
now := time.Now()
updated, err := r.updateAccountTable(ctx, id, map[string]any{
"config": cfgJSON,
"phone_number": phoneNumber,
"active": active,
"updated_at": now,
})
if err != nil {
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
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()
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
}
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
type SessionRepository struct {

View File

@@ -224,6 +224,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
// Account management (with auth)
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/update", h.Auth(h.UpdateAccount))
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))

View File

@@ -19,6 +19,7 @@ import (
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/google/uuid"
"github.com/uptrace/bun"
"go.mau.fi/whatsmeow/types"
)
@@ -116,7 +117,9 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
// Initialize message cache
cacheConfig := cache.Config{
Enabled: cfg.MessageCache.Enabled,
Storage: cfg.MessageCache.Storage,
DataPath: cfg.MessageCache.DataPath,
DBType: cfg.Database.Type,
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
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 {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// 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
@@ -219,16 +226,30 @@ func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
// connectFromConfig loads and connects WhatsApp accounts from config file (legacy)
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 {
// Skip disabled accounts
if waCfg.Disabled {
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
}
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// 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
@@ -374,9 +395,6 @@ func (wh *WhatsHooked) syncConfigToDatabase(ctx context.Context) error {
// --- Sync WhatsApp accounts ---
if len(wh.config.WhatsApp) > 0 {
accountRepo := storage.NewWhatsAppAccountRepository(db)
_ = accountRepo // used via db directly for upsert
for _, wa := range wh.config.WhatsApp {
if wa.ID == "" {
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),
Config: resolvespec_common.NewSqlString(cfgJSON),
Active: !wa.Disabled,
Status: resolvespec_common.NewSqlString("disconnected"),
UserID: resolvespec_common.NewSqlString(adminID),
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
}
_, err := db.NewInsert().
result, err := db.NewInsert().
Model(&row).
On("CONFLICT (id) DO UPDATE").
Set("account_type = EXCLUDED.account_type").
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").
// Config should only prime missing accounts, never mutate existing DB rows.
On("CONFLICT (id) DO NOTHING").
Exec(ctx)
if err != nil {
logging.Error("Failed to sync WhatsApp account from config", "account_id", wa.ID, "error", err)
} 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
}
// 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)
logging.Info("Seeding initial data")
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
wh.dbReady = true
// Keep whatsapp_account.status synchronized with runtime events.
wh.subscribeAccountStatusEvents(db)
// Persist hook events to the event_log table
eventLogRepo := storage.NewEventLogRepository(db)
wh.eventBus.Subscribe(events.EventHookFailed, func(event events.Event) {
@@ -542,6 +572,41 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
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
func (wh *WhatsHooked) StopAPIServer(ctx context.Context) error {
if wh.apiServer != nil {

View File

@@ -5,7 +5,7 @@
DROP TABLE IF EXISTS public.message_cache CASCADE;
DROP TABLE IF EXISTS public.sessions 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.api_keys 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
);
CREATE TABLE IF NOT EXISTS public.whatsapp_accounts (
CREATE TABLE IF NOT EXISTS public.whatsapp_account (
account_type varchar(50) NOT NULL,
active boolean NOT NULL DEFAULT true,
config text,
@@ -94,15 +94,18 @@ CREATE TABLE IF NOT EXISTS public.sessions (
);
CREATE TABLE IF NOT EXISTS public.message_cache (
account_id varchar(36) NOT NULL,
chat_id varchar(255) NOT NULL,
content text NOT NULL,
created_at timestamp NOT NULL DEFAULT now(),
from_me boolean NOT NULL,
id varchar(36) NOT NULL,
message_id varchar(255) NOT NULL,
message_type varchar(50) NOT NULL,
timestamp timestamp NOT NULL
id varchar(128) NOT NULL,
account_id varchar(64) NOT NULL DEFAULT '',
event_type varchar(100) NOT NULL,
event_data jsonb NOT NULL DEFAULT '{}'::jsonb,
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 DEFAULT now(),
last_attempt timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Add missing columns for schema: public
@@ -592,10 +595,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'account_type'
) 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;
$$;
@@ -605,10 +608,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'active'
) 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;
$$;
@@ -618,10 +621,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'config'
) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN config text;
ALTER TABLE public.whatsapp_account ADD COLUMN config text;
END IF;
END;
$$;
@@ -631,10 +634,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'created_at'
) 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;
$$;
@@ -644,10 +647,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'deleted_at'
) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN deleted_at timestamp;
ALTER TABLE public.whatsapp_account ADD COLUMN deleted_at timestamp;
END IF;
END;
$$;
@@ -657,10 +660,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'display_name'
) 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;
$$;
@@ -670,10 +673,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'id'
) 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;
$$;
@@ -683,10 +686,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'last_connected_at'
) 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;
$$;
@@ -696,10 +699,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'phone_number'
) 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;
$$;
@@ -709,10 +712,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'session_path'
) THEN
ALTER TABLE public.whatsapp_accounts ADD COLUMN session_path text;
ALTER TABLE public.whatsapp_account ADD COLUMN session_path text;
END IF;
END;
$$;
@@ -722,10 +725,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'status'
) 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;
$$;
@@ -735,10 +738,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'updated_at'
) 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;
$$;
@@ -748,10 +751,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND column_name = 'user_id'
) 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;
$$;
@@ -1016,71 +1019,6 @@ BEGIN
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 $$
BEGIN
IF NOT EXISTS (
@@ -1089,7 +1027,46 @@ BEGIN
AND table_name = 'message_cache'
AND column_name = 'id'
) 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;
$$;
@@ -1102,7 +1079,7 @@ BEGIN
AND table_name = 'message_cache'
AND column_name = 'message_id'
) 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;
$$;
@@ -1113,9 +1090,48 @@ BEGIN
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'message_cache'
AND column_name = 'message_type'
AND column_name = 'from_number'
) 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;
$$;
@@ -1128,7 +1144,33 @@ BEGIN
AND table_name = 'message_cache'
AND column_name = 'timestamp'
) 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;
$$;
@@ -1226,22 +1268,22 @@ BEGIN
SELECT constraint_name INTO auto_pk_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND table_name = 'whatsapp_account'
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
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;
-- Add named primary key if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND constraint_name = 'pk_public_whatsapp_accounts'
AND table_name = 'whatsapp_account'
AND constraint_name = 'pk_public_whatsapp_account'
) 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;
$$;
@@ -1346,11 +1388,11 @@ CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at
CREATE INDEX IF NOT EXISTS idx_hooks_user_id
ON public.hooks USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at
ON public.whatsapp_accounts USING btree (deleted_at);
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at
ON public.whatsapp_account USING btree (deleted_at);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id
ON public.whatsapp_accounts USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id
ON public.whatsapp_account USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_event_logs_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
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
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
DO $$
@@ -1430,10 +1466,10 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND constraint_name = 'ukey_whatsapp_accounts_phone_number'
AND table_name = 'whatsapp_account'
AND constraint_name = 'ukey_whatsapp_account_phone_number'
) 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;
$$;
@@ -1451,19 +1487,6 @@ BEGIN
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
-- Foreign keys for schema: public
DO $$
@@ -1503,11 +1526,11 @@ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'whatsapp_accounts'
AND constraint_name = 'fk_whatsapp_accounts_user_id'
AND table_name = 'whatsapp_account'
AND constraint_name = 'fk_whatsapp_account_user_id'
) THEN
ALTER TABLE public.whatsapp_accounts
ADD CONSTRAINT fk_whatsapp_accounts_user_id
ALTER TABLE public.whatsapp_account
ADD CONSTRAINT fk_whatsapp_account_user_id
FOREIGN KEY (user_id)
REFERENCES public.users (id)
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]
indexes {
(user_id) [name: 'idx_whatsapp_accounts_user_id']
(deleted_at) [name: 'idx_whatsapp_accounts_deleted_at']
(user_id) [name: 'idx_whatsapp_account_user_id']
(deleted_at) [name: 'idx_whatsapp_account_deleted_at']
}
}
@@ -123,26 +123,27 @@ Table session {
}
Table message_cache {
id varchar(36) [primary key, note: 'UUID']
account_id varchar(36) [not null]
message_id varchar(255) [unique, not null]
chat_id varchar(255) [not null]
from_me boolean [not null]
timestamp timestamp [not null]
message_type varchar(50) [not null, note: 'text, image, video, etc.']
content text [not null, note: 'JSON encoded message content']
id varchar(128) [primary key]
account_id varchar(64) [not null, default: '']
event_type varchar(100) [not null]
event_data text [not null, note: 'JSON encoded event payload']
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 timestamp [not null, default: `now()`]
last_attempt timestamp [null]
created_at timestamp [not null, default: `now()`]
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']
(event_type) [name: 'idx_message_cache_event_type']
}
}
// Reference documentation
Ref: api_keys.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]

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);
-- WhatsApp Accounts table
CREATE TABLE IF NOT EXISTS whatsapp_accounts (
CREATE TABLE IF NOT EXISTS whatsapp_account (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
account_id VARCHAR(100) UNIQUE,
phone_number VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(255),
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
);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id ON whatsapp_accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at ON whatsapp_accounts(deleted_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_whatsapp_accounts_account_id ON whatsapp_accounts(account_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id ON whatsapp_account(user_id);
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at ON whatsapp_account(deleted_at);
-- Event Logs table
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
CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(36) PRIMARY KEY,
account_id VARCHAR(36) NOT NULL,
message_id VARCHAR(255) NOT NULL UNIQUE,
chat_id VARCHAR(255) NOT NULL,
message_type VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
from_me BOOLEAN NOT NULL,
timestamp TIMESTAMP NOT NULL,
id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL,
event_data TEXT 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_attempt 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_chat_id ON message_cache(chat_id);
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);
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);

1
web/.gitignore vendored
View File

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

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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" />
<title>web</title>
</head>

View File

@@ -19,12 +19,13 @@
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20",
"@warkypublic/oranguru": "^0.0.49",
"@warkypublic/resolvespec-js": "^1.0.1",
"axios": "^1.13.4",
"dayjs": "^1.11.19",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"@warkypublic/resolvespec-js": "^1.0.1",
"swagger-ui-react": "^5.32.0",
"zustand": "^5.0.11"
},
"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 { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
@@ -11,11 +11,13 @@ import UsersPage from './pages/UsersPage';
import HooksPage from './pages/HooksPage';
import AccountsPage from './pages/AccountsPage';
import EventLogsPage from './pages/EventLogsPage';
import MessageCachePage from './pages/MessageCachePage';
import SendMessagePage from './pages/SendMessagePage';
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
import TemplateManagementPage from './pages/TemplateManagementPage';
import CatalogManagementPage from './pages/CatalogManagementPage';
import FlowManagementPage from './pages/FlowManagementPage';
const SwaggerPage = lazy(() => import('./pages/SwaggerPage'));
// Import Mantine styles
import '@mantine/core/styles.css';
@@ -55,6 +57,12 @@ function App() {
<Route path="flows" element={<FlowManagementPage />} />
<Route path="send-message" element={<SendMessagePage />} />
<Route path="event-logs" element={<EventLogsPage />} />
<Route path="message-cache" element={<MessageCachePage />} />
<Route path="sw" element={
<Suspense fallback={null}>
<SwaggerPage />
</Suspense>
} />
</Route>
{/* Catch all */}

View File

@@ -8,7 +8,7 @@ import {
Button,
Avatar,
Stack,
Badge,
Image,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
@@ -22,6 +22,7 @@ import {
IconCategory,
IconArrowsShuffle,
IconFileText,
IconDatabase,
IconLogout,
} from "@tabler/icons-react";
import { useAuthStore } from "../stores/authStore";
@@ -40,6 +41,14 @@ export default function DashboardLayout() {
const isActive = (path: string) => location.pathname === path;
const isAnyActive = (paths: string[]) =>
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 (
<AppShell
@@ -60,19 +69,17 @@ export default function DashboardLayout() {
hiddenFrom="sm"
size="sm"
/>
<Image src={logoSrc} alt="WhatsHooked logo" w={24} h={24} fit="contain" />
<Text size="xl" fw={700}>
WhatsHooked
</Text>
<Badge color="blue" variant="light">
Admin
</Badge>
</Group>
<Group>
<Text size="sm" c="dimmed">
{user?.username || "User"}
{displayName}
</Text>
<Avatar color="blue" radius="xl" size="sm">
{user?.username?.[0]?.toUpperCase() || "U"}
{displayInitial}
</Avatar>
</Group>
</Group>
@@ -222,6 +229,32 @@ export default function DashboardLayout() {
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>
</AppShell.Section>
@@ -230,7 +263,7 @@ export default function DashboardLayout() {
<Group justify="space-between" px="sm">
<div>
<Text size="sm" fw={500}>
{user?.username || "User"}
{displayName}
</Text>
<Text size="xs" c="dimmed">
{user?.role || "user"}

View File

@@ -15,6 +15,10 @@ import type {
APIKey,
LoginRequest,
LoginResponse,
MessageCacheListResponse,
MessageCacheStats,
SystemStats,
WhatsAppAccountRuntimeStatus,
} from "../types";
function getApiBaseUrl(): string {
@@ -26,6 +30,47 @@ function getApiBaseUrl(): string {
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 {
private client: AxiosInstance;
@@ -82,7 +127,11 @@ class ApiClient {
);
if (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;
}
@@ -97,7 +146,12 @@ class ApiClient {
getCurrentUser(): User | null {
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 {
@@ -213,6 +267,13 @@ class ApiClient {
return data;
}
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> {
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>(
"/api/accounts/status",
);
return data;
}
async addAccountConfig(
account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> {
@@ -377,6 +438,73 @@ class ApiClient {
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
async getAPIKeys(): Promise<APIKey[]> {
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");
return data;
}
async getSystemStats(): Promise<SystemStats> {
const { data } = await this.client.get<SystemStats>("/api/v1/system/stats");
return data;
}
}
export const apiClient = new ApiClient();

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Container,
Title,
@@ -10,74 +10,127 @@ import {
Modal,
TextInput,
Select,
Textarea,
Checkbox,
Stack,
Alert,
Loader,
Center,
ActionIcon
ActionIcon,
Code,
Anchor
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
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 type { WhatsAppAccount, WhatsAppAccountConfig } from '../types';
import type { BusinessAPIConfig, WhatsAppAccount, WhatsAppAccountConfig } from '../types';
function buildSessionPath(accountId: string) {
return `./sessions/${accountId}`;
}
function toPrettyJSON(value: unknown) {
return JSON.stringify(value, null, 2);
}
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
return [...accounts].sort((a, b) =>
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
);
}
function mergeAccounts(
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);
function mapConfigToAccount(configuredAccount: WhatsAppAccountConfig): WhatsAppAccount {
return {
...databaseAccount,
id: configuredAccount.id,
account_id: configuredAccount.id,
user_id: databaseAccount?.user_id || '',
phone_number: configuredAccount.phone_number || databaseAccount?.phone_number || '',
display_name: databaseAccount?.display_name || '',
account_type: configuredAccount.type || databaseAccount?.account_type || 'whatsmeow',
status: databaseAccount?.status || 'disconnected',
config: configuredAccount.business_api
? toPrettyJSON(configuredAccount.business_api)
: (databaseAccount?.config || ''),
session_path: configuredAccount.session_path || databaseAccount?.session_path || buildSessionPath(configuredAccount.id),
last_connected_at: databaseAccount?.last_connected_at,
user_id: '',
phone_number: configuredAccount.phone_number || '',
display_name: '',
account_type: configuredAccount.type || 'whatsmeow',
status: configuredAccount.status || 'disconnected',
show_qr: configuredAccount.show_qr,
business_api: configuredAccount.business_api,
config: configuredAccount.business_api ? JSON.stringify(configuredAccount.business_api, null, 2) : '',
session_path: configuredAccount.session_path || buildSessionPath(configuredAccount.id),
last_connected_at: undefined,
active: !configuredAccount.disabled,
created_at: databaseAccount?.created_at || '',
updated_at: databaseAccount?.updated_at || '',
created_at: '',
updated_at: '',
};
});
}
const configuredIds = new Set(configuredAccounts.map((account) => account.id));
const orphanedDatabaseAccounts = databaseAccounts
.filter((account) => !configuredIds.has(account.id))
.map((account) => ({
...account,
account_id: account.account_id || account.id,
}));
type BusinessAPIFormData = {
phone_number_id: string;
access_token: string;
waba_id: string;
business_account_id: string;
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() {
@@ -85,36 +138,49 @@ export default function AccountsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [opened, { open, close }] = useDisclosure(false);
const [qrModalOpened, { open: openQRModal, close: closeQRModal }] = useDisclosure(false);
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({
account_id: '',
phone_number: '',
display_name: '',
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
config: '',
business_api: emptyBusinessAPIFormData(),
active: true
});
useEffect(() => {
loadAccounts();
}, []);
const loadAccounts = async () => {
const loadAccounts = useCallback(async (showLoader = true) => {
try {
if (showLoader) {
setLoading(true);
const [configuredAccounts, databaseAccounts] = await Promise.all([
apiClient.getAccountConfigs(),
apiClient.getAccounts(),
]);
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
}
const configuredAccounts = await apiClient.getAccountConfigs();
setAccounts(sortAccountsAlphabetically((configuredAccounts || []).map(mapConfigToAccount)));
setError(null);
} catch (err) {
setError('Failed to load accounts');
console.error(err);
} finally {
if (showLoader) {
setLoading(false);
}
};
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
useEffect(() => {
const interval = setInterval(() => {
void loadAccounts(false);
}, 5000);
return () => clearInterval(interval);
}, [loadAccounts]);
const handleCreate = () => {
setEditingAccount(null);
@@ -123,7 +189,7 @@ export default function AccountsPage() {
phone_number: '',
display_name: '',
account_type: 'whatsmeow',
config: '',
business_api: emptyBusinessAPIFormData(),
active: true
});
open();
@@ -131,12 +197,13 @@ export default function AccountsPage() {
const handleEdit = (account: WhatsAppAccount) => {
setEditingAccount(account);
const accountBusinessAPI = account.business_api || parseLegacyBusinessAPIConfig(account.config);
setFormData({
account_id: account.account_id || account.id || '',
phone_number: account.phone_number,
display_name: account.display_name || '',
account_type: account.account_type,
config: account.config || '',
business_api: toBusinessAPIFormData(accountBusinessAPI),
active: account.active
});
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) => {
e.preventDefault();
const accountId = (editingAccount?.id || formData.account_id).trim();
const parsedConfig = formData.config ? (() => {
try {
return JSON.parse(formData.config);
} catch {
return null;
}
})() : null;
const businessAPIPayload = fromBusinessAPIFormData(formData.business_api);
if (formData.config && parsedConfig === null) {
if (
formData.account_type === 'business-api' &&
(!businessAPIPayload.phone_number_id || !businessAPIPayload.access_token)
) {
notifications.show({
title: 'Error',
message: 'Config must be valid JSON',
message: 'Phone Number ID and Access Token are required for Business API accounts',
color: 'red',
});
return;
@@ -194,8 +289,8 @@ export default function AccountsPage() {
disabled: !formData.active,
};
if (formData.account_type === 'business-api' && parsedConfig) {
payload.business_api = parsedConfig;
if (formData.account_type === 'business-api') {
payload.business_api = businessAPIPayload;
}
if (editingAccount) {
@@ -229,6 +324,7 @@ export default function AccountsPage() {
switch (status) {
case 'connected': return 'green';
case 'connecting': return 'yellow';
case 'pairing': return 'yellow';
case 'disconnected': return 'red';
default: return 'gray';
}
@@ -250,7 +346,7 @@ export default function AccountsPage() {
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Button onClick={loadAccounts}>Retry</Button>
<Button onClick={() => loadAccounts()}>Retry</Button>
</Container>
);
}
@@ -293,19 +389,21 @@ export default function AccountsPage() {
</Table.Td>
</Table.Tr>
) : (
accounts.map((account) => (
accounts.map((account) => {
const connectionStatus = getConnectionStatus(account);
return (
<Table.Tr key={account.id}>
<Table.Td fw={500}>{account.account_id || '-'}</Table.Td>
<Table.Td>{account.phone_number || '-'}</Table.Td>
<Table.Td>{account.display_name || '-'}</Table.Td>
<Table.Td>
<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>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(account.status)} variant="light">
{account.status}
<Badge color={getStatusColor(connectionStatus)} variant="light">
{connectionStatus}
</Badge>
</Table.Td>
<Table.Td>
@@ -320,6 +418,16 @@ export default function AccountsPage() {
</Table.Td>
<Table.Td>
<Group gap="xs">
{account.account_type === 'whatsmeow' && (
<ActionIcon
variant="light"
color="teal"
onClick={() => handleOpenQRModal(account)}
title="View QR code"
>
<IconQrcode size={16} />
</ActionIcon>
)}
<ActionIcon
variant="light"
color="blue"
@@ -337,7 +445,8 @@ export default function AccountsPage() {
</Group>
</Table.Td>
</Table.Tr>
))
);
})
)}
</Table.Tbody>
</Table>
@@ -385,28 +494,106 @@ export default function AccountsPage() {
value={formData.account_type}
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
data={[
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' },
{ value: 'business-api', label: 'Business API' }
{ value: 'whatsmeow', label: 'Whatsapp' },
{ value: 'business-api', label: 'Meta Business API' }
]}
required
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' && (
<Textarea
label="Business API Config (JSON)"
placeholder={`{
"api_key": "your-api-key",
"api_url": "https://api.whatsapp.com",
"phone_number_id": "123456"
}`}
value={formData.config}
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
rows={6}
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
description="Business API credentials and configuration"
<>
<TextInput
label="Phone Number ID"
placeholder="123456789012345"
value={formData.business_api.phone_number_id}
onChange={(e) =>
setFormData({
...formData,
business_api: { ...formData.business_api, phone_number_id: e.target.value },
})
}
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
@@ -422,6 +609,43 @@ export default function AccountsPage() {
</Stack>
</form>
</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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, memo } from 'react';
import {
Container,
Title,
@@ -9,33 +9,48 @@ import {
ThemeIcon,
Loader,
Center,
Stack
Stack,
Image,
} from '@mantine/core';
import {
IconUsers,
IconWebhook,
IconBrandWhatsapp,
IconFileText
IconFileText,
IconDatabase,
IconCpu,
IconDeviceDesktopAnalytics,
IconNetwork,
} from '@tabler/icons-react';
import { apiClient } from '../lib/api';
interface Stats {
interface DashboardStats {
users: number;
hooks: number;
accounts: number;
eventLogs: number;
messageCacheEnabled: boolean;
messageCacheCount: number;
}
function StatCard({
interface RuntimeStats {
goMemoryMB: number;
goCPUPercent: number;
networkBytesPerSec: number;
}
const StatCard = memo(function StatCard({
title,
value,
icon: Icon,
color
color,
valueColor,
}: {
title: string;
value: number;
value: number | string;
icon: any;
color: string;
valueColor?: string;
}) {
return (
<Paper withBorder p="md" radius="md">
@@ -44,8 +59,8 @@ function StatCard({
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
{title}
</Text>
<Text fw={700} fz="xl" mt="md">
{value.toLocaleString()}
<Text fw={700} fz="xl" mt="md" c={valueColor}>
{typeof value === 'number' ? value.toLocaleString() : value}
</Text>
</div>
<ThemeIcon
@@ -59,51 +74,74 @@ function StatCard({
</Group>
</Paper>
);
}
});
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,
hooks: 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);
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 {
setLoading(true);
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([
const [usersResult, hooksResult, accountsResult, eventLogsResult, messageCacheResult] = await Promise.allSettled([
apiClient.getUsers(),
apiClient.getHooks(),
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 hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
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 messageCacheEnabled = !!messageCache?.enabled;
const messageCacheCount = messageCache?.total_count ?? messageCache?.count ?? 0;
setStats({
users: users?.length || 0,
hooks: hooks?.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', {
users: usersResult.status === 'rejected' ? usersResult.reason : null,
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
messageCache: messageCacheResult.status === 'rejected' ? messageCacheResult.reason : null,
});
}
} 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) {
return (
<Container size="xl" py="xl">
@@ -127,11 +178,12 @@ export default function DashboardPage() {
<Container size="xl" py="xl">
<Stack gap="xl">
<div>
<Image src={logoSrc} alt="WhatsHooked logo" w={120} h={120} fit="contain" mb="sm" />
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }}>
<StatCard
title="Total Users"
value={stats.users}
@@ -156,6 +208,31 @@ export default function DashboardPage() {
icon={IconFileText}
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>
</Stack>
</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;
display_name?: string;
account_type: 'whatsmeow' | 'business-api';
status: 'connected' | 'disconnected' | 'connecting';
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
show_qr?: boolean;
business_api?: BusinessAPIConfig;
config?: string; // JSON string
session_path?: string;
last_connected_at?: string;
@@ -197,9 +199,20 @@ export interface WhatsAppAccountConfig {
session_path?: string;
show_qr?: boolean;
disabled?: boolean;
status?: 'connected' | 'disconnected' | 'connecting' | 'pairing';
connected?: boolean;
qr_available?: boolean;
business_api?: BusinessAPIConfig;
}
export interface WhatsAppAccountRuntimeStatus {
account_id: string;
type: string;
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
connected: boolean;
qr_available: boolean;
}
export interface EventLog {
id: string;
user_id?: string;
@@ -215,6 +228,50 @@ export interface EventLog {
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 {
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";