Major refactor to library
This commit is contained in:
153
pkg/config/config.go
Normal file
153
pkg/config/config.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
WhatsApp []WhatsAppConfig `json:"whatsapp"`
|
||||
Hooks []Hook `json:"hooks"`
|
||||
Database DatabaseConfig `json:"database,omitempty"`
|
||||
Media MediaConfig `json:"media"`
|
||||
EventLogger EventLoggerConfig `json:"event_logger,omitempty"`
|
||||
LogLevel string `json:"log_level"`
|
||||
}
|
||||
|
||||
// ServerConfig holds server-specific configuration
|
||||
type ServerConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
DefaultCountryCode string `json:"default_country_code,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
AuthKey string `json:"auth_key,omitempty"`
|
||||
}
|
||||
|
||||
// WhatsAppConfig holds configuration for a WhatsApp account
|
||||
type WhatsAppConfig struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "whatsmeow" or "business-api"
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
SessionPath string `json:"session_path,omitempty"`
|
||||
ShowQR bool `json:"show_qr,omitempty"`
|
||||
BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"`
|
||||
}
|
||||
|
||||
// BusinessAPIConfig holds configuration for WhatsApp Business API
|
||||
type BusinessAPIConfig struct {
|
||||
PhoneNumberID string `json:"phone_number_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
BusinessAccountID string `json:"business_account_id,omitempty"`
|
||||
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
|
||||
WebhookPath string `json:"webhook_path,omitempty"`
|
||||
VerifyToken string `json:"verify_token,omitempty"`
|
||||
}
|
||||
|
||||
// Hook represents a registered webhook
|
||||
type Hook struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Events []string `json:"events,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database connection information
|
||||
type DatabaseConfig struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
SQLitePath string `json:"sqlite_path,omitempty"` // Path to SQLite database file
|
||||
}
|
||||
|
||||
// MediaConfig holds media storage and delivery configuration
|
||||
type MediaConfig struct {
|
||||
DataPath string `json:"data_path"`
|
||||
Mode string `json:"mode"` // "base64", "link", or "both"
|
||||
BaseURL string `json:"base_url,omitempty"` // Base URL for media links
|
||||
}
|
||||
|
||||
// EventLoggerConfig holds event logging configuration
|
||||
type EventLoggerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Targets []string `json:"targets"` // "file", "sqlite", "postgres"
|
||||
|
||||
// File-based logging
|
||||
FileDir string `json:"file_dir,omitempty"` // Base directory for event files
|
||||
|
||||
// Database logging (uses main Database config for connection)
|
||||
TableName string `json:"table_name,omitempty"` // Table name for event logs (default: "event_logs")
|
||||
}
|
||||
|
||||
// Load reads configuration from a file
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "info"
|
||||
}
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "localhost"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Media.DataPath == "" {
|
||||
cfg.Media.DataPath = "./data/media"
|
||||
}
|
||||
if cfg.Media.Mode == "" {
|
||||
cfg.Media.Mode = "link"
|
||||
}
|
||||
if cfg.EventLogger.FileDir == "" {
|
||||
cfg.EventLogger.FileDir = "./data/events"
|
||||
}
|
||||
if cfg.EventLogger.TableName == "" {
|
||||
cfg.EventLogger.TableName = "event_logs"
|
||||
}
|
||||
if cfg.Database.SQLitePath == "" {
|
||||
cfg.Database.SQLitePath = "./data/events.db"
|
||||
}
|
||||
|
||||
// Default WhatsApp account type to whatsmeow for backwards compatibility
|
||||
for i := range cfg.WhatsApp {
|
||||
if cfg.WhatsApp[i].Type == "" {
|
||||
cfg.WhatsApp[i].Type = "whatsmeow"
|
||||
}
|
||||
// Set default API version for Business API
|
||||
if cfg.WhatsApp[i].Type == "business-api" && cfg.WhatsApp[i].BusinessAPI != nil {
|
||||
if cfg.WhatsApp[i].BusinessAPI.APIVersion == "" {
|
||||
cfg.WhatsApp[i].BusinessAPI.APIVersion = "v21.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes configuration to a file
|
||||
func Save(path string, cfg *Config) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
102
pkg/eventlogger/eventlogger.go
Normal file
102
pkg/eventlogger/eventlogger.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
)
|
||||
|
||||
// Logger handles event logging to multiple targets
|
||||
type Logger struct {
|
||||
config config.EventLoggerConfig
|
||||
dbConfig config.DatabaseConfig
|
||||
targets []Target
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Target represents a logging target
|
||||
type Target interface {
|
||||
Log(event events.Event) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewLogger creates a new event logger
|
||||
func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig) (*Logger, error) {
|
||||
logger := &Logger{
|
||||
config: cfg,
|
||||
dbConfig: dbConfig,
|
||||
targets: make([]Target, 0),
|
||||
}
|
||||
|
||||
// Initialize targets based on configuration
|
||||
for _, targetType := range cfg.Targets {
|
||||
switch strings.ToLower(targetType) {
|
||||
case "file":
|
||||
fileTarget, err := NewFileTarget(cfg.FileDir)
|
||||
if err != nil {
|
||||
logging.Error("Failed to initialize file target", "error", err)
|
||||
continue
|
||||
}
|
||||
logger.targets = append(logger.targets, fileTarget)
|
||||
logging.Info("Event logger file target initialized", "dir", cfg.FileDir)
|
||||
|
||||
case "sqlite":
|
||||
sqliteTarget, err := NewSQLiteTarget(dbConfig, cfg.TableName)
|
||||
if err != nil {
|
||||
logging.Error("Failed to initialize SQLite target", "error", err)
|
||||
continue
|
||||
}
|
||||
logger.targets = append(logger.targets, sqliteTarget)
|
||||
logging.Info("Event logger SQLite target initialized")
|
||||
|
||||
case "postgres", "postgresql":
|
||||
postgresTarget, err := NewPostgresTarget(dbConfig, cfg.TableName)
|
||||
if err != nil {
|
||||
logging.Error("Failed to initialize PostgreSQL target", "error", err)
|
||||
continue
|
||||
}
|
||||
logger.targets = append(logger.targets, postgresTarget)
|
||||
logging.Info("Event logger PostgreSQL target initialized")
|
||||
|
||||
default:
|
||||
logging.Error("Unknown event logger target type", "type", targetType)
|
||||
}
|
||||
}
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// Log logs an event to all configured targets
|
||||
func (l *Logger) Log(event events.Event) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
for _, target := range l.targets {
|
||||
if err := target.Log(event); err != nil {
|
||||
logging.Error("Failed to log event", "target", fmt.Sprintf("%T", target), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes all logging targets
|
||||
func (l *Logger) Close() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var errors []string
|
||||
for _, target := range l.targets {
|
||||
if err := target.Close(); err != nil {
|
||||
errors = append(errors, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("errors closing targets: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
69
pkg/eventlogger/file_target.go
Normal file
69
pkg/eventlogger/file_target.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
)
|
||||
|
||||
// FileTarget logs events to organized file structure
|
||||
type FileTarget struct {
|
||||
baseDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileTarget creates a new file-based logging target
|
||||
func NewFileTarget(baseDir string) (*FileTarget, error) {
|
||||
// Create base directory if it doesn't exist
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create event log directory: %w", err)
|
||||
}
|
||||
|
||||
return &FileTarget{
|
||||
baseDir: baseDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Log writes an event to a file
|
||||
func (ft *FileTarget) Log(event events.Event) error {
|
||||
ft.mu.Lock()
|
||||
defer ft.mu.Unlock()
|
||||
|
||||
// Create directory structure: baseDir/[type]/[YYYYMMDD]/
|
||||
eventType := string(event.Type)
|
||||
dateDir := event.Timestamp.Format("20060102")
|
||||
dirPath := filepath.Join(ft.baseDir, eventType, dateDir)
|
||||
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create event directory: %w", err)
|
||||
}
|
||||
|
||||
// Create filename: [hh24_mi_ss]_[type].json
|
||||
filename := fmt.Sprintf("%s_%s.json",
|
||||
event.Timestamp.Format("15_04_05"),
|
||||
eventType,
|
||||
)
|
||||
filePath := filepath.Join(dirPath, filename)
|
||||
|
||||
// Marshal event to JSON
|
||||
data, err := json.MarshalIndent(event, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %w", err)
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write event file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the file target (no-op for file target)
|
||||
func (ft *FileTarget) Close() error {
|
||||
return nil
|
||||
}
|
||||
120
pkg/eventlogger/postgres_target.go
Normal file
120
pkg/eventlogger/postgres_target.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
|
||||
_ "github.com/lib/pq" // PostgreSQL driver
|
||||
)
|
||||
|
||||
// PostgresTarget logs events to PostgreSQL database
|
||||
type PostgresTarget struct {
|
||||
db *sql.DB
|
||||
tableName string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPostgresTarget creates a new PostgreSQL logging target
|
||||
func NewPostgresTarget(dbConfig config.DatabaseConfig, tableName string) (*PostgresTarget, error) {
|
||||
// Build connection string
|
||||
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
dbConfig.Host,
|
||||
dbConfig.Port,
|
||||
dbConfig.Username,
|
||||
dbConfig.Password,
|
||||
dbConfig.Database,
|
||||
)
|
||||
|
||||
// Open PostgreSQL connection
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
target := &PostgresTarget{
|
||||
db: db,
|
||||
tableName: tableName,
|
||||
}
|
||||
|
||||
// Create table if it doesn't exist
|
||||
if err := target.createTable(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// createTable creates the event logs table if it doesn't exist
|
||||
func (pt *PostgresTarget) createTable() error {
|
||||
query := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, pt.tableName)
|
||||
|
||||
if _, err := pt.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
// Create index on event_type and timestamp
|
||||
indexQuery := fmt.Sprintf(`
|
||||
CREATE INDEX IF NOT EXISTS idx_%s_type_timestamp
|
||||
ON %s(event_type, timestamp)
|
||||
`, pt.tableName, pt.tableName)
|
||||
|
||||
if _, err := pt.db.Exec(indexQuery); err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log writes an event to PostgreSQL database
|
||||
func (pt *PostgresTarget) Log(event events.Event) error {
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
|
||||
// Marshal event data to JSON
|
||||
data, err := json.Marshal(event.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event data: %w", err)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (event_type, timestamp, data)
|
||||
VALUES ($1, $2, $3)
|
||||
`, pt.tableName)
|
||||
|
||||
_, err = pt.db.Exec(query, string(event.Type), event.Timestamp, string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the PostgreSQL database connection
|
||||
func (pt *PostgresTarget) Close() error {
|
||||
return pt.db.Close()
|
||||
}
|
||||
111
pkg/eventlogger/sqlite_target.go
Normal file
111
pkg/eventlogger/sqlite_target.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
// SQLiteTarget logs events to SQLite database
|
||||
type SQLiteTarget struct {
|
||||
db *sql.DB
|
||||
tableName string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSQLiteTarget creates a new SQLite logging target
|
||||
func NewSQLiteTarget(dbConfig config.DatabaseConfig, tableName string) (*SQLiteTarget, error) {
|
||||
// Use the SQLite path from config (defaults to "./data/events.db")
|
||||
dbPath := dbConfig.SQLitePath
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Open SQLite connection
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||
}
|
||||
|
||||
target := &SQLiteTarget{
|
||||
db: db,
|
||||
tableName: tableName,
|
||||
}
|
||||
|
||||
// Create table if it doesn't exist
|
||||
if err := target.createTable(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// createTable creates the event logs table if it doesn't exist
|
||||
func (st *SQLiteTarget) createTable() error {
|
||||
query := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, st.tableName)
|
||||
|
||||
if _, err := st.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
// Create index on event_type and timestamp
|
||||
indexQuery := fmt.Sprintf(`
|
||||
CREATE INDEX IF NOT EXISTS idx_%s_type_timestamp
|
||||
ON %s(event_type, timestamp)
|
||||
`, st.tableName, st.tableName)
|
||||
|
||||
if _, err := st.db.Exec(indexQuery); err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log writes an event to SQLite database
|
||||
func (st *SQLiteTarget) Log(event events.Event) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
// Marshal event data to JSON
|
||||
data, err := json.Marshal(event.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event data: %w", err)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (event_type, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
`, st.tableName)
|
||||
|
||||
_, err = st.db.Exec(query, string(event.Type), event.Timestamp, string(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SQLite database connection
|
||||
func (st *SQLiteTarget) Close() error {
|
||||
return st.db.Close()
|
||||
}
|
||||
161
pkg/events/builders.go
Normal file
161
pkg/events/builders.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WhatsAppConnectedEvent creates a WhatsApp connected event
|
||||
func WhatsAppConnectedEvent(ctx context.Context, accountID string, phoneNumber string) Event {
|
||||
return NewEvent(ctx, EventWhatsAppConnected, map[string]any{
|
||||
"account_id": accountID,
|
||||
"phone_number": phoneNumber,
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppDisconnectedEvent creates a WhatsApp disconnected event
|
||||
func WhatsAppDisconnectedEvent(ctx context.Context, accountID string, reason string) Event {
|
||||
return NewEvent(ctx, EventWhatsAppDisconnected, map[string]any{
|
||||
"account_id": accountID,
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppPairSuccessEvent creates a WhatsApp pair success event
|
||||
func WhatsAppPairSuccessEvent(ctx context.Context, accountID string) Event {
|
||||
return NewEvent(ctx, EventWhatsAppPairSuccess, map[string]any{
|
||||
"account_id": accountID,
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppPairFailedEvent creates a WhatsApp pair failed event
|
||||
func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) Event {
|
||||
return NewEvent(ctx, EventWhatsAppPairFailed, map[string]any{
|
||||
"account_id": accountID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
|
||||
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string) Event {
|
||||
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
|
||||
"account_id": accountID,
|
||||
"qr_code": qrCode,
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppQRTimeoutEvent creates a WhatsApp QR timeout event
|
||||
func WhatsAppQRTimeoutEvent(ctx context.Context, accountID string) Event {
|
||||
return NewEvent(ctx, EventWhatsAppQRTimeout, map[string]any{
|
||||
"account_id": accountID,
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppQRErrorEvent creates a WhatsApp QR error event
|
||||
func WhatsAppQRErrorEvent(ctx context.Context, accountID string, err error) Event {
|
||||
return NewEvent(ctx, EventWhatsAppQRError, map[string]any{
|
||||
"account_id": accountID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// WhatsAppPairEventGeneric creates a generic WhatsApp pairing event
|
||||
func WhatsAppPairEventGeneric(ctx context.Context, accountID string, eventName string, data map[string]any) Event {
|
||||
eventData := map[string]any{
|
||||
"account_id": accountID,
|
||||
"event": eventName,
|
||||
}
|
||||
for k, v := range data {
|
||||
eventData[k] = v
|
||||
}
|
||||
return NewEvent(ctx, EventWhatsAppPairEvent, eventData)
|
||||
}
|
||||
|
||||
// MessageReceivedEvent creates a message received event
|
||||
func MessageReceivedEvent(ctx context.Context, accountID, messageID, from, to, text string, timestamp time.Time, isGroup bool, groupName, senderName, messageType, mimeType, filename, mediaBase64, mediaURL string) Event {
|
||||
return NewEvent(ctx, EventMessageReceived, map[string]any{
|
||||
"account_id": accountID,
|
||||
"message_id": messageID,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"text": text,
|
||||
"timestamp": timestamp,
|
||||
"is_group": isGroup,
|
||||
"group_name": groupName,
|
||||
"sender_name": senderName,
|
||||
"message_type": messageType,
|
||||
"mime_type": mimeType,
|
||||
"filename": filename,
|
||||
"media_base64": mediaBase64,
|
||||
"media_url": mediaURL,
|
||||
})
|
||||
}
|
||||
|
||||
// MessageSentEvent creates a message sent event
|
||||
func MessageSentEvent(ctx context.Context, accountID, messageID, to, text string) Event {
|
||||
return NewEvent(ctx, EventMessageSent, map[string]any{
|
||||
"account_id": accountID,
|
||||
"message_id": messageID,
|
||||
"to": to,
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
|
||||
// MessageFailedEvent creates a message failed event
|
||||
func MessageFailedEvent(ctx context.Context, accountID, to, text string, err error) Event {
|
||||
return NewEvent(ctx, EventMessageFailed, map[string]any{
|
||||
"account_id": accountID,
|
||||
"to": to,
|
||||
"text": text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// MessageDeliveredEvent creates a message delivered event
|
||||
func MessageDeliveredEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
|
||||
return NewEvent(ctx, EventMessageDelivered, map[string]any{
|
||||
"account_id": accountID,
|
||||
"message_id": messageID,
|
||||
"from": from,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
// MessageReadEvent creates a message read event
|
||||
func MessageReadEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
|
||||
return NewEvent(ctx, EventMessageRead, map[string]any{
|
||||
"account_id": accountID,
|
||||
"message_id": messageID,
|
||||
"from": from,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
// HookTriggeredEvent creates a hook triggered event
|
||||
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
|
||||
return NewEvent(ctx, EventHookTriggered, map[string]any{
|
||||
"hook_id": hookID,
|
||||
"hook_name": hookName,
|
||||
"url": url,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
|
||||
// HookSuccessEvent creates a hook success event
|
||||
func HookSuccessEvent(ctx context.Context, hookID, hookName string, statusCode int, response any) Event {
|
||||
return NewEvent(ctx, EventHookSuccess, map[string]any{
|
||||
"hook_id": hookID,
|
||||
"hook_name": hookName,
|
||||
"status_code": statusCode,
|
||||
"response": response,
|
||||
})
|
||||
}
|
||||
|
||||
// HookFailedEvent creates a hook failed event
|
||||
func HookFailedEvent(ctx context.Context, hookID, hookName string, err error) Event {
|
||||
return NewEvent(ctx, EventHookFailed, map[string]any{
|
||||
"hook_id": hookID,
|
||||
"hook_name": hookName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
161
pkg/events/events.go
Normal file
161
pkg/events/events.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType represents the type of event
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// WhatsApp connection events
|
||||
EventWhatsAppConnected EventType = "whatsapp.connected"
|
||||
EventWhatsAppDisconnected EventType = "whatsapp.disconnected"
|
||||
EventWhatsAppPairSuccess EventType = "whatsapp.pair.success"
|
||||
EventWhatsAppPairFailed EventType = "whatsapp.pair.failed"
|
||||
EventWhatsAppQRCode EventType = "whatsapp.qr.code"
|
||||
EventWhatsAppQRTimeout EventType = "whatsapp.qr.timeout"
|
||||
EventWhatsAppQRError EventType = "whatsapp.qr.error"
|
||||
EventWhatsAppPairEvent EventType = "whatsapp.pair.event"
|
||||
|
||||
// WhatsApp message events
|
||||
EventMessageReceived EventType = "message.received"
|
||||
EventMessageSent EventType = "message.sent"
|
||||
EventMessageFailed EventType = "message.failed"
|
||||
EventMessageDelivered EventType = "message.delivered"
|
||||
EventMessageRead EventType = "message.read"
|
||||
|
||||
// Hook events
|
||||
EventHookTriggered EventType = "hook.triggered"
|
||||
EventHookSuccess EventType = "hook.success"
|
||||
EventHookFailed EventType = "hook.failed"
|
||||
)
|
||||
|
||||
// Event represents an event in the system
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Data map[string]any `json:"data"`
|
||||
Context context.Context `json:"-"`
|
||||
}
|
||||
|
||||
// Subscriber is a function that handles events
|
||||
type Subscriber func(event Event)
|
||||
|
||||
// EventBus manages event publishing and subscription
|
||||
type EventBus struct {
|
||||
subscribers map[EventType][]Subscriber
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewEventBus creates a new event bus
|
||||
func NewEventBus() *EventBus {
|
||||
return &EventBus{
|
||||
subscribers: make(map[EventType][]Subscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a subscriber for a specific event type
|
||||
func (eb *EventBus) Subscribe(eventType EventType, subscriber Subscriber) {
|
||||
eb.mu.Lock()
|
||||
defer eb.mu.Unlock()
|
||||
|
||||
if eb.subscribers[eventType] == nil {
|
||||
eb.subscribers[eventType] = make([]Subscriber, 0)
|
||||
}
|
||||
|
||||
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
|
||||
}
|
||||
|
||||
// SubscribeAll registers a subscriber for all event types
|
||||
func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
|
||||
eb.mu.Lock()
|
||||
defer eb.mu.Unlock()
|
||||
|
||||
allTypes := []EventType{
|
||||
EventWhatsAppConnected,
|
||||
EventWhatsAppDisconnected,
|
||||
EventWhatsAppPairSuccess,
|
||||
EventWhatsAppPairFailed,
|
||||
EventMessageReceived,
|
||||
EventMessageSent,
|
||||
EventMessageFailed,
|
||||
EventMessageDelivered,
|
||||
EventMessageRead,
|
||||
EventHookTriggered,
|
||||
EventHookSuccess,
|
||||
EventHookFailed,
|
||||
}
|
||||
|
||||
for _, eventType := range allTypes {
|
||||
if eb.subscribers[eventType] == nil {
|
||||
eb.subscribers[eventType] = make([]Subscriber, 0)
|
||||
}
|
||||
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish publishes an event to all subscribers asynchronously
|
||||
func (eb *EventBus) Publish(event Event) {
|
||||
eb.mu.RLock()
|
||||
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
|
||||
copy(subscribers, eb.subscribers[event.Type])
|
||||
eb.mu.RUnlock()
|
||||
|
||||
// Use event context if available, otherwise background
|
||||
ctx := event.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
go func(sub Subscriber, evt Event) {
|
||||
// Check if context is already cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
sub(evt)
|
||||
}
|
||||
}(subscriber, event)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishSync publishes an event to all subscribers synchronously
|
||||
func (eb *EventBus) PublishSync(event Event) {
|
||||
eb.mu.RLock()
|
||||
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
|
||||
copy(subscribers, eb.subscribers[event.Type])
|
||||
eb.mu.RUnlock()
|
||||
|
||||
// Use event context if available, otherwise background
|
||||
ctx := event.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
// Check if context is already cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
subscriber(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewEvent creates a new event with the current timestamp and context
|
||||
func NewEvent(ctx context.Context, eventType EventType, data map[string]any) Event {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return Event{
|
||||
Type: eventType,
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Context: ctx,
|
||||
}
|
||||
}
|
||||
47
pkg/handlers/accounts.go
Normal file
47
pkg/handlers/accounts.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
)
|
||||
|
||||
// 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")
|
||||
json.NewEncoder(w).Encode(h.config.WhatsApp)
|
||||
}
|
||||
|
||||
// AddAccount adds a new WhatsApp account to the system
|
||||
func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var account config.WhatsAppConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&account); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to the account
|
||||
if err := h.whatsappMgr.Connect(context.Background(), account); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update config
|
||||
h.config.WhatsApp = append(h.config.WhatsApp, account)
|
||||
if h.configPath != "" {
|
||||
if err := config.Save(h.configPath, h.config); err != nil {
|
||||
logging.Error("Failed to save config", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
151
pkg/handlers/businessapi.go
Normal file
151
pkg/handlers/businessapi.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
|
||||
)
|
||||
|
||||
// BusinessAPIWebhook handles both verification (GET) and webhook events (POST)
|
||||
func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
h.businessAPIWebhookVerify(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
h.businessAPIWebhookEvent(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// businessAPIWebhookVerify handles webhook verification from Meta
|
||||
// GET /webhooks/whatsapp/{accountID}?hub.mode=subscribe&hub.verify_token=XXX&hub.challenge=YYY
|
||||
func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract account ID from URL path
|
||||
accountID := extractAccountIDFromPath(r.URL.Path)
|
||||
if accountID == "" {
|
||||
http.Error(w, "Account ID required in path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the account configuration
|
||||
var accountConfig *struct {
|
||||
ID string
|
||||
Type string
|
||||
VerifyToken string
|
||||
}
|
||||
|
||||
for _, cfg := range h.config.WhatsApp {
|
||||
if cfg.ID == accountID && cfg.Type == "business-api" {
|
||||
if cfg.BusinessAPI != nil {
|
||||
accountConfig = &struct {
|
||||
ID string
|
||||
Type string
|
||||
VerifyToken string
|
||||
}{
|
||||
ID: cfg.ID,
|
||||
Type: cfg.Type,
|
||||
VerifyToken: cfg.BusinessAPI.VerifyToken,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accountConfig == nil {
|
||||
logging.Error("Business API account not found or not configured", "account_id", accountID)
|
||||
http.Error(w, "Account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
mode := r.URL.Query().Get("hub.mode")
|
||||
token := r.URL.Query().Get("hub.verify_token")
|
||||
challenge := r.URL.Query().Get("hub.challenge")
|
||||
|
||||
logging.Info("Webhook verification request",
|
||||
"account_id", accountID,
|
||||
"mode", mode,
|
||||
"has_challenge", challenge != "")
|
||||
|
||||
// Verify the token matches
|
||||
if mode == "subscribe" && token == accountConfig.VerifyToken {
|
||||
logging.Info("Webhook verification successful", "account_id", accountID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(challenge))
|
||||
return
|
||||
}
|
||||
|
||||
logging.Warn("Webhook verification failed",
|
||||
"account_id", accountID,
|
||||
"mode", mode,
|
||||
"token_match", token == accountConfig.VerifyToken)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// businessAPIWebhookEvent handles incoming webhook events from Meta
|
||||
// POST /webhooks/whatsapp/{accountID}
|
||||
func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract account ID from URL path
|
||||
accountID := extractAccountIDFromPath(r.URL.Path)
|
||||
if accountID == "" {
|
||||
http.Error(w, "Account ID required in path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the client from the manager
|
||||
client, exists := h.whatsappMgr.GetClient(accountID)
|
||||
if !exists {
|
||||
logging.Error("Client not found for webhook", "account_id", accountID)
|
||||
http.Error(w, "Account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a Business API client
|
||||
if client.GetType() != "business-api" {
|
||||
logging.Error("Account is not a Business API client", "account_id", accountID, "type", client.GetType())
|
||||
http.Error(w, "Not a Business API account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Cast to Business API client to access HandleWebhook
|
||||
baClient, ok := client.(*businessapi.Client)
|
||||
if !ok {
|
||||
logging.Error("Failed to cast to Business API client", "account_id", accountID)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the webhook
|
||||
if err := baClient.HandleWebhook(r); err != nil {
|
||||
logging.Error("Failed to process webhook", "account_id", accountID, "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return 200 OK to acknowledge receipt
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
// extractAccountIDFromPath extracts the account ID from the URL path
|
||||
// Example: /webhooks/whatsapp/business -> "business"
|
||||
func extractAccountIDFromPath(path string) string {
|
||||
// Remove trailing slash if present
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
// Split by /
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
// Expected format: /webhooks/whatsapp/{accountID}
|
||||
if len(parts) >= 4 {
|
||||
return parts[3]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
56
pkg/handlers/handlers.go
Normal file
56
pkg/handlers/handlers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
||||
)
|
||||
|
||||
// Handlers holds all HTTP handlers with their dependencies
|
||||
type Handlers struct {
|
||||
whatsappMgr *whatsapp.Manager
|
||||
hookMgr *hooks.Manager
|
||||
config *config.Config
|
||||
configPath string
|
||||
|
||||
// Auth configuration
|
||||
authConfig *AuthConfig
|
||||
}
|
||||
|
||||
// AuthConfig configures authentication behavior
|
||||
type AuthConfig struct {
|
||||
// Validator is a custom auth validator function
|
||||
// If nil, uses built-in auth (API key, basic auth)
|
||||
Validator func(r *http.Request) bool
|
||||
|
||||
// Built-in auth settings
|
||||
APIKey string
|
||||
Username string
|
||||
Password string
|
||||
|
||||
// Skip auth entirely (not recommended for production)
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance
|
||||
func New(mgr *whatsapp.Manager, hookMgr *hooks.Manager, cfg *config.Config, configPath string) *Handlers {
|
||||
return &Handlers{
|
||||
whatsappMgr: mgr,
|
||||
hookMgr: hookMgr,
|
||||
config: cfg,
|
||||
configPath: configPath,
|
||||
authConfig: &AuthConfig{
|
||||
APIKey: cfg.Server.AuthKey,
|
||||
Username: cfg.Server.Username,
|
||||
Password: cfg.Server.Password,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthConfig sets custom auth configuration
|
||||
func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers {
|
||||
h.authConfig = cfg
|
||||
return h
|
||||
}
|
||||
11
pkg/handlers/health.go
Normal file
11
pkg/handlers/health.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Health handles health check requests
|
||||
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
74
pkg/handlers/hooks.go
Normal file
74
pkg/handlers/hooks.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
)
|
||||
|
||||
// Hooks returns the list of all configured hooks
|
||||
func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) {
|
||||
hooks := h.hookMgr.ListHooks()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(hooks)
|
||||
}
|
||||
|
||||
// AddHook adds a new hook to the system
|
||||
func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var hook config.Hook
|
||||
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.hookMgr.AddHook(hook)
|
||||
|
||||
// Update config
|
||||
h.config.Hooks = h.hookMgr.ListHooks()
|
||||
if h.configPath != "" {
|
||||
if err := config.Save(h.configPath, h.config); err != nil {
|
||||
logging.Error("Failed to save config", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// RemoveHook removes a hook from the system
|
||||
func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.hookMgr.RemoveHook(req.ID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update config
|
||||
h.config.Hooks = h.hookMgr.ListHooks()
|
||||
if h.configPath != "" {
|
||||
if err := config.Save(h.configPath, h.config); err != nil {
|
||||
logging.Error("Failed to save config", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
52
pkg/handlers/media.go
Normal file
52
pkg/handlers/media.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ServeMedia serves media files with path traversal protection
|
||||
func (h *Handlers) ServeMedia(w http.ResponseWriter, r *http.Request) {
|
||||
// Expected path format: /api/media/{accountID}/{filename}
|
||||
path := r.URL.Path[len("/api/media/"):]
|
||||
|
||||
// Split path into accountID and filename
|
||||
var accountID, filename string
|
||||
for i, ch := range path {
|
||||
if ch == '/' {
|
||||
accountID = path[:i]
|
||||
filename = path[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if accountID == "" || filename == "" {
|
||||
http.Error(w, "Invalid media path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full file path
|
||||
filePath := filepath.Join(h.config.Media.DataPath, accountID, filename)
|
||||
|
||||
// Security check: ensure the resolved path is within the media directory
|
||||
mediaDir := filepath.Join(h.config.Media.DataPath, accountID)
|
||||
absFilePath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
absMediaDir, err := filepath.Abs(mediaDir)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file path is within media directory (prevent directory traversal)
|
||||
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
http.ServeFile(w, r, absFilePath)
|
||||
}
|
||||
71
pkg/handlers/middleware.go
Normal file
71
pkg/handlers/middleware.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Auth is the middleware that wraps handlers with authentication
|
||||
func (h *Handlers) Auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// If auth is disabled
|
||||
if h.authConfig.Disabled {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If custom validator is provided
|
||||
if h.authConfig.Validator != nil {
|
||||
if h.authConfig.Validator(r) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Built-in auth logic (API key, basic auth)
|
||||
if h.validateBuiltinAuth(r) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// validateBuiltinAuth checks API key or basic auth
|
||||
func (h *Handlers) validateBuiltinAuth(r *http.Request) bool {
|
||||
// Check if any authentication is configured
|
||||
hasAuth := h.authConfig.APIKey != "" || h.authConfig.Username != "" || h.authConfig.Password != ""
|
||||
if !hasAuth {
|
||||
// No authentication configured, allow access
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for API key authentication (x-api-key header or Authorization bearer token)
|
||||
if h.authConfig.APIKey != "" {
|
||||
// Check x-api-key header
|
||||
apiKey := r.Header.Get("x-api-key")
|
||||
if apiKey == h.authConfig.APIKey {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Authorization header for bearer token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token := authHeader[7:]
|
||||
if token == h.authConfig.APIKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for username/password authentication (HTTP Basic Auth)
|
||||
if h.authConfig.Username != "" && h.authConfig.Password != "" {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if ok && username == h.authConfig.Username && password == h.authConfig.Password {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
189
pkg/handlers/send.go
Normal file
189
pkg/handlers/send.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/utils"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// SendMessage sends a text message via WhatsApp
|
||||
func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
|
||||
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// SendImage sends an image via WhatsApp
|
||||
func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Caption string `json:"caption"`
|
||||
MimeType string `json:"mime_type"`
|
||||
ImageData string `json:"image_data"` // base64 encoded
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64 image data
|
||||
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default mime type if not provided
|
||||
if req.MimeType == "" {
|
||||
req.MimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
if err := h.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// SendVideo sends a video via WhatsApp
|
||||
func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Caption string `json:"caption"`
|
||||
MimeType string `json:"mime_type"`
|
||||
VideoData string `json:"video_data"` // base64 encoded
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64 video data
|
||||
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default mime type if not provided
|
||||
if req.MimeType == "" {
|
||||
req.MimeType = "video/mp4"
|
||||
}
|
||||
|
||||
if err := h.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// SendDocument sends a document via WhatsApp
|
||||
func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Caption string `json:"caption"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Filename string `json:"filename"`
|
||||
DocumentData string `json:"document_data"` // base64 encoded
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64 document data
|
||||
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default values if not provided
|
||||
if req.MimeType == "" {
|
||||
req.MimeType = "application/octet-stream"
|
||||
}
|
||||
if req.Filename == "" {
|
||||
req.Filename = "document"
|
||||
}
|
||||
|
||||
if err := h.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
365
pkg/hooks/manager.go
Normal file
365
pkg/hooks/manager.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
)
|
||||
|
||||
// MediaInfo represents media attachment information
|
||||
type MediaInfo struct {
|
||||
Type string `json:"type"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Base64 string `json:"base64,omitempty"`
|
||||
}
|
||||
|
||||
// MessagePayload represents a message sent to webhooks
|
||||
type MessagePayload struct {
|
||||
AccountID string `json:"account_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
IsGroup bool `json:"is_group"`
|
||||
GroupName string `json:"group_name,omitempty"`
|
||||
SenderName string `json:"sender_name,omitempty"`
|
||||
MessageType string `json:"message_type"`
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
// HookResponse represents a response from a webhook
|
||||
type HookResponse struct {
|
||||
SendMessage bool `json:"send_message"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
}
|
||||
|
||||
// Manager manages webhooks
|
||||
type Manager struct {
|
||||
hooks map[string]config.Hook
|
||||
mu sync.RWMutex
|
||||
client *http.Client
|
||||
eventBus *events.EventBus
|
||||
}
|
||||
|
||||
// NewManager creates a new hook manager
|
||||
func NewManager(eventBus *events.EventBus) *Manager {
|
||||
return &Manager{
|
||||
hooks: make(map[string]config.Hook),
|
||||
eventBus: eventBus,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for events
|
||||
func (m *Manager) Start() {
|
||||
// Get all possible event types
|
||||
allEventTypes := []events.EventType{
|
||||
events.EventWhatsAppConnected,
|
||||
events.EventWhatsAppDisconnected,
|
||||
events.EventWhatsAppPairSuccess,
|
||||
events.EventWhatsAppPairFailed,
|
||||
events.EventWhatsAppQRCode,
|
||||
events.EventWhatsAppQRTimeout,
|
||||
events.EventWhatsAppQRError,
|
||||
events.EventWhatsAppPairEvent,
|
||||
events.EventMessageReceived,
|
||||
events.EventMessageSent,
|
||||
events.EventMessageFailed,
|
||||
events.EventMessageDelivered,
|
||||
events.EventMessageRead,
|
||||
}
|
||||
|
||||
// Subscribe to all event types with a generic handler
|
||||
for _, eventType := range allEventTypes {
|
||||
m.eventBus.Subscribe(eventType, m.handleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvent processes any event and triggers relevant hooks
|
||||
func (m *Manager) handleEvent(event events.Event) {
|
||||
// Get hooks that are subscribed to this event type
|
||||
m.mu.RLock()
|
||||
relevantHooks := make([]config.Hook, 0)
|
||||
for _, hook := range m.hooks {
|
||||
if !hook.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
// If hook has no events specified, subscribe to all events
|
||||
if len(hook.Events) == 0 {
|
||||
relevantHooks = append(relevantHooks, hook)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this hook is subscribed to this event type
|
||||
eventTypeStr := string(event.Type)
|
||||
for _, subscribedEvent := range hook.Events {
|
||||
if subscribedEvent == eventTypeStr {
|
||||
relevantHooks = append(relevantHooks, hook)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Trigger each relevant hook
|
||||
if len(relevantHooks) > 0 {
|
||||
m.triggerHooksForEvent(event, relevantHooks)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerHooksForEvent sends event data to specific hooks
|
||||
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) {
|
||||
ctx := event.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Create payload based on event type
|
||||
var payload interface{}
|
||||
|
||||
// For message events, create MessagePayload
|
||||
if event.Type == events.EventMessageReceived || event.Type == events.EventMessageSent {
|
||||
messageType := getStringFromEvent(event.Data, "message_type")
|
||||
|
||||
msgPayload := MessagePayload{
|
||||
AccountID: getStringFromEvent(event.Data, "account_id"),
|
||||
MessageID: getStringFromEvent(event.Data, "message_id"),
|
||||
From: getStringFromEvent(event.Data, "from"),
|
||||
To: getStringFromEvent(event.Data, "to"),
|
||||
Text: getStringFromEvent(event.Data, "text"),
|
||||
Timestamp: getTimeFromEvent(event.Data, "timestamp"),
|
||||
IsGroup: getBoolFromEvent(event.Data, "is_group"),
|
||||
GroupName: getStringFromEvent(event.Data, "group_name"),
|
||||
SenderName: getStringFromEvent(event.Data, "sender_name"),
|
||||
MessageType: messageType,
|
||||
}
|
||||
|
||||
// Add media info if message has media content
|
||||
if messageType != "" && messageType != "text" {
|
||||
msgPayload.Media = &MediaInfo{
|
||||
Type: messageType,
|
||||
MimeType: getStringFromEvent(event.Data, "mime_type"),
|
||||
Filename: getStringFromEvent(event.Data, "filename"),
|
||||
URL: getStringFromEvent(event.Data, "media_url"),
|
||||
Base64: getStringFromEvent(event.Data, "media_base64"),
|
||||
}
|
||||
}
|
||||
|
||||
payload = msgPayload
|
||||
} else {
|
||||
// For other events, create generic payload with event type and data
|
||||
payload = map[string]interface{}{
|
||||
"event_type": string(event.Type),
|
||||
"timestamp": event.Timestamp,
|
||||
"data": event.Data,
|
||||
}
|
||||
}
|
||||
|
||||
// Send to each hook with the event type
|
||||
var wg sync.WaitGroup
|
||||
for _, hook := range hooks {
|
||||
wg.Add(1)
|
||||
go func(h config.Hook, et events.EventType) {
|
||||
defer wg.Done()
|
||||
_ = m.sendToHook(ctx, h, payload, et)
|
||||
}(hook, event.Type)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Helper functions to extract data from event map
|
||||
func getStringFromEvent(data map[string]interface{}, key string) string {
|
||||
if val, ok := data[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getTimeFromEvent(data map[string]interface{}, key string) time.Time {
|
||||
if val, ok := data[key].(time.Time); ok {
|
||||
return val
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func getBoolFromEvent(data map[string]interface{}, key string) bool {
|
||||
if val, ok := data[key].(bool); ok {
|
||||
return val
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadHooks loads hooks from configuration
|
||||
func (m *Manager) LoadHooks(hooks []config.Hook) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, hook := range hooks {
|
||||
m.hooks[hook.ID] = hook
|
||||
}
|
||||
|
||||
logging.Info("Hooks loaded", "count", len(hooks))
|
||||
}
|
||||
|
||||
// AddHook adds a new hook
|
||||
func (m *Manager) AddHook(hook config.Hook) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.hooks[hook.ID] = hook
|
||||
logging.Info("Hook added", "id", hook.ID, "name", hook.Name)
|
||||
}
|
||||
|
||||
// RemoveHook removes a hook
|
||||
func (m *Manager) RemoveHook(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.hooks[id]; !exists {
|
||||
return fmt.Errorf("hook %s not found", id)
|
||||
}
|
||||
|
||||
delete(m.hooks, id)
|
||||
logging.Info("Hook removed", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHook returns a hook by ID
|
||||
func (m *Manager) GetHook(id string) (config.Hook, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
hook, exists := m.hooks[id]
|
||||
return hook, exists
|
||||
}
|
||||
|
||||
// ListHooks returns all hooks
|
||||
func (m *Manager) ListHooks() []config.Hook {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
hooks := make([]config.Hook, 0, len(m.hooks))
|
||||
for _, hook := range m.hooks {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
return hooks
|
||||
}
|
||||
|
||||
// sendToHook sends any payload to a specific hook with explicit event type
|
||||
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Publish hook triggered event
|
||||
m.eventBus.Publish(events.HookTriggeredEvent(ctx, hook.ID, hook.Name, hook.URL, payload))
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
parsedURL, err := url.Parse(hook.URL)
|
||||
if err != nil {
|
||||
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract account_id from payload
|
||||
var accountID string
|
||||
|
||||
switch p := payload.(type) {
|
||||
case MessagePayload:
|
||||
accountID = p.AccountID
|
||||
case map[string]interface{}:
|
||||
if data, ok := p["data"].(map[string]interface{}); ok {
|
||||
if aid, ok := data["account_id"].(string); ok {
|
||||
accountID = aid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
query := parsedURL.Query()
|
||||
if eventType != "" {
|
||||
query.Set("event", string(eventType))
|
||||
}
|
||||
if accountID != "" {
|
||||
query.Set("account_id", accountID)
|
||||
}
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, hook.Method, parsedURL.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for key, value := range hook.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
logging.Debug("Sending to hook", "hook_id", hook.ID, "url", hook.URL)
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to parse response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
|
||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
var hookResp HookResponse
|
||||
if err := json.Unmarshal(body, &hookResp); err != nil {
|
||||
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
|
||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, string(body)))
|
||||
return nil
|
||||
}
|
||||
|
||||
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
|
||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, hookResp))
|
||||
return &hookResp
|
||||
}
|
||||
56
pkg/logging/logging.go
Normal file
56
pkg/logging/logging.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package logging
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// Logger interface allows users to plug in their own logger
|
||||
type Logger interface {
|
||||
Debug(msg string, args ...any)
|
||||
Info(msg string, args ...any)
|
||||
Warn(msg string, args ...any)
|
||||
Error(msg string, args ...any)
|
||||
}
|
||||
|
||||
var defaultLogger Logger = &slogLogger{logger: slog.Default()}
|
||||
|
||||
// SetLogger allows users to plug in their own logger
|
||||
func SetLogger(l Logger) {
|
||||
defaultLogger = l
|
||||
}
|
||||
|
||||
// Init initializes the default slog logger with a specific log level
|
||||
func Init(level string) {
|
||||
var slogLevel slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
case "info":
|
||||
slogLevel = slog.LevelInfo
|
||||
case "warn":
|
||||
slogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(nil, &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
})
|
||||
defaultLogger = &slogLogger{logger: slog.New(handler)}
|
||||
}
|
||||
|
||||
// Default slog implementation
|
||||
type slogLogger struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *slogLogger) Debug(msg string, args ...any) { s.logger.Debug(msg, args...) }
|
||||
func (s *slogLogger) Info(msg string, args ...any) { s.logger.Info(msg, args...) }
|
||||
func (s *slogLogger) Warn(msg string, args ...any) { s.logger.Warn(msg, args...) }
|
||||
func (s *slogLogger) Error(msg string, args ...any) { s.logger.Error(msg, args...) }
|
||||
|
||||
// Package-level functions for convenience
|
||||
func Debug(msg string, args ...any) { defaultLogger.Debug(msg, args...) }
|
||||
func Info(msg string, args ...any) { defaultLogger.Info(msg, args...) }
|
||||
func Warn(msg string, args ...any) { defaultLogger.Warn(msg, args...) }
|
||||
func Error(msg string, args ...any) { defaultLogger.Error(msg, args...) }
|
||||
72
pkg/utils/phone.go
Normal file
72
pkg/utils/phone.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatPhoneToJID converts a phone number to WhatsApp JID format
|
||||
// If the number already contains @, it returns as-is
|
||||
// Otherwise, applies formatting rules:
|
||||
// - If starts with 0, assumes no country code and replaces 0 with country code
|
||||
// - If starts with +, assumes it already has country code
|
||||
// - Otherwise adds country code if not present
|
||||
// - Adds @s.whatsapp.net suffix
|
||||
func FormatPhoneToJID(phone string, defaultCountryCode string) string {
|
||||
// If already in JID format, return as-is
|
||||
if strings.Contains(phone, "@") {
|
||||
return phone
|
||||
}
|
||||
|
||||
// Remove all non-digit characters
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, phone)
|
||||
|
||||
// If empty after cleaning, return original
|
||||
if cleaned == "" {
|
||||
return phone
|
||||
}
|
||||
|
||||
// If number starts with 0, it definitely doesn't have a country code
|
||||
// Replace the leading 0 with the country code
|
||||
if strings.HasPrefix(cleaned, "0") && defaultCountryCode != "" {
|
||||
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
|
||||
cleaned = countryCode + strings.TrimLeft(cleaned, "0")
|
||||
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||
}
|
||||
|
||||
// Remove all leading zeros
|
||||
cleaned = strings.TrimLeft(cleaned, "0")
|
||||
|
||||
// If original phone started with +, it already has country code
|
||||
if strings.HasPrefix(phone, "+") {
|
||||
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||
}
|
||||
|
||||
// Add country code if provided and number doesn't start with it
|
||||
if defaultCountryCode != "" {
|
||||
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
|
||||
if !strings.HasPrefix(cleaned, countryCode) {
|
||||
cleaned = countryCode + cleaned
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||
}
|
||||
|
||||
// IsGroupJID checks if a JID is a group JID
|
||||
func IsGroupJID(jid string) bool {
|
||||
return strings.HasSuffix(jid, "@g.us")
|
||||
}
|
||||
|
||||
// IsValidJID checks if a string is a valid WhatsApp JID
|
||||
func IsValidJID(jid string) bool {
|
||||
return strings.Contains(jid, "@") &&
|
||||
(strings.HasSuffix(jid, "@s.whatsapp.net") ||
|
||||
strings.HasSuffix(jid, "@g.us") ||
|
||||
strings.HasSuffix(jid, "@broadcast"))
|
||||
}
|
||||
200
pkg/utils/phone_test.go
Normal file
200
pkg/utils/phone_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatPhoneToJID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phone string
|
||||
defaultCountryCode string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Already in JID format",
|
||||
phone: "27834606792@s.whatsapp.net",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Plain number with leading zero",
|
||||
phone: "0834606792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Number with country code",
|
||||
phone: "27834606792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Number with plus sign",
|
||||
phone: "+27834606792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Number without country code config",
|
||||
phone: "0834606792",
|
||||
defaultCountryCode: "",
|
||||
want: "834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Number with spaces and dashes",
|
||||
phone: "083-460-6792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Number with parentheses",
|
||||
phone: "(083) 460 6792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "US number with leading 1",
|
||||
phone: "12025551234",
|
||||
defaultCountryCode: "1",
|
||||
want: "12025551234@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "US number with area code",
|
||||
phone: "202-555-1234",
|
||||
defaultCountryCode: "1",
|
||||
want: "12025551234@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Group JID unchanged",
|
||||
phone: "123456789-1234567890@g.us",
|
||||
defaultCountryCode: "27",
|
||||
want: "123456789-1234567890@g.us",
|
||||
},
|
||||
{
|
||||
name: "Number with different country code via plus sign",
|
||||
phone: "+12025551234",
|
||||
defaultCountryCode: "27",
|
||||
want: "12025551234@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Country code with plus in config",
|
||||
phone: "0834606792",
|
||||
defaultCountryCode: "+27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
{
|
||||
name: "Empty phone number",
|
||||
phone: "",
|
||||
defaultCountryCode: "27",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple leading zeros",
|
||||
phone: "00834606792",
|
||||
defaultCountryCode: "27",
|
||||
want: "27834606792@s.whatsapp.net",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatPhoneToJID(tt.phone, tt.defaultCountryCode)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatPhoneToJID(%q, %q) = %q, want %q",
|
||||
tt.phone, tt.defaultCountryCode, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGroupJID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jid string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Individual JID",
|
||||
jid: "27834606792@s.whatsapp.net",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Group JID",
|
||||
jid: "123456789-1234567890@g.us",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
jid: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid JID",
|
||||
jid: "notajid",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsGroupJID(tt.jid)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsGroupJID(%q) = %v, want %v", tt.jid, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidJID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jid string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Valid individual JID",
|
||||
jid: "27834606792@s.whatsapp.net",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Valid group JID",
|
||||
jid: "123456789-1234567890@g.us",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Valid broadcast JID",
|
||||
jid: "123456789@broadcast",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid - no @ symbol",
|
||||
jid: "27834606792",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid - wrong suffix",
|
||||
jid: "27834606792@invalid.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
jid: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Just @ symbol",
|
||||
jid: "@",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsValidJID(tt.jid)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsValidJID(%q) = %v, want %v", tt.jid, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
354
pkg/whatsapp/businessapi/client.go
Normal file
354
pkg/whatsapp/businessapi/client.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// Client represents a WhatsApp Business API client
|
||||
type Client struct {
|
||||
id string
|
||||
phoneNumber string
|
||||
config config.BusinessAPIConfig
|
||||
httpClient *http.Client
|
||||
eventBus *events.EventBus
|
||||
mediaConfig config.MediaConfig
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new Business API client
|
||||
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) {
|
||||
if cfg.Type != "business-api" {
|
||||
return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type)
|
||||
}
|
||||
|
||||
if cfg.BusinessAPI == nil {
|
||||
return nil, fmt.Errorf("business_api configuration is required for business-api type")
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.BusinessAPI.PhoneNumberID == "" {
|
||||
return nil, fmt.Errorf("phone_number_id is required")
|
||||
}
|
||||
if cfg.BusinessAPI.AccessToken == "" {
|
||||
return nil, fmt.Errorf("access_token is required")
|
||||
}
|
||||
|
||||
// Set default API version
|
||||
if cfg.BusinessAPI.APIVersion == "" {
|
||||
cfg.BusinessAPI.APIVersion = "v21.0"
|
||||
}
|
||||
|
||||
return &Client{
|
||||
id: cfg.ID,
|
||||
phoneNumber: cfg.PhoneNumber,
|
||||
config: *cfg.BusinessAPI,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
eventBus: eventBus,
|
||||
mediaConfig: mediaConfig,
|
||||
connected: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Connect validates the Business API credentials
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
// Validate credentials by making a test request to get phone number details
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||
c.config.APIVersion,
|
||||
c.config.PhoneNumberID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return fmt.Errorf("failed to validate credentials: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber)
|
||||
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the Business API client
|
||||
func (c *Client) Disconnect() error {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
|
||||
logging.Info("Business API client disconnected", "account_id", c.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *Client) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// GetID returns the client ID
|
||||
func (c *Client) GetID() string {
|
||||
return c.id
|
||||
}
|
||||
|
||||
// GetPhoneNumber returns the phone number
|
||||
func (c *Client) GetPhoneNumber() string {
|
||||
return c.phoneNumber
|
||||
}
|
||||
|
||||
// GetType returns the client type
|
||||
func (c *Client) GetType() string {
|
||||
return "business-api"
|
||||
}
|
||||
|
||||
// SendTextMessage sends a text message via Business API
|
||||
func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Convert JID to phone number
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
// Create request
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "text",
|
||||
Text: &TextObject{
|
||||
Body: text,
|
||||
},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, text, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Message sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, text))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendImage sends an image message via Business API
|
||||
func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
// Upload media first
|
||||
mediaID, err := c.uploadMedia(ctx, imageData, mimeType)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", fmt.Errorf("failed to upload image: %w", err)
|
||||
}
|
||||
|
||||
// Send message with media ID
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "image",
|
||||
Image: &MediaObject{
|
||||
ID: mediaID,
|
||||
Caption: caption,
|
||||
},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Image sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendVideo sends a video message via Business API
|
||||
func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
// Upload media first
|
||||
mediaID, err := c.uploadMedia(ctx, videoData, mimeType)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", fmt.Errorf("failed to upload video: %w", err)
|
||||
}
|
||||
|
||||
// Send message with media ID
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "video",
|
||||
Video: &MediaObject{
|
||||
ID: mediaID,
|
||||
Caption: caption,
|
||||
},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Video sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendDocument sends a document message via Business API
|
||||
func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
// Upload media first
|
||||
mediaID, err := c.uploadMedia(ctx, documentData, mimeType)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", fmt.Errorf("failed to upload document: %w", err)
|
||||
}
|
||||
|
||||
// Send message with media ID
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "document",
|
||||
Document: &DocumentObject{
|
||||
ID: mediaID,
|
||||
Caption: caption,
|
||||
Filename: filename,
|
||||
},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Document sent via Business API", "account_id", c.id, "to", phoneNumber, "filename", filename)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// sendMessage sends a message request to the Business API
|
||||
func (c *Client) sendMessage(ctx context.Context, reqBody SendMessageRequest) (string, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages",
|
||||
c.config.APIVersion,
|
||||
c.config.PhoneNumberID)
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||
}
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var sendResp SendMessageResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(sendResp.Messages) == 0 {
|
||||
return "", fmt.Errorf("no message ID in response")
|
||||
}
|
||||
|
||||
return sendResp.Messages[0].ID, nil
|
||||
}
|
||||
|
||||
// jidToPhoneNumber converts a WhatsApp JID to E.164 phone number format
|
||||
func jidToPhoneNumber(jid types.JID) string {
|
||||
// JID format is like "27123456789@s.whatsapp.net"
|
||||
// Extract the phone number part before @
|
||||
phone := jid.User
|
||||
|
||||
// Ensure it starts with + for E.164
|
||||
if !strings.HasPrefix(phone, "+") {
|
||||
phone = "+" + phone
|
||||
}
|
||||
|
||||
return phone
|
||||
}
|
||||
|
||||
// phoneNumberToJID converts an E.164 phone number to WhatsApp JID
|
||||
func phoneNumberToJID(phoneNumber string) types.JID {
|
||||
// Remove + if present
|
||||
phone := strings.TrimPrefix(phoneNumber, "+")
|
||||
|
||||
// Create JID
|
||||
return types.JID{
|
||||
User: phone,
|
||||
Server: types.DefaultUserServer, // "s.whatsapp.net"
|
||||
}
|
||||
}
|
||||
288
pkg/whatsapp/businessapi/events.go
Normal file
288
pkg/whatsapp/businessapi/events.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
)
|
||||
|
||||
// HandleWebhook processes incoming webhook events from WhatsApp Business API
|
||||
func (c *Client) HandleWebhook(r *http.Request) error {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
|
||||
var payload WebhookPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return fmt.Errorf("failed to parse webhook payload: %w", err)
|
||||
}
|
||||
|
||||
// Process each entry
|
||||
for _, entry := range payload.Entry {
|
||||
for _, change := range entry.Changes {
|
||||
c.processChange(change)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processChange processes a webhook change
|
||||
func (c *Client) processChange(change WebhookChange) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Process messages
|
||||
for _, msg := range change.Value.Messages {
|
||||
c.processMessage(ctx, msg, change.Value.Contacts)
|
||||
}
|
||||
|
||||
// Process statuses
|
||||
for _, status := range change.Value.Statuses {
|
||||
c.processStatus(ctx, status)
|
||||
}
|
||||
}
|
||||
|
||||
// processMessage processes an incoming message
|
||||
func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contacts []WebhookContact) {
|
||||
// Get sender name from contacts
|
||||
senderName := ""
|
||||
for _, contact := range contacts {
|
||||
if contact.WaID == msg.From {
|
||||
senderName = contact.Profile.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
timestamp := c.parseTimestamp(msg.Timestamp)
|
||||
|
||||
var text string
|
||||
var messageType string
|
||||
var mimeType string
|
||||
var filename string
|
||||
var mediaBase64 string
|
||||
var mediaURL string
|
||||
|
||||
// Process based on message type
|
||||
switch msg.Type {
|
||||
case "text":
|
||||
if msg.Text != nil {
|
||||
text = msg.Text.Body
|
||||
}
|
||||
messageType = "text"
|
||||
|
||||
case "image":
|
||||
if msg.Image != nil {
|
||||
messageType = "image"
|
||||
mimeType = msg.Image.MimeType
|
||||
text = msg.Image.Caption
|
||||
|
||||
// Download and process media
|
||||
data, _, err := c.downloadMedia(ctx, msg.Image.ID)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download image", "account_id", c.id, "media_id", msg.Image.ID, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
case "video":
|
||||
if msg.Video != nil {
|
||||
messageType = "video"
|
||||
mimeType = msg.Video.MimeType
|
||||
text = msg.Video.Caption
|
||||
|
||||
// Download and process media
|
||||
data, _, err := c.downloadMedia(ctx, msg.Video.ID)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download video", "account_id", c.id, "media_id", msg.Video.ID, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
case "document":
|
||||
if msg.Document != nil {
|
||||
messageType = "document"
|
||||
mimeType = msg.Document.MimeType
|
||||
text = msg.Document.Caption
|
||||
filename = msg.Document.Filename
|
||||
|
||||
// Download and process media
|
||||
data, _, err := c.downloadMedia(ctx, msg.Document.ID)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download document", "account_id", c.id, "media_id", msg.Document.ID, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish message received event
|
||||
c.eventBus.Publish(events.MessageReceivedEvent(
|
||||
ctx,
|
||||
c.id,
|
||||
msg.ID,
|
||||
msg.From,
|
||||
msg.From, // For Business API, chat is same as sender for individual messages
|
||||
text,
|
||||
timestamp,
|
||||
false, // Business API doesn't indicate groups in this webhook
|
||||
"",
|
||||
senderName,
|
||||
messageType,
|
||||
mimeType,
|
||||
filename,
|
||||
mediaBase64,
|
||||
mediaURL,
|
||||
))
|
||||
|
||||
logging.Debug("Message received via Business API", "account_id", c.id, "from", msg.From, "type", messageType)
|
||||
}
|
||||
|
||||
// processStatus processes a message status update
|
||||
func (c *Client) processStatus(ctx context.Context, status WebhookStatus) {
|
||||
timestamp := c.parseTimestamp(status.Timestamp)
|
||||
|
||||
switch status.Status {
|
||||
case "sent":
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, ""))
|
||||
logging.Debug("Message sent status", "account_id", c.id, "message_id", status.ID)
|
||||
|
||||
case "delivered":
|
||||
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
|
||||
logging.Debug("Message delivered", "account_id", c.id, "message_id", status.ID)
|
||||
|
||||
case "read":
|
||||
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
|
||||
logging.Debug("Message read", "account_id", c.id, "message_id", status.ID)
|
||||
|
||||
case "failed":
|
||||
errMsg := "unknown error"
|
||||
if len(status.Errors) > 0 {
|
||||
errMsg = fmt.Sprintf("%s (code: %d)", status.Errors[0].Title, status.Errors[0].Code)
|
||||
}
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, status.RecipientID, "", fmt.Errorf("%s", errMsg)))
|
||||
logging.Error("Message failed", "account_id", c.id, "message_id", status.ID, "error", errMsg)
|
||||
|
||||
default:
|
||||
logging.Debug("Unknown status type", "account_id", c.id, "status", status.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTimestamp parses a Unix timestamp string to time.Time
|
||||
func (c *Client) parseTimestamp(ts string) time.Time {
|
||||
unix, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
logging.Warn("Failed to parse timestamp", "timestamp", ts, "error", err)
|
||||
return time.Now()
|
||||
}
|
||||
return time.Unix(unix, 0)
|
||||
}
|
||||
|
||||
// processMediaData processes media based on the configured mode
|
||||
func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) {
|
||||
mode := c.mediaConfig.Mode
|
||||
var filename string
|
||||
var mediaURL string
|
||||
|
||||
// Generate filename
|
||||
ext := getExtensionFromMimeType(mimeType)
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := hex.EncodeToString(hash[:8])
|
||||
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
|
||||
|
||||
// Handle base64 mode
|
||||
if mode == "base64" || mode == "both" {
|
||||
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Handle link mode
|
||||
if mode == "link" || mode == "both" {
|
||||
// Save file to disk
|
||||
filePath, err := c.saveMediaFile(messageID, data, mimeType)
|
||||
if err != nil {
|
||||
logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err)
|
||||
} else {
|
||||
filename = filepath.Base(filePath)
|
||||
mediaURL = c.generateMediaURL(messageID, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename, mediaURL
|
||||
}
|
||||
|
||||
// saveMediaFile saves media data to disk
|
||||
func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) {
|
||||
mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id)
|
||||
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create media directory: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := hex.EncodeToString(hash[:8])
|
||||
ext := getExtensionFromMimeType(mimeType)
|
||||
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
|
||||
|
||||
filePath := filepath.Join(mediaDir, filename)
|
||||
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write media file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// generateMediaURL generates a URL for accessing stored media
|
||||
func (c *Client) generateMediaURL(messageID, filename string) string {
|
||||
baseURL := c.mediaConfig.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename)
|
||||
}
|
||||
|
||||
// getExtensionFromMimeType returns the file extension for a given MIME type
|
||||
func getExtensionFromMimeType(mimeType string) string {
|
||||
extensions := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"video/mp4": ".mp4",
|
||||
"video/mpeg": ".mpeg",
|
||||
"video/webm": ".webm",
|
||||
"video/3gpp": ".3gp",
|
||||
"application/pdf": ".pdf",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"text/plain": ".txt",
|
||||
"application/json": ".json",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
}
|
||||
|
||||
if ext, ok := extensions[mimeType]; ok {
|
||||
return ext
|
||||
}
|
||||
return ""
|
||||
}
|
||||
138
pkg/whatsapp/businessapi/media.go
Normal file
138
pkg/whatsapp/businessapi/media.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// uploadMedia uploads media to the Business API and returns the media ID
|
||||
func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/media",
|
||||
c.config.APIVersion,
|
||||
c.config.PhoneNumberID)
|
||||
|
||||
// Create multipart form data
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
// Add the file
|
||||
part, err := writer.CreateFormFile("file", "media")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return "", fmt.Errorf("failed to write file data: %w", err)
|
||||
}
|
||||
|
||||
// Add messaging_product field
|
||||
if err := writer.WriteField("messaging_product", "whatsapp"); err != nil {
|
||||
return "", fmt.Errorf("failed to write messaging_product field: %w", err)
|
||||
}
|
||||
|
||||
// Add type field (mime type)
|
||||
if err := writer.WriteField("type", mimeType); err != nil {
|
||||
return "", fmt.Errorf("failed to write type field: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Send request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload media: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return "", fmt.Errorf("upload error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||
}
|
||||
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var uploadResp MediaUploadResponse
|
||||
if err := json.Unmarshal(body, &uploadResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse upload response: %w", err)
|
||||
}
|
||||
|
||||
return uploadResp.ID, nil
|
||||
}
|
||||
|
||||
// downloadMedia downloads media from the Business API using the media ID
|
||||
func (c *Client) downloadMedia(ctx context.Context, mediaID string) ([]byte, string, error) {
|
||||
// Step 1: Get the media URL
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||
c.config.APIVersion,
|
||||
mediaID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get media URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("failed to get media URL, status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var mediaResp MediaURLResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mediaResp); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to parse media URL response: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Download from the CDN URL
|
||||
downloadReq, err := http.NewRequestWithContext(ctx, "GET", mediaResp.URL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
downloadReq.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
downloadResp, err := c.httpClient.Do(downloadReq)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to download media: %w", err)
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
|
||||
if downloadResp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("failed to download media, status %d", downloadResp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(downloadResp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read media data: %w", err)
|
||||
}
|
||||
|
||||
return data, mediaResp.MimeType, nil
|
||||
}
|
||||
193
pkg/whatsapp/businessapi/types.go
Normal file
193
pkg/whatsapp/businessapi/types.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package businessapi
|
||||
|
||||
// SendMessageRequest represents a request to send a text message via Business API
|
||||
type SendMessageRequest struct {
|
||||
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
|
||||
RecipientType string `json:"recipient_type,omitempty"` // "individual"
|
||||
To string `json:"to"` // Phone number in E.164 format
|
||||
Type string `json:"type"` // "text", "image", "video", "document"
|
||||
Text *TextObject `json:"text,omitempty"`
|
||||
Image *MediaObject `json:"image,omitempty"`
|
||||
Video *MediaObject `json:"video,omitempty"`
|
||||
Document *DocumentObject `json:"document,omitempty"`
|
||||
}
|
||||
|
||||
// TextObject represents a text message
|
||||
type TextObject struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// MediaObject represents media (image/video) message
|
||||
type MediaObject struct {
|
||||
ID string `json:"id,omitempty"` // Media ID (from upload)
|
||||
Link string `json:"link,omitempty"` // Or direct URL
|
||||
Caption string `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentObject represents a document message
|
||||
type DocumentObject struct {
|
||||
ID string `json:"id,omitempty"` // Media ID (from upload)
|
||||
Link string `json:"link,omitempty"` // Or direct URL
|
||||
Caption string `json:"caption,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessageResponse represents the response from sending a message
|
||||
type SendMessageResponse struct {
|
||||
MessagingProduct string `json:"messaging_product"`
|
||||
Contacts []struct {
|
||||
Input string `json:"input"`
|
||||
WaID string `json:"wa_id"`
|
||||
} `json:"contacts"`
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
// MediaUploadResponse represents the response from uploading media
|
||||
type MediaUploadResponse struct {
|
||||
ID string `json:"id"` // Media ID to use in messages
|
||||
}
|
||||
|
||||
// MediaURLResponse represents the response when getting media URL
|
||||
type MediaURLResponse struct {
|
||||
URL string `json:"url"` // CDN URL to download media
|
||||
MimeType string `json:"mime_type"`
|
||||
SHA256 string `json:"sha256"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ID string `json:"id"`
|
||||
MessagingProduct string `json:"messaging_product"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error from the Business API
|
||||
type ErrorResponse struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code int `json:"code"`
|
||||
ErrorSubcode int `json:"error_subcode,omitempty"`
|
||||
FBTraceID string `json:"fbtrace_id,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// WebhookPayload represents the incoming webhook from WhatsApp Business API
|
||||
type WebhookPayload struct {
|
||||
Object string `json:"object"` // "whatsapp_business_account"
|
||||
Entry []WebhookEntry `json:"entry"`
|
||||
}
|
||||
|
||||
// WebhookEntry represents an entry in the webhook
|
||||
type WebhookEntry struct {
|
||||
ID string `json:"id"` // WhatsApp Business Account ID
|
||||
Changes []WebhookChange `json:"changes"`
|
||||
}
|
||||
|
||||
// WebhookChange represents a change notification
|
||||
type WebhookChange struct {
|
||||
Value WebhookValue `json:"value"`
|
||||
Field string `json:"field"` // "messages"
|
||||
}
|
||||
|
||||
// WebhookValue contains the actual webhook data
|
||||
type WebhookValue struct {
|
||||
MessagingProduct string `json:"messaging_product"`
|
||||
Metadata WebhookMetadata `json:"metadata"`
|
||||
Contacts []WebhookContact `json:"contacts,omitempty"`
|
||||
Messages []WebhookMessage `json:"messages,omitempty"`
|
||||
Statuses []WebhookStatus `json:"statuses,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookMetadata contains metadata about the phone number
|
||||
type WebhookMetadata struct {
|
||||
DisplayPhoneNumber string `json:"display_phone_number"`
|
||||
PhoneNumberID string `json:"phone_number_id"`
|
||||
}
|
||||
|
||||
// WebhookContact represents a contact in the webhook
|
||||
type WebhookContact struct {
|
||||
Profile WebhookProfile `json:"profile"`
|
||||
WaID string `json:"wa_id"`
|
||||
}
|
||||
|
||||
// WebhookProfile contains profile information
|
||||
type WebhookProfile struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WebhookMessage represents a message in the webhook
|
||||
type WebhookMessage struct {
|
||||
From string `json:"from"` // Sender phone number
|
||||
ID string `json:"id"` // Message ID
|
||||
Timestamp string `json:"timestamp"` // Unix timestamp as string
|
||||
Type string `json:"type"` // "text", "image", "video", "document", etc.
|
||||
Text *WebhookText `json:"text,omitempty"`
|
||||
Image *WebhookMediaMessage `json:"image,omitempty"`
|
||||
Video *WebhookMediaMessage `json:"video,omitempty"`
|
||||
Document *WebhookDocumentMessage `json:"document,omitempty"`
|
||||
Context *WebhookContext `json:"context,omitempty"` // Reply context
|
||||
}
|
||||
|
||||
// WebhookText represents a text message
|
||||
type WebhookText struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// WebhookMediaMessage represents a media message (image/video)
|
||||
type WebhookMediaMessage struct {
|
||||
ID string `json:"id"` // Media ID
|
||||
MimeType string `json:"mime_type"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookDocumentMessage represents a document message
|
||||
type WebhookDocumentMessage struct {
|
||||
ID string `json:"id"` // Media ID
|
||||
MimeType string `json:"mime_type"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookContext represents reply context
|
||||
type WebhookContext struct {
|
||||
From string `json:"from"`
|
||||
ID string `json:"id"` // Message ID being replied to
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookStatus represents a message status update
|
||||
type WebhookStatus struct {
|
||||
ID string `json:"id"` // Message ID
|
||||
Status string `json:"status"` // "sent", "delivered", "read", "failed"
|
||||
Timestamp string `json:"timestamp"` // Unix timestamp as string
|
||||
RecipientID string `json:"recipient_id"`
|
||||
Conversation *WebhookConversation `json:"conversation,omitempty"`
|
||||
Pricing *WebhookPricing `json:"pricing,omitempty"`
|
||||
Errors []WebhookError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookConversation contains conversation details
|
||||
type WebhookConversation struct {
|
||||
ID string `json:"id"`
|
||||
ExpirationTimestamp string `json:"expiration_timestamp,omitempty"`
|
||||
Origin WebhookOrigin `json:"origin"`
|
||||
}
|
||||
|
||||
// WebhookOrigin contains conversation origin
|
||||
type WebhookOrigin struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// WebhookPricing contains pricing information
|
||||
type WebhookPricing struct {
|
||||
Billable bool `json:"billable"`
|
||||
PricingModel string `json:"pricing_model"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// WebhookError represents an error in status update
|
||||
type WebhookError struct {
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
34
pkg/whatsapp/interface.go
Normal file
34
pkg/whatsapp/interface.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// ClientType identifies the type of WhatsApp client
|
||||
type ClientType string
|
||||
|
||||
const (
|
||||
ClientTypeWhatsmeow ClientType = "whatsmeow"
|
||||
ClientTypeBusinessAPI ClientType = "business-api"
|
||||
)
|
||||
|
||||
// Client represents any WhatsApp client implementation (whatsmeow or Business API)
|
||||
type Client interface {
|
||||
// Connection Management
|
||||
Connect(ctx context.Context) error
|
||||
Disconnect() error
|
||||
IsConnected() bool
|
||||
|
||||
// Account Information
|
||||
GetID() string
|
||||
GetPhoneNumber() string
|
||||
GetType() string
|
||||
|
||||
// Message Sending
|
||||
SendTextMessage(ctx context.Context, jid types.JID, text string) (messageID string, err error)
|
||||
SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (messageID string, err error)
|
||||
SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (messageID string, err error)
|
||||
SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (messageID string, err error)
|
||||
}
|
||||
171
pkg/whatsapp/manager.go
Normal file
171
pkg/whatsapp/manager.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/whatsmeow"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// Manager manages multiple WhatsApp client connections
|
||||
type Manager struct {
|
||||
clients map[string]Client
|
||||
mu sync.RWMutex
|
||||
eventBus *events.EventBus
|
||||
mediaConfig config.MediaConfig
|
||||
config *config.Config
|
||||
configPath string
|
||||
onConfigUpdate func(*config.Config) error
|
||||
}
|
||||
|
||||
// NewManager creates a new WhatsApp manager
|
||||
func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager {
|
||||
return &Manager{
|
||||
clients: make(map[string]Client),
|
||||
eventBus: eventBus,
|
||||
mediaConfig: mediaConfig,
|
||||
config: cfg,
|
||||
configPath: configPath,
|
||||
onConfigUpdate: onConfigUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes a connection to a WhatsApp account using the appropriate client type
|
||||
func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.clients[cfg.ID]; exists {
|
||||
return fmt.Errorf("client %s already connected", cfg.ID)
|
||||
}
|
||||
|
||||
var client Client
|
||||
var err error
|
||||
|
||||
// Factory pattern based on type
|
||||
switch cfg.Type {
|
||||
case "business-api":
|
||||
client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig)
|
||||
case "whatsmeow", "":
|
||||
client, err = whatsmeow.NewClient(cfg, m.eventBus, m.mediaConfig)
|
||||
default:
|
||||
return fmt.Errorf("unknown client type: %s", cfg.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
m.clients[cfg.ID] = client
|
||||
logging.Info("Client connected", "account_id", cfg.ID, "type", client.GetType())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect disconnects a WhatsApp client
|
||||
func (m *Manager) Disconnect(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client, exists := m.clients[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("client %s not found", id)
|
||||
}
|
||||
|
||||
if err := client.Disconnect(); err != nil {
|
||||
return fmt.Errorf("failed to disconnect: %w", err)
|
||||
}
|
||||
|
||||
delete(m.clients, id)
|
||||
logging.Info("Client disconnected", "account_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisconnectAll disconnects all WhatsApp clients
|
||||
func (m *Manager) DisconnectAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, client := range m.clients {
|
||||
if err := client.Disconnect(); err != nil {
|
||||
logging.Error("Failed to disconnect client", "account_id", id, "error", err)
|
||||
} else {
|
||||
logging.Info("Client disconnected", "account_id", id)
|
||||
}
|
||||
}
|
||||
m.clients = make(map[string]Client)
|
||||
}
|
||||
|
||||
// SendTextMessage sends a text message from a specific account
|
||||
func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error {
|
||||
m.mu.RLock()
|
||||
client, exists := m.clients[accountID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("client %s not found", accountID)
|
||||
}
|
||||
|
||||
_, err := client.SendTextMessage(ctx, jid, text)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendImage sends an image message from a specific account
|
||||
func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error {
|
||||
m.mu.RLock()
|
||||
client, exists := m.clients[accountID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("client %s not found", accountID)
|
||||
}
|
||||
|
||||
_, err := client.SendImage(ctx, jid, imageData, mimeType, caption)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendVideo sends a video message from a specific account
|
||||
func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error {
|
||||
m.mu.RLock()
|
||||
client, exists := m.clients[accountID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("client %s not found", accountID)
|
||||
}
|
||||
|
||||
_, err := client.SendVideo(ctx, jid, videoData, mimeType, caption)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendDocument sends a document message from a specific account
|
||||
func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error {
|
||||
m.mu.RLock()
|
||||
client, exists := m.clients[accountID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("client %s not found", accountID)
|
||||
}
|
||||
|
||||
_, err := client.SendDocument(ctx, jid, documentData, mimeType, filename, caption)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetClient returns a client by ID
|
||||
func (m *Manager) GetClient(id string) (Client, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
client, exists := m.clients[id]
|
||||
return client, exists
|
||||
}
|
||||
678
pkg/whatsapp/whatsmeow/client.go
Normal file
678
pkg/whatsapp/whatsmeow/client.go
Normal file
@@ -0,0 +1,678 @@
|
||||
package whatsmeow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
|
||||
qrterminal "github.com/mdp/qrterminal/v3"
|
||||
"go.mau.fi/whatsmeow"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
waEvents "go.mau.fi/whatsmeow/types/events"
|
||||
waLog "go.mau.fi/whatsmeow/util/log"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Client represents a WhatsApp connection using whatsmeow
|
||||
type Client struct {
|
||||
id string
|
||||
phoneNumber string
|
||||
sessionPath string
|
||||
client *whatsmeow.Client
|
||||
container *sqlstore.Container
|
||||
eventBus *events.EventBus
|
||||
mediaConfig config.MediaConfig
|
||||
showQR bool
|
||||
keepAliveCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewClient creates a new whatsmeow client
|
||||
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) {
|
||||
if cfg.Type != "whatsmeow" && cfg.Type != "" {
|
||||
return nil, fmt.Errorf("invalid client type for whatsmeow: %s", cfg.Type)
|
||||
}
|
||||
|
||||
sessionPath := cfg.SessionPath
|
||||
if sessionPath == "" {
|
||||
sessionPath = fmt.Sprintf("./sessions/%s", cfg.ID)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
id: cfg.ID,
|
||||
phoneNumber: cfg.PhoneNumber,
|
||||
sessionPath: sessionPath,
|
||||
eventBus: eventBus,
|
||||
mediaConfig: mediaConfig,
|
||||
showQR: cfg.ShowQR,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Connect establishes a connection to WhatsApp
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
// Ensure session directory exists
|
||||
if err := os.MkdirAll(c.sessionPath, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create session directory: %w", err)
|
||||
}
|
||||
|
||||
// Create database container for session storage
|
||||
dbPath := filepath.Join(c.sessionPath, "session.db")
|
||||
dbLog := waLog.Stdout("Database", "ERROR", true)
|
||||
container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database container: %w", err)
|
||||
}
|
||||
c.container = container
|
||||
|
||||
// Get device store
|
||||
deviceStore, err := container.GetFirstDevice(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device: %w", err)
|
||||
}
|
||||
|
||||
// Set custom client information
|
||||
deviceStore.Platform = "WhatsHooked"
|
||||
deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked"
|
||||
|
||||
// Create client
|
||||
clientLog := waLog.Stdout("Client", "ERROR", true)
|
||||
client := whatsmeow.NewClient(deviceStore, clientLog)
|
||||
c.client = client
|
||||
|
||||
// Register event handler
|
||||
client.AddEventHandler(func(evt interface{}) {
|
||||
c.handleEvent(evt)
|
||||
})
|
||||
|
||||
// Connect
|
||||
if client.Store.ID == nil {
|
||||
// New device, need to pair
|
||||
qrChan, _ := client.GetQRChannel(ctx)
|
||||
if err := client.Connect(); err != nil {
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
// Wait for QR code
|
||||
for evt := range qrChan {
|
||||
switch evt.Event {
|
||||
case "code":
|
||||
logging.Info("QR code received for pairing", "account_id", c.id)
|
||||
|
||||
// Display QR code in terminal
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Printf("WhatsApp QR Code for account: %s\n", c.id)
|
||||
fmt.Printf("Phone: %s\n", c.phoneNumber)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("Scan this QR code with WhatsApp on your phone:")
|
||||
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Publish QR code event
|
||||
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code))
|
||||
|
||||
case "success":
|
||||
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber)
|
||||
c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id))
|
||||
|
||||
case "timeout":
|
||||
logging.Warn("QR code timeout", "account_id", c.id)
|
||||
c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id))
|
||||
|
||||
case "error":
|
||||
logging.Error("QR code error", "account_id", c.id, "error", evt.Error)
|
||||
c.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, c.id, fmt.Errorf("%v", evt.Error)))
|
||||
|
||||
default:
|
||||
logging.Info("Pairing event", "account_id", c.id, "event", evt.Event)
|
||||
c.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, c.id, evt.Event, map[string]any{
|
||||
"code": evt.Code,
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Already paired, just connect
|
||||
if err := client.Connect(); err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if deviceStore.PushName == "" {
|
||||
deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", c.phoneNumber)
|
||||
if err := deviceStore.Save(ctx); err != nil {
|
||||
logging.Error("failed to save device store", "account_id", c.id)
|
||||
}
|
||||
}
|
||||
|
||||
if client.IsConnected() {
|
||||
err := client.SendPresence(ctx, types.PresenceAvailable)
|
||||
if err != nil {
|
||||
logging.Warn("Failed to send presence", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
logging.Debug("Sent presence update", "account_id", c.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Start keep-alive routine
|
||||
c.startKeepAlive()
|
||||
|
||||
logging.Info("WhatsApp client connected", "account_id", c.id, "phone", c.phoneNumber)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the WhatsApp connection
|
||||
func (c *Client) Disconnect() error {
|
||||
// Stop keep-alive
|
||||
if c.keepAliveCancel != nil {
|
||||
c.keepAliveCancel()
|
||||
}
|
||||
|
||||
if c.client != nil {
|
||||
c.client.Disconnect()
|
||||
}
|
||||
|
||||
logging.Info("WhatsApp client disconnected", "account_id", c.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *Client) IsConnected() bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
return c.client.IsConnected()
|
||||
}
|
||||
|
||||
// GetID returns the client ID
|
||||
func (c *Client) GetID() string {
|
||||
return c.id
|
||||
}
|
||||
|
||||
// GetPhoneNumber returns the phone number
|
||||
func (c *Client) GetPhoneNumber() string {
|
||||
return c.phoneNumber
|
||||
}
|
||||
|
||||
// GetType returns the client type
|
||||
func (c *Client) GetType() string {
|
||||
return "whatsmeow"
|
||||
}
|
||||
|
||||
// SendTextMessage sends a text message
|
||||
func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if c.client == nil {
|
||||
err := fmt.Errorf("client not initialized")
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
msg := &waE2E.Message{
|
||||
Conversation: proto.String(text),
|
||||
}
|
||||
|
||||
resp, err := c.client.SendMessage(ctx, jid, msg)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err))
|
||||
return "", fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
logging.Debug("Message sent", "account_id", c.id, "to", jid.String())
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), text))
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// SendImage sends an image message
|
||||
func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if c.client == nil {
|
||||
err := fmt.Errorf("client not initialized")
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Upload the image
|
||||
uploaded, err := c.client.Upload(ctx, imageData, whatsmeow.MediaImage)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to upload image: %w", err)
|
||||
}
|
||||
|
||||
// Create image message
|
||||
msg := &waE2E.Message{
|
||||
ImageMessage: &waE2E.ImageMessage{
|
||||
URL: proto.String(uploaded.URL),
|
||||
DirectPath: proto.String(uploaded.DirectPath),
|
||||
MediaKey: uploaded.MediaKey,
|
||||
Mimetype: proto.String(mimeType),
|
||||
FileEncSHA256: uploaded.FileEncSHA256,
|
||||
FileSHA256: uploaded.FileSHA256,
|
||||
FileLength: proto.Uint64(uint64(len(imageData))),
|
||||
},
|
||||
}
|
||||
|
||||
// Add caption if provided
|
||||
if caption != "" {
|
||||
msg.ImageMessage.Caption = proto.String(caption)
|
||||
}
|
||||
|
||||
// Send the message
|
||||
resp, err := c.client.SendMessage(ctx, jid, msg)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to send image: %w", err)
|
||||
}
|
||||
|
||||
logging.Debug("Image sent", "account_id", c.id, "to", jid.String())
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// SendVideo sends a video message
|
||||
func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if c.client == nil {
|
||||
err := fmt.Errorf("client not initialized")
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Upload the video
|
||||
uploaded, err := c.client.Upload(ctx, videoData, whatsmeow.MediaVideo)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to upload video: %w", err)
|
||||
}
|
||||
|
||||
// Create video message
|
||||
msg := &waE2E.Message{
|
||||
VideoMessage: &waE2E.VideoMessage{
|
||||
URL: proto.String(uploaded.URL),
|
||||
DirectPath: proto.String(uploaded.DirectPath),
|
||||
MediaKey: uploaded.MediaKey,
|
||||
Mimetype: proto.String(mimeType),
|
||||
FileEncSHA256: uploaded.FileEncSHA256,
|
||||
FileSHA256: uploaded.FileSHA256,
|
||||
FileLength: proto.Uint64(uint64(len(videoData))),
|
||||
},
|
||||
}
|
||||
|
||||
// Add caption if provided
|
||||
if caption != "" {
|
||||
msg.VideoMessage.Caption = proto.String(caption)
|
||||
}
|
||||
|
||||
// Send the message
|
||||
resp, err := c.client.SendMessage(ctx, jid, msg)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to send video: %w", err)
|
||||
}
|
||||
|
||||
logging.Debug("Video sent", "account_id", c.id, "to", jid.String())
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// SendDocument sends a document message
|
||||
func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if c.client == nil {
|
||||
err := fmt.Errorf("client not initialized")
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Upload the document
|
||||
uploaded, err := c.client.Upload(ctx, documentData, whatsmeow.MediaDocument)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to upload document: %w", err)
|
||||
}
|
||||
|
||||
// Create document message
|
||||
msg := &waE2E.Message{
|
||||
DocumentMessage: &waE2E.DocumentMessage{
|
||||
URL: proto.String(uploaded.URL),
|
||||
DirectPath: proto.String(uploaded.DirectPath),
|
||||
MediaKey: uploaded.MediaKey,
|
||||
Mimetype: proto.String(mimeType),
|
||||
FileEncSHA256: uploaded.FileEncSHA256,
|
||||
FileSHA256: uploaded.FileSHA256,
|
||||
FileLength: proto.Uint64(uint64(len(documentData))),
|
||||
FileName: proto.String(filename),
|
||||
},
|
||||
}
|
||||
|
||||
// Add caption if provided
|
||||
if caption != "" {
|
||||
msg.DocumentMessage.Caption = proto.String(caption)
|
||||
}
|
||||
|
||||
// Send the message
|
||||
resp, err := c.client.SendMessage(ctx, jid, msg)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
|
||||
return "", fmt.Errorf("failed to send document: %w", err)
|
||||
}
|
||||
|
||||
logging.Debug("Document sent", "account_id", c.id, "to", jid.String(), "filename", filename)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// handleEvent processes WhatsApp events
|
||||
func (c *Client) handleEvent(evt interface{}) {
|
||||
ctx := context.Background()
|
||||
|
||||
switch v := evt.(type) {
|
||||
case *waEvents.Message:
|
||||
logging.Debug("Message received", "account_id", c.id, "from", v.Info.Sender.String())
|
||||
|
||||
// Extract message content based on type
|
||||
var text string
|
||||
var messageType string = "text"
|
||||
var mimeType string
|
||||
var filename string
|
||||
var mediaBase64 string
|
||||
var mediaURL string
|
||||
|
||||
// Handle text messages
|
||||
if v.Message.Conversation != nil {
|
||||
text = *v.Message.Conversation
|
||||
messageType = "text"
|
||||
} else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil {
|
||||
text = *v.Message.ExtendedTextMessage.Text
|
||||
messageType = "text"
|
||||
}
|
||||
|
||||
// Handle image messages
|
||||
if v.Message.ImageMessage != nil {
|
||||
img := v.Message.ImageMessage
|
||||
messageType = "image"
|
||||
mimeType = img.GetMimetype()
|
||||
|
||||
if img.Caption != nil {
|
||||
text = *img.Caption
|
||||
}
|
||||
|
||||
// Download image
|
||||
data, err := c.client.Download(ctx, img)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download image", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video messages
|
||||
if v.Message.VideoMessage != nil {
|
||||
vid := v.Message.VideoMessage
|
||||
messageType = "video"
|
||||
mimeType = vid.GetMimetype()
|
||||
|
||||
if vid.Caption != nil {
|
||||
text = *vid.Caption
|
||||
}
|
||||
|
||||
// Download video
|
||||
data, err := c.client.Download(ctx, vid)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download video", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle document messages
|
||||
if v.Message.DocumentMessage != nil {
|
||||
doc := v.Message.DocumentMessage
|
||||
messageType = "document"
|
||||
mimeType = doc.GetMimetype()
|
||||
|
||||
if doc.FileName != nil {
|
||||
filename = *doc.FileName
|
||||
}
|
||||
|
||||
if doc.Caption != nil {
|
||||
text = *doc.Caption
|
||||
}
|
||||
|
||||
// Download document
|
||||
data, err := c.client.Download(ctx, doc)
|
||||
if err != nil {
|
||||
logging.Error("Failed to download document", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish message received event
|
||||
c.eventBus.Publish(events.MessageReceivedEvent(
|
||||
ctx,
|
||||
c.id,
|
||||
v.Info.ID,
|
||||
v.Info.Sender.String(),
|
||||
v.Info.Chat.String(),
|
||||
text,
|
||||
v.Info.Timestamp,
|
||||
v.Info.IsGroup,
|
||||
"", // group name - TODO: extract from message
|
||||
"", // sender name - TODO: extract from message
|
||||
messageType,
|
||||
mimeType,
|
||||
filename,
|
||||
mediaBase64,
|
||||
mediaURL,
|
||||
))
|
||||
|
||||
case *waEvents.Connected:
|
||||
logging.Info("WhatsApp connected", "account_id", c.id)
|
||||
|
||||
// Get the actual phone number from WhatsApp
|
||||
phoneNumber := ""
|
||||
if c.client.Store.ID != nil {
|
||||
actualPhone := c.client.Store.ID.User
|
||||
phoneNumber = "+" + actualPhone
|
||||
|
||||
// Update phone number in client if it's different
|
||||
if c.phoneNumber != phoneNumber {
|
||||
c.phoneNumber = phoneNumber
|
||||
logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", phoneNumber)
|
||||
}
|
||||
} else if c.phoneNumber != "" {
|
||||
phoneNumber = c.phoneNumber
|
||||
}
|
||||
|
||||
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneNumber))
|
||||
|
||||
case *waEvents.Disconnected:
|
||||
logging.Warn("WhatsApp disconnected", "account_id", c.id)
|
||||
c.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, c.id, "connection lost"))
|
||||
|
||||
case *waEvents.Receipt:
|
||||
// Handle delivery and read receipts
|
||||
if v.Type == types.ReceiptTypeDelivered {
|
||||
for _, messageID := range v.MessageIDs {
|
||||
logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String())
|
||||
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp))
|
||||
}
|
||||
} else if v.Type == types.ReceiptTypeRead {
|
||||
for _, messageID := range v.MessageIDs {
|
||||
logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String())
|
||||
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive
|
||||
func (c *Client) startKeepAlive() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.keepAliveCancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logging.Debug("Keep-alive stopped", "account_id", c.id)
|
||||
return
|
||||
case <-ticker.C:
|
||||
if c.client != nil && c.client.IsConnected() {
|
||||
err := c.client.SendPresence(ctx, types.PresenceAvailable)
|
||||
if err != nil {
|
||||
logging.Warn("Failed to send presence", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
logging.Debug("Sent presence update", "account_id", c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logging.Info("Keep-alive started", "account_id", c.id)
|
||||
}
|
||||
|
||||
// processMediaData processes media based on the configured mode
|
||||
func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) {
|
||||
mode := c.mediaConfig.Mode
|
||||
var filename string
|
||||
var mediaURL string
|
||||
|
||||
// Generate filename
|
||||
ext := getExtensionFromMimeType(mimeType)
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := hex.EncodeToString(hash[:8])
|
||||
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
|
||||
|
||||
// Handle base64 mode
|
||||
if mode == "base64" || mode == "both" {
|
||||
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Handle link mode
|
||||
if mode == "link" || mode == "both" {
|
||||
// Save file to disk
|
||||
filePath, err := c.saveMediaFile(messageID, data, mimeType)
|
||||
if err != nil {
|
||||
logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err)
|
||||
} else {
|
||||
// Extract just the filename from the full path
|
||||
filename = filepath.Base(filePath)
|
||||
mediaURL = c.generateMediaURL(messageID, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename, mediaURL
|
||||
}
|
||||
|
||||
// saveMediaFile saves media data to disk and returns the file path
|
||||
func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) {
|
||||
// Create account-specific media directory
|
||||
mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id)
|
||||
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create media directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique filename using message ID and hash
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := hex.EncodeToString(hash[:8])
|
||||
ext := getExtensionFromMimeType(mimeType)
|
||||
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
|
||||
|
||||
// Full path to file
|
||||
filePath := filepath.Join(mediaDir, filename)
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write media file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// generateMediaURL generates a URL for accessing stored media
|
||||
func (c *Client) generateMediaURL(messageID, filename string) string {
|
||||
baseURL := c.mediaConfig.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename)
|
||||
}
|
||||
|
||||
// getExtensionFromMimeType returns the file extension for a given MIME type
|
||||
func getExtensionFromMimeType(mimeType string) string {
|
||||
extensions := map[string]string{
|
||||
// Images
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/svg+xml": ".svg",
|
||||
|
||||
// Videos
|
||||
"video/mp4": ".mp4",
|
||||
"video/mpeg": ".mpeg",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-msvideo": ".avi",
|
||||
"video/webm": ".webm",
|
||||
"video/3gpp": ".3gp",
|
||||
|
||||
// Documents
|
||||
"application/pdf": ".pdf",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"application/zip": ".zip",
|
||||
"application/x-rar-compressed": ".rar",
|
||||
"application/x-7z-compressed": ".7z",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
|
||||
// Audio
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/aac": ".aac",
|
||||
"audio/x-m4a": ".m4a",
|
||||
}
|
||||
|
||||
if ext, ok := extensions[mimeType]; ok {
|
||||
return ext
|
||||
}
|
||||
return ""
|
||||
}
|
||||
133
pkg/whatshooked/options.go
Normal file
133
pkg/whatshooked/options.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package whatshooked
|
||||
|
||||
import "git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
|
||||
// Option is a functional option for configuring WhatsHooked
|
||||
type Option func(*config.Config)
|
||||
|
||||
// WithServer configures server settings
|
||||
func WithServer(host string, port int) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Server.Host = host
|
||||
c.Server.Port = port
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuth configures authentication
|
||||
func WithAuth(apiKey, username, password string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Server.AuthKey = apiKey
|
||||
c.Server.Username = username
|
||||
c.Server.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultCountryCode sets the default country code for phone numbers
|
||||
func WithDefaultCountryCode(code string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Server.DefaultCountryCode = code
|
||||
}
|
||||
}
|
||||
|
||||
// WithWhatsAppAccount adds a WhatsApp account
|
||||
func WithWhatsAppAccount(account config.WhatsAppConfig) Option {
|
||||
return func(c *config.Config) {
|
||||
c.WhatsApp = append(c.WhatsApp, account)
|
||||
}
|
||||
}
|
||||
|
||||
// WithWhatsmeowAccount adds a Whatsmeow account (convenience)
|
||||
func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option {
|
||||
return func(c *config.Config) {
|
||||
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
|
||||
ID: id,
|
||||
Type: "whatsmeow",
|
||||
PhoneNumber: phoneNumber,
|
||||
SessionPath: sessionPath,
|
||||
ShowQR: showQR,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithBusinessAPIAccount adds a Business API account (convenience)
|
||||
func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
|
||||
ID: id,
|
||||
Type: "business-api",
|
||||
PhoneNumber: phoneNumber,
|
||||
BusinessAPI: &config.BusinessAPIConfig{
|
||||
PhoneNumberID: phoneNumberID,
|
||||
AccessToken: accessToken,
|
||||
VerifyToken: verifyToken,
|
||||
APIVersion: "v21.0",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithHook adds a webhook
|
||||
func WithHook(hook config.Hook) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Hooks = append(c.Hooks, hook)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEventLogger enables event logging
|
||||
func WithEventLogger(targets []string, fileDir string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.EventLogger.Enabled = true
|
||||
c.EventLogger.Targets = targets
|
||||
c.EventLogger.FileDir = fileDir
|
||||
}
|
||||
}
|
||||
|
||||
// WithEventLoggerConfig configures event logger with full config
|
||||
func WithEventLoggerConfig(cfg config.EventLoggerConfig) Option {
|
||||
return func(c *config.Config) {
|
||||
c.EventLogger = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// WithMedia configures media settings
|
||||
func WithMedia(dataPath, mode, baseURL string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Media.DataPath = dataPath
|
||||
c.Media.Mode = mode
|
||||
c.Media.BaseURL = baseURL
|
||||
}
|
||||
}
|
||||
|
||||
// WithMediaConfig configures media with full config
|
||||
func WithMediaConfig(cfg config.MediaConfig) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Media = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogLevel sets the log level
|
||||
func WithLogLevel(level string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.LogLevel = level
|
||||
}
|
||||
}
|
||||
|
||||
// WithDatabase configures database settings
|
||||
func WithDatabase(dbType, host string, port int, username, password, database string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Database.Type = dbType
|
||||
c.Database.Host = host
|
||||
c.Database.Port = port
|
||||
c.Database.Username = username
|
||||
c.Database.Password = password
|
||||
c.Database.Database = database
|
||||
}
|
||||
}
|
||||
|
||||
// WithSQLiteDatabase configures SQLite database
|
||||
func WithSQLiteDatabase(sqlitePath string) Option {
|
||||
return func(c *config.Config) {
|
||||
c.Database.Type = "sqlite"
|
||||
c.Database.SQLitePath = sqlitePath
|
||||
}
|
||||
}
|
||||
157
pkg/whatshooked/server.go
Normal file
157
pkg/whatshooked/server.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package whatshooked
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/utils"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// Server is the optional built-in HTTP server
|
||||
type Server struct {
|
||||
wh *WhatsHooked
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server instance
|
||||
func NewServer(wh *WhatsHooked) *Server {
|
||||
return &Server{
|
||||
wh: wh,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
// Subscribe to hook success events for two-way communication
|
||||
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
|
||||
|
||||
// Setup routes
|
||||
mux := s.setupRoutes()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
logging.Info("Starting HTTP server",
|
||||
"host", s.wh.config.Server.Host,
|
||||
"port", s.wh.config.Server.Port,
|
||||
"address", addr)
|
||||
|
||||
// Connect to WhatsApp accounts
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond) // Give HTTP server a moment to start
|
||||
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
|
||||
if err := s.wh.ConnectAll(context.Background()); err != nil {
|
||||
logging.Error("Failed to connect to WhatsApp accounts", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start server (blocking)
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the HTTP server gracefully
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.httpServer != nil {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupRoutes configures all HTTP routes
|
||||
func (s *Server) setupRoutes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
h := s.wh.Handlers()
|
||||
|
||||
// Health check (no auth required)
|
||||
mux.HandleFunc("/health", h.Health)
|
||||
|
||||
// Hook management (with auth)
|
||||
mux.HandleFunc("/api/hooks", h.Auth(h.Hooks))
|
||||
mux.HandleFunc("/api/hooks/add", h.Auth(h.AddHook))
|
||||
mux.HandleFunc("/api/hooks/remove", h.Auth(h.RemoveHook))
|
||||
|
||||
// Account management (with auth)
|
||||
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
||||
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
|
||||
|
||||
// Send messages (with auth)
|
||||
mux.HandleFunc("/api/send", h.Auth(h.SendMessage))
|
||||
mux.HandleFunc("/api/send/image", h.Auth(h.SendImage))
|
||||
mux.HandleFunc("/api/send/video", h.Auth(h.SendVideo))
|
||||
mux.HandleFunc("/api/send/document", h.Auth(h.SendDocument))
|
||||
|
||||
// Serve media files (with auth)
|
||||
mux.HandleFunc("/api/media/", h.ServeMedia)
|
||||
|
||||
// Business API webhooks (no auth - Meta validates via verify_token)
|
||||
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
|
||||
|
||||
logging.Info("HTTP server endpoints configured",
|
||||
"health", "/health",
|
||||
"hooks", "/api/hooks",
|
||||
"accounts", "/api/accounts",
|
||||
"send", "/api/send")
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleHookResponse processes hook success events for two-way communication
|
||||
func (s *Server) handleHookResponse(event events.Event) {
|
||||
// Use event context for sending message
|
||||
ctx := event.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Extract response from event data
|
||||
responseData, ok := event.Data["response"]
|
||||
if !ok || responseData == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to cast to HookResponse
|
||||
resp, ok := responseData.(hooks.HookResponse)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.SendMessage {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which account to use - default to first available if not specified
|
||||
targetAccountID := resp.AccountID
|
||||
if targetAccountID == "" && len(s.wh.config.WhatsApp) > 0 {
|
||||
targetAccountID = s.wh.config.WhatsApp[0].ID
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(resp.To, s.wh.config.Server.DefaultCountryCode)
|
||||
|
||||
// Parse JID
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send message with context
|
||||
if err := s.wh.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
|
||||
logging.Error("Failed to send message from hook response", "error", err)
|
||||
} else {
|
||||
logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To)
|
||||
}
|
||||
}
|
||||
169
pkg/whatshooked/whatshooked.go
Normal file
169
pkg/whatshooked/whatshooked.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package whatshooked
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/eventlogger"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/handlers"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
||||
)
|
||||
|
||||
// WhatsHooked is the main library instance
|
||||
type WhatsHooked struct {
|
||||
config *config.Config
|
||||
configPath string
|
||||
eventBus *events.EventBus
|
||||
whatsappMgr *whatsapp.Manager
|
||||
hookMgr *hooks.Manager
|
||||
eventLogger *eventlogger.Logger
|
||||
handlers *handlers.Handlers
|
||||
server *Server // Optional built-in server
|
||||
}
|
||||
|
||||
// NewFromFile creates a WhatsHooked instance from a config file
|
||||
func NewFromFile(configPath string) (*WhatsHooked, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize logging from config
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
return newWithConfig(cfg, configPath)
|
||||
}
|
||||
|
||||
// New creates a WhatsHooked instance with programmatic config
|
||||
func New(opts ...Option) (*WhatsHooked, error) {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
},
|
||||
Media: config.MediaConfig{
|
||||
DataPath: "./data/media",
|
||||
Mode: "link",
|
||||
},
|
||||
LogLevel: "info",
|
||||
}
|
||||
|
||||
// Apply options
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Initialize logging from config
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
return newWithConfig(cfg, "")
|
||||
}
|
||||
|
||||
// newWithConfig is the internal constructor
|
||||
func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error) {
|
||||
wh := &WhatsHooked{
|
||||
config: cfg,
|
||||
configPath: configPath,
|
||||
eventBus: events.NewEventBus(),
|
||||
}
|
||||
|
||||
// Initialize WhatsApp manager
|
||||
wh.whatsappMgr = whatsapp.NewManager(
|
||||
wh.eventBus,
|
||||
cfg.Media,
|
||||
cfg,
|
||||
configPath,
|
||||
func(updatedCfg *config.Config) error {
|
||||
if configPath != "" {
|
||||
return config.Save(configPath, updatedCfg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
// Initialize hook manager
|
||||
wh.hookMgr = hooks.NewManager(wh.eventBus)
|
||||
wh.hookMgr.LoadHooks(cfg.Hooks)
|
||||
wh.hookMgr.Start()
|
||||
|
||||
// Initialize event logger if enabled
|
||||
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
|
||||
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database)
|
||||
if err == nil {
|
||||
wh.eventLogger = logger
|
||||
wh.eventBus.SubscribeAll(func(event events.Event) {
|
||||
wh.eventLogger.Log(event)
|
||||
})
|
||||
logging.Info("Event logger initialized", "targets", cfg.EventLogger.Targets)
|
||||
} else {
|
||||
logging.Error("Failed to initialize event logger", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create handlers
|
||||
wh.handlers = handlers.New(wh.whatsappMgr, wh.hookMgr, cfg, configPath)
|
||||
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
// ConnectAll connects to all configured WhatsApp accounts
|
||||
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
|
||||
for _, waCfg := range wh.config.WhatsApp {
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handlers returns the HTTP handlers instance
|
||||
func (wh *WhatsHooked) Handlers() *handlers.Handlers {
|
||||
return wh.handlers
|
||||
}
|
||||
|
||||
// Manager returns the WhatsApp manager
|
||||
func (wh *WhatsHooked) Manager() *whatsapp.Manager {
|
||||
return wh.whatsappMgr
|
||||
}
|
||||
|
||||
// EventBus returns the event bus
|
||||
func (wh *WhatsHooked) EventBus() *events.EventBus {
|
||||
return wh.eventBus
|
||||
}
|
||||
|
||||
// HookManager returns the hook manager
|
||||
func (wh *WhatsHooked) HookManager() *hooks.Manager {
|
||||
return wh.hookMgr
|
||||
}
|
||||
|
||||
// Config returns the configuration
|
||||
func (wh *WhatsHooked) Config() *config.Config {
|
||||
return wh.config
|
||||
}
|
||||
|
||||
// Close shuts down all components gracefully
|
||||
func (wh *WhatsHooked) Close() error {
|
||||
wh.whatsappMgr.DisconnectAll()
|
||||
if wh.eventLogger != nil {
|
||||
return wh.eventLogger.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartServer starts the built-in HTTP server (convenience method)
|
||||
func (wh *WhatsHooked) StartServer() error {
|
||||
wh.server = NewServer(wh)
|
||||
return wh.server.Start()
|
||||
}
|
||||
|
||||
// StopServer stops the built-in HTTP server
|
||||
func (wh *WhatsHooked) StopServer(ctx context.Context) error {
|
||||
if wh.server != nil {
|
||||
return wh.server.Stop(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user