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

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 err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
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

@@ -62,7 +62,7 @@ type WhatsAppConfig struct {
type BusinessAPIConfig struct {
PhoneNumberID string `json:"phone_number_id"`
AccessToken string `json:"access_token"`
WABAId string `json:"waba_id,omitempty"` // WhatsApp Business Account ID (resolved at connect time)
WABAId string `json:"waba_id,omitempty"` // WhatsApp Business Account ID (resolved at connect time)
BusinessAccountID string `json:"business_account_id,omitempty"` // Facebook Business Manager ID (optional)
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
WebhookPath string `json:"webhook_path,omitempty"`
@@ -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,29 +203,63 @@ 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)
return err
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)
now := time.Now()
fields := map[string]any{
"status": status,
"updated_at": now,
}
if status == "connected" {
now := time.Now()
query = query.Set("last_connected_at = ?", now)
fields["last_connected_at"] = now
}
_, err := query.Exec(ctx)
return err
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

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 {