feat(ui): add message cache management page and dashboard enhancements
- 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:
@@ -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")
|
||||
|
||||
521
pkg/cache/message_cache.go
vendored
521
pkg/cache/message_cache.go
vendored
@@ -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, ¬Null, &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 ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
pkg/serverembed/dist/assets/SwaggerPage-DyOXnmua.css
vendored
Normal file
1
pkg/serverembed/dist/assets/SwaggerPage-DyOXnmua.css
vendored
Normal file
File diff suppressed because one or more lines are too long
257
pkg/serverembed/dist/assets/SwaggerPage-DzmFUDQ3.js
vendored
Normal file
257
pkg/serverembed/dist/assets/SwaggerPage-DzmFUDQ3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
70
pkg/serverembed/dist/assets/index-BKXFy3Jy.js
vendored
Normal file
70
pkg/serverembed/dist/assets/index-BKXFy3Jy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
File diff suppressed because one or more lines are too long
BIN
pkg/serverembed/dist/favicon.ico
vendored
Normal file
BIN
pkg/serverembed/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
4
pkg/serverembed/dist/index.html
vendored
4
pkg/serverembed/dist/index.html
vendored
@@ -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
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
4
pkg/serverembed/dist/swagger-icon.svg
vendored
Normal 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 |
29
pkg/serverembed/dist/swagger/index.html
vendored
29
pkg/serverembed/dist/swagger/index.html
vendored
@@ -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>
|
||||
@@ -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, ¤tCount); 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, ¬Null, &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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user