mqtt
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -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
|
|
||||||
86
DOCKER.md
86
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
|
./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 <hook_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <<EOF
|
||||||
|
{
|
||||||
|
"server_url": "http://localhost:8080",
|
||||||
|
"api_key": "your-api-key"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Exit and use CLI without flags:
|
||||||
|
```bash
|
||||||
|
docker exec whatshooked-server /app/bin/whatshook-cli hooks list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shell Alias for Convenience
|
||||||
|
|
||||||
|
Create an alias on your host machine for easier CLI access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alias whatshook-cli='docker exec -it whatshooked-server /app/bin/whatshook-cli --server http://localhost:8080'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use it like:
|
||||||
|
```bash
|
||||||
|
whatshook-cli hooks list
|
||||||
|
whatshook-cli send
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this to your `~/.bashrc` or `~/.zshrc` to make it permanent.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Container won't start
|
### Container won't start
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
# Install build dependencies (SQLite requires CGO)
|
# Install build dependencies (SQLite requires CGO)
|
||||||
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
||||||
@@ -13,9 +13,11 @@ RUN go mod download
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the server binary
|
# Build the binaries
|
||||||
# CGO is required for mattn/go-sqlite3
|
# CGO is required for mattn/go-sqlite3
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o whatshooked-server ./cmd/server
|
RUN mkdir -p bin && \
|
||||||
|
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o bin/whatshook-server ./cmd/server && \
|
||||||
|
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o bin/whatshook-cli ./cmd/cli
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
@@ -25,15 +27,15 @@ RUN apk add --no-cache ca-certificates sqlite-libs tzdata
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binaries from builder
|
||||||
COPY --from=builder /build/whatshooked-server .
|
COPY --from=builder /build/bin ./bin
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
RUN mkdir -p /app/sessions /app/data/media
|
RUN mkdir -p /app/sessions /app/data/media /app/data/certs
|
||||||
|
|
||||||
# Expose the default server port
|
# Expose the default server port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Run the server
|
# Run the server
|
||||||
ENTRYPOINT ["/app/whatshooked-server"]
|
ENTRYPOINT ["/app/bin/whatshook-server"]
|
||||||
CMD ["-config", "/app/config.json"]
|
CMD ["-config", "/app/config.json"]
|
||||||
|
|||||||
370
MQTT_CONFIG_EXAMPLE.md
Normal file
370
MQTT_CONFIG_EXAMPLE.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# MQTT Configuration Example
|
||||||
|
|
||||||
|
This document provides examples of how to configure MQTT support in whatshooked.
|
||||||
|
|
||||||
|
## Configuration Structure
|
||||||
|
|
||||||
|
Add the following to your `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_logger": {
|
||||||
|
"enabled": true,
|
||||||
|
"targets": ["mqtt"],
|
||||||
|
"mqtt": {
|
||||||
|
"broker": "tcp://localhost:1883",
|
||||||
|
"client_id": "whatshooked-mqtt",
|
||||||
|
"username": "your_mqtt_username",
|
||||||
|
"password": "your_mqtt_password",
|
||||||
|
"topic_prefix": "whatshooked",
|
||||||
|
"qos": 1,
|
||||||
|
"retained": false,
|
||||||
|
"events": [
|
||||||
|
"message.received",
|
||||||
|
"message.sent",
|
||||||
|
"whatsapp.connected",
|
||||||
|
"whatsapp.disconnected"
|
||||||
|
],
|
||||||
|
"subscribe": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Fields
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
- **broker**: MQTT broker URL (e.g., `tcp://localhost:1883`, `ssl://broker.example.com:8883`)
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
- **client_id**: MQTT client identifier (auto-generated if not specified)
|
||||||
|
- **username**: Username for MQTT broker authentication
|
||||||
|
- **password**: Password for MQTT broker authentication
|
||||||
|
- **topic_prefix**: Prefix for all MQTT topics (default: `whatshooked`)
|
||||||
|
- **qos**: Quality of Service level (0, 1, or 2; default: 1)
|
||||||
|
- 0: At most once delivery
|
||||||
|
- 1: At least once delivery
|
||||||
|
- 2: Exactly once delivery
|
||||||
|
- **retained**: Whether messages should be retained by the broker (default: false)
|
||||||
|
- **events**: Array of event types to publish. If empty or omitted, all events will be published.
|
||||||
|
- **subscribe**: Enable subscription for sending WhatsApp messages via MQTT (default: false)
|
||||||
|
|
||||||
|
## Topic Structure
|
||||||
|
|
||||||
|
### Published Events
|
||||||
|
|
||||||
|
Events are published to topics in the format:
|
||||||
|
```
|
||||||
|
{topic_prefix}/{account_id}/{event_type}
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `whatshooked/my-account/message.received`
|
||||||
|
- `whatshooked/my-account/whatsapp.connected`
|
||||||
|
- `whatshooked/business-account/message.sent`
|
||||||
|
|
||||||
|
### Sending Messages (Subscribe Mode)
|
||||||
|
|
||||||
|
When `subscribe: true` is enabled, you can send WhatsApp messages by publishing to:
|
||||||
|
```
|
||||||
|
{topic_prefix}/{account_id}/send
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text Messages
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"text": "Hello from MQTT!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"caption": "Check out this image!",
|
||||||
|
"mime_type": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Messages (via Base64)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"caption": "A small test image",
|
||||||
|
"mime_type": "image/png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Video Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/video.mp4",
|
||||||
|
"caption": "Check out this video!",
|
||||||
|
"mime_type": "video/mp4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Document Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/report.pdf",
|
||||||
|
"filename": "monthly-report.pdf",
|
||||||
|
"caption": "Here's the monthly report",
|
||||||
|
"mime_type": "application/pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Document Messages (via Base64)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...",
|
||||||
|
"filename": "document.pdf",
|
||||||
|
"caption": "Important document",
|
||||||
|
"mime_type": "application/pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Payload Fields:**
|
||||||
|
- `type`: Message type - "text", "image", "video", or "document" (default: "text")
|
||||||
|
- `to`: Phone number in JID format (required)
|
||||||
|
- `text`: Message text (required for text messages)
|
||||||
|
- `caption`: Optional caption for media messages
|
||||||
|
- `mime_type`: MIME type of the media (defaults: image/jpeg, video/mp4, application/pdf)
|
||||||
|
- `filename`: Filename for documents (required for document type)
|
||||||
|
- `base64`: Base64 encoded media data (mutually exclusive with `url`)
|
||||||
|
- `url`: URL to download media from (mutually exclusive with `base64`)
|
||||||
|
|
||||||
|
**Note:** For media messages, you must provide either `base64` or `url`, but not both.
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
Available event types for filtering:
|
||||||
|
|
||||||
|
### WhatsApp Connection Events
|
||||||
|
- `whatsapp.connected`
|
||||||
|
- `whatsapp.disconnected`
|
||||||
|
- `whatsapp.pair.success`
|
||||||
|
- `whatsapp.pair.failed`
|
||||||
|
- `whatsapp.qr.code`
|
||||||
|
- `whatsapp.qr.timeout`
|
||||||
|
- `whatsapp.qr.error`
|
||||||
|
- `whatsapp.pair.event`
|
||||||
|
|
||||||
|
### Message Events
|
||||||
|
- `message.received`
|
||||||
|
- `message.sent`
|
||||||
|
- `message.failed`
|
||||||
|
- `message.delivered`
|
||||||
|
- `message.read`
|
||||||
|
|
||||||
|
### Hook Events
|
||||||
|
- `hook.triggered`
|
||||||
|
- `hook.success`
|
||||||
|
- `hook.failed`
|
||||||
|
|
||||||
|
## Home Assistant Integration Example
|
||||||
|
|
||||||
|
To integrate with Home Assistant, you can use the MQTT integration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# configuration.yaml
|
||||||
|
mqtt:
|
||||||
|
sensor:
|
||||||
|
- name: "WhatsApp Status"
|
||||||
|
state_topic: "whatshooked/my-account/whatsapp.connected"
|
||||||
|
value_template: "{{ value_json.type }}"
|
||||||
|
|
||||||
|
- name: "Last WhatsApp Message"
|
||||||
|
state_topic: "whatshooked/my-account/message.received"
|
||||||
|
value_template: "{{ value_json.data.text }}"
|
||||||
|
json_attributes_topic: "whatshooked/my-account/message.received"
|
||||||
|
json_attributes_template: "{{ value_json.data | tojson }}"
|
||||||
|
|
||||||
|
# Send messages from Home Assistant
|
||||||
|
script:
|
||||||
|
send_whatsapp_text:
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: '{"type": "text", "to": "27821234567@s.whatsapp.net", "text": "Alert from Home Assistant!"}'
|
||||||
|
|
||||||
|
send_whatsapp_image:
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"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()
|
||||||
|
```
|
||||||
10
README.md
10
README.md
@@ -9,9 +9,15 @@ A Go library and service that connects to WhatsApp and forwards messages to regi
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[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
|
## Features
|
||||||
|
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -8,6 +8,6 @@
|
|||||||
- [ ] Optional Postgres server connection for Whatsmeo
|
- [ ] Optional Postgres server connection for Whatsmeo
|
||||||
- [ ] Optional Postgres server,database for event saving and hook registration
|
- [ ] Optional Postgres server,database for event saving and hook registration
|
||||||
- [✔️] Optional Event logging into directory for each type
|
- [✔️] 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
|
- [✔️] 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.
|
- [✔️] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths.
|
||||||
@@ -10,13 +10,13 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
# Mount config file
|
# Mount config file
|
||||||
- ./config.json:/app/config.json:ro
|
- ./bin/config.json:/app/config.json:ro
|
||||||
|
|
||||||
# Mount sessions directory for WhatsApp authentication persistence
|
# Mount sessions directory for WhatsApp authentication persistence
|
||||||
- ./sessions:/app/sessions
|
- ./bin/sessions:/app/sessions
|
||||||
|
|
||||||
# Mount media directory for storing downloaded media files
|
# Mount media directory for storing downloaded media files
|
||||||
- ./data/media:/app/data/media
|
- ./bin/data/media:/app/data/media
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -3,6 +3,7 @@ module git.warky.dev/wdevs/whatshooked
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
@@ -11,6 +12,7 @@ require (
|
|||||||
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -21,6 +23,7 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -39,8 +42,8 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||||
golang.org/x/net v0.48.0 // 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/sys v0.39.0 // indirect
|
||||||
golang.org/x/term v0.38.0 // indirect
|
golang.org/x/term v0.38.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
6
go.sum
6
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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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/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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -97,13 +97,29 @@ type MediaConfig struct {
|
|||||||
// EventLoggerConfig holds event logging configuration
|
// EventLoggerConfig holds event logging configuration
|
||||||
type EventLoggerConfig struct {
|
type EventLoggerConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Targets []string `json:"targets"` // "file", "sqlite", "postgres"
|
Targets []string `json:"targets"` // "file", "sqlite", "postgres", "mqtt"
|
||||||
|
|
||||||
// File-based logging
|
// File-based logging
|
||||||
FileDir string `json:"file_dir,omitempty"` // Base directory for event files
|
FileDir string `json:"file_dir,omitempty"` // Base directory for event files
|
||||||
|
|
||||||
// Database logging (uses main Database config for connection)
|
// Database logging (uses main Database config for connection)
|
||||||
TableName string `json:"table_name,omitempty"` // Table name for event logs (default: "event_logs")
|
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
|
// Load reads configuration from a file
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eventlogger
|
package eventlogger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger handles event logging to multiple targets
|
// Logger handles event logging to multiple targets
|
||||||
@@ -24,8 +26,16 @@ type Target interface {
|
|||||||
Close() error
|
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
|
// 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{
|
logger := &Logger{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
dbConfig: dbConfig,
|
dbConfig: dbConfig,
|
||||||
@@ -62,6 +72,15 @@ func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig) (*L
|
|||||||
logger.targets = append(logger.targets, postgresTarget)
|
logger.targets = append(logger.targets, postgresTarget)
|
||||||
logging.Info("Event logger PostgreSQL target initialized")
|
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:
|
default:
|
||||||
logging.Error("Unknown event logger target type", "type", targetType)
|
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
|
// Initialize event logger if enabled
|
||||||
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
|
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 {
|
if err == nil {
|
||||||
wh.eventLogger = logger
|
wh.eventLogger = logger
|
||||||
wh.eventBus.SubscribeAll(func(event events.Event) {
|
wh.eventBus.SubscribeAll(func(event events.Event) {
|
||||||
|
|||||||
Reference in New Issue
Block a user