mqtt
This commit is contained in:
@@ -97,13 +97,29 @@ type MediaConfig struct {
|
||||
// EventLoggerConfig holds event logging configuration
|
||||
type EventLoggerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Targets []string `json:"targets"` // "file", "sqlite", "postgres"
|
||||
Targets []string `json:"targets"` // "file", "sqlite", "postgres", "mqtt"
|
||||
|
||||
// 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")
|
||||
|
||||
// MQTT logging
|
||||
MQTT MQTTConfig `json:"mqtt,omitempty"` // MQTT broker configuration
|
||||
}
|
||||
|
||||
// MQTTConfig holds MQTT broker configuration
|
||||
type MQTTConfig struct {
|
||||
Broker string `json:"broker"` // MQTT broker URL (e.g., "tcp://localhost:1883")
|
||||
ClientID string `json:"client_id,omitempty"` // Client ID (auto-generated if empty)
|
||||
Username string `json:"username,omitempty"` // Username for authentication
|
||||
Password string `json:"password,omitempty"` // Password for authentication
|
||||
TopicPrefix string `json:"topic_prefix,omitempty"` // Topic prefix (default: "whatshooked")
|
||||
QoS int `json:"qos,omitempty"` // Quality of Service (0, 1, or 2; default: 1)
|
||||
Retained bool `json:"retained,omitempty"` // Retain messages on broker
|
||||
Events []string `json:"events,omitempty"` // Events to publish (empty = all events)
|
||||
Subscribe bool `json:"subscribe,omitempty"` // Enable subscription for sending messages
|
||||
}
|
||||
|
||||
// Load reads configuration from a file
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// Logger handles event logging to multiple targets
|
||||
@@ -24,8 +26,16 @@ type Target interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// WhatsAppManager interface for MQTT target
|
||||
type WhatsAppManager interface {
|
||||
SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error
|
||||
SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error
|
||||
SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error
|
||||
SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error
|
||||
}
|
||||
|
||||
// NewLogger creates a new event logger
|
||||
func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig) (*Logger, error) {
|
||||
func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig, waManager WhatsAppManager) (*Logger, error) {
|
||||
logger := &Logger{
|
||||
config: cfg,
|
||||
dbConfig: dbConfig,
|
||||
@@ -62,6 +72,15 @@ func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig) (*L
|
||||
logger.targets = append(logger.targets, postgresTarget)
|
||||
logging.Info("Event logger PostgreSQL target initialized")
|
||||
|
||||
case "mqtt":
|
||||
mqttTarget, err := NewMQTTTarget(cfg.MQTT, waManager)
|
||||
if err != nil {
|
||||
logging.Error("Failed to initialize MQTT target", "error", err)
|
||||
continue
|
||||
}
|
||||
logger.targets = append(logger.targets, mqttTarget)
|
||||
logging.Info("Event logger MQTT target initialized")
|
||||
|
||||
default:
|
||||
logging.Error("Unknown event logger target type", "type", targetType)
|
||||
}
|
||||
|
||||
297
pkg/eventlogger/mqtt_target.go
Normal file
297
pkg/eventlogger/mqtt_target.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package eventlogger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/utils"
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
// MQTTTarget represents an MQTT logging target
|
||||
type MQTTTarget struct {
|
||||
client mqtt.Client
|
||||
config config.MQTTConfig
|
||||
waManager WhatsAppManager
|
||||
eventFilter map[string]bool
|
||||
}
|
||||
|
||||
// NewMQTTTarget creates a new MQTT target
|
||||
func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager) (*MQTTTarget, error) {
|
||||
if cfg.Broker == "" {
|
||||
return nil, fmt.Errorf("MQTT broker is required")
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if cfg.ClientID == "" {
|
||||
cfg.ClientID = fmt.Sprintf("whatshooked-%d", time.Now().Unix())
|
||||
}
|
||||
if cfg.TopicPrefix == "" {
|
||||
cfg.TopicPrefix = "whatshooked"
|
||||
}
|
||||
if cfg.QoS < 0 || cfg.QoS > 2 {
|
||||
cfg.QoS = 1 // Default to QoS 1
|
||||
}
|
||||
|
||||
target := &MQTTTarget{
|
||||
config: cfg,
|
||||
waManager: waManager,
|
||||
eventFilter: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Build event filter map for fast lookup
|
||||
if len(cfg.Events) > 0 {
|
||||
for _, eventType := range cfg.Events {
|
||||
target.eventFilter[eventType] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Create MQTT client options
|
||||
opts := mqtt.NewClientOptions()
|
||||
opts.AddBroker(cfg.Broker)
|
||||
opts.SetClientID(cfg.ClientID)
|
||||
|
||||
if cfg.Username != "" {
|
||||
opts.SetUsername(cfg.Username)
|
||||
}
|
||||
if cfg.Password != "" {
|
||||
opts.SetPassword(cfg.Password)
|
||||
}
|
||||
|
||||
opts.SetKeepAlive(60 * time.Second)
|
||||
opts.SetPingTimeout(10 * time.Second)
|
||||
opts.SetAutoReconnect(true)
|
||||
opts.SetMaxReconnectInterval(10 * time.Second)
|
||||
|
||||
// Connection lost handler
|
||||
opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
|
||||
logging.Error("MQTT connection lost", "error", err)
|
||||
})
|
||||
|
||||
// On connect handler - subscribe to send topics if enabled
|
||||
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
||||
logging.Info("MQTT connected to broker", "broker", cfg.Broker)
|
||||
|
||||
if cfg.Subscribe {
|
||||
// Subscribe to send command topic for all accounts
|
||||
topic := fmt.Sprintf("%s/+/send", cfg.TopicPrefix)
|
||||
if token := client.Subscribe(topic, byte(cfg.QoS), target.handleSendMessage); token.Wait() && token.Error() != nil {
|
||||
logging.Error("Failed to subscribe to MQTT topic", "topic", topic, "error", token.Error())
|
||||
} else {
|
||||
logging.Info("Subscribed to MQTT send topic", "topic", topic)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create and connect the client
|
||||
client := mqtt.NewClient(opts)
|
||||
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return nil, fmt.Errorf("failed to connect to MQTT broker: %w", token.Error())
|
||||
}
|
||||
|
||||
target.client = client
|
||||
logging.Info("MQTT target initialized", "broker", cfg.Broker, "client_id", cfg.ClientID, "subscribe", cfg.Subscribe)
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Log publishes an event to MQTT
|
||||
func (m *MQTTTarget) Log(event events.Event) error {
|
||||
// Check if we should filter this event
|
||||
if len(m.eventFilter) > 0 {
|
||||
if !m.eventFilter[string(event.Type)] {
|
||||
// Event is filtered out
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Extract account_id from event data
|
||||
accountID := "unknown"
|
||||
if id, ok := event.Data["account_id"].(string); ok && id != "" {
|
||||
accountID = id
|
||||
}
|
||||
|
||||
// Build the topic: whatshooked/accountid/eventtype
|
||||
topic := fmt.Sprintf("%s/%s/%s", m.config.TopicPrefix, accountID, event.Type)
|
||||
|
||||
// Marshal event to JSON
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %w", err)
|
||||
}
|
||||
|
||||
// Publish to MQTT
|
||||
token := m.client.Publish(topic, byte(m.config.QoS), m.config.Retained, payload)
|
||||
token.Wait()
|
||||
|
||||
if token.Error() != nil {
|
||||
return fmt.Errorf("failed to publish to MQTT: %w", token.Error())
|
||||
}
|
||||
|
||||
logging.Debug("Event published to MQTT", "topic", topic, "event_type", event.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSendMessage handles incoming MQTT messages for sending WhatsApp messages
|
||||
func (m *MQTTTarget) handleSendMessage(client mqtt.Client, msg mqtt.Message) {
|
||||
logging.Debug("MQTT send message received", "topic", msg.Topic(), "payload", string(msg.Payload()))
|
||||
|
||||
// Parse topic: whatshooked/accountid/send
|
||||
parts := strings.Split(msg.Topic(), "/")
|
||||
if len(parts) < 3 {
|
||||
logging.Error("Invalid MQTT send topic format", "topic", msg.Topic())
|
||||
return
|
||||
}
|
||||
|
||||
accountID := parts[len(parts)-2]
|
||||
|
||||
// Parse message payload
|
||||
var sendReq struct {
|
||||
Type string `json:"type"` // Message type: "text", "image", "video", "document"
|
||||
To string `json:"to"` // Phone number or JID
|
||||
Text string `json:"text"` // Message text (for text messages)
|
||||
Caption string `json:"caption"` // Optional caption for media
|
||||
MimeType string `json:"mime_type"` // MIME type for media
|
||||
Filename string `json:"filename"` // Filename for documents
|
||||
|
||||
// Media can be provided as either base64 or URL
|
||||
Base64 string `json:"base64"` // Base64 encoded media data
|
||||
URL string `json:"url"` // URL to download media from
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg.Payload(), &sendReq); err != nil {
|
||||
logging.Error("Failed to parse MQTT send message", "error", err, "payload", string(msg.Payload()))
|
||||
return
|
||||
}
|
||||
|
||||
if sendReq.To == "" {
|
||||
logging.Error("Missing required field 'to' in MQTT send message", "to", sendReq.To)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to text message if type not specified
|
||||
if sendReq.Type == "" {
|
||||
sendReq.Type = "text"
|
||||
}
|
||||
|
||||
// Parse JID
|
||||
jid, err := types.ParseJID(sendReq.To)
|
||||
if err != nil {
|
||||
logging.Error("Failed to parse JID", "to", sendReq.To, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Handle different message types
|
||||
switch sendReq.Type {
|
||||
case "text":
|
||||
if sendReq.Text == "" {
|
||||
logging.Error("Missing required field 'text' for text message", "account_id", accountID)
|
||||
return
|
||||
}
|
||||
if err := m.waManager.SendTextMessage(ctx, accountID, jid, sendReq.Text); err != nil {
|
||||
logging.Error("Failed to send text message via MQTT", "account_id", accountID, "to", sendReq.To, "error", err)
|
||||
} else {
|
||||
logging.Info("Text message sent via MQTT", "account_id", accountID, "to", sendReq.To)
|
||||
}
|
||||
|
||||
case "image":
|
||||
mediaData, err := m.getMediaData(sendReq.Base64, sendReq.URL)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get image data", "account_id", accountID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Default MIME type if not specified
|
||||
if sendReq.MimeType == "" {
|
||||
sendReq.MimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
if err := m.waManager.SendImage(ctx, accountID, jid, mediaData, sendReq.MimeType, sendReq.Caption); err != nil {
|
||||
logging.Error("Failed to send image via MQTT", "account_id", accountID, "to", sendReq.To, "error", err)
|
||||
} else {
|
||||
logging.Info("Image sent via MQTT", "account_id", accountID, "to", sendReq.To, "size", len(mediaData))
|
||||
}
|
||||
|
||||
case "video":
|
||||
mediaData, err := m.getMediaData(sendReq.Base64, sendReq.URL)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get video data", "account_id", accountID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Default MIME type if not specified
|
||||
if sendReq.MimeType == "" {
|
||||
sendReq.MimeType = "video/mp4"
|
||||
}
|
||||
|
||||
if err := m.waManager.SendVideo(ctx, accountID, jid, mediaData, sendReq.MimeType, sendReq.Caption); err != nil {
|
||||
logging.Error("Failed to send video via MQTT", "account_id", accountID, "to", sendReq.To, "error", err)
|
||||
} else {
|
||||
logging.Info("Video sent via MQTT", "account_id", accountID, "to", sendReq.To, "size", len(mediaData))
|
||||
}
|
||||
|
||||
case "document":
|
||||
mediaData, err := m.getMediaData(sendReq.Base64, sendReq.URL)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get document data", "account_id", accountID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filename is required for documents
|
||||
if sendReq.Filename == "" {
|
||||
sendReq.Filename = "document"
|
||||
}
|
||||
|
||||
// Default MIME type if not specified
|
||||
if sendReq.MimeType == "" {
|
||||
sendReq.MimeType = "application/pdf"
|
||||
}
|
||||
|
||||
if err := m.waManager.SendDocument(ctx, accountID, jid, mediaData, sendReq.MimeType, sendReq.Filename, sendReq.Caption); err != nil {
|
||||
logging.Error("Failed to send document via MQTT", "account_id", accountID, "to", sendReq.To, "error", err)
|
||||
} else {
|
||||
logging.Info("Document sent via MQTT", "account_id", accountID, "to", sendReq.To, "filename", sendReq.Filename, "size", len(mediaData))
|
||||
}
|
||||
|
||||
default:
|
||||
logging.Error("Unknown message type", "type", sendReq.Type, "account_id", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
// getMediaData retrieves media data from either base64 string or URL
|
||||
func (m *MQTTTarget) getMediaData(base64Data, url string) ([]byte, error) {
|
||||
if base64Data != "" {
|
||||
return utils.DecodeBase64(base64Data)
|
||||
}
|
||||
|
||||
if url != "" {
|
||||
return utils.DownloadMedia(url)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("either 'base64' or 'url' must be provided for media")
|
||||
}
|
||||
|
||||
// Close disconnects from the MQTT broker
|
||||
func (m *MQTTTarget) Close() error {
|
||||
if m.client != nil && m.client.IsConnected() {
|
||||
// Unsubscribe if subscribed
|
||||
if m.config.Subscribe {
|
||||
topic := fmt.Sprintf("%s/+/send", m.config.TopicPrefix)
|
||||
if token := m.client.Unsubscribe(topic); token.Wait() && token.Error() != nil {
|
||||
logging.Error("Failed to unsubscribe from MQTT topic", "topic", topic, "error", token.Error())
|
||||
}
|
||||
}
|
||||
m.client.Disconnect(250)
|
||||
logging.Info("MQTT target closed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
pkg/utils/media.go
Normal file
42
pkg/utils/media.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadMedia downloads media from a URL and returns the data
|
||||
func DownloadMedia(url string) ([]byte, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download media: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to download media: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read media data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DecodeBase64 decodes a base64 string and returns the data
|
||||
func DecodeBase64(encoded string) ([]byte, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
|
||||
|
||||
// Initialize event logger if enabled
|
||||
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
|
||||
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database)
|
||||
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database, wh.whatsappMgr)
|
||||
if err == nil {
|
||||
wh.eventLogger = logger
|
||||
wh.eventBus.SubscribeAll(func(event events.Event) {
|
||||
|
||||
Reference in New Issue
Block a user