diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6e22e24..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,29 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**whatshooked** is a Go project currently in its initial setup phase. The repository structure and development workflow will be established as the project evolves. - -## Technology Stack - -- **Language**: Go -- **Version Control**: Git - -## Development Setup - -This is a new Go project. Standard Go development commands will apply once code is added: - -- `go build` - Build the project -- `go test ./...` - Run all tests -- `go test -v ./path/to/package` - Run tests for a specific package -- `go run .` - Run the main application (once a main package exists) -- `go mod tidy` - Clean up dependencies - -## Project Status - -The repository has been initialized but does not yet contain application code. When adding initial code: -- Follow standard Go project layout conventions -- Use `go mod init` if a go.mod file needs to be created -- Consider the intended purpose of "whatshooked" when designing the architecture diff --git a/DOCKER.md b/DOCKER.md index f314554..4464ca5 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -176,6 +176,92 @@ You can also use the CLI tool outside Docker to link accounts, then mount the se ./bin/whatshook-cli accounts add ``` +## Using the CLI Inside Docker + +The Docker image includes both the server and CLI binaries in the `/app/bin` directory. You can use the CLI to manage hooks and accounts while the server is running. + +### Available CLI Commands + +List all hooks: +```bash +docker exec whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 hooks list +``` + +Add a new hook: +```bash +docker exec -it whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 hooks add +``` + +Remove a hook: +```bash +docker exec whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 hooks remove +``` + +List WhatsApp accounts: +```bash +docker exec whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 accounts list +``` + +Send a message: +```bash +docker exec -it whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 send +``` + +Check server health: +```bash +docker exec whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080 health +``` + +### Authentication with CLI + +If your server has authentication enabled, you need to configure it in the CLI: + +**Option 1: Using command-line flags** +```bash +docker exec whatshooked-server /app/bin/whatshook-cli \ + --server http://localhost:8080 \ + --api-key your-api-key \ + hooks list +``` + +**Option 2: Create a CLI config file** + +1. Access the container: + ```bash + docker exec -it whatshooked-server sh + ``` + +2. Create the CLI config: + ```bash + cat > /app/.whatshooked-cli.json < + { + "type": "image", + "to": "27821234567@s.whatsapp.net", + "url": "https://example.com/camera-snapshot.jpg", + "caption": "Motion detected at front door" + } + + send_whatsapp_camera_snapshot: + sequence: + # Take a snapshot + - service: camera.snapshot + target: + entity_id: camera.front_door + data: + filename: /tmp/snapshot.jpg + # Convert to base64 and send via MQTT + - service: mqtt.publish + data: + topic: "whatshooked/my-account/send" + payload: > + { + "type": "image", + "to": "27821234567@s.whatsapp.net", + "base64": "{{ state_attr('camera.front_door', 'entity_picture') | base64_encode }}", + "caption": "Front door snapshot" + } + + send_whatsapp_document: + sequence: + - service: mqtt.publish + data: + topic: "whatshooked/my-account/send" + payload: > + { + "type": "document", + "to": "27821234567@s.whatsapp.net", + "url": "https://example.com/daily-report.pdf", + "filename": "daily-report.pdf", + "caption": "Today's energy usage report" + } +``` + +## Complete Configuration Example + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 8080 + }, + "whatsapp": [ + { + "id": "my-account", + "type": "whatsmeow", + "phone_number": "27821234567", + "session_path": "./data/sessions/my-account", + "show_qr": true + } + ], + "media": { + "data_path": "./data/media", + "mode": "link" + }, + "event_logger": { + "enabled": true, + "targets": ["file", "mqtt"], + "file_dir": "./data/events", + "mqtt": { + "broker": "tcp://homeassistant.local:1883", + "username": "mqtt_user", + "password": "mqtt_password", + "topic_prefix": "whatshooked", + "qos": 1, + "retained": false, + "events": [ + "message.received", + "message.sent", + "whatsapp.connected", + "whatsapp.disconnected" + ], + "subscribe": true + } + }, + "log_level": "info" +} +``` + +## Testing MQTT Connection + +You can test your MQTT connection using `mosquitto_sub` and `mosquitto_pub`: + +```bash +# Subscribe to all whatshooked events +mosquitto_sub -h localhost -t "whatshooked/#" -v + +# Subscribe to specific account events +mosquitto_sub -h localhost -t "whatshooked/my-account/#" -v + +# Send a test text message +mosquitto_pub -h localhost -t "whatshooked/my-account/send" \ + -m '{"type": "text", "to": "27821234567@s.whatsapp.net", "text": "Test message"}' + +# Send an image from URL +mosquitto_pub -h localhost -t "whatshooked/my-account/send" \ + -m '{"type": "image", "to": "27821234567@s.whatsapp.net", "url": "https://picsum.photos/200", "caption": "Random image"}' + +# Send an image from base64 (1x1 red pixel example) +mosquitto_pub -h localhost -t "whatshooked/my-account/send" \ + -m '{"type": "image", "to": "27821234567@s.whatsapp.net", "base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", "caption": "Red pixel"}' + +# Send a document from URL +mosquitto_pub -h localhost -t "whatshooked/my-account/send" \ + -m '{"type": "document", "to": "27821234567@s.whatsapp.net", "url": "https://example.com/document.pdf", "filename": "test.pdf", "caption": "Test document"}' + +# Send a video from URL +mosquitto_pub -h localhost -t "whatshooked/my-account/send" \ + -m '{"type": "video", "to": "27821234567@s.whatsapp.net", "url": "https://example.com/video.mp4", "caption": "Test video"}' +``` + +### Using Python for Testing + +```python +import paho.mqtt.client as mqtt +import json +import base64 + +# Connect to broker +client = mqtt.Client() +client.connect("localhost", 1883, 60) + +# Send text message +payload = { + "type": "text", + "to": "27821234567@s.whatsapp.net", + "text": "Hello from Python!" +} +client.publish("whatshooked/my-account/send", json.dumps(payload)) + +# Send image from file +with open("image.jpg", "rb") as f: + image_data = base64.b64encode(f.read()).decode() + +payload = { + "type": "image", + "to": "27821234567@s.whatsapp.net", + "base64": image_data, + "caption": "Image from Python", + "mime_type": "image/jpeg" +} +client.publish("whatshooked/my-account/send", json.dumps(payload)) + +client.disconnect() +``` diff --git a/README.md b/README.md index 2c920b8..93eec9c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,15 @@ A Go library and service that connects to WhatsApp and forwards messages to regi ![1.00](./assets/image/whatshooked.jpg) -[TODO LIST](TODO.md) - Things I still need to do +## Documentation -[Rules when using AI](AI_USE.md) +- [TODO List](TODO.md) - Current tasks and planned improvements +- [AI Usage Guidelines](AI_USE.md) - Rules when using AI tools with this project +- [Project Plan](PLAN.md) - Development plan and architecture decisions +- [CLI Documentation](CLI.md) - Command-line interface usage guide +- [Event Logger](EVENT_LOGGER.md) - Event logging system documentation +- [Docker Guide](DOCKER.md) - Docker deployment and configuration +- [MQTT Configuration Example](MQTT_CONFIG_EXAMPLE.md) - MQTT integration example ## Features diff --git a/TODO.md b/TODO.md index ed9a271..ca9b0fb 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,6 @@ - [ ] Optional Postgres server connection for Whatsmeo - [ ] Optional Postgres server,database for event saving and hook registration - [✔️] Optional Event logging into directory for each type -- [ ] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages) +- [✔️] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages) - [✔️] Refactor into pkg to be able to use the system as a client library instead of starting a server - [✔️] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 019a396..b7f4319 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,13 +10,13 @@ services: - "8080:8080" volumes: # Mount config file - - ./config.json:/app/config.json:ro + - ./bin/config.json:/app/config.json:ro # Mount sessions directory for WhatsApp authentication persistence - - ./sessions:/app/sessions + - ./bin/sessions:/app/sessions # Mount media directory for storing downloaded media files - - ./data/media:/app/data/media + - ./bin/data/media:/app/data/media restart: unless-stopped diff --git a/go.mod b/go.mod index 8f499cb..6ef55c9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.warky.dev/wdevs/whatshooked go 1.25.5 require ( + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.32 github.com/mdp/qrterminal/v3 v3.2.1 @@ -11,6 +12,7 @@ require ( go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32 golang.org/x/crypto v0.46.0 google.golang.org/protobuf v1.36.11 + rsc.io/qr v0.2.0 ) require ( @@ -21,6 +23,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -39,8 +42,8 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect - rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index bcdad5d..7131a75 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -27,6 +29,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -96,6 +100,8 @@ golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/config/config.go b/pkg/config/config.go index 92295d4..deaf775 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/eventlogger/eventlogger.go b/pkg/eventlogger/eventlogger.go index 8b74a55..5825c5c 100644 --- a/pkg/eventlogger/eventlogger.go +++ b/pkg/eventlogger/eventlogger.go @@ -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) } diff --git a/pkg/eventlogger/mqtt_target.go b/pkg/eventlogger/mqtt_target.go new file mode 100644 index 0000000..2c75697 --- /dev/null +++ b/pkg/eventlogger/mqtt_target.go @@ -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 +} diff --git a/pkg/utils/media.go b/pkg/utils/media.go new file mode 100644 index 0000000..c56d704 --- /dev/null +++ b/pkg/utils/media.go @@ -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 +} diff --git a/pkg/whatshooked/whatshooked.go b/pkg/whatshooked/whatshooked.go index cfd1542..1eaed6b 100644 --- a/pkg/whatshooked/whatshooked.go +++ b/pkg/whatshooked/whatshooked.go @@ -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) {