Compare commits

...

9 Commits

Author SHA1 Message Date
ea1209c84c mqtt
Some checks failed
CI / Test (1.22) (push) Failing after -23m51s
CI / Test (1.23) (push) Failing after -23m51s
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
Release / Build and Release (push) Successful in -18m25s
2025-12-29 23:36:22 +02:00
fd2527219e Server qr fixes.
Some checks failed
CI / Test (1.23) (push) Failing after -25m23s
CI / Test (1.22) (push) Failing after -25m21s
CI / Build (push) Failing after -25m59s
CI / Lint (push) Successful in -25m51s
2025-12-29 22:44:10 +02:00
94fc899bab Updated qr code events and tls server
Some checks failed
CI / Test (1.22) (push) Failing after -25m23s
CI / Test (1.23) (push) Failing after -25m25s
CI / Build (push) Failing after -25m51s
CI / Lint (push) Failing after -25m40s
2025-12-29 17:22:06 +02:00
bb9aa01519 Release fixes.
Some checks failed
CI / Test (1.22) (push) Failing after -23m32s
CI / Test (1.23) (push) Failing after -23m16s
CI / Build (push) Successful in -23m47s
CI / Lint (push) Successful in -23m19s
2025-12-29 10:26:50 +02:00
a3eca09502 Lint fixes and testing workflow actions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Test (1.23) (push) Has been cancelled
CI / Test (1.22) (push) Has been cancelled
2025-12-29 10:24:18 +02:00
767a9e211f Major refactor to library 2025-12-29 09:51:16 +02:00
ae169f81e4 Whatsapp Business support 2025-12-29 06:23:16 +02:00
09a12560d3 Event logging 2025-12-29 06:01:04 +02:00
2b1b77334a Server refactor completed 2025-12-29 05:42:57 +02:00
60 changed files with 6763 additions and 1588 deletions

103
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: CI
run-name: "CI Pipeline"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.22', '1.23']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run unit tests
run: make test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Run linter
run: make lint
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Download dependencies
run: go mod download
- name: Build binary
run: make build
- name: Verify binaries exist
run: |
if [ ! -f bin/whatshook-server ]; then
echo "Error: Server binary not found at bin/whatshook-server"
exit 1
fi
if [ ! -f bin/whatshook-cli ]; then
echo "Error: CLI binary not found at bin/whatshook-cli"
exit 1
fi
echo "Build successful!"
ls -lh bin/
- name: Check mod tidiness
run: |
go mod tidy
git diff --exit-code go.mod go.sum

150
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,150 @@
name: Release
run-name: "Making Release"
on:
push:
tags:
- 'v*.*.*'
jobs:
build-and-release:
name: Build and Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Get version from tag
id: get_version
run: |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "Version: ${GITHUB_REF#refs/tags/}"
- name: Build binaries for multiple platforms
run: |
mkdir -p dist
# Build Server
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o dist/whatshook-server-linux-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/server
# Linux ARM64
GOOS=linux GOARCH=arm64 go build -o dist/whatshook-server-linux-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/server
# macOS AMD64
GOOS=darwin GOARCH=amd64 go build -o dist/whatshook-server-darwin-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/server
# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o dist/whatshook-server-darwin-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/server
# Windows AMD64
GOOS=windows GOARCH=amd64 go build -o dist/whatshook-server-windows-amd64.exe -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/server
# Build CLI
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o dist/whatshook-cli-linux-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/cli
# Linux ARM64
GOOS=linux GOARCH=arm64 go build -o dist/whatshook-cli-linux-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/cli
# macOS AMD64
GOOS=darwin GOARCH=amd64 go build -o dist/whatshook-cli-darwin-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/cli
# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o dist/whatshook-cli-darwin-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/cli
# Windows AMD64
GOOS=windows GOARCH=amd64 go build -o dist/whatshook-cli-windows-amd64.exe -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/cli
# Create checksums
cd dist
sha256sum * > checksums.txt
cd ..
- name: Generate release notes
id: release_notes
run: |
# Get the previous tag
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$previous_tag" ]; then
# No previous tag, get all commits
commits=$(git log --pretty=format:"- %s (%h)" --no-merges)
else
# Get commits since the previous tag
commits=$(git log "${previous_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges)
fi
# Create release notes
cat > release_notes.md << EOF
# Release ${{ steps.get_version.outputs.VERSION }}
## Changes
${commits}
## Installation
WhatsHooked provides two binaries:
- **Server**: The WhatsApp webhook server
- **CLI**: Command-line interface for managing the server
Download the appropriate binaries for your platform:
### Server
- **Linux (AMD64)**: \`whatshook-server-linux-amd64\`
- **Linux (ARM64)**: \`whatshook-server-linux-arm64\`
- **macOS (Intel)**: \`whatshook-server-darwin-amd64\`
- **macOS (Apple Silicon)**: \`whatshook-server-darwin-arm64\`
- **Windows (AMD64)**: \`whatshook-server-windows-amd64.exe\`
### CLI
- **Linux (AMD64)**: \`whatshook-cli-linux-amd64\`
- **Linux (ARM64)**: \`whatshook-cli-linux-arm64\`
- **macOS (Intel)**: \`whatshook-cli-darwin-amd64\`
- **macOS (Apple Silicon)**: \`whatshook-cli-darwin-arm64\`
- **Windows (AMD64)**: \`whatshook-cli-windows-amd64.exe\`
Make the binaries executable (Linux/macOS):
\`\`\`bash
chmod +x whatshook-*
\`\`\`
Verify downloads with the provided checksums.
EOF
- name: Create Release
uses: softprops/action-gh-release@v1
with:
body_path: release_notes.md
files: |
dist/whatshook-server-linux-amd64
dist/whatshook-server-linux-arm64
dist/whatshook-server-darwin-amd64
dist/whatshook-server-darwin-arm64
dist/whatshook-server-windows-amd64.exe
dist/whatshook-cli-linux-amd64
dist/whatshook-cli-linux-arm64
dist/whatshook-cli-darwin-amd64
dist/whatshook-cli-darwin-arm64
dist/whatshook-cli-windows-amd64.exe
dist/checksums.txt
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
run: |
echo "Release ${{ steps.get_version.outputs.VERSION }} created successfully!"
echo "Binaries built for:"
echo " - Linux (amd64, arm64)"
echo " - macOS (amd64, arm64)"
echo " - Windows (amd64)"

3
.gitignore vendored
View File

@@ -45,4 +45,5 @@ sessions/
# OS
.DS_Store
Thumbs.db
Thumbs.db
/server

114
.golangci.json Normal file
View File

@@ -0,0 +1,114 @@
{
"formatters": {
"enable": [
"gofmt",
"goimports"
],
"exclusions": {
"generated": "lax",
"paths": [
"third_party$",
"builtin$",
"examples$"
]
},
"settings": {
"gofmt": {
"simplify": true
},
"goimports": {
"local-prefixes": [
"git.warky.dev/wdevs/relspecgo"
]
}
}
},
"issues": {
"max-issues-per-linter": 0,
"max-same-issues": 0
},
"linters": {
"enable": [
"gocritic",
"misspell",
"revive"
],
"exclusions": {
"generated": "lax",
"paths": [
"third_party$",
"builtin$",
"examples$",
"mocks?",
"tests?"
],
"rules": [
{
"linters": [
"dupl",
"errcheck",
"gocritic",
"gosec"
],
"path": "_test\\.go"
},
{
"linters": [
"errcheck"
],
"text": "Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*print(f|ln)?|os\\.(Un)?Setenv). is not checked"
},
{
"path": "_test\\.go",
"text": "cognitive complexity|cyclomatic complexity"
}
]
},
"settings": {
"errcheck": {
"check-blank": false,
"check-type-assertions": false
},
"gocritic": {
"enabled-checks": [
"boolExprSimplify",
"builtinShadow",
"emptyFallthrough",
"equalFold",
"indexAlloc",
"initClause",
"methodExprCall",
"nilValReturn",
"rangeExprCopy",
"rangeValCopy",
"stringXbytes",
"typeAssertChain",
"unlabelStmt",
"unnamedResult",
"unnecessaryBlock",
"weakCond",
"yodaStyleExpr"
],
"disabled-checks": [
"ifElseChain"
]
},
"revive": {
"rules": [
{
"disabled": true,
"name": "exported"
},
{
"disabled": true,
"name": "package-comments"
}
]
}
}
},
"run": {
"tests": true
},
"version": "2"
}

View File

@@ -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

View File

@@ -156,12 +156,19 @@ When you first start the server, you'll need to scan a QR code to authenticate w
docker-compose logs -f whatshooked
```
The QR code will be displayed in ASCII art in the terminal. Scan it with WhatsApp on your phone:
The QR code will be displayed in ASCII art in the terminal along with a browser link. You have two options:
**Option 1: Scan from Terminal**
1. Open WhatsApp
2. Go to Settings > Linked Devices
3. Tap "Link a Device"
4. Scan the QR code from the terminal
**Option 2: View in Browser**
1. Look for the line: `Or open in browser: http://localhost:8080/api/qr/{account_id}`
2. Open that URL in your web browser to see a larger PNG image
3. Scan the QR code from your browser
### Alternative: Use CLI Tool
You can also use the CLI tool outside Docker to link accounts, then mount the session:
@@ -169,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 <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
### Container won't start

View File

@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.23-alpine AS builder
FROM golang:1.25-alpine AS builder
# Install build dependencies (SQLite requires CGO)
RUN apk add --no-cache gcc musl-dev sqlite-dev
@@ -13,9 +13,11 @@ RUN go mod download
# Copy source code
COPY . .
# Build the server binary
# Build the binaries
# 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
FROM alpine:latest
@@ -25,15 +27,15 @@ RUN apk add --no-cache ca-certificates sqlite-libs tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/whatshooked-server .
# Copy binaries from builder
COPY --from=builder /build/bin ./bin
# 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 8080
# Run the server
ENTRYPOINT ["/app/whatshooked-server"]
ENTRYPOINT ["/app/bin/whatshook-server"]
CMD ["-config", "/app/config.json"]

249
EVENT_LOGGER.md Normal file
View File

@@ -0,0 +1,249 @@
# Event Logger Configuration
The event logger allows you to persist all system events to various storage targets for auditing, debugging, and analytics.
## Configuration
Add the `event_logger` section to your `config.json`:
```json
{
"event_logger": {
"enabled": true,
"targets": ["file", "sqlite", "postgres"],
"file_dir": "./data/events",
"table_name": "event_logs"
},
"database": {
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "whatshooked",
"password": "your_password_here",
"database": "whatshooked",
"sqlite_path": "./data/events.db"
}
}
```
## Configuration Options
### event_logger
- **enabled** (boolean): Enable or disable event logging
- Default: `false`
- **targets** (array): List of storage targets to use. Options:
- `"file"` - Store events as JSON files in organized directories
- `"sqlite"` - Store events in a local SQLite database
- `"postgres"` or `"postgresql"` - Store events in PostgreSQL database
- **file_dir** (string): Base directory for file-based event storage
- Default: `"./data/events"`
- Events are organized as: `{file_dir}/{event_type}/{YYYYMMDD}/{HH_MM_SS}_{event_type}.json`
- **table_name** (string): Database table name for storing events
- Default: `"event_logs"`
### database
Database configuration is shared with the event logger when using `sqlite` or `postgres` targets.
For **SQLite**:
- `sqlite_path`: Path to SQLite database file (e.g., `"./data/events.db"`)
- If not specified, defaults to `"./data/events.db"`
For **PostgreSQL**:
- `type`: `"postgres"`
- `host`: Database host (e.g., `"localhost"`)
- `port`: Database port (e.g., `5432`)
- `username`: Database username
- `password`: Database password
- `database`: Database name
## Storage Targets
### File Target
Events are stored as JSON files in an organized directory structure:
```
./data/events/
├── message.received/
│ ├── 20231225/
│ │ ├── 14_30_45_message.received.json
│ │ ├── 14_31_12_message.received.json
│ │ └── 14_32_00_message.received.json
│ └── 20231226/
│ └── 09_15_30_message.received.json
├── message.sent/
│ └── 20231225/
│ └── 14_30_50_message.sent.json
└── whatsapp.connected/
└── 20231225/
└── 14_00_00_whatsapp.connected.json
```
Each file contains the complete event data:
```json
{
"type": "message.received",
"timestamp": "2023-12-25T14:30:45Z",
"data": {
"account_id": "acc1",
"from": "+1234567890",
"message": "Hello, world!",
...
}
}
```
### SQLite Target
Events are stored in a SQLite database with the following schema:
```sql
CREATE TABLE event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
timestamp DATETIME NOT NULL,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_event_logs_type_timestamp
ON event_logs(event_type, timestamp);
```
### PostgreSQL Target
Events are stored in PostgreSQL with JSONB support for efficient querying:
```sql
CREATE TABLE event_logs (
id SERIAL PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
timestamp TIMESTAMP NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_event_logs_type_timestamp
ON event_logs(event_type, timestamp);
```
## Event Types
The following event types are logged:
### 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`
## Examples
### Enable File Logging Only
```json
{
"event_logger": {
"enabled": true,
"targets": ["file"],
"file_dir": "./logs/events"
}
}
```
### Enable SQLite Logging Only
```json
{
"event_logger": {
"enabled": true,
"targets": ["sqlite"]
},
"database": {
"sqlite_path": "./data/whatshooked.db"
}
}
```
### Enable Multiple Targets
```json
{
"event_logger": {
"enabled": true,
"targets": ["file", "sqlite", "postgres"],
"file_dir": "./data/events",
"table_name": "event_logs"
},
"database": {
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "whatshooked",
"password": "securepassword",
"database": "whatshooked",
"sqlite_path": "./data/events.db"
}
}
```
## Querying Events
### SQLite Query Examples
```sql
-- Get all message events from the last 24 hours
SELECT * FROM event_logs
WHERE event_type LIKE 'message.%'
AND timestamp > datetime('now', '-1 day')
ORDER BY timestamp DESC;
-- Count events by type
SELECT event_type, COUNT(*) as count
FROM event_logs
GROUP BY event_type
ORDER BY count DESC;
```
### PostgreSQL Query Examples
```sql
-- Get all message events with specific sender
SELECT * FROM event_logs
WHERE event_type = 'message.received'
AND data->>'from' = '+1234567890'
ORDER BY timestamp DESC;
-- Find events with specific data fields
SELECT event_type, timestamp, data
FROM event_logs
WHERE data @> '{"account_id": "acc1"}'
ORDER BY timestamp DESC
LIMIT 100;
```
## Performance Considerations
- **File Target**: Suitable for low to medium event volumes. Easy to backup and archive.
- **SQLite Target**: Good for single-server deployments. Supports moderate event volumes.
- **PostgreSQL Target**: Best for high-volume deployments and complex queries. Supports concurrent access.
You can enable multiple targets simultaneously to balance immediate access (file) with queryability (database).

370
MQTT_CONFIG_EXAMPLE.md Normal file
View 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()
```

View File

@@ -1,4 +1,4 @@
.PHONY: build clean test run-server run-cli help
.PHONY: build clean test lint lintfix run-server run-cli help
# Build both server and CLI
build:
@@ -29,10 +29,11 @@ clean:
@rm -f bin/whatshook*
@echo "Clean complete!"
# Run tests
# Run tests with coverage
test:
@echo "Running tests..."
@go test ./...
@go test -v -coverprofile=coverage.out -covermode=atomic ./...
@echo "Coverage report saved to coverage.out"
# Run server (requires config.json)
run-server:
@@ -49,6 +50,51 @@ deps:
@go mod tidy
@echo "Dependencies installed!"
release-version: ## Create and push a release with specific version (use: make release-version VERSION=v1.2.3)
@if [ -z "$(VERSION)" ]; then \
echo "Error: VERSION is required. Usage: make release-version VERSION=v1.2.3"; \
exit 1; \
fi
@version="$(VERSION)"; \
if ! echo "$$version" | grep -q "^v"; then \
version="v$$version"; \
fi; \
echo "Creating release: $$version"; \
latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
if [ -z "$$latest_tag" ]; then \
commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \
else \
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
fi; \
if [ -z "$$commit_logs" ]; then \
tag_message="Release $$version"; \
else \
tag_message="Release $$version\n\n$$commit_logs"; \
fi; \
git tag -a "$$version" -m "$$tag_message"; \
git push origin "$$version"; \
echo "Tag $$version created and pushed to remote repository."
lint: ## Run linter
@echo "Running linter..."
@if command -v golangci-lint > /dev/null; then \
golangci-lint run --config=.golangci.json; \
else \
echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
exit 1; \
fi
lintfix: ## Run linter
@echo "Running linter..."
@if command -v golangci-lint > /dev/null; then \
golangci-lint run --config=.golangci.json --fix; \
else \
echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
exit 1; \
fi
# Help
help:
@echo "WhatsHooked Makefile"
@@ -58,7 +104,9 @@ help:
@echo " make build-server - Build server only"
@echo " make build-cli - Build CLI only"
@echo " make clean - Remove build artifacts (preserves bin directory)"
@echo " make test - Run tests"
@echo " make test - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make lintfix - Run linter with auto-fix"
@echo " make run-server - Run server (requires config.json)"
@echo " make run-cli ARGS='health' - Run CLI with arguments"
@echo " make deps - Install dependencies"

729
README.md
View File

@@ -1,17 +1,29 @@
# WhatsHooked
A service that connects to WhatsApp via the whatsmeow API and forwards messages to registered webhooks. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp.
A Go library and service that connects to WhatsApp and forwards messages to registered webhooks. Supports both personal WhatsApp accounts (via whatsmeow) and WhatsApp Business API. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp.
**Use WhatsHooked as:**
- 📦 **Go Library** - Import into your own applications for programmatic WhatsApp integration
- 🚀 **Standalone Server** - Run as a service with HTTP API and CLI management
- 🔧 **Custom Integration** - Mount individual handlers in your existing HTTP servers
![1.00](./assets/image/whatshooked.jpg)
## Documentation
[TODO LIST](TODO.md) - Things I still need to do
- [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
[Rules when using AI](AI_USE.md)
## Phase 1 Features
## Features
- **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously
- **Dual Client Types**: Support for both personal WhatsApp (whatsmeow) and WhatsApp Business API
- **QR Code Pairing**: Browser-based QR code display for easy device pairing with PNG image endpoint
- **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages
- **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp
- **Instance/Config Level Hooks**: Global hooks that receive all messages from all accounts
@@ -19,17 +31,109 @@ A service that connects to WhatsApp via the whatsmeow API and forwards messages
- **CLI Management**: Command-line tool for managing accounts and hooks
- **Structured Logging**: JSON-based logging with configurable log levels
- **Authentication**: HTTP Basic Auth and API key authentication for server endpoints
- **HTTPS/TLS Support**: Three certificate modes - self-signed, custom certificates, and Let's Encrypt autocert
- **Event Logging**: Optional event persistence to file, SQLite, or PostgreSQL
- **Library Mode**: Use WhatsHooked as a Go library in your own applications
- **Flexible Handlers**: Mount individual HTTP handlers in custom servers
## Quick Start
### As a Library
```go
import "git.warky.dev/wdevs/whatshooked/pkg/whatshooked"
// File-based configuration
wh, err := whatshooked.NewFromFile("config.json")
if err != nil {
panic(err)
}
defer wh.Close()
// Start built-in server
wh.StartServer()
```
Or with programmatic configuration:
```go
wh, err := whatshooked.New(
whatshooked.WithServer("0.0.0.0", 8080),
whatshooked.WithAuth("my-api-key", "", ""),
whatshooked.WithWhatsmeowAccount("personal", "+1234567890", "./session", true),
)
defer wh.Close()
wh.StartServer()
```
### As a Standalone Server
```bash
make build
./bin/whatshook-server -config config.json
```
## Pairing WhatsApp Accounts
When using personal WhatsApp accounts (whatsmeow), you'll need to pair the device on first launch. The QR code will be displayed in two ways:
### Terminal Display
The QR code is shown as ASCII art directly in the terminal:
```
========================================
WhatsApp QR Code for account: personal
Phone: +1234567890
========================================
Scan this QR code with WhatsApp on your phone:
[ASCII QR Code displayed here]
Or open in browser: http://localhost:8080/api/qr/personal
========================================
```
### Browser Display
For easier scanning, open the provided URL in your browser to view a larger PNG image:
- URL format: `http://localhost:8080/api/qr/{account_id}`
- No authentication required for this endpoint
- The QR code updates automatically when a new code is generated
### Webhook Events
The QR code URL is also included in the `whatsapp.qr.code` event:
```json
{
"type": "whatsapp.qr.code",
"data": {
"account_id": "personal",
"qr_code": "2@...",
"qr_url": "http://localhost:8080/api/qr/personal"
}
}
```
This allows your webhooks to programmatically display or forward QR codes for remote device pairing.
## Architecture
The project uses an event-driven architecture with the following packages:
- **internal/config**: Configuration management and persistence
- **internal/logging**: Structured logging using Go's slog package
- **internal/events**: Event bus for publish/subscribe messaging between components
- **internal/whatsapp**: WhatsApp client management using whatsmeow
- **internal/hooks**: Webhook management and message forwarding
- **cmd/server**: Main server application
### Library Packages (pkg/)
- **pkg/whatshooked**: Main library entry point with NewFromFile() and New() constructors
- **pkg/config**: Configuration management and persistence
- **pkg/logging**: Pluggable structured logging interface
- **pkg/events**: Event bus for publish/subscribe messaging between components
- **pkg/whatsapp**: WhatsApp client management (supports both whatsmeow and Business API)
- **whatsmeow/**: Personal WhatsApp client implementation
- **businessapi/**: WhatsApp Business API client implementation
- **pkg/hooks**: Webhook management and message forwarding
- **pkg/handlers**: HTTP handlers that can be mounted in any server
- **pkg/eventlogger**: Event persistence to file/SQLite/PostgreSQL
- **pkg/utils**: Utility functions (phone formatting, etc.)
### Application Packages (cmd/)
- **cmd/server**: Standalone server application (thin wrapper around library)
- **cmd/cli**: Command-line interface for management
### Event-Driven Architecture
@@ -54,7 +158,186 @@ This architecture enables:
- Context propagation for cancellation and timeout handling
- Proper request lifecycle management across components
## Installation
## Using WhatsHooked as a Library
### Installation
```bash
go get git.warky.dev/wdevs/whatshooked/pkg/whatshooked
```
### Example 1: Custom Server with Individual Handlers
Mount WhatsHooked handlers at custom paths in your existing HTTP server:
```go
package main
import (
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/whatshooked"
)
func main() {
// Initialize from config file
wh, err := whatshooked.NewFromFile("config.json")
if err != nil {
panic(err)
}
defer wh.Close()
// Get handlers
h := wh.Handlers()
// Custom HTTP server with your own routing
mux := http.NewServeMux()
// Mount WhatsHooked handlers at custom paths
mux.HandleFunc("/api/v1/whatsapp/send", h.Auth(h.SendMessage))
mux.HandleFunc("/api/v1/whatsapp/send/image", h.Auth(h.SendImage))
mux.HandleFunc("/api/v1/accounts", h.Auth(h.Accounts))
mux.HandleFunc("/healthz", h.Health)
// Your own handlers
mux.HandleFunc("/api/v1/custom", yourCustomHandler)
http.ListenAndServe(":8080", mux)
}
```
### Example 2: Programmatic Configuration
Configure WhatsHooked entirely in code without a config file:
```go
wh, err := whatshooked.New(
whatshooked.WithServer("0.0.0.0", 8080),
whatshooked.WithAuth("my-api-key", "", ""),
whatshooked.WithWhatsmeowAccount(
"personal",
"+1234567890",
"./sessions/personal",
true, // show QR
),
whatshooked.WithBusinessAPIAccount(
"business",
"+9876543210",
"phone-number-id",
"access-token",
"verify-token",
),
whatshooked.WithHook(config.Hook{
ID: "webhook1",
Name: "My Webhook",
URL: "https://example.com/webhook",
Method: "POST",
Active: true,
Events: []string{"message.received"},
}),
whatshooked.WithEventLogger([]string{"file", "sqlite"}, "./events"),
whatshooked.WithLogLevel("debug"),
)
if err != nil {
panic(err)
}
defer wh.Close()
// Use built-in server
wh.StartServer()
```
### Example 3: Embedded Library (No HTTP Server)
Use WhatsHooked purely as a library for programmatic WhatsApp access:
```go
wh, err := whatshooked.NewFromFile("config.json")
if err != nil {
panic(err)
}
defer wh.Close()
// Connect to WhatsApp accounts
ctx := context.Background()
if err := wh.ConnectAll(ctx); err != nil {
panic(err)
}
// Listen for incoming messages
wh.EventBus().Subscribe(events.EventMessageReceived, func(e events.Event) {
fmt.Printf("Received message: %s from %s\n",
e.Data["text"], e.Data["from"])
// Process message in your application
processMessage(e.Data)
})
// Send messages programmatically
jid, _ := types.ParseJID("27834606792@s.whatsapp.net")
err = wh.Manager().SendTextMessage(ctx, "account1", jid, "Hello from code!")
```
### Example 4: Custom Authentication
Replace the default authentication with your own (e.g., JWT):
```go
wh, err := whatshooked.NewFromFile("config.json")
if err != nil {
panic(err)
}
h := wh.Handlers()
// Use custom JWT authentication
h.WithAuthConfig(&handlers.AuthConfig{
Validator: func(r *http.Request) bool {
token := r.Header.Get("Authorization")
return validateJWTToken(token) // your JWT validation
},
})
// Or disable auth entirely
h.WithAuthConfig(&handlers.AuthConfig{
Disabled: true,
})
```
### Library API
```go
// Main WhatsHooked instance
type WhatsHooked struct { ... }
// Constructors
func NewFromFile(configPath string) (*WhatsHooked, error)
func New(opts ...Option) (*WhatsHooked, error)
// Methods
func (wh *WhatsHooked) Handlers() *handlers.Handlers // Get HTTP handlers
func (wh *WhatsHooked) Manager() *whatsapp.Manager // Get WhatsApp manager
func (wh *WhatsHooked) EventBus() *events.EventBus // Get event bus
func (wh *WhatsHooked) HookManager() *hooks.Manager // Get hook manager
func (wh *WhatsHooked) Config() *config.Config // Get configuration
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error // Connect all accounts
func (wh *WhatsHooked) StartServer() error // Start built-in HTTP server
func (wh *WhatsHooked) StopServer(ctx context.Context) error // Stop server
func (wh *WhatsHooked) Close() error // Graceful shutdown
// Configuration Options
func WithServer(host string, port int) Option
func WithAuth(apiKey, username, password string) Option
func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option
func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option
func WithHook(hook config.Hook) Option
func WithEventLogger(targets []string, fileDir string) Option
func WithMedia(dataPath, mode, baseURL string) Option
func WithLogLevel(level string) Option
func WithDatabase(dbType, host string, port int, username, password, database string) Option
func WithSQLiteDatabase(sqlitePath string) Option
```
## Installation (Standalone Server)
### Build from source
@@ -77,6 +360,18 @@ Create a `config.json` file based on the example:
cp config.example.json config.json
```
Or use one of the HTTPS examples:
```bash
# Self-signed certificate (development)
cp config.https-self-signed.example.json config.json
# Custom certificate (production)
cp config.https-custom.example.json config.json
# Let's Encrypt autocert (production)
cp config.https-letsencrypt.example.json config.json
```
Edit the configuration file to add your WhatsApp accounts and webhooks:
```json
@@ -88,9 +383,23 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
},
"whatsapp": [
{
"id": "account1",
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/account1"
"session_path": "./sessions/personal",
"show_qr": true
},
{
"id": "business",
"type": "business-api",
"phone_number": "+9876543210",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx",
"business_account_id": "987654321098765",
"api_version": "v21.0",
"verify_token": "my-secure-verify-token"
}
}
],
"hooks": [
@@ -122,8 +431,20 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
**WhatsApp Account Configuration:**
- `id`: Unique identifier for this account
- `type`: Client type - `"whatsmeow"` for personal or `"business-api"` for Business API (defaults to "whatsmeow")
- `phone_number`: Phone number with country code
- `session_path`: Path to store session data
**For whatsmeow (personal) accounts:**
- `session_path`: Path to store session data (default: `./sessions/{id}`)
- `show_qr`: Display QR code in terminal for pairing (default: false)
**For business-api accounts:**
- `business_api`: Business API configuration object
- `phone_number_id`: WhatsApp Business Phone Number ID from Meta
- `access_token`: Access token from Meta Business Manager
- `business_account_id`: Business Account ID (optional)
- `api_version`: Graph API version (default: "v21.0")
- `verify_token`: Token for webhook verification (required for receiving messages)
**Hook Configuration:**
- `id`: Unique identifier for this hook
@@ -132,8 +453,171 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
- `method`: HTTP method (usually "POST")
- `headers`: Optional HTTP headers
- `active`: Whether this hook is enabled
- `events`: List of event types to subscribe to (optional, defaults to all)
- `description`: Optional description
### HTTPS/TLS Configuration
WhatsHooked supports HTTPS with three certificate modes for secure connections:
#### 1. Self-Signed Certificates (Development/Testing)
Automatically generates and manages self-signed certificates. Ideal for development and testing environments.
```json
{
"server": {
"host": "localhost",
"port": 8443,
"tls": {
"enabled": true,
"mode": "self-signed",
"cert_dir": "./data/certs"
}
}
}
```
Or programmatically:
```go
wh, err := whatshooked.New(
whatshooked.WithServer("0.0.0.0", 8443),
whatshooked.WithSelfSignedTLS("./data/certs"),
)
```
**Features:**
- Automatically generates certificates on first run
- Certificates valid for 1 year
- Auto-renewal when expiring within 30 days
- Supports both IP addresses and hostnames as SANs
- No external dependencies
**Note:** Browsers will show security warnings for self-signed certificates. This is normal and expected for development environments.
#### 2. Custom Certificates (Production)
Use your own certificate files from a trusted Certificate Authority (CA) or an existing certificate.
```json
{
"server": {
"host": "0.0.0.0",
"port": 8443,
"tls": {
"enabled": true,
"mode": "custom",
"cert_file": "/etc/ssl/certs/myserver.crt",
"key_file": "/etc/ssl/private/myserver.key"
}
}
}
```
Or programmatically:
```go
wh, err := whatshooked.New(
whatshooked.WithServer("0.0.0.0", 8443),
whatshooked.WithCustomTLS("/etc/ssl/certs/myserver.crt", "/etc/ssl/private/myserver.key"),
)
```
**Features:**
- Use certificates from any CA (Let's Encrypt, DigiCert, etc.)
- Full control over certificate lifecycle
- Validates certificate files on startup
- Supports PKCS1, PKCS8, and EC private keys
#### 3. Let's Encrypt with Autocert (Production)
Automatically obtains and renews SSL certificates from Let's Encrypt. Best for production deployments with a registered domain.
```json
{
"server": {
"host": "0.0.0.0",
"port": 443,
"tls": {
"enabled": true,
"mode": "autocert",
"domain": "whatshooked.example.com",
"email": "admin@example.com",
"cache_dir": "./data/autocert",
"production": true
}
}
}
```
Or programmatically:
```go
wh, err := whatshooked.New(
whatshooked.WithServer("0.0.0.0", 443),
whatshooked.WithAutocertTLS("whatshooked.example.com", "admin@example.com", true),
)
```
**Features:**
- Automatic certificate provisioning from Let's Encrypt
- Automatic certificate renewal before expiration
- Fully managed - no manual intervention required
- Automatically starts HTTP challenge server on port 80 (when using port 443)
- Production and staging modes available
**Requirements:**
- Server must be publicly accessible
- Port 443 (HTTPS) must be open
- Port 80 (HTTP) must be open for ACME challenges
- Valid domain name pointing to your server
- Email address for Let's Encrypt notifications
**Important Notes:**
- Set `production: false` for testing to use Let's Encrypt staging environment (avoids rate limits)
- Set `production: true` for production deployments to get trusted certificates
- Ensure your domain's DNS A/AAAA record points to your server's IP
- Let's Encrypt has rate limits: 50 certificates per domain per week
#### TLS Configuration Reference
All TLS configuration options:
```json
{
"server": {
"tls": {
"enabled": true, // Enable HTTPS (default: false)
"mode": "self-signed", // Mode: "self-signed", "custom", or "autocert" (required if enabled)
// Self-signed mode options
"cert_dir": "./data/certs", // Directory for generated certificates (default: ./data/certs)
// Custom mode options
"cert_file": "/path/to/cert", // Path to certificate file (required for custom mode)
"key_file": "/path/to/key", // Path to private key file (required for custom mode)
// Autocert mode options
"domain": "example.com", // Domain name (required for autocert mode)
"email": "admin@example.com", // Email for Let's Encrypt notifications (optional)
"cache_dir": "./data/autocert", // Cache directory for certificates (default: ./data/autocert)
"production": true // Use Let's Encrypt production (default: false/staging)
}
}
}
```
#### Switching Between HTTP and HTTPS
To disable HTTPS and use HTTP, set `enabled: false` or omit the `tls` section entirely:
```json
{
"server": {
"host": "localhost",
"port": 8080
}
}
```
### Server Authentication
The server supports two authentication methods to protect API endpoints:
@@ -187,6 +671,146 @@ Clients can provide the API key using either:
- All `/api/*` endpoints require authentication when enabled
- Both authentication methods can be configured simultaneously - the server will accept either valid credentials or a valid API key
## WhatsApp Business API Setup
WhatsHooked supports the official WhatsApp Business Cloud API alongside personal WhatsApp accounts. This allows you to use official business phone numbers with enhanced features and reliability.
### Prerequisites
1. **Meta Business Account**: Sign up at [Meta Business Suite](https://business.facebook.com/)
2. **WhatsApp Business App**: Create a WhatsApp Business app in the [Meta for Developers](https://developers.facebook.com/) console
3. **Phone Number**: Register a business phone number with WhatsApp Business API
### Getting Your Credentials
1. Go to [Meta for Developers](https://developers.facebook.com/) and select your app
2. Navigate to **WhatsApp** → **API Setup**
3. Obtain the following:
- **Phone Number ID**: Found in the API Setup page
- **WhatsApp Business Account ID**: Found in the API Setup page (optional but recommended)
- **Access Token**: Generate a permanent token (not the temporary 24-hour token)
- **API Version**: Use the current stable version (e.g., `v21.0`)
### Configuring the Account
Add a Business API account to your `config.json`:
```json
{
"whatsapp": [
{
"id": "business",
"type": "business-api",
"phone_number": "+1234567890",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx_your_permanent_token",
"business_account_id": "987654321098765",
"api_version": "v21.0",
"verify_token": "my-secure-random-token-12345"
}
}
]
}
```
**Important Notes:**
- Use a **permanent access token**, not the temporary 24-hour token
- The `verify_token` is a random string you create - it will be used to verify Meta's webhook requests
- Keep your access token secure and never commit it to version control
### Setting Up Webhooks (Required for Receiving Messages)
To receive incoming messages from WhatsApp Business API, you must register your webhook with Meta:
1. **Start the WhatsHooked server** with your Business API configuration
2. **Ensure your server is publicly accessible** (use ngrok for testing):
```bash
ngrok http 8080
```
3. **In Meta for Developers**, go to **WhatsApp** → **Configuration**
4. **Add Webhook URL**:
- **Callback URL**: `https://your-domain.com/webhooks/whatsapp/{accountID}`
- Replace `your-domain.com` with your public domain or ngrok URL
- Replace `{accountID}` with your account ID from config (e.g., `business`)
- Example: `https://abc123.ngrok.io/webhooks/whatsapp/business`
- **Verify Token**: Enter the same `verify_token` from your config
5. **Subscribe to Webhook Fields**:
- Check **messages** (required for receiving messages)
- Check **message_status** (optional, for delivery/read receipts)
6. Click **Verify and Save**
### Testing Your Business API Connection
Once configured, start the server and the Business API account will connect automatically:
```bash
./bin/whatshook-server -config config.json
```
Look for logs indicating successful connection:
```
Business API client connected account_id=business phone=+1234567890
```
Send a test message:
```bash
./bin/whatshook-cli send
# Select your business account
# Enter recipient phone number
# Type your message
```
### Business API Features
**Supported:**
- ✅ Send/receive text messages
- ✅ Send/receive images with captions
- ✅ Send/receive videos with captions
- ✅ Send/receive documents with filenames
- ✅ Media upload via Meta CDN
- ✅ Delivery and read receipts
- ✅ Event publishing to webhooks (same format as whatsmeow)
**Differences from whatsmeow:**
- No QR code pairing (uses access token authentication)
- Rate limits apply based on your Meta Business tier
- Official support from Meta
- Better reliability for business use cases
- Costs apply based on conversation pricing
### Running Both Client Types Simultaneously
You can run both personal (whatsmeow) and Business API accounts at the same time:
```json
{
"whatsapp": [
{
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/personal"
},
{
"id": "business",
"type": "business-api",
"phone_number": "+9876543210",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx"
}
}
]
}
```
Both accounts will:
- Receive messages independently
- Trigger the same webhooks
- Publish identical event formats
- Support the same API endpoints
## Usage
### Starting the Server
@@ -365,17 +989,21 @@ Examples with `default_country_code: "27"`:
The server exposes the following HTTP endpoints:
**Public Endpoints:**
- `GET /health` - Health check (no authentication required)
- `GET /api/hooks` - List all hooks (requires authentication if enabled)
- `POST /api/hooks/add` - Add a new hook (requires authentication if enabled)
- `POST /api/hooks/remove` - Remove a hook (requires authentication if enabled)
- `GET /api/accounts` - List all WhatsApp accounts (requires authentication if enabled)
- `POST /api/accounts/add` - Add a new WhatsApp account (requires authentication if enabled)
- `POST /api/send` - Send a message (requires authentication if enabled)
- `POST /api/send/image` - Send an image (requires authentication if enabled)
- `POST /api/send/video` - Send a video (requires authentication if enabled)
- `POST /api/send/document` - Send a document (requires authentication if enabled)
- `GET /api/media/{accountID}/{filename}` - Serve media files (requires authentication if enabled)
- `GET/POST /webhooks/whatsapp/{accountID}` - Business API webhook verification and events (no authentication, validated by Meta's verify_token)
**Protected Endpoints (require authentication if enabled):**
- `GET /api/hooks` - List all hooks
- `POST /api/hooks/add` - Add a new hook
- `POST /api/hooks/remove` - Remove a hook
- `GET /api/accounts` - List all WhatsApp accounts
- `POST /api/accounts/add` - Add a new WhatsApp account
- `POST /api/send` - Send a message
- `POST /api/send/image` - Send an image
- `POST /api/send/video` - Send a video
- `POST /api/send/document` - Send a document
- `GET /api/media/{accountID}/{filename}` - Serve media files
## WhatsApp JID Format
@@ -393,17 +1021,43 @@ The server accepts both full JID format and plain phone numbers. When using plai
```
whatshooked/
├── cmd/
│ ├── server/ # Main server application
└── cli/ # CLI tool
├── internal/
├── config/ # Configuration management
├── events/ # Event bus and event types
│ ├── logging/ # Structured logging
│ ├── whatsapp/ # WhatsApp client management
├── hooks/ # Webhook management
└── utils/ # Utility functions (phone formatting, etc.)
├── config.example.json # Example configuration
└── go.mod # Go module definition
│ ├── server/ # Standalone server (thin wrapper)
│ └── main.go
│ └── cli/ # CLI tool
├── main.go
└── commands_*.go
├── pkg/ # Public library packages
│ ├── whatshooked/ # Main library entry point
│ ├── whatshooked.go # NewFromFile(), New()
│ ├── options.go # Functional options
│ │ └── server.go # Built-in HTTP server
│ ├── handlers/ # HTTP handlers
│ │ ├── handlers.go # Handler struct
│ │ ├── middleware.go # Auth middleware
│ │ ├── send.go # Send handlers
│ │ ├── accounts.go # Account handlers
│ │ ├── hooks.go # Hook handlers
│ │ ├── media.go # Media handlers
│ │ ├── health.go # Health handler
│ │ └── businessapi.go # Business API webhook
│ ├── config/ # Configuration types
│ ├── events/ # Event bus
│ ├── logging/ # Pluggable logging
│ ├── whatsapp/ # WhatsApp client management
│ │ ├── interface.go # Client interface
│ │ ├── manager.go # Multi-client manager
│ │ ├── whatsmeow/ # Personal WhatsApp
│ │ │ └── client.go
│ │ └── businessapi/ # WhatsApp Business API
│ │ ├── client.go
│ │ ├── types.go
│ │ ├── events.go
│ │ └── media.go
│ ├── hooks/ # Webhook management
│ ├── eventlogger/ # Event persistence
│ └── utils/ # Utility functions
├── config.example.json # Example configuration
└── go.mod # Go module definition
```
### Event Types
@@ -442,9 +1096,8 @@ go test ./...
go build ./...
```
## Future Phases
## Future Plans
### Phase 2 (Planned)
- User level hooks and WhatsApp accounts
- Web server with frontend UI
- Enhanced authentication with user roles and permissions

14
TODO.md
View File

@@ -1,11 +1,13 @@
# Todo List
## General todo
- [ ] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
- [ ] Authentication options for cli
- [ ] **Refactor** the code to make it more readable and maintainable. (Split server, hooks and routes. Split CLI into commands etc. Common connection code.)
- [ ] Whatsapp Business API support add
- [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
- [✔️] Authentication options for cli
- [✔️] **Refactor** the code to make it more readable and maintainable. (Split server, hooks and routes. Split CLI into commands etc. Common connection code.)
- [✔️] Whatsapp Business API support add
- [ ] 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)
- [✔️] 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)
- [✔️] 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.

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"github.com/spf13/cobra"
)
@@ -66,13 +66,19 @@ func addAccount(client *Client) {
var account config.WhatsAppConfig
fmt.Print("Account ID: ")
fmt.Scanln(&account.ID)
if _, err := fmt.Scanln(&account.ID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Phone Number (with country code): ")
fmt.Scanln(&account.PhoneNumber)
if _, err := fmt.Scanln(&account.PhoneNumber); err != nil {
checkError(fmt.Errorf("error reading phone number: %v", err))
}
fmt.Print("Session Path: ")
fmt.Scanln(&account.SessionPath)
if _, err := fmt.Scanln(&account.SessionPath); err != nil {
checkError(fmt.Errorf("error reading session path: %v", err))
}
resp, err := client.Post("/api/accounts/add", account)
checkError(err)

View File

@@ -6,7 +6,7 @@ import (
"os"
"strings"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"github.com/spf13/cobra"
)
@@ -95,16 +95,25 @@ func addHook(client *Client) {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Hook ID: ")
fmt.Scanln(&hook.ID)
if _, err := fmt.Scanln(&hook.ID); err != nil {
checkError(fmt.Errorf("error reading hook ID: %v", err))
}
fmt.Print("Hook Name: ")
fmt.Scanln(&hook.Name)
if _, err := fmt.Scanln(&hook.Name); err != nil {
checkError(fmt.Errorf("error reading hook name: %v", err))
}
fmt.Print("Webhook URL: ")
fmt.Scanln(&hook.URL)
if _, err := fmt.Scanln(&hook.URL); err != nil {
checkError(fmt.Errorf("error reading webhook URL: %v", err))
}
fmt.Print("HTTP Method (POST): ")
fmt.Scanln(&hook.Method)
if _, err := fmt.Scanln(&hook.Method); err == nil {
// Successfully read input
fmt.Printf("Selected Method %s", hook.Method)
}
if hook.Method == "" {
hook.Method = "POST"
}

View File

@@ -70,10 +70,14 @@ func sendMessage(client *Client) {
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number or JID, e.g., 0834606792 or 1234567890@s.whatsapp.net): ")
fmt.Scanln(&req.To)
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Message text: ")
reader := os.Stdin
@@ -101,10 +105,14 @@ func sendImage(client *Client, filePath string) {
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin
@@ -151,10 +159,14 @@ func sendVideo(client *Client, filePath string) {
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin
@@ -204,10 +216,14 @@ func sendDocument(client *Client, filePath string) {
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin

View File

@@ -2,89 +2,80 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/hooks"
"git.warky.dev/wdevs/whatshooked/internal/logging"
"git.warky.dev/wdevs/whatshooked/internal/utils"
"git.warky.dev/wdevs/whatshooked/internal/whatsapp"
"go.mau.fi/whatsmeow/types"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatshooked"
)
var (
configPath = flag.String("config", "config.json", "Path to configuration file")
configPath = flag.String("config", "", "Path to configuration file (optional, defaults to user home directory)")
)
type Server struct {
config *config.Config
whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager
httpServer *http.Server
eventBus *events.EventBus
// resolveConfigPath determines the config file path to use
// Priority: 1) provided path (if exists), 2) config.json in current dir, 3) .whatshooked/config.json in user home
func resolveConfigPath(providedPath string) (string, error) {
// If a path was explicitly provided, check if it exists
if providedPath != "" {
if _, err := os.Stat(providedPath); err == nil {
return providedPath, nil
}
// Directory doesn't exist, fall through to default locations
fmt.Fprintf(os.Stderr, "Provided config path directory does not exist, using default locations: %s\n", providedPath)
}
// Check for config.json in current directory
currentDirConfig := "config.json"
if _, err := os.Stat(currentDirConfig); err == nil {
return currentDirConfig, nil
}
// Fall back to user home directory
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
// Create .whatshooked directory if it doesn't exist
configDir := filepath.Join(homeDir, ".whatshooked")
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
return filepath.Join(configDir, "config.json"), nil
}
func main() {
flag.Parse()
// Load configuration
cfg, err := config.Load(*configPath)
// Resolve config path
cfgPath, err := resolveConfigPath(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to resolve config path: %v\n", err)
os.Exit(1)
}
// Initialize logging
logging.Init(cfg.LogLevel)
logging.Info("Starting WhatsHooked server")
// Create event bus
eventBus := events.NewEventBus()
// Create server with config update callback
srv := &Server{
config: cfg,
eventBus: eventBus,
whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, *configPath, func(updatedCfg *config.Config) error {
return config.Save(*configPath, updatedCfg)
}),
hookMgr: hooks.NewManager(eventBus),
// Create WhatsHooked instance from config file
wh, err := whatshooked.NewFromFile(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize WhatsHooked from %s: %v\n", cfgPath, err)
os.Exit(1)
}
// Load hooks
srv.hookMgr.LoadHooks(cfg.Hooks)
logging.Info("Starting WhatsHooked server", "config_path", cfgPath)
// Start hook manager to listen for events
srv.hookMgr.Start()
// Subscribe to hook success events to handle webhook responses
srv.eventBus.Subscribe(events.EventHookSuccess, srv.handleHookResponse)
// Start HTTP server for CLI BEFORE connecting to WhatsApp
// This ensures all infrastructure is ready before events start flowing
srv.startHTTPServer()
// Give HTTP server a moment to start
time.Sleep(100 * time.Millisecond)
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
// Connect to WhatsApp accounts
ctx := context.Background()
for _, waCfg := range cfg.WhatsApp {
if err := srv.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// Start the built-in HTTP server (non-blocking goroutine)
go func() {
if err := wh.StartServer(); err != nil {
logging.Error("HTTP server error", "error", err)
}
}
}()
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
@@ -97,477 +88,15 @@ func main() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if srv.httpServer != nil {
srv.httpServer.Shutdown(shutdownCtx)
// Stop server
if err := wh.StopServer(shutdownCtx); err != nil {
logging.Error("Error stopping server", "error", err)
}
// Close WhatsHooked (disconnects WhatsApp, closes event logger, etc.)
if err := wh.Close(); err != nil {
logging.Error("Error closing WhatsHooked", "error", err)
}
srv.whatsappMgr.DisconnectAll()
logging.Info("Server stopped")
}
// handleHookResponse processes hook success events to handle two-way communication
func (s *Server) handleHookResponse(event events.Event) {
// Use event context for sending message
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
// Extract response from event data
responseData, ok := event.Data["response"]
if !ok || responseData == nil {
return
}
// Try to cast to HookResponse
resp, ok := responseData.(hooks.HookResponse)
if !ok {
return
}
if !resp.SendMessage {
return
}
// Determine which account to use - default to first available if not specified
targetAccountID := resp.AccountID
if targetAccountID == "" && len(s.config.WhatsApp) > 0 {
targetAccountID = s.config.WhatsApp[0].ID
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(resp.To, s.config.Server.DefaultCountryCode)
// Parse JID
jid, err := types.ParseJID(formattedJID)
if err != nil {
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
return
}
// Send message with context
if err := s.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
logging.Error("Failed to send message from hook response", "error", err)
} else {
logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To)
}
}
// authMiddleware validates authentication credentials
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if any authentication is configured
hasAuth := s.config.Server.Username != "" || s.config.Server.Password != "" || s.config.Server.AuthKey != ""
if !hasAuth {
// No authentication configured, allow access
next(w, r)
return
}
authenticated := false
// Check for API key authentication (x-api-key header or Authorization bearer token)
if s.config.Server.AuthKey != "" {
// Check x-api-key header
apiKey := r.Header.Get("x-api-key")
if apiKey == s.config.Server.AuthKey {
authenticated = true
}
// Check Authorization header for bearer token
if !authenticated {
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
if token == s.config.Server.AuthKey {
authenticated = true
}
}
}
}
// Check for username/password authentication (HTTP Basic Auth)
if !authenticated && s.config.Server.Username != "" && s.config.Server.Password != "" {
username, password, ok := r.BasicAuth()
if ok && username == s.config.Server.Username && password == s.config.Server.Password {
authenticated = true
}
}
if !authenticated {
w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked Server"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// startHTTPServer starts the HTTP server for CLI communication
func (s *Server) startHTTPServer() {
mux := http.NewServeMux()
// Health check (no auth required)
mux.HandleFunc("/health", s.handleHealth)
// Hook management (with auth)
mux.HandleFunc("/api/hooks", s.authMiddleware(s.handleHooks))
mux.HandleFunc("/api/hooks/add", s.authMiddleware(s.handleAddHook))
mux.HandleFunc("/api/hooks/remove", s.authMiddleware(s.handleRemoveHook))
// Account management (with auth)
mux.HandleFunc("/api/accounts", s.authMiddleware(s.handleAccounts))
mux.HandleFunc("/api/accounts/add", s.authMiddleware(s.handleAddAccount))
// Send messages (with auth)
mux.HandleFunc("/api/send", s.authMiddleware(s.handleSendMessage))
mux.HandleFunc("/api/send/image", s.authMiddleware(s.handleSendImage))
mux.HandleFunc("/api/send/video", s.authMiddleware(s.handleSendVideo))
mux.HandleFunc("/api/send/document", s.authMiddleware(s.handleSendDocument))
// Serve media files (with auth)
mux.HandleFunc("/api/media/", s.authMiddleware(s.handleServeMedia))
addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
logging.Info("Starting HTTP server",
"host", s.config.Server.Host,
"port", s.config.Server.Port,
"address", addr,
)
logging.Info("HTTP server endpoints available",
"health", "/health",
"hooks", "/api/hooks",
"accounts", "/api/accounts",
"send", "/api/send",
)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logging.Error("HTTP server error", "error", err)
}
}()
}
// HTTP Handlers
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) {
hooks := s.hookMgr.ListHooks()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hooks)
}
func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var hook config.Hook
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.hookMgr.AddHook(hook)
// Update config
s.config.Hooks = s.hookMgr.ListHooks()
if err := config.Save(*configPath, s.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.hookMgr.RemoveHook(req.ID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Update config
s.config.Hooks = s.hookMgr.ListHooks()
if err := config.Save(*configPath, s.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.config.WhatsApp)
}
func (s *Server) handleAddAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var account config.WhatsAppConfig
if err := json.NewDecoder(r.Body).Decode(&account); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Connect to the account
if err := s.whatsappMgr.Connect(context.Background(), account); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Update config
s.config.WhatsApp = append(s.config.WhatsApp, account)
if err := config.Save(*configPath, s.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
if err := s.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
ImageData string `json:"image_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 image data
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
if err != nil {
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "image/jpeg"
}
if err := s.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
VideoData string `json:"video_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 video data
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
if err != nil {
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "video/mp4"
}
if err := s.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
DocumentData string `json:"document_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 document data
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
if err != nil {
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default values if not provided
if req.MimeType == "" {
req.MimeType = "application/octet-stream"
}
if req.Filename == "" {
req.Filename = "document"
}
if err := s.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/media/{accountID}/{filename}
path := r.URL.Path[len("/api/media/"):]
// Split path into accountID and filename
var accountID, filename string
for i, ch := range path {
if ch == '/' {
accountID = path[:i]
filename = path[i+1:]
break
}
}
if accountID == "" || filename == "" {
http.Error(w, "Invalid media path", http.StatusBadRequest)
return
}
// Construct full file path
filePath := filepath.Join(s.config.Media.DataPath, accountID, filename)
// Security check: ensure the resolved path is within the media directory
mediaDir := filepath.Join(s.config.Media.DataPath, accountID)
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
absMediaDir, err := filepath.Abs(mediaDir)
if err != nil {
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
return
}
// Check if file path is within media directory (prevent directory traversal)
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Serve the file
http.ServeFile(w, r, absFilePath)
}

View File

@@ -5,14 +5,38 @@
"default_country_code": "27",
"username": "",
"password": "",
"auth_key": ""
"auth_key": "",
"tls": {
"enabled": false,
"mode": "self-signed",
"cert_dir": "./data/certs",
"cert_file": "",
"key_file": "",
"domain": "",
"email": "",
"cache_dir": "./data/autocert",
"production": false
}
},
"whatsapp": [
{
"id": "acc1",
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/account1",
"session_path": "./sessions/personal",
"show_qr": true
},
{
"id": "business",
"type": "business-api",
"phone_number": "+9876543210",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx_your_access_token_here",
"business_account_id": "987654321098765",
"api_version": "v21.0",
"verify_token": "my-secure-verify-token-12345"
}
}
],
"hooks": [
@@ -78,5 +102,23 @@
"mode": "link",
"base_url": "http://localhost:8080"
},
"database": {
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "whatshooked",
"password": "your_password_here",
"database": "whatshooked",
"sqlite_path": "./data/events.db"
},
"event_logger": {
"enabled": false,
"targets": [
"file",
"sqlite"
],
"file_dir": "./data/events",
"table_name": "event_logs"
},
"log_level": "info"
}

View File

@@ -0,0 +1,39 @@
{
"server": {
"host": "0.0.0.0",
"port": 8443,
"default_country_code": "27",
"auth_key": "your-secure-api-key-here",
"tls": {
"enabled": true,
"mode": "custom",
"cert_file": "/etc/ssl/certs/whatshooked.crt",
"key_file": "/etc/ssl/private/whatshooked.key"
}
},
"whatsapp": [
{
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/personal",
"show_qr": true
}
],
"hooks": [
{
"id": "message_hook",
"name": "Message Handler",
"url": "https://example.com/webhook",
"method": "POST",
"active": true,
"events": ["message.received"]
}
],
"media": {
"data_path": "./data/media",
"mode": "link",
"base_url": "https://whatshooked.example.com"
},
"log_level": "info"
}

View File

@@ -0,0 +1,41 @@
{
"server": {
"host": "0.0.0.0",
"port": 443,
"default_country_code": "27",
"auth_key": "your-secure-api-key-here",
"tls": {
"enabled": true,
"mode": "autocert",
"domain": "whatshooked.example.com",
"email": "admin@example.com",
"cache_dir": "./data/autocert",
"production": true
}
},
"whatsapp": [
{
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/personal",
"show_qr": true
}
],
"hooks": [
{
"id": "message_hook",
"name": "Message Handler",
"url": "https://example.com/webhook",
"method": "POST",
"active": true,
"events": ["message.received"]
}
],
"media": {
"data_path": "./data/media",
"mode": "link",
"base_url": "https://whatshooked.example.com"
},
"log_level": "info"
}

View File

@@ -0,0 +1,38 @@
{
"server": {
"host": "0.0.0.0",
"port": 8443,
"default_country_code": "27",
"auth_key": "your-secure-api-key-here",
"tls": {
"enabled": true,
"mode": "self-signed",
"cert_dir": "./data/certs"
}
},
"whatsapp": [
{
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/personal",
"show_qr": true
}
],
"hooks": [
{
"id": "message_hook",
"name": "Message Handler",
"url": "https://example.com/webhook",
"method": "POST",
"active": true,
"events": ["message.received"]
}
],
"media": {
"data_path": "./data/media",
"mode": "link",
"base_url": "https://localhost:8443"
},
"log_level": "info"
}

View File

@@ -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

8
go.mod
View File

@@ -3,12 +3,16 @@ 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
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
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 (
@@ -19,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
@@ -35,11 +40,10 @@ require (
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // 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
)

8
go.sum
View File

@@ -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,12 +29,16 @@ 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=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -94,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=

View File

@@ -1,105 +0,0 @@
package config
import (
"encoding/json"
"os"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `json:"server"`
WhatsApp []WhatsAppConfig `json:"whatsapp"`
Hooks []Hook `json:"hooks"`
Database DatabaseConfig `json:"database,omitempty"`
Media MediaConfig `json:"media"`
LogLevel string `json:"log_level"`
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
DefaultCountryCode string `json:"default_country_code,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"`
}
// WhatsAppConfig holds configuration for a WhatsApp account
type WhatsAppConfig struct {
ID string `json:"id"`
PhoneNumber string `json:"phone_number"`
SessionPath string `json:"session_path"`
ShowQR bool `json:"show_qr,omitempty"`
}
// Hook represents a registered webhook
type Hook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers,omitempty"`
Active bool `json:"active"`
Events []string `json:"events,omitempty"`
Description string `json:"description,omitempty"`
}
// DatabaseConfig holds database connection information
type DatabaseConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
}
// MediaConfig holds media storage and delivery configuration
type MediaConfig struct {
DataPath string `json:"data_path"`
Mode string `json:"mode"` // "base64", "link", or "both"
BaseURL string `json:"base_url,omitempty"` // Base URL for media links
}
// Load reads configuration from a file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Set defaults
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
if cfg.Server.Host == "" {
cfg.Server.Host = "localhost"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Media.DataPath == "" {
cfg.Media.DataPath = "./data/media"
}
if cfg.Media.Mode == "" {
cfg.Media.Mode = "link"
}
return &cfg, nil
}
// Save writes configuration to a file
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

View File

@@ -1,70 +0,0 @@
package logging
import (
"log/slog"
"os"
"strings"
)
var logger *slog.Logger
// Init initializes the logger with the specified log level
func Init(level string) {
var logLevel slog.Level
switch strings.ToLower(level) {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn", "warning":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
opts := &slog.HandlerOptions{
Level: logLevel,
}
handler := slog.NewJSONHandler(os.Stdout, opts)
logger = slog.New(handler)
slog.SetDefault(logger)
}
// Debug logs a debug message
func Debug(msg string, args ...any) {
if logger != nil {
logger.Debug(msg, args...)
}
}
// Info logs an info message
func Info(msg string, args ...any) {
if logger != nil {
logger.Info(msg, args...)
}
}
// Warn logs a warning message
func Warn(msg string, args ...any) {
if logger != nil {
logger.Warn(msg, args...)
}
}
// Error logs an error message
func Error(msg string, args ...any) {
if logger != nil {
logger.Error(msg, args...)
}
}
// With returns a new logger with additional attributes
func With(args ...any) *slog.Logger {
if logger != nil {
return logger.With(args...)
}
return slog.Default()
}

View File

@@ -1,767 +0,0 @@
package whatsapp
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/logging"
qrterminal "github.com/mdp/qrterminal/v3"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
waEvents "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
_ "github.com/mattn/go-sqlite3"
)
// Manager manages multiple WhatsApp client connections
type Manager struct {
clients map[string]*Client
mu sync.RWMutex
eventBus *events.EventBus
mediaConfig config.MediaConfig
config *config.Config
configPath string
onConfigUpdate func(*config.Config) error
}
// Client represents a single WhatsApp connection
type Client struct {
ID string
PhoneNumber string
Client *whatsmeow.Client
Container *sqlstore.Container
keepAliveCancel context.CancelFunc
}
// NewManager creates a new WhatsApp manager
func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager {
return &Manager{
clients: make(map[string]*Client),
eventBus: eventBus,
mediaConfig: mediaConfig,
config: cfg,
configPath: configPath,
onConfigUpdate: onConfigUpdate,
}
}
// Connect establishes a connection to a WhatsApp account
func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.clients[cfg.ID]; exists {
return fmt.Errorf("client %s already connected", cfg.ID)
}
// Ensure session directory exists
if err := os.MkdirAll(cfg.SessionPath, 0700); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
}
// Create database container for session storage
dbPath := filepath.Join(cfg.SessionPath, "session.db")
dbLog := waLog.Stdout("Database", "ERROR", true)
container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog)
if err != nil {
return fmt.Errorf("failed to create database container: %w", err)
}
// Get device store
deviceStore, err := container.GetFirstDevice(ctx)
if err != nil {
return fmt.Errorf("failed to get device: %w", err)
}
// Set custom client information
//if deviceStore.ID == nil {
// Only set for new devices
deviceStore.Platform = "WhatsHooked"
deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked"
//}
// Create client
clientLog := waLog.Stdout("Client", "ERROR", true)
client := whatsmeow.NewClient(deviceStore, clientLog)
// Register event handler
client.AddEventHandler(func(evt interface{}) {
m.handleEvent(cfg.ID, evt)
})
// Connect
if client.Store.ID == nil {
// New device, need to pair
qrChan, _ := client.GetQRChannel(ctx)
if err := client.Connect(); err != nil {
m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err))
return fmt.Errorf("failed to connect: %w", err)
}
// Wait for QR code
for evt := range qrChan {
switch evt.Event {
case "code":
logging.Info("QR code received for pairing", "account_id", cfg.ID)
// Always display QR code in terminal
fmt.Println("\n========================================")
fmt.Printf("WhatsApp QR Code for account: %s\n", cfg.ID)
fmt.Printf("Phone: %s\n", cfg.PhoneNumber)
fmt.Println("========================================")
fmt.Println("Scan this QR code with WhatsApp on your phone:")
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
fmt.Println("========================================")
// Publish QR code event
m.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, cfg.ID, evt.Code))
case "success":
logging.Info("Pairing successful", "account_id", cfg.ID, "phone", cfg.PhoneNumber)
m.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, cfg.ID))
case "timeout":
logging.Warn("QR code timeout", "account_id", cfg.ID)
m.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, cfg.ID))
case "error":
logging.Error("QR code error", "account_id", cfg.ID, "error", evt.Error)
m.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, cfg.ID, fmt.Errorf("%v", evt.Error)))
default:
logging.Info("Pairing event", "account_id", cfg.ID, "event", evt.Event)
m.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, cfg.ID, evt.Event, map[string]any{
"code": evt.Code,
}))
}
}
} else {
// Already paired, just connect
if err := client.Connect(); err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
if deviceStore.PushName == "" {
deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", cfg.PhoneNumber)
if err := deviceStore.Save(ctx); err != nil {
logging.Error("failed to save device store %s", cfg.ID)
}
}
waClient := &Client{
ID: cfg.ID,
PhoneNumber: cfg.PhoneNumber,
Client: client,
Container: container,
}
m.clients[cfg.ID] = waClient
if client.IsConnected() {
err := client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", cfg.ID, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", cfg.ID)
}
}
// Start keep-alive routine
m.startKeepAlive(waClient)
logging.Info("WhatsApp client connected", "account_id", cfg.ID, "phone", cfg.PhoneNumber)
return nil
}
// Disconnect disconnects a WhatsApp client
func (m *Manager) Disconnect(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
client, exists := m.clients[id]
if !exists {
return fmt.Errorf("client %s not found", id)
}
// Stop keep-alive
if client.keepAliveCancel != nil {
client.keepAliveCancel()
}
client.Client.Disconnect()
delete(m.clients, id)
logging.Info("WhatsApp client disconnected", "account_id", id)
return nil
}
// DisconnectAll disconnects all WhatsApp clients
func (m *Manager) DisconnectAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, client := range m.clients {
// Stop keep-alive
if client.keepAliveCancel != nil {
client.keepAliveCancel()
}
client.Client.Disconnect()
logging.Info("WhatsApp client disconnected", "account_id", id)
}
m.clients = make(map[string]*Client)
}
// SendTextMessage sends a text message from a specific account
func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err))
return err
}
msg := &waE2E.Message{
Conversation: proto.String(text),
}
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err))
return fmt.Errorf("failed to send message: %w", err)
}
logging.Debug("Message sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), text))
return nil
}
// SendImage sends an image message from a specific account
func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the image
uploaded, err := client.Client.Upload(ctx, imageData, whatsmeow.MediaImage)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload image: %w", err)
}
// Create image message
msg := &waE2E.Message{
ImageMessage: &waE2E.ImageMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(imageData))),
},
}
// Add caption if provided
if caption != "" {
msg.ImageMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send image: %w", err)
}
logging.Debug("Image sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// SendVideo sends a video message from a specific account
func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the video
uploaded, err := client.Client.Upload(ctx, videoData, whatsmeow.MediaVideo)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload video: %w", err)
}
// Create video message
msg := &waE2E.Message{
VideoMessage: &waE2E.VideoMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(videoData))),
},
}
// Add caption if provided
if caption != "" {
msg.VideoMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send video: %w", err)
}
logging.Debug("Video sent", "account_id", accountID, "to", jid.String())
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// SendDocument sends a document message from a specific account
func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
err := fmt.Errorf("client %s not found", accountID)
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return err
}
// Upload the document
uploaded, err := client.Client.Upload(ctx, documentData, whatsmeow.MediaDocument)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to upload document: %w", err)
}
// Create document message
msg := &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(documentData))),
FileName: proto.String(filename),
},
}
// Add caption if provided
if caption != "" {
msg.DocumentMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := client.Client.SendMessage(ctx, jid, msg)
if err != nil {
m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err))
return fmt.Errorf("failed to send document: %w", err)
}
logging.Debug("Document sent", "account_id", accountID, "to", jid.String(), "filename", filename)
m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption))
return nil
}
// GetClient returns a client by ID
func (m *Manager) GetClient(id string) (*Client, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
client, exists := m.clients[id]
return client, exists
}
// handleEvent processes WhatsApp events
func (m *Manager) handleEvent(accountID string, evt interface{}) {
ctx := context.Background()
switch v := evt.(type) {
case *waEvents.Message:
logging.Debug("Message received", "account_id", accountID, "from", v.Info.Sender.String())
// Get the client for downloading media
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
logging.Error("Client not found for message event", "account_id", accountID)
return
}
// Extract message content based on type
var text string
var messageType string = "text"
var mimeType string
var filename string
var mediaBase64 string
var mediaURL string
// Handle text messages
if v.Message.Conversation != nil {
text = *v.Message.Conversation
messageType = "text"
} else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil {
text = *v.Message.ExtendedTextMessage.Text
messageType = "text"
}
// Handle image messages
if v.Message.ImageMessage != nil {
img := v.Message.ImageMessage
messageType = "image"
mimeType = img.GetMimetype()
// Use filename from caption or default
if img.Caption != nil {
text = *img.Caption
}
// Download image
data, err := client.Client.Download(ctx, img)
if err != nil {
logging.Error("Failed to download image", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle video messages
if v.Message.VideoMessage != nil {
vid := v.Message.VideoMessage
messageType = "video"
mimeType = vid.GetMimetype()
// Use filename from caption or default
if vid.Caption != nil {
text = *vid.Caption
}
// Download video
data, err := client.Client.Download(ctx, vid)
if err != nil {
logging.Error("Failed to download video", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle document messages
if v.Message.DocumentMessage != nil {
doc := v.Message.DocumentMessage
messageType = "document"
mimeType = doc.GetMimetype()
// Use provided filename or generate one
if doc.FileName != nil {
filename = *doc.FileName
}
// Use caption as text if provided
if doc.Caption != nil {
text = *doc.Caption
}
// Download document
data, err := client.Client.Download(ctx, doc)
if err != nil {
logging.Error("Failed to download document", "account_id", accountID, "error", err)
} else {
filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Publish message received event
m.eventBus.Publish(events.MessageReceivedEvent(
ctx,
accountID,
v.Info.ID,
v.Info.Sender.String(),
v.Info.Chat.String(),
text,
v.Info.Timestamp,
v.Info.IsGroup,
"", // group name - TODO: extract from message
"", // sender name - TODO: extract from message
messageType,
mimeType,
filename,
mediaBase64,
mediaURL,
))
case *waEvents.Connected:
logging.Info("WhatsApp connected", "account_id", accountID)
// Get phone number and client for account
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
phoneNumber := ""
if exists {
// Get the actual phone number from WhatsApp
if client.Client.Store.ID != nil {
actualPhone := client.Client.Store.ID.User
phoneNumber = "+" + actualPhone
// Update phone number in client and config if it's different
if client.PhoneNumber != phoneNumber {
client.PhoneNumber = phoneNumber
logging.Info("Updated phone number from WhatsApp", "account_id", accountID, "phone", phoneNumber)
// Update config
m.updateConfigPhoneNumber(accountID, phoneNumber)
}
} else if client.PhoneNumber != "" {
phoneNumber = client.PhoneNumber
}
}
m.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, accountID, phoneNumber))
case *waEvents.Disconnected:
logging.Warn("WhatsApp disconnected", "account_id", accountID)
m.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, accountID, "connection lost"))
case *waEvents.Receipt:
// Handle delivery and read receipts
if v.Type == types.ReceiptTypeDelivered {
for _, messageID := range v.MessageIDs {
logging.Debug("Message delivered", "account_id", accountID, "message_id", messageID, "from", v.Sender.String())
m.eventBus.Publish(events.MessageDeliveredEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp))
}
} else if v.Type == types.ReceiptTypeRead {
for _, messageID := range v.MessageIDs {
logging.Debug("Message read", "account_id", accountID, "message_id", messageID, "from", v.Sender.String())
m.eventBus.Publish(events.MessageReadEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp))
}
}
}
}
// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive
func (m *Manager) startKeepAlive(client *Client) {
ctx, cancel := context.WithCancel(context.Background())
client.keepAliveCancel = cancel
go func() {
ticker := time.NewTicker(60 * time.Second) // Send presence every 60 seconds
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logging.Debug("Keep-alive stopped", "account_id", client.ID)
return
case <-ticker.C:
// Send presence as "available"
if client.Client.IsConnected() {
err := client.Client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", client.ID, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", client.ID)
}
}
}
}
}()
logging.Info("Keep-alive started", "account_id", client.ID)
}
// updateConfigPhoneNumber updates the phone number for an account in the config and saves it
func (m *Manager) updateConfigPhoneNumber(accountID, phoneNumber string) {
if m.config == nil || m.onConfigUpdate == nil {
return
}
// Find and update the account in the config
for i := range m.config.WhatsApp {
if m.config.WhatsApp[i].ID == accountID {
m.config.WhatsApp[i].PhoneNumber = phoneNumber
// Save the updated config
if err := m.onConfigUpdate(m.config); err != nil {
logging.Error("Failed to save updated config", "account_id", accountID, "error", err)
} else {
logging.Info("Config updated with phone number", "account_id", accountID, "phone", phoneNumber)
}
break
}
}
}
// processMediaData processes media based on the configured mode
// Returns filename and mediaURL, and optionally sets mediaBase64
func (m *Manager) processMediaData(accountID, messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) {
mode := m.mediaConfig.Mode
var filename string
var mediaURL string
// Generate filename
ext := getExtensionFromMimeType(mimeType)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Handle base64 mode
if mode == "base64" || mode == "both" {
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
}
// Handle link mode
if mode == "link" || mode == "both" {
// Save file to disk
filePath, err := m.saveMediaFile(accountID, messageID, data, mimeType)
if err != nil {
logging.Error("Failed to save media file", "account_id", accountID, "message_id", messageID, "error", err)
} else {
// Extract just the filename from the full path
filename = filepath.Base(filePath)
mediaURL = m.generateMediaURL(accountID, messageID, filename)
}
}
return filename, mediaURL
}
// saveMediaFile saves media data to disk and returns the file path
func (m *Manager) saveMediaFile(accountID, messageID string, data []byte, mimeType string) (string, error) {
// Create account-specific media directory
mediaDir := filepath.Join(m.mediaConfig.DataPath, accountID)
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return "", fmt.Errorf("failed to create media directory: %w", err)
}
// Generate unique filename using message ID and hash
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes of hash
ext := getExtensionFromMimeType(mimeType)
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Full path to file
filePath := filepath.Join(mediaDir, filename)
// Write file
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to write media file: %w", err)
}
return filePath, nil
}
// generateMediaURL generates a URL for accessing stored media
func (m *Manager) generateMediaURL(accountID, messageID, filename string) string {
baseURL := m.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080" // default
}
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, accountID, filename)
}
// getExtensionFromMimeType returns the file extension for a given MIME type
func getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{
// Images
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
// Videos
"video/mp4": ".mp4",
"video/mpeg": ".mpeg",
"video/quicktime": ".mov",
"video/x-msvideo": ".avi",
"video/webm": ".webm",
"video/3gpp": ".3gp",
// Documents
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"text/plain": ".txt",
"text/html": ".html",
"application/zip": ".zip",
"application/x-rar-compressed": ".rar",
"application/x-7z-compressed": ".7z",
"application/json": ".json",
"application/xml": ".xml",
// Audio
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/aac": ".aac",
"audio/x-m4a": ".m4a",
}
if ext, ok := extensions[mimeType]; ok {
return ext
}
return "" // No extension if mime type is unknown
}

200
pkg/config/config.go Normal file
View File

@@ -0,0 +1,200 @@
package config
import (
"encoding/json"
"os"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `json:"server"`
WhatsApp []WhatsAppConfig `json:"whatsapp"`
Hooks []Hook `json:"hooks"`
Database DatabaseConfig `json:"database,omitempty"`
Media MediaConfig `json:"media"`
EventLogger EventLoggerConfig `json:"event_logger,omitempty"`
LogLevel string `json:"log_level"`
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
DefaultCountryCode string `json:"default_country_code,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"`
TLS TLSConfig `json:"tls,omitempty"`
}
// TLSConfig holds TLS/HTTPS configuration
type TLSConfig struct {
Enabled bool `json:"enabled"` // Enable HTTPS
Mode string `json:"mode"` // "self-signed", "custom", or "autocert"
CertFile string `json:"cert_file,omitempty"` // Path to certificate file (for custom mode)
KeyFile string `json:"key_file,omitempty"` // Path to key file (for custom mode)
// Self-signed certificate options
CertDir string `json:"cert_dir,omitempty"` // Directory to store generated certificates
// Let's Encrypt / autocert options
Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt
Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications
CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert
Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging)
}
// WhatsAppConfig holds configuration for a WhatsApp account
type WhatsAppConfig struct {
ID string `json:"id"`
Type string `json:"type"` // "whatsmeow" or "business-api"
PhoneNumber string `json:"phone_number"`
SessionPath string `json:"session_path,omitempty"`
ShowQR bool `json:"show_qr,omitempty"`
BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"`
}
// BusinessAPIConfig holds configuration for WhatsApp Business API
type BusinessAPIConfig struct {
PhoneNumberID string `json:"phone_number_id"`
AccessToken string `json:"access_token"`
BusinessAccountID string `json:"business_account_id,omitempty"`
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
WebhookPath string `json:"webhook_path,omitempty"`
VerifyToken string `json:"verify_token,omitempty"`
}
// Hook represents a registered webhook
type Hook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers,omitempty"`
Active bool `json:"active"`
Events []string `json:"events,omitempty"`
Description string `json:"description,omitempty"`
}
// DatabaseConfig holds database connection information
type DatabaseConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
SQLitePath string `json:"sqlite_path,omitempty"` // Path to SQLite database file
}
// MediaConfig holds media storage and delivery configuration
type MediaConfig struct {
DataPath string `json:"data_path"`
Mode string `json:"mode"` // "base64", "link", or "both"
BaseURL string `json:"base_url,omitempty"` // Base URL for media links
}
// EventLoggerConfig holds event logging configuration
type EventLoggerConfig struct {
Enabled bool `json:"enabled"`
Targets []string `json:"targets"` // "file", "sqlite", "postgres", "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
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Set defaults
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
if cfg.Server.Host == "" {
cfg.Server.Host = "localhost"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Media.DataPath == "" {
cfg.Media.DataPath = "./data/media"
}
if cfg.Media.Mode == "" {
cfg.Media.Mode = "link"
}
if cfg.EventLogger.FileDir == "" {
cfg.EventLogger.FileDir = "./data/events"
}
if cfg.EventLogger.TableName == "" {
cfg.EventLogger.TableName = "event_logs"
}
if cfg.Database.SQLitePath == "" {
cfg.Database.SQLitePath = "./data/events.db"
}
// Default WhatsApp account type to whatsmeow for backwards compatibility
for i := range cfg.WhatsApp {
if cfg.WhatsApp[i].Type == "" {
cfg.WhatsApp[i].Type = "whatsmeow"
}
// Set default API version for Business API
if cfg.WhatsApp[i].Type == "business-api" && cfg.WhatsApp[i].BusinessAPI != nil {
if cfg.WhatsApp[i].BusinessAPI.APIVersion == "" {
cfg.WhatsApp[i].BusinessAPI.APIVersion = "v21.0"
}
}
}
// Set TLS defaults if enabled
if cfg.Server.TLS.Enabled {
if cfg.Server.TLS.Mode == "" {
cfg.Server.TLS.Mode = "self-signed"
}
if cfg.Server.TLS.CertDir == "" {
cfg.Server.TLS.CertDir = "./data/certs"
}
if cfg.Server.TLS.CacheDir == "" {
cfg.Server.TLS.CacheDir = "./data/autocert"
}
}
return &cfg, nil
}
// Save writes configuration to a file
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

View File

@@ -0,0 +1,121 @@
package eventlogger
import (
"context"
"fmt"
"strings"
"sync"
"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
type Logger struct {
config config.EventLoggerConfig
dbConfig config.DatabaseConfig
targets []Target
mu sync.Mutex
}
// Target represents a logging target
type Target interface {
Log(event events.Event) error
Close() error
}
// 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, waManager WhatsAppManager) (*Logger, error) {
logger := &Logger{
config: cfg,
dbConfig: dbConfig,
targets: make([]Target, 0),
}
// Initialize targets based on configuration
for _, targetType := range cfg.Targets {
switch strings.ToLower(targetType) {
case "file":
fileTarget, err := NewFileTarget(cfg.FileDir)
if err != nil {
logging.Error("Failed to initialize file target", "error", err)
continue
}
logger.targets = append(logger.targets, fileTarget)
logging.Info("Event logger file target initialized", "dir", cfg.FileDir)
case "sqlite":
sqliteTarget, err := NewSQLiteTarget(dbConfig, cfg.TableName)
if err != nil {
logging.Error("Failed to initialize SQLite target", "error", err)
continue
}
logger.targets = append(logger.targets, sqliteTarget)
logging.Info("Event logger SQLite target initialized")
case "postgres", "postgresql":
postgresTarget, err := NewPostgresTarget(dbConfig, cfg.TableName)
if err != nil {
logging.Error("Failed to initialize PostgreSQL target", "error", err)
continue
}
logger.targets = append(logger.targets, postgresTarget)
logging.Info("Event logger PostgreSQL target initialized")
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)
}
}
return logger, nil
}
// Log logs an event to all configured targets
func (l *Logger) Log(event events.Event) {
l.mu.Lock()
defer l.mu.Unlock()
for _, target := range l.targets {
if err := target.Log(event); err != nil {
logging.Error("Failed to log event", "target", fmt.Sprintf("%T", target), "error", err)
}
}
}
// Close closes all logging targets
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
var errors []string
for _, target := range l.targets {
if err := target.Close(); err != nil {
errors = append(errors, err.Error())
}
}
if len(errors) > 0 {
return fmt.Errorf("errors closing targets: %s", strings.Join(errors, "; "))
}
return nil
}

View File

@@ -0,0 +1,69 @@
package eventlogger
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"git.warky.dev/wdevs/whatshooked/pkg/events"
)
// FileTarget logs events to organized file structure
type FileTarget struct {
baseDir string
mu sync.Mutex
}
// NewFileTarget creates a new file-based logging target
func NewFileTarget(baseDir string) (*FileTarget, error) {
// Create base directory if it doesn't exist
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create event log directory: %w", err)
}
return &FileTarget{
baseDir: baseDir,
}, nil
}
// Log writes an event to a file
func (ft *FileTarget) Log(event events.Event) error {
ft.mu.Lock()
defer ft.mu.Unlock()
// Create directory structure: baseDir/[type]/[YYYYMMDD]/
eventType := string(event.Type)
dateDir := event.Timestamp.Format("20060102")
dirPath := filepath.Join(ft.baseDir, eventType, dateDir)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return fmt.Errorf("failed to create event directory: %w", err)
}
// Create filename: [hh24_mi_ss]_[type].json
filename := fmt.Sprintf("%s_%s.json",
event.Timestamp.Format("15_04_05"),
eventType,
)
filePath := filepath.Join(dirPath, filename)
// Marshal event to JSON
data, err := json.MarshalIndent(event, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
// Write to file
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write event file: %w", err)
}
return nil
}
// Close closes the file target (no-op for file target)
func (ft *FileTarget) Close() error {
return nil
}

View 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
}

View File

@@ -0,0 +1,120 @@
package eventlogger
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
_ "github.com/lib/pq" // PostgreSQL driver
)
// PostgresTarget logs events to PostgreSQL database
type PostgresTarget struct {
db *sql.DB
tableName string
mu sync.Mutex
}
// NewPostgresTarget creates a new PostgreSQL logging target
func NewPostgresTarget(dbConfig config.DatabaseConfig, tableName string) (*PostgresTarget, error) {
// Build connection string
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
dbConfig.Host,
dbConfig.Port,
dbConfig.Username,
dbConfig.Password,
dbConfig.Database,
)
// Open PostgreSQL connection
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
}
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
}
target := &PostgresTarget{
db: db,
tableName: tableName,
}
// Create table if it doesn't exist
if err := target.createTable(); err != nil {
db.Close()
return nil, err
}
return target, nil
}
// createTable creates the event logs table if it doesn't exist
func (pt *PostgresTarget) createTable() error {
query := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id SERIAL PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
timestamp TIMESTAMP NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`, pt.tableName)
if _, err := pt.db.Exec(query); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
// Create index on event_type and timestamp
indexQuery := fmt.Sprintf(`
CREATE INDEX IF NOT EXISTS idx_%s_type_timestamp
ON %s(event_type, timestamp)
`, pt.tableName, pt.tableName)
if _, err := pt.db.Exec(indexQuery); err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
return nil
}
// Log writes an event to PostgreSQL database
func (pt *PostgresTarget) Log(event events.Event) error {
pt.mu.Lock()
defer pt.mu.Unlock()
// Marshal event data to JSON
data, err := json.Marshal(event.Data)
if err != nil {
return fmt.Errorf("failed to marshal event data: %w", err)
}
query := fmt.Sprintf(`
INSERT INTO %s (event_type, timestamp, data)
VALUES ($1, $2, $3)
`, pt.tableName)
_, err = pt.db.Exec(query, string(event.Type), event.Timestamp, string(data))
if err != nil {
return fmt.Errorf("failed to insert event: %w", err)
}
return nil
}
// Close closes the PostgreSQL database connection
func (pt *PostgresTarget) Close() error {
return pt.db.Close()
}

View File

@@ -0,0 +1,111 @@
package eventlogger
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
// SQLiteTarget logs events to SQLite database
type SQLiteTarget struct {
db *sql.DB
tableName string
mu sync.Mutex
}
// NewSQLiteTarget creates a new SQLite logging target
func NewSQLiteTarget(dbConfig config.DatabaseConfig, tableName string) (*SQLiteTarget, error) {
// Use the SQLite path from config (defaults to "./data/events.db")
dbPath := dbConfig.SQLitePath
// Create directory if needed
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// Open SQLite connection
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
}
target := &SQLiteTarget{
db: db,
tableName: tableName,
}
// Create table if it doesn't exist
if err := target.createTable(); err != nil {
db.Close()
return nil, err
}
return target, nil
}
// createTable creates the event logs table if it doesn't exist
func (st *SQLiteTarget) createTable() error {
query := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
timestamp DATETIME NOT NULL,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, st.tableName)
if _, err := st.db.Exec(query); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
// Create index on event_type and timestamp
indexQuery := fmt.Sprintf(`
CREATE INDEX IF NOT EXISTS idx_%s_type_timestamp
ON %s(event_type, timestamp)
`, st.tableName, st.tableName)
if _, err := st.db.Exec(indexQuery); err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
return nil
}
// Log writes an event to SQLite database
func (st *SQLiteTarget) Log(event events.Event) error {
st.mu.Lock()
defer st.mu.Unlock()
// Marshal event data to JSON
data, err := json.Marshal(event.Data)
if err != nil {
return fmt.Errorf("failed to marshal event data: %w", err)
}
query := fmt.Sprintf(`
INSERT INTO %s (event_type, timestamp, data)
VALUES (?, ?, ?)
`, st.tableName)
_, err = st.db.Exec(query, string(event.Type), event.Timestamp, string(data))
if err != nil {
return fmt.Errorf("failed to insert event: %w", err)
}
return nil
}
// Close closes the SQLite database connection
func (st *SQLiteTarget) Close() error {
return st.db.Close()
}

View File

@@ -37,10 +37,11 @@ func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) E
}
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string) Event {
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string, qrURL string) Event {
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
"account_id": accountID,
"qr_code": qrCode,
"qr_url": qrURL,
})
}

View File

@@ -35,10 +35,10 @@ const (
// Event represents an event in the system
type Event struct {
Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data map[string]any `json:"data"`
Context context.Context `json:"-"`
Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data map[string]any `json:"data"`
Context context.Context `json:"-"`
}
// Subscriber is a function that handles events
@@ -79,6 +79,10 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
EventWhatsAppDisconnected,
EventWhatsAppPairSuccess,
EventWhatsAppPairFailed,
EventWhatsAppQRCode,
EventWhatsAppQRTimeout,
EventWhatsAppQRError,
EventWhatsAppPairEvent,
EventMessageReceived,
EventMessageSent,
EventMessageFailed,

100
pkg/handlers/accounts.go Normal file
View File

@@ -0,0 +1,100 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// Accounts returns the list of all configured WhatsApp accounts
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
writeJSON(w, h.config.WhatsApp)
}
// AddAccount adds a new WhatsApp account to the system
func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var account config.WhatsAppConfig
if err := json.NewDecoder(r.Body).Decode(&account); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Connect to the account
if err := h.whatsappMgr.Connect(context.Background(), account); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Update config
h.config.WhatsApp = append(h.config.WhatsApp, account)
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after adding account", "account_id", account.ID, "error", err)
} else {
logging.Info("Config saved after adding account", "account_id", account.ID)
}
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, map[string]string{"status": "ok", "account_id": account.ID})
}
// RemoveAccount removes a WhatsApp account from the system
func (h *Handlers) RemoveAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Disconnect the account
if err := h.whatsappMgr.Disconnect(req.ID); err != nil {
logging.Warn("Failed to disconnect account during removal", "account_id", req.ID, "error", err)
// Continue with removal even if disconnect fails
}
// Remove from config
found := false
newAccounts := make([]config.WhatsAppConfig, 0)
for _, acc := range h.config.WhatsApp {
if acc.ID != req.ID {
newAccounts = append(newAccounts, acc)
} else {
found = true
}
}
if !found {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
h.config.WhatsApp = newAccounts
// Save config
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after removing account", "account_id", req.ID, "error", err)
} else {
logging.Info("Config saved after removing account", "account_id", req.ID)
}
}
writeJSON(w, map[string]string{"status": "ok"})
}

151
pkg/handlers/businessapi.go Normal file
View File

@@ -0,0 +1,151 @@
package handlers
import (
"net/http"
"strings"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
)
// BusinessAPIWebhook handles both verification (GET) and webhook events (POST)
func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.businessAPIWebhookVerify(w, r)
return
}
if r.Method == http.MethodPost {
h.businessAPIWebhookEvent(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// businessAPIWebhookVerify handles webhook verification from Meta
// GET /webhooks/whatsapp/{accountID}?hub.mode=subscribe&hub.verify_token=XXX&hub.challenge=YYY
func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the account configuration
var accountConfig *struct {
ID string
Type string
VerifyToken string
}
for _, cfg := range h.config.WhatsApp {
if cfg.ID == accountID && cfg.Type == "business-api" {
if cfg.BusinessAPI != nil {
accountConfig = &struct {
ID string
Type string
VerifyToken string
}{
ID: cfg.ID,
Type: cfg.Type,
VerifyToken: cfg.BusinessAPI.VerifyToken,
}
break
}
}
}
if accountConfig == nil {
logging.Error("Business API account not found or not configured", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Get query parameters
mode := r.URL.Query().Get("hub.mode")
token := r.URL.Query().Get("hub.verify_token")
challenge := r.URL.Query().Get("hub.challenge")
logging.Info("Webhook verification request",
"account_id", accountID,
"mode", mode,
"has_challenge", challenge != "")
// Verify the token matches
if mode == "subscribe" && token == accountConfig.VerifyToken {
logging.Info("Webhook verification successful", "account_id", accountID)
w.WriteHeader(http.StatusOK)
writeBytes(w, []byte(challenge))
return
}
logging.Warn("Webhook verification failed",
"account_id", accountID,
"mode", mode,
"token_match", token == accountConfig.VerifyToken)
http.Error(w, "Forbidden", http.StatusForbidden)
}
// businessAPIWebhookEvent handles incoming webhook events from Meta
// POST /webhooks/whatsapp/{accountID}
func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the client from the manager
client, exists := h.whatsappMgr.GetClient(accountID)
if !exists {
logging.Error("Client not found for webhook", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Verify it's a Business API client
if client.GetType() != "business-api" {
logging.Error("Account is not a Business API client", "account_id", accountID, "type", client.GetType())
http.Error(w, "Not a Business API account", http.StatusBadRequest)
return
}
// Cast to Business API client to access HandleWebhook
baClient, ok := client.(*businessapi.Client)
if !ok {
logging.Error("Failed to cast to Business API client", "account_id", accountID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Process the webhook
if err := baClient.HandleWebhook(r); err != nil {
logging.Error("Failed to process webhook", "account_id", accountID, "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Return 200 OK to acknowledge receipt
w.WriteHeader(http.StatusOK)
writeBytes(w, []byte("OK"))
}
// extractAccountIDFromPath extracts the account ID from the URL path
// Example: /webhooks/whatsapp/business -> "business"
func extractAccountIDFromPath(path string) string {
// Remove trailing slash if present
path = strings.TrimSuffix(path, "/")
// Split by /
parts := strings.Split(path, "/")
// Expected format: /webhooks/whatsapp/{accountID}
if len(parts) >= 4 {
return parts[3]
}
return ""
}

72
pkg/handlers/handlers.go Normal file
View File

@@ -0,0 +1,72 @@
package handlers
import (
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
)
// Handlers holds all HTTP handlers with their dependencies
type Handlers struct {
whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager
config *config.Config
configPath string
// Auth configuration
authConfig *AuthConfig
}
// AuthConfig configures authentication behavior
type AuthConfig struct {
// Validator is a custom auth validator function
// If nil, uses built-in auth (API key, basic auth)
Validator func(r *http.Request) bool
// Built-in auth settings
APIKey string
Username string
Password string
// Skip auth entirely (not recommended for production)
Disabled bool
}
// New creates a new Handlers instance
func New(mgr *whatsapp.Manager, hookMgr *hooks.Manager, cfg *config.Config, configPath string) *Handlers {
return &Handlers{
whatsappMgr: mgr,
hookMgr: hookMgr,
config: cfg,
configPath: configPath,
authConfig: &AuthConfig{
APIKey: cfg.Server.AuthKey,
Username: cfg.Server.Username,
Password: cfg.Server.Password,
},
}
}
// WithAuthConfig sets custom auth configuration
func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers {
h.authConfig = cfg
return h
}
// writeJSON is a helper that writes JSON response and logs errors
func writeJSON(w http.ResponseWriter, v interface{}) {
if err := json.NewEncoder(w).Encode(v); err != nil {
logging.Error("Failed to encode JSON response", "error", err)
}
}
// writeBytes is a helper that writes bytes and logs errors
func writeBytes(w http.ResponseWriter, data []byte) {
if _, err := w.Write(data); err != nil {
logging.Error("Failed to write response", "error", err)
}
}

10
pkg/handlers/health.go Normal file
View File

@@ -0,0 +1,10 @@
package handlers
import (
"net/http"
)
// Health handles health check requests
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}

78
pkg/handlers/hooks.go Normal file
View File

@@ -0,0 +1,78 @@
package handlers
import (
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// Hooks returns the list of all configured hooks
func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) {
hooks := h.hookMgr.ListHooks()
w.Header().Set("Content-Type", "application/json")
writeJSON(w, hooks)
}
// AddHook adds a new hook to the system
func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var hook config.Hook
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.hookMgr.AddHook(hook)
// Update config
h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after adding hook", "hook_id", hook.ID, "error", err)
} else {
logging.Info("Config saved after adding hook", "hook_id", hook.ID)
}
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, map[string]string{"status": "ok", "hook_id": hook.ID})
}
// RemoveHook removes a hook from the system
func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.hookMgr.RemoveHook(req.ID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Update config
h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after removing hook", "hook_id", req.ID, "error", err)
} else {
logging.Info("Config saved after removing hook", "hook_id", req.ID)
}
}
writeJSON(w, map[string]string{"status": "ok"})
}

52
pkg/handlers/media.go Normal file
View File

@@ -0,0 +1,52 @@
package handlers
import (
"net/http"
"path/filepath"
)
// ServeMedia serves media files with path traversal protection
func (h *Handlers) ServeMedia(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/media/{accountID}/{filename}
path := r.URL.Path[len("/api/media/"):]
// Split path into accountID and filename
var accountID, filename string
for i, ch := range path {
if ch == '/' {
accountID = path[:i]
filename = path[i+1:]
break
}
}
if accountID == "" || filename == "" {
http.Error(w, "Invalid media path", http.StatusBadRequest)
return
}
// Construct full file path
filePath := filepath.Join(h.config.Media.DataPath, accountID, filename)
// Security check: ensure the resolved path is within the media directory
mediaDir := filepath.Join(h.config.Media.DataPath, accountID)
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
absMediaDir, err := filepath.Abs(mediaDir)
if err != nil {
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
return
}
// Check if file path is within media directory (prevent directory traversal)
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Serve the file
http.ServeFile(w, r, absFilePath)
}

View File

@@ -0,0 +1,71 @@
package handlers
import "net/http"
// Auth is the middleware that wraps handlers with authentication
func (h *Handlers) Auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// If auth is disabled
if h.authConfig.Disabled {
next(w, r)
return
}
// If custom validator is provided
if h.authConfig.Validator != nil {
if h.authConfig.Validator(r) {
next(w, r)
return
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Built-in auth logic (API key, basic auth)
if h.validateBuiltinAuth(r) {
next(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
// validateBuiltinAuth checks API key or basic auth
func (h *Handlers) validateBuiltinAuth(r *http.Request) bool {
// Check if any authentication is configured
hasAuth := h.authConfig.APIKey != "" || h.authConfig.Username != "" || h.authConfig.Password != ""
if !hasAuth {
// No authentication configured, allow access
return true
}
// Check for API key authentication (x-api-key header or Authorization bearer token)
if h.authConfig.APIKey != "" {
// Check x-api-key header
apiKey := r.Header.Get("x-api-key")
if apiKey == h.authConfig.APIKey {
return true
}
// Check Authorization header for bearer token
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
if token == h.authConfig.APIKey {
return true
}
}
}
// Check for username/password authentication (HTTP Basic Auth)
if h.authConfig.Username != "" && h.authConfig.Password != "" {
username, password, ok := r.BasicAuth()
if ok && username == h.authConfig.Username && password == h.authConfig.Password {
return true
}
}
return false
}

57
pkg/handlers/qr.go Normal file
View File

@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/whatsmeow"
)
// ServeQRCode serves the QR code image for a WhatsApp account
func (h *Handlers) ServeQRCode(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/qr/{accountID}
path := r.URL.Path[len("/api/qr/"):]
accountID := path
if accountID == "" {
http.Error(w, "Invalid QR code path", http.StatusBadRequest)
return
}
// Get client from manager
client, exists := h.whatsappMgr.GetClient(accountID)
if !exists {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Type assert to whatsmeow client (only whatsmeow clients have QR codes)
whatsmeowClient, ok := client.(*whatsmeow.Client)
if !ok {
http.Error(w, "QR codes are only available for whatsmeow clients", http.StatusBadRequest)
logging.Warn("QR code requested for non-whatsmeow client", "account_id", accountID)
return
}
// Get QR code PNG
pngData, err := whatsmeowClient.GetQRCodePNG()
if err != nil {
if err.Error() == "no QR code available" {
http.Error(w, "No QR code available. The account may already be connected or pairing has not started yet.", http.StatusNotFound)
} else {
logging.Error("Failed to generate QR code PNG", "account_id", accountID, "error", err)
http.Error(w, "Failed to generate QR code", http.StatusInternalServerError)
}
return
}
// Set headers
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// Write PNG data
writeBytes(w, pngData)
logging.Debug("QR code served", "account_id", accountID)
}

189
pkg/handlers/send.go Normal file
View File

@@ -0,0 +1,189 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/utils"
"go.mau.fi/whatsmeow/types"
)
// SendMessage sends a text message via WhatsApp
func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
if err := h.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
// SendImage sends an image via WhatsApp
func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
ImageData string `json:"image_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 image data
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
if err != nil {
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "image/jpeg"
}
if err := h.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
// SendVideo sends a video via WhatsApp
func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
VideoData string `json:"video_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 video data
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
if err != nil {
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "video/mp4"
}
if err := h.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
// SendDocument sends a document via WhatsApp
func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
DocumentData string `json:"document_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 document data
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
if err != nil {
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default values if not provided
if req.MimeType == "" {
req.MimeType = "application/octet-stream"
}
if req.Filename == "" {
req.Filename = "document"
}
if err := h.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}

View File

@@ -11,9 +11,9 @@ import (
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/logging"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// MediaInfo represents media attachment information

59
pkg/logging/logging.go Normal file
View File

@@ -0,0 +1,59 @@
package logging
import (
"log/slog"
"os"
)
// Logger interface allows users to plug in their own logger
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
}
var defaultLogger Logger = &slogLogger{logger: slog.Default()}
// SetLogger allows users to plug in their own logger
func SetLogger(l Logger) {
defaultLogger = l
}
// Init initializes the default slog logger with a specific log level
func Init(level string) {
var slogLevel slog.Level
switch level {
case "debug":
slogLevel = slog.LevelDebug
case "info":
slogLevel = slog.LevelInfo
case "warn":
slogLevel = slog.LevelWarn
case "error":
slogLevel = slog.LevelError
default:
slogLevel = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})
defaultLogger = &slogLogger{logger: slog.New(handler)}
}
// Default slog implementation
type slogLogger struct {
logger *slog.Logger
}
func (s *slogLogger) Debug(msg string, args ...any) { s.logger.Debug(msg, args...) }
func (s *slogLogger) Info(msg string, args ...any) { s.logger.Info(msg, args...) }
func (s *slogLogger) Warn(msg string, args ...any) { s.logger.Warn(msg, args...) }
func (s *slogLogger) Error(msg string, args ...any) { s.logger.Error(msg, args...) }
// Package-level functions for convenience
func Debug(msg string, args ...any) { defaultLogger.Debug(msg, args...) }
func Info(msg string, args ...any) { defaultLogger.Info(msg, args...) }
func Warn(msg string, args ...any) { defaultLogger.Warn(msg, args...) }
func Error(msg string, args ...any) { defaultLogger.Error(msg, args...) }

42
pkg/utils/media.go Normal file
View 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
}

207
pkg/utils/tls.go Normal file
View File

@@ -0,0 +1,207 @@
package utils
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
const (
certFileName = "cert.pem"
keyFileName = "key.pem"
)
// GenerateSelfSignedCert generates a self-signed TLS certificate
func GenerateSelfSignedCert(certDir, host string) (certPath, keyPath string, err error) {
// Create cert directory if it doesn't exist
if err := os.MkdirAll(certDir, 0755); err != nil {
return "", "", fmt.Errorf("failed to create cert directory: %w", err)
}
certPath = filepath.Join(certDir, certFileName)
keyPath = filepath.Join(certDir, keyFileName)
// Check if certificate already exists and is valid
if certExists(certPath, keyPath) {
if isCertValid(certPath) {
return certPath, keyPath, nil
}
// Certificate exists but is invalid/expired, regenerate
}
// Generate private key using ECDSA P-256
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", fmt.Errorf("failed to generate private key: %w", err)
}
// Generate serial number
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return "", "", fmt.Errorf("failed to generate serial number: %w", err)
}
// Create certificate template
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"WhatsHooked Self-Signed"},
CommonName: host,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Add host as SAN (Subject Alternative Name)
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, host)
}
// Add localhost and common IPs as SANs
template.DNSNames = append(template.DNSNames, "localhost")
template.IPAddresses = append(template.IPAddresses,
net.ParseIP("127.0.0.1"),
net.ParseIP("::1"),
)
// Create self-signed certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return "", "", fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certFile, err := os.Create(certPath)
if err != nil {
return "", "", fmt.Errorf("failed to create cert file: %w", err)
}
defer certFile.Close()
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return "", "", fmt.Errorf("failed to write cert file: %w", err)
}
// Write private key to file
keyFile, err := os.Create(keyPath)
if err != nil {
return "", "", fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
privBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return "", "", fmt.Errorf("failed to marshal private key: %w", err)
}
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil {
return "", "", fmt.Errorf("failed to write key file: %w", err)
}
return certPath, keyPath, nil
}
// certExists checks if both certificate and key files exist
func certExists(certPath, keyPath string) bool {
_, certErr := os.Stat(certPath)
_, keyErr := os.Stat(keyPath)
return certErr == nil && keyErr == nil
}
// isCertValid checks if a certificate file is valid and not expired
func isCertValid(certPath string) bool {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return false
}
block, _ := pem.Decode(certPEM)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
// Check if certificate is expired or will expire in the next 30 days
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return false
}
// Regenerate if expiring within 30 days
if cert.NotAfter.Sub(now) < 30*24*time.Hour {
return false
}
return true
}
// ValidateCertificateFiles checks if custom certificate files exist and are valid
func ValidateCertificateFiles(certPath, keyPath string) error {
// Check if files exist
if _, err := os.Stat(certPath); err != nil {
return fmt.Errorf("certificate file not found: %w", err)
}
if _, err := os.Stat(keyPath); err != nil {
return fmt.Errorf("key file not found: %w", err)
}
// Try to load the certificate to validate it
certPEM, err := os.ReadFile(certPath)
if err != nil {
return fmt.Errorf("failed to read certificate: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to read key: %w", err)
}
// Decode certificate
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return fmt.Errorf("failed to decode certificate PEM")
}
_, err = x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err)
}
// Decode key
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("failed to decode key PEM")
}
// Try parsing as different key types
if _, err := x509.ParseECPrivateKey(keyBlock.Bytes); err != nil {
if _, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err != nil {
if _, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err != nil {
return fmt.Errorf("failed to parse private key (tried EC, PKCS1, PKCS8): %w", err)
}
}
}
return nil
}

View File

@@ -0,0 +1,342 @@
package businessapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"go.mau.fi/whatsmeow/types"
)
// Client represents a WhatsApp Business API client
type Client struct {
id string
phoneNumber string
config config.BusinessAPIConfig
httpClient *http.Client
eventBus *events.EventBus
mediaConfig config.MediaConfig
connected bool
mu sync.RWMutex
}
// NewClient creates a new Business API client
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) {
if cfg.Type != "business-api" {
return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type)
}
if cfg.BusinessAPI == nil {
return nil, fmt.Errorf("business_api configuration is required for business-api type")
}
// Validate required fields
if cfg.BusinessAPI.PhoneNumberID == "" {
return nil, fmt.Errorf("phone_number_id is required")
}
if cfg.BusinessAPI.AccessToken == "" {
return nil, fmt.Errorf("access_token is required")
}
// Set default API version
if cfg.BusinessAPI.APIVersion == "" {
cfg.BusinessAPI.APIVersion = "v21.0"
}
return &Client{
id: cfg.ID,
phoneNumber: cfg.PhoneNumber,
config: *cfg.BusinessAPI,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
eventBus: eventBus,
mediaConfig: mediaConfig,
connected: false,
}, nil
}
// Connect validates the Business API credentials
func (c *Client) Connect(ctx context.Context) error {
// Validate credentials by making a test request to get phone number details
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion,
c.config.PhoneNumberID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("failed to validate credentials: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return err
}
c.mu.Lock()
c.connected = true
c.mu.Unlock()
logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber)
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber))
return nil
}
// Disconnect closes the Business API client
func (c *Client) Disconnect() error {
c.mu.Lock()
c.connected = false
c.mu.Unlock()
logging.Info("Business API client disconnected", "account_id", c.id)
return nil
}
// IsConnected returns whether the client is connected
func (c *Client) IsConnected() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.connected
}
// GetID returns the client ID
func (c *Client) GetID() string {
return c.id
}
// GetPhoneNumber returns the phone number
func (c *Client) GetPhoneNumber() string {
return c.phoneNumber
}
// GetType returns the client type
func (c *Client) GetType() string {
return "business-api"
}
// SendTextMessage sends a text message via Business API
func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
// Convert JID to phone number
phoneNumber := jidToPhoneNumber(jid)
// Create request
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "text",
Text: &TextObject{
Body: text,
},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, text, err))
return "", err
}
logging.Debug("Message sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, text))
return messageID, nil
}
// SendImage sends an image message via Business API
func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
// Upload media first
mediaID, err := c.uploadMedia(ctx, imageData, mimeType)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", fmt.Errorf("failed to upload image: %w", err)
}
// Send message with media ID
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "image",
Image: &MediaObject{
ID: mediaID,
Caption: caption,
},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", err
}
logging.Debug("Image sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
return messageID, nil
}
// SendVideo sends a video message via Business API
func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
// Upload media first
mediaID, err := c.uploadMedia(ctx, videoData, mimeType)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", fmt.Errorf("failed to upload video: %w", err)
}
// Send message with media ID
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "video",
Video: &MediaObject{
ID: mediaID,
Caption: caption,
},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", err
}
logging.Debug("Video sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
return messageID, nil
}
// SendDocument sends a document message via Business API
func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
// Upload media first
mediaID, err := c.uploadMedia(ctx, documentData, mimeType)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", fmt.Errorf("failed to upload document: %w", err)
}
// Send message with media ID
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "document",
Document: &DocumentObject{
ID: mediaID,
Caption: caption,
Filename: filename,
},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err))
return "", err
}
logging.Debug("Document sent via Business API", "account_id", c.id, "to", phoneNumber, "filename", filename)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption))
return messageID, nil
}
// sendMessage sends a message request to the Business API
func (c *Client) sendMessage(ctx context.Context, reqBody SendMessageRequest) (string, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages",
c.config.APIVersion,
c.config.PhoneNumberID)
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var sendResp SendMessageResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if len(sendResp.Messages) == 0 {
return "", fmt.Errorf("no message ID in response")
}
return sendResp.Messages[0].ID, nil
}
// jidToPhoneNumber converts a WhatsApp JID to E.164 phone number format
func jidToPhoneNumber(jid types.JID) string {
// JID format is like "27123456789@s.whatsapp.net"
// Extract the phone number part before @
phone := jid.User
// Ensure it starts with + for E.164
if !strings.HasPrefix(phone, "+") {
phone = "+" + phone
}
return phone
}

View File

@@ -0,0 +1,286 @@
package businessapi
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// HandleWebhook processes incoming webhook events from WhatsApp Business API
func (c *Client) HandleWebhook(r *http.Request) error {
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("failed to read request body: %w", err)
}
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
return fmt.Errorf("failed to parse webhook payload: %w", err)
}
// Process each entry
for _, entry := range payload.Entry {
for i := range entry.Changes {
c.processChange(entry.Changes[i])
}
}
return nil
}
// processChange processes a webhook change
func (c *Client) processChange(change WebhookChange) {
ctx := context.Background()
// Process messages
for _, msg := range change.Value.Messages {
c.processMessage(ctx, msg, change.Value.Contacts)
}
// Process statuses
for _, status := range change.Value.Statuses {
c.processStatus(ctx, status)
}
}
// processMessage processes an incoming message
func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contacts []WebhookContact) {
// Get sender name from contacts
senderName := ""
for _, contact := range contacts {
if contact.WaID == msg.From {
senderName = contact.Profile.Name
break
}
}
// Parse timestamp
timestamp := c.parseTimestamp(msg.Timestamp)
var text string
var messageType string
var mimeType string
var filename string
var mediaBase64 string
var mediaURL string
// Process based on message type
switch msg.Type {
case "text":
if msg.Text != nil {
text = msg.Text.Body
}
messageType = "text"
case "image":
if msg.Image != nil {
messageType = "image"
mimeType = msg.Image.MimeType
text = msg.Image.Caption
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Image.ID)
if err != nil {
logging.Error("Failed to download image", "account_id", c.id, "media_id", msg.Image.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "video":
if msg.Video != nil {
messageType = "video"
mimeType = msg.Video.MimeType
text = msg.Video.Caption
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Video.ID)
if err != nil {
logging.Error("Failed to download video", "account_id", c.id, "media_id", msg.Video.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "document":
if msg.Document != nil {
messageType = "document"
mimeType = msg.Document.MimeType
text = msg.Document.Caption
filename = msg.Document.Filename
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Document.ID)
if err != nil {
logging.Error("Failed to download document", "account_id", c.id, "media_id", msg.Document.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
default:
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
return
}
// Publish message received event
c.eventBus.Publish(events.MessageReceivedEvent(
ctx,
c.id,
msg.ID,
msg.From,
msg.From, // For Business API, chat is same as sender for individual messages
text,
timestamp,
false, // Business API doesn't indicate groups in this webhook
"",
senderName,
messageType,
mimeType,
filename,
mediaBase64,
mediaURL,
))
logging.Debug("Message received via Business API", "account_id", c.id, "from", msg.From, "type", messageType)
}
// processStatus processes a message status update
func (c *Client) processStatus(ctx context.Context, status WebhookStatus) {
timestamp := c.parseTimestamp(status.Timestamp)
switch status.Status {
case "sent":
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, ""))
logging.Debug("Message sent status", "account_id", c.id, "message_id", status.ID)
case "delivered":
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Debug("Message delivered", "account_id", c.id, "message_id", status.ID)
case "read":
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Debug("Message read", "account_id", c.id, "message_id", status.ID)
case "failed":
errMsg := "unknown error"
if len(status.Errors) > 0 {
errMsg = fmt.Sprintf("%s (code: %d)", status.Errors[0].Title, status.Errors[0].Code)
}
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, status.RecipientID, "", fmt.Errorf("%s", errMsg)))
logging.Error("Message failed", "account_id", c.id, "message_id", status.ID, "error", errMsg)
default:
logging.Debug("Unknown status type", "account_id", c.id, "status", status.Status)
}
}
// parseTimestamp parses a Unix timestamp string to time.Time
func (c *Client) parseTimestamp(ts string) time.Time {
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
logging.Warn("Failed to parse timestamp", "timestamp", ts, "error", err)
return time.Now()
}
return time.Unix(unix, 0)
}
// processMediaData processes media based on the configured mode
func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (filename string, mediaURL string) {
mode := c.mediaConfig.Mode
// Generate filename
ext := getExtensionFromMimeType(mimeType)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Handle base64 mode
if mode == "base64" || mode == "both" {
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
}
// Handle link mode
if mode == "link" || mode == "both" {
// Save file to disk
filePath, err := c.saveMediaFile(messageID, data, mimeType)
if err != nil {
logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err)
} else {
filename = filepath.Base(filePath)
mediaURL = c.generateMediaURL(messageID, filename)
}
}
return filename, mediaURL
}
// saveMediaFile saves media data to disk
func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) {
mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id)
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return "", fmt.Errorf("failed to create media directory: %w", err)
}
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
ext := getExtensionFromMimeType(mimeType)
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
filePath := filepath.Join(mediaDir, filename)
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to write media file: %w", err)
}
return filePath, nil
}
// generateMediaURL generates a URL for accessing stored media
func (c *Client) generateMediaURL(messageID, filename string) string {
baseURL := c.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename)
}
// getExtensionFromMimeType returns the file extension for a given MIME type
func getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"video/mp4": ".mp4",
"video/mpeg": ".mpeg",
"video/webm": ".webm",
"video/3gpp": ".3gp",
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"text/plain": ".txt",
"application/json": ".json",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
}
if ext, ok := extensions[mimeType]; ok {
return ext
}
return ""
}

View File

@@ -0,0 +1,139 @@
package businessapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
)
// uploadMedia uploads media to the Business API and returns the media ID
func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/media",
c.config.APIVersion,
c.config.PhoneNumberID)
// Create multipart form data
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
// Add the file
part, err := writer.CreateFormFile("file", "media")
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err := part.Write(data); err != nil {
return "", fmt.Errorf("failed to write file data: %w", err)
}
// Add messaging_product field
if err := writer.WriteField("messaging_product", "whatsapp"); err != nil {
return "", fmt.Errorf("failed to write messaging_product field: %w", err)
}
// Add type field (mime type)
if err := writer.WriteField("type", mimeType); err != nil {
return "", fmt.Errorf("failed to write type field: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to close multipart writer: %w", err)
}
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
req.Header.Set("Content-Type", writer.FormDataContentType())
// Send request
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to upload media: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return "", fmt.Errorf("upload error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body))
}
var uploadResp MediaUploadResponse
if err := json.Unmarshal(body, &uploadResp); err != nil {
return "", fmt.Errorf("failed to parse upload response: %w", err)
}
return uploadResp.ID, nil
}
// downloadMedia downloads media from the Business API using the media ID
func (c *Client) downloadMedia(ctx context.Context, mediaID string) (data []byte, mimeType string, err error) {
// Step 1: Get the media URL
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion,
mediaID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("failed to get media URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("failed to get media URL, status %d: %s", resp.StatusCode, string(body))
}
var mediaResp MediaURLResponse
if err := json.NewDecoder(resp.Body).Decode(&mediaResp); err != nil {
return nil, "", fmt.Errorf("failed to parse media URL response: %w", err)
}
// Step 2: Download from the CDN URL
downloadReq, err := http.NewRequestWithContext(ctx, "GET", mediaResp.URL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create download request: %w", err)
}
downloadReq.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
downloadResp, err := c.httpClient.Do(downloadReq)
if err != nil {
return nil, "", fmt.Errorf("failed to download media: %w", err)
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("failed to download media, status %d", downloadResp.StatusCode)
}
data, err = io.ReadAll(downloadResp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read media data: %w", err)
}
mimeType = mediaResp.MimeType
return
}

View File

@@ -0,0 +1,193 @@
package businessapi
// SendMessageRequest represents a request to send a text message via Business API
type SendMessageRequest struct {
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
RecipientType string `json:"recipient_type,omitempty"` // "individual"
To string `json:"to"` // Phone number in E.164 format
Type string `json:"type"` // "text", "image", "video", "document"
Text *TextObject `json:"text,omitempty"`
Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"`
Document *DocumentObject `json:"document,omitempty"`
}
// TextObject represents a text message
type TextObject struct {
Body string `json:"body"`
}
// MediaObject represents media (image/video) message
type MediaObject struct {
ID string `json:"id,omitempty"` // Media ID (from upload)
Link string `json:"link,omitempty"` // Or direct URL
Caption string `json:"caption,omitempty"`
}
// DocumentObject represents a document message
type DocumentObject struct {
ID string `json:"id,omitempty"` // Media ID (from upload)
Link string `json:"link,omitempty"` // Or direct URL
Caption string `json:"caption,omitempty"`
Filename string `json:"filename,omitempty"`
}
// SendMessageResponse represents the response from sending a message
type SendMessageResponse struct {
MessagingProduct string `json:"messaging_product"`
Contacts []struct {
Input string `json:"input"`
WaID string `json:"wa_id"`
} `json:"contacts"`
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
// MediaUploadResponse represents the response from uploading media
type MediaUploadResponse struct {
ID string `json:"id"` // Media ID to use in messages
}
// MediaURLResponse represents the response when getting media URL
type MediaURLResponse struct {
URL string `json:"url"` // CDN URL to download media
MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"`
FileSize int64 `json:"file_size"`
ID string `json:"id"`
MessagingProduct string `json:"messaging_product"`
}
// ErrorResponse represents an error from the Business API
type ErrorResponse struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
ErrorSubcode int `json:"error_subcode,omitempty"`
FBTraceID string `json:"fbtrace_id,omitempty"`
} `json:"error"`
}
// WebhookPayload represents the incoming webhook from WhatsApp Business API
type WebhookPayload struct {
Object string `json:"object"` // "whatsapp_business_account"
Entry []WebhookEntry `json:"entry"`
}
// WebhookEntry represents an entry in the webhook
type WebhookEntry struct {
ID string `json:"id"` // WhatsApp Business Account ID
Changes []WebhookChange `json:"changes"`
}
// WebhookChange represents a change notification
type WebhookChange struct {
Value WebhookValue `json:"value"`
Field string `json:"field"` // "messages"
}
// WebhookValue contains the actual webhook data
type WebhookValue struct {
MessagingProduct string `json:"messaging_product"`
Metadata WebhookMetadata `json:"metadata"`
Contacts []WebhookContact `json:"contacts,omitempty"`
Messages []WebhookMessage `json:"messages,omitempty"`
Statuses []WebhookStatus `json:"statuses,omitempty"`
}
// WebhookMetadata contains metadata about the phone number
type WebhookMetadata struct {
DisplayPhoneNumber string `json:"display_phone_number"`
PhoneNumberID string `json:"phone_number_id"`
}
// WebhookContact represents a contact in the webhook
type WebhookContact struct {
Profile WebhookProfile `json:"profile"`
WaID string `json:"wa_id"`
}
// WebhookProfile contains profile information
type WebhookProfile struct {
Name string `json:"name"`
}
// WebhookMessage represents a message in the webhook
type WebhookMessage struct {
From string `json:"from"` // Sender phone number
ID string `json:"id"` // Message ID
Timestamp string `json:"timestamp"` // Unix timestamp as string
Type string `json:"type"` // "text", "image", "video", "document", etc.
Text *WebhookText `json:"text,omitempty"`
Image *WebhookMediaMessage `json:"image,omitempty"`
Video *WebhookMediaMessage `json:"video,omitempty"`
Document *WebhookDocumentMessage `json:"document,omitempty"`
Context *WebhookContext `json:"context,omitempty"` // Reply context
}
// WebhookText represents a text message
type WebhookText struct {
Body string `json:"body"`
}
// WebhookMediaMessage represents a media message (image/video)
type WebhookMediaMessage struct {
ID string `json:"id"` // Media ID
MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"`
Caption string `json:"caption,omitempty"`
}
// WebhookDocumentMessage represents a document message
type WebhookDocumentMessage struct {
ID string `json:"id"` // Media ID
MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"`
Filename string `json:"filename,omitempty"`
Caption string `json:"caption,omitempty"`
}
// WebhookContext represents reply context
type WebhookContext struct {
From string `json:"from"`
ID string `json:"id"` // Message ID being replied to
MessageID string `json:"message_id,omitempty"`
}
// WebhookStatus represents a message status update
type WebhookStatus struct {
ID string `json:"id"` // Message ID
Status string `json:"status"` // "sent", "delivered", "read", "failed"
Timestamp string `json:"timestamp"` // Unix timestamp as string
RecipientID string `json:"recipient_id"`
Conversation *WebhookConversation `json:"conversation,omitempty"`
Pricing *WebhookPricing `json:"pricing,omitempty"`
Errors []WebhookError `json:"errors,omitempty"`
}
// WebhookConversation contains conversation details
type WebhookConversation struct {
ID string `json:"id"`
ExpirationTimestamp string `json:"expiration_timestamp,omitempty"`
Origin WebhookOrigin `json:"origin"`
}
// WebhookOrigin contains conversation origin
type WebhookOrigin struct {
Type string `json:"type"`
}
// WebhookPricing contains pricing information
type WebhookPricing struct {
Billable bool `json:"billable"`
PricingModel string `json:"pricing_model"`
Category string `json:"category"`
}
// WebhookError represents an error in status update
type WebhookError struct {
Code int `json:"code"`
Title string `json:"title"`
}

34
pkg/whatsapp/interface.go Normal file
View File

@@ -0,0 +1,34 @@
package whatsapp
import (
"context"
"go.mau.fi/whatsmeow/types"
)
// ClientType identifies the type of WhatsApp client
type ClientType string
const (
ClientTypeWhatsmeow ClientType = "whatsmeow"
ClientTypeBusinessAPI ClientType = "business-api"
)
// Client represents any WhatsApp client implementation (whatsmeow or Business API)
type Client interface {
// Connection Management
Connect(ctx context.Context) error
Disconnect() error
IsConnected() bool
// Account Information
GetID() string
GetPhoneNumber() string
GetType() string
// Message Sending
SendTextMessage(ctx context.Context, jid types.JID, text string) (messageID string, err error)
SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (messageID string, err error)
SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (messageID string, err error)
SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (messageID string, err error)
}

214
pkg/whatsapp/manager.go Normal file
View File

@@ -0,0 +1,214 @@
package whatsapp
import (
"context"
"fmt"
"sync"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/whatsmeow"
"go.mau.fi/whatsmeow/types"
)
// Manager manages multiple WhatsApp client connections
type Manager struct {
clients map[string]Client
mu sync.RWMutex
eventBus *events.EventBus
mediaConfig config.MediaConfig
config *config.Config
configPath string
onConfigUpdate func(*config.Config) error
}
// NewManager creates a new WhatsApp manager
func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager {
return &Manager{
clients: make(map[string]Client),
eventBus: eventBus,
mediaConfig: mediaConfig,
config: cfg,
configPath: configPath,
onConfigUpdate: onConfigUpdate,
}
}
// Connect establishes a connection to a WhatsApp account using the appropriate client type
func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error {
m.mu.Lock()
if _, exists := m.clients[cfg.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("client %s already connected", cfg.ID)
}
var client Client
var err error
// Factory pattern based on type
switch cfg.Type {
case "business-api":
client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig)
case "whatsmeow", "":
// Create callback for phone number updates
onPhoneUpdate := func(accountID, phoneNumber string) {
m.updatePhoneNumberInConfig(accountID, phoneNumber)
}
client, err = whatsmeow.NewClient(cfg, m.eventBus, m.mediaConfig, onPhoneUpdate)
default:
m.mu.Unlock()
return fmt.Errorf("unknown client type: %s", cfg.Type)
}
if err != nil {
m.mu.Unlock()
return fmt.Errorf("failed to create client: %w", err)
}
// Register client immediately so it's available for QR code serving during pairing
m.clients[cfg.ID] = client
m.mu.Unlock()
// Connect in background (this can block during QR pairing)
go func() {
if err := client.Connect(ctx); err != nil {
logging.Error("Failed to connect client", "account_id", cfg.ID, "error", err)
// Remove client if connection fails
m.mu.Lock()
delete(m.clients, cfg.ID)
m.mu.Unlock()
m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err))
} else {
logging.Info("Client connected", "account_id", cfg.ID, "type", client.GetType())
}
}()
return nil
}
// Disconnect disconnects a WhatsApp client
func (m *Manager) Disconnect(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
client, exists := m.clients[id]
if !exists {
return fmt.Errorf("client %s not found", id)
}
if err := client.Disconnect(); err != nil {
return fmt.Errorf("failed to disconnect: %w", err)
}
delete(m.clients, id)
logging.Info("Client disconnected", "account_id", id)
return nil
}
// DisconnectAll disconnects all WhatsApp clients
func (m *Manager) DisconnectAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, client := range m.clients {
if err := client.Disconnect(); err != nil {
logging.Error("Failed to disconnect client", "account_id", id, "error", err)
} else {
logging.Info("Client disconnected", "account_id", id)
}
}
m.clients = make(map[string]Client)
}
// SendTextMessage sends a text message from a specific account
func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error {
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("client %s not found", accountID)
}
_, err := client.SendTextMessage(ctx, jid, text)
return err
}
// SendImage sends an image message from a specific account
func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error {
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("client %s not found", accountID)
}
_, err := client.SendImage(ctx, jid, imageData, mimeType, caption)
return err
}
// SendVideo sends a video message from a specific account
func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error {
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("client %s not found", accountID)
}
_, err := client.SendVideo(ctx, jid, videoData, mimeType, caption)
return err
}
// SendDocument sends a document message from a specific account
func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error {
m.mu.RLock()
client, exists := m.clients[accountID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("client %s not found", accountID)
}
_, err := client.SendDocument(ctx, jid, documentData, mimeType, filename, caption)
return err
}
// GetClient returns a client by ID
func (m *Manager) GetClient(id string) (Client, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
client, exists := m.clients[id]
return client, exists
}
// updatePhoneNumberInConfig updates the phone number for an account in config and saves it
func (m *Manager) updatePhoneNumberInConfig(accountID, phoneNumber string) {
m.mu.Lock()
defer m.mu.Unlock()
// Find and update the account in config
for i := range m.config.WhatsApp {
if m.config.WhatsApp[i].ID == accountID {
if m.config.WhatsApp[i].PhoneNumber != phoneNumber {
m.config.WhatsApp[i].PhoneNumber = phoneNumber
logging.Info("Updated phone number in config", "account_id", accountID, "phone", phoneNumber)
// Save config if callback is available
if m.onConfigUpdate != nil {
if err := m.onConfigUpdate(m.config); err != nil {
logging.Error("Failed to save config after phone update", "account_id", accountID, "error", err)
} else {
logging.Debug("Config saved with updated phone number", "account_id", accountID)
}
}
}
break
}
}
}

View File

@@ -0,0 +1,772 @@
package whatsmeow
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
qrterminal "github.com/mdp/qrterminal/v3"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
waEvents "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
"rsc.io/qr"
_ "github.com/mattn/go-sqlite3"
)
// Client represents a WhatsApp connection using whatsmeow
type Client struct {
id string
phoneNumber string
sessionPath string
client *whatsmeow.Client
container *sqlstore.Container
eventBus *events.EventBus
mediaConfig config.MediaConfig
showQR bool
keepAliveCancel context.CancelFunc
qrCode string
qrCodePNG []byte // Cached PNG data
qrCodeMutex sync.RWMutex
onPhoneUpdate func(accountID, phoneNumber string) // Callback when phone number is updated
}
// NewClient creates a new whatsmeow client
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig, onPhoneUpdate func(accountID, phoneNumber string)) (*Client, error) {
if cfg.Type != "whatsmeow" && cfg.Type != "" {
return nil, fmt.Errorf("invalid client type for whatsmeow: %s", cfg.Type)
}
sessionPath := cfg.SessionPath
if sessionPath == "" {
sessionPath = fmt.Sprintf("./sessions/%s", cfg.ID)
}
return &Client{
id: cfg.ID,
phoneNumber: cfg.PhoneNumber,
sessionPath: sessionPath,
eventBus: eventBus,
mediaConfig: mediaConfig,
showQR: cfg.ShowQR,
onPhoneUpdate: onPhoneUpdate,
}, nil
}
// Connect establishes a connection to WhatsApp
func (c *Client) Connect(ctx context.Context) error {
// Ensure session directory exists
if err := os.MkdirAll(c.sessionPath, 0700); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
}
// store.SetOSInfo("Linux", store.GetWAVersion())
store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_CLOUD_API.Enum()
store.DeviceProps.Os = proto.String("whatshooked.warky.dev")
store.DeviceProps.RequireFullSync = proto.Bool(false)
// Create database container for session storage
dbPath := filepath.Join(c.sessionPath, "session.db")
dbLog := waLog.Stdout("Database", "ERROR", true)
container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog)
if err != nil {
return fmt.Errorf("failed to create database container: %w", err)
}
c.container = container
// Get device store
deviceStore, err := container.GetFirstDevice(ctx)
if err != nil {
return fmt.Errorf("failed to get device: %w", err)
}
// Set custom client information that will be shown in WhatsApp
deviceStore.Platform = "git.warky.dev/wdevs/whatshooked"
// Set PushName BEFORE pairing - this is what shows up in WhatsApp linked devices
if deviceStore.PushName == "" {
deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", c.phoneNumber)
}
// Save device store to persist device info before pairing
if err := deviceStore.Save(ctx); err != nil {
logging.Warn("Failed to save device store", "account_id", c.id, "error", err)
}
// Create client
clientLog := waLog.Stdout("Client", "ERROR", true)
client := whatsmeow.NewClient(deviceStore, clientLog)
c.client = client
// Register event handler
client.AddEventHandler(func(evt interface{}) {
c.handleEvent(evt)
})
// Connect
if client.Store.ID == nil {
// New device, need to pair
qrChan, _ := client.GetQRChannel(ctx)
if err := client.Connect(); err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("failed to connect: %w", err)
}
// Wait for QR code
for evt := range qrChan {
switch evt.Event {
case "code":
logging.Info("QR code received for pairing", "account_id", c.id)
// Generate PNG (this regenerates on each new QR code)
pngData, err := c.generateQRCodePNG(evt.Code)
if err != nil {
logging.Error("Failed to generate QR code PNG", "account_id", c.id, "error", err)
}
// Store QR code and PNG (updates cached version)
c.qrCodeMutex.Lock()
c.qrCode = evt.Code
c.qrCodePNG = pngData
qrCodeSize := len(pngData)
c.qrCodeMutex.Unlock()
logging.Debug("QR code PNG updated", "account_id", c.id, "size_bytes", qrCodeSize)
// Generate QR code URL
qrURL := c.generateQRCodeURL()
// Display QR code in terminal
fmt.Println("\n========================================")
fmt.Printf("WhatsApp QR Code for account: %s\n", c.id)
fmt.Printf("Phone: %s\n", c.phoneNumber)
fmt.Println("========================================")
fmt.Println("Scan this QR code with WhatsApp on your phone:")
fmt.Println()
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
fmt.Println()
fmt.Printf("Or open in browser: %s\n", qrURL)
fmt.Println("========================================")
// Publish QR code event with URL
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code, qrURL))
case "success":
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber)
// Clear cached QR code after successful pairing
c.qrCodeMutex.Lock()
c.qrCode = ""
c.qrCodePNG = nil
c.qrCodeMutex.Unlock()
c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id))
case "timeout":
logging.Warn("QR code timeout", "account_id", c.id)
// Clear cached QR code on timeout
c.qrCodeMutex.Lock()
c.qrCode = ""
c.qrCodePNG = nil
c.qrCodeMutex.Unlock()
c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id))
case "error":
logging.Error("QR code error", "account_id", c.id, "error", evt.Error)
c.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, c.id, fmt.Errorf("%v", evt.Error)))
default:
logging.Info("Pairing event", "account_id", c.id, "event", evt.Event)
c.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, c.id, evt.Event, map[string]any{
"code": evt.Code,
}))
}
}
} else {
// Already paired, just connect
if err := client.Connect(); err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
// PushName is already set before pairing, no need to set it again here
if client.IsConnected() {
err := client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", c.id, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", c.id)
}
}
// Start keep-alive routine
c.startKeepAlive()
logging.Info("WhatsApp client connected", "account_id", c.id, "phone", c.phoneNumber)
return nil
}
// Disconnect closes the WhatsApp connection
func (c *Client) Disconnect() error {
// Stop keep-alive
if c.keepAliveCancel != nil {
c.keepAliveCancel()
}
if c.client != nil {
c.client.Disconnect()
}
logging.Info("WhatsApp client disconnected", "account_id", c.id)
return nil
}
// IsConnected returns whether the client is connected
func (c *Client) IsConnected() bool {
if c.client == nil {
return false
}
return c.client.IsConnected()
}
// GetID returns the client ID
func (c *Client) GetID() string {
return c.id
}
// GetPhoneNumber returns the phone number
func (c *Client) GetPhoneNumber() string {
return c.phoneNumber
}
// GetType returns the client type
func (c *Client) GetType() string {
return "whatsmeow"
}
// SendTextMessage sends a text message
func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if c.client == nil {
err := fmt.Errorf("client not initialized")
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err))
return "", err
}
msg := &waE2E.Message{
Conversation: proto.String(text),
}
resp, err := c.client.SendMessage(ctx, jid, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err))
return "", fmt.Errorf("failed to send message: %w", err)
}
logging.Debug("Message sent", "account_id", c.id, "to", jid.String())
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), text))
return resp.ID, nil
}
// SendImage sends an image message
func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if c.client == nil {
err := fmt.Errorf("client not initialized")
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", err
}
// Upload the image
uploaded, err := c.client.Upload(ctx, imageData, whatsmeow.MediaImage)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to upload image: %w", err)
}
// Create image message
msg := &waE2E.Message{
ImageMessage: &waE2E.ImageMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(imageData))),
},
}
// Add caption if provided
if caption != "" {
msg.ImageMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := c.client.SendMessage(ctx, jid, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to send image: %w", err)
}
logging.Debug("Image sent", "account_id", c.id, "to", jid.String())
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
return resp.ID, nil
}
// SendVideo sends a video message
func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if c.client == nil {
err := fmt.Errorf("client not initialized")
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", err
}
// Upload the video
uploaded, err := c.client.Upload(ctx, videoData, whatsmeow.MediaVideo)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to upload video: %w", err)
}
// Create video message
msg := &waE2E.Message{
VideoMessage: &waE2E.VideoMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(videoData))),
},
}
// Add caption if provided
if caption != "" {
msg.VideoMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := c.client.SendMessage(ctx, jid, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to send video: %w", err)
}
logging.Debug("Video sent", "account_id", c.id, "to", jid.String())
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
return resp.ID, nil
}
// SendDocument sends a document message
func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if c.client == nil {
err := fmt.Errorf("client not initialized")
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", err
}
// Upload the document
uploaded, err := c.client.Upload(ctx, documentData, whatsmeow.MediaDocument)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to upload document: %w", err)
}
// Create document message
msg := &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mimeType),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(documentData))),
FileName: proto.String(filename),
},
}
// Add caption if provided
if caption != "" {
msg.DocumentMessage.Caption = proto.String(caption)
}
// Send the message
resp, err := c.client.SendMessage(ctx, jid, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err))
return "", fmt.Errorf("failed to send document: %w", err)
}
logging.Debug("Document sent", "account_id", c.id, "to", jid.String(), "filename", filename)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption))
return resp.ID, nil
}
// handleEvent processes WhatsApp events
func (c *Client) handleEvent(evt interface{}) {
ctx := context.Background()
switch v := evt.(type) {
case *waEvents.Message:
logging.Debug("Message received", "account_id", c.id, "from", v.Info.Sender.String())
// Extract message content based on type
var text string
var messageType = "text"
var mimeType string
var filename string
var mediaBase64 string
var mediaURL string
// Handle text messages
if v.Message.Conversation != nil {
text = *v.Message.Conversation
messageType = "text"
} else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil {
text = *v.Message.ExtendedTextMessage.Text
messageType = "text"
}
// Handle image messages
if v.Message.ImageMessage != nil {
img := v.Message.ImageMessage
messageType = "image"
mimeType = img.GetMimetype()
if img.Caption != nil {
text = *img.Caption
}
// Download image
data, err := c.client.Download(ctx, img)
if err != nil {
logging.Error("Failed to download image", "account_id", c.id, "error", err)
} else {
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle video messages
if v.Message.VideoMessage != nil {
vid := v.Message.VideoMessage
messageType = "video"
mimeType = vid.GetMimetype()
if vid.Caption != nil {
text = *vid.Caption
}
// Download video
data, err := c.client.Download(ctx, vid)
if err != nil {
logging.Error("Failed to download video", "account_id", c.id, "error", err)
} else {
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Handle document messages
if v.Message.DocumentMessage != nil {
doc := v.Message.DocumentMessage
messageType = "document"
mimeType = doc.GetMimetype()
if doc.FileName != nil {
filename = *doc.FileName
}
if doc.Caption != nil {
text = *doc.Caption
}
// Download document
data, err := c.client.Download(ctx, doc)
if err != nil {
logging.Error("Failed to download document", "account_id", c.id, "error", err)
} else {
filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64)
}
}
// Publish message received event
c.eventBus.Publish(events.MessageReceivedEvent(
ctx,
c.id,
v.Info.ID,
v.Info.Sender.String(),
v.Info.Chat.String(),
text,
v.Info.Timestamp,
v.Info.IsGroup,
"", // group name - TODO: extract from message
"", // sender name - TODO: extract from message
messageType,
mimeType,
filename,
mediaBase64,
mediaURL,
))
case *waEvents.Connected:
logging.Info("WhatsApp connected", "account_id", c.id)
// Get the actual phone number from WhatsApp
phoneNumber := ""
if c.client.Store.ID != nil {
actualPhone := c.client.Store.ID.User
phoneNumber = "+" + actualPhone
// Update phone number in client if it's different
if c.phoneNumber != phoneNumber {
c.phoneNumber = phoneNumber
logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", phoneNumber)
// Trigger config update callback to persist the phone number
if c.onPhoneUpdate != nil {
c.onPhoneUpdate(c.id, phoneNumber)
}
}
} else if c.phoneNumber != "" {
phoneNumber = c.phoneNumber
}
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneNumber))
case *waEvents.Disconnected:
logging.Warn("WhatsApp disconnected", "account_id", c.id)
c.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, c.id, "connection lost"))
case *waEvents.Receipt:
// Handle delivery and read receipts
switch v.Type {
case types.ReceiptTypeDelivered:
for _, messageID := range v.MessageIDs {
logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String())
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp))
}
case types.ReceiptTypeRead:
for _, messageID := range v.MessageIDs {
logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String())
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp))
}
}
}
}
// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive
func (c *Client) startKeepAlive() {
ctx, cancel := context.WithCancel(context.Background())
c.keepAliveCancel = cancel
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logging.Debug("Keep-alive stopped", "account_id", c.id)
return
case <-ticker.C:
if c.client != nil && c.client.IsConnected() {
err := c.client.SendPresence(ctx, types.PresenceAvailable)
if err != nil {
logging.Warn("Failed to send presence", "account_id", c.id, "error", err)
} else {
logging.Debug("Sent presence update", "account_id", c.id)
}
}
}
}
}()
logging.Info("Keep-alive started", "account_id", c.id)
}
// processMediaData processes media based on the configured mode
func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (filename string, mediaURL string) {
mode := c.mediaConfig.Mode
// Generate filename
ext := getExtensionFromMimeType(mimeType)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Handle base64 mode
if mode == "base64" || mode == "both" {
*mediaBase64 = base64.StdEncoding.EncodeToString(data)
}
// Handle link mode
if mode == "link" || mode == "both" {
// Save file to disk
filePath, err := c.saveMediaFile(messageID, data, mimeType)
if err != nil {
logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err)
} else {
// Extract just the filename from the full path
filename = filepath.Base(filePath)
mediaURL = c.generateMediaURL(messageID, filename)
}
}
return filename, mediaURL
}
// saveMediaFile saves media data to disk and returns the file path
func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) {
// Create account-specific media directory
mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id)
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return "", fmt.Errorf("failed to create media directory: %w", err)
}
// Generate unique filename using message ID and hash
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:8])
ext := getExtensionFromMimeType(mimeType)
filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext)
// Full path to file
filePath := filepath.Join(mediaDir, filename)
// Write file
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to write media file: %w", err)
}
return filePath, nil
}
// generateMediaURL generates a URL for accessing stored media
func (c *Client) generateMediaURL(messageID, filename string) string {
baseURL := c.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename)
}
// getExtensionFromMimeType returns the file extension for a given MIME type
func getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{
// Images
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
// Videos
"video/mp4": ".mp4",
"video/mpeg": ".mpeg",
"video/quicktime": ".mov",
"video/x-msvideo": ".avi",
"video/webm": ".webm",
"video/3gpp": ".3gp",
// Documents
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"text/plain": ".txt",
"text/html": ".html",
"application/zip": ".zip",
"application/x-rar-compressed": ".rar",
"application/x-7z-compressed": ".7z",
"application/json": ".json",
"application/xml": ".xml",
// Audio
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/aac": ".aac",
"audio/x-m4a": ".m4a",
}
if ext, ok := extensions[mimeType]; ok {
return ext
}
return ""
}
// generateQRCodeURL generates a URL for accessing the QR code
func (c *Client) generateQRCodeURL() string {
baseURL := c.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return fmt.Sprintf("%s/api/qr/%s", baseURL, c.id)
}
// GetQRCodePNG returns the cached PNG image of the current QR code
func (c *Client) GetQRCodePNG() ([]byte, error) {
c.qrCodeMutex.RLock()
defer c.qrCodeMutex.RUnlock()
if c.qrCode == "" {
return nil, fmt.Errorf("no QR code available")
}
if c.qrCodePNG == nil {
return nil, fmt.Errorf("QR code PNG not yet generated")
}
return c.qrCodePNG, nil
}
// generateQRCodePNG generates a PNG image from QR code data
func (c *Client) generateQRCodePNG(qrCodeData string) ([]byte, error) {
// Generate QR code using rsc.io/qr
code, err := qr.Encode(qrCodeData, qr.H)
if err != nil {
return nil, fmt.Errorf("failed to encode QR code: %w", err)
}
// Use the library's built-in PNG method with a scale factor
// Scale of 8 means each QR module is 8x8 pixels
return code.PNG(), nil
}

178
pkg/whatshooked/options.go Normal file
View File

@@ -0,0 +1,178 @@
package whatshooked
import "git.warky.dev/wdevs/whatshooked/pkg/config"
// Option is a functional option for configuring WhatsHooked
type Option func(*config.Config)
// WithServer configures server settings
func WithServer(host string, port int) Option {
return func(c *config.Config) {
c.Server.Host = host
c.Server.Port = port
}
}
// WithAuth configures authentication
func WithAuth(apiKey, username, password string) Option {
return func(c *config.Config) {
c.Server.AuthKey = apiKey
c.Server.Username = username
c.Server.Password = password
}
}
// WithDefaultCountryCode sets the default country code for phone numbers
func WithDefaultCountryCode(code string) Option {
return func(c *config.Config) {
c.Server.DefaultCountryCode = code
}
}
// WithWhatsAppAccount adds a WhatsApp account
func WithWhatsAppAccount(account config.WhatsAppConfig) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, account)
}
}
// WithWhatsmeowAccount adds a Whatsmeow account (convenience)
func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
ID: id,
Type: "whatsmeow",
PhoneNumber: phoneNumber,
SessionPath: sessionPath,
ShowQR: showQR,
})
}
}
// WithBusinessAPIAccount adds a Business API account (convenience)
func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option {
return func(c *config.Config) {
c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{
ID: id,
Type: "business-api",
PhoneNumber: phoneNumber,
BusinessAPI: &config.BusinessAPIConfig{
PhoneNumberID: phoneNumberID,
AccessToken: accessToken,
VerifyToken: verifyToken,
APIVersion: "v21.0",
},
})
}
}
// WithHook adds a webhook
func WithHook(hook config.Hook) Option {
return func(c *config.Config) {
c.Hooks = append(c.Hooks, hook)
}
}
// WithEventLogger enables event logging
func WithEventLogger(targets []string, fileDir string) Option {
return func(c *config.Config) {
c.EventLogger.Enabled = true
c.EventLogger.Targets = targets
c.EventLogger.FileDir = fileDir
}
}
// WithEventLoggerConfig configures event logger with full config
func WithEventLoggerConfig(cfg config.EventLoggerConfig) Option {
return func(c *config.Config) {
c.EventLogger = cfg
}
}
// WithMedia configures media settings
func WithMedia(dataPath, mode, baseURL string) Option {
return func(c *config.Config) {
c.Media.DataPath = dataPath
c.Media.Mode = mode
c.Media.BaseURL = baseURL
}
}
// WithMediaConfig configures media with full config
func WithMediaConfig(cfg config.MediaConfig) Option {
return func(c *config.Config) {
c.Media = cfg
}
}
// WithLogLevel sets the log level
func WithLogLevel(level string) Option {
return func(c *config.Config) {
c.LogLevel = level
}
}
// WithDatabase configures database settings
func WithDatabase(dbType, host string, port int, username, password, database string) Option {
return func(c *config.Config) {
c.Database.Type = dbType
c.Database.Host = host
c.Database.Port = port
c.Database.Username = username
c.Database.Password = password
c.Database.Database = database
}
}
// WithSQLiteDatabase configures SQLite database
func WithSQLiteDatabase(sqlitePath string) Option {
return func(c *config.Config) {
c.Database.Type = "sqlite"
c.Database.SQLitePath = sqlitePath
}
}
// WithTLS configures TLS settings
func WithTLS(enabled bool, mode string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = enabled
c.Server.TLS.Mode = mode
}
}
// WithTLSConfig configures TLS with full config
func WithTLSConfig(cfg config.TLSConfig) Option {
return func(c *config.Config) {
c.Server.TLS = cfg
}
}
// WithSelfSignedTLS enables HTTPS with self-signed certificates
func WithSelfSignedTLS(certDir string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "self-signed"
c.Server.TLS.CertDir = certDir
}
}
// WithCustomTLS enables HTTPS with custom certificate files
func WithCustomTLS(certFile, keyFile string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "custom"
c.Server.TLS.CertFile = certFile
c.Server.TLS.KeyFile = keyFile
}
}
// WithAutocertTLS enables HTTPS with Let's Encrypt autocert
func WithAutocertTLS(domain, email string, production bool) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "autocert"
c.Server.TLS.Domain = domain
c.Server.TLS.Email = email
c.Server.TLS.Production = production
}
}

291
pkg/whatshooked/server.go Normal file
View File

@@ -0,0 +1,291 @@
package whatshooked
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/utils"
"go.mau.fi/whatsmeow/types"
"golang.org/x/crypto/acme/autocert"
)
// Server is the optional built-in HTTP server
type Server struct {
wh *WhatsHooked
httpServer *http.Server
}
// NewServer creates a new HTTP server instance
func NewServer(wh *WhatsHooked) *Server {
return &Server{
wh: wh,
}
}
// Start starts the HTTP/HTTPS server
func (s *Server) Start() error {
// Subscribe to hook success events for two-way communication
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
// Setup routes
mux := s.setupRoutes()
addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
// Connect to WhatsApp accounts after server starts
go func() {
time.Sleep(100 * time.Millisecond) // Give server a moment to start
logging.Info("Server ready, connecting to WhatsApp accounts")
if err := s.wh.ConnectAll(context.Background()); err != nil {
logging.Error("Failed to connect to WhatsApp accounts", "error", err)
}
}()
// Start server with or without TLS
if s.wh.config.Server.TLS.Enabled {
return s.startTLS()
}
logging.Info("Starting HTTP server",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr)
// Start server (blocking)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// startTLS starts the server with TLS based on the configured mode
func (s *Server) startTLS() error {
tlsConfig := &s.wh.config.Server.TLS
addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port)
switch tlsConfig.Mode {
case "self-signed":
return s.startSelfSignedTLS(tlsConfig, addr)
case "custom":
return s.startCustomTLS(tlsConfig, addr)
case "autocert":
return s.startAutocertTLS(tlsConfig, addr)
default:
return fmt.Errorf("invalid TLS mode: %s (must be 'self-signed', 'custom', or 'autocert')", tlsConfig.Mode)
}
}
// startSelfSignedTLS starts the server with a self-signed certificate
func (s *Server) startSelfSignedTLS(tlsConfig *config.TLSConfig, addr string) error {
logging.Info("Generating/loading self-signed certificate",
"cert_dir", tlsConfig.CertDir,
"host", s.wh.config.Server.Host)
certPath, keyPath, err := utils.GenerateSelfSignedCert(tlsConfig.CertDir, s.wh.config.Server.Host)
if err != nil {
return fmt.Errorf("failed to generate self-signed certificate: %w", err)
}
logging.Info("Starting HTTPS server with self-signed certificate",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"cert", certPath,
"key", keyPath)
if err := s.httpServer.ListenAndServeTLS(certPath, keyPath); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// startCustomTLS starts the server with custom certificate files
func (s *Server) startCustomTLS(tlsConfig *config.TLSConfig, addr string) error {
if tlsConfig.CertFile == "" || tlsConfig.KeyFile == "" {
return fmt.Errorf("custom TLS mode requires cert_file and key_file to be specified")
}
logging.Info("Validating custom TLS certificates",
"cert", tlsConfig.CertFile,
"key", tlsConfig.KeyFile)
// Validate certificate files
if err := utils.ValidateCertificateFiles(tlsConfig.CertFile, tlsConfig.KeyFile); err != nil {
return fmt.Errorf("invalid certificate files: %w", err)
}
logging.Info("Starting HTTPS server with custom certificate",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"cert", tlsConfig.CertFile,
"key", tlsConfig.KeyFile)
if err := s.httpServer.ListenAndServeTLS(tlsConfig.CertFile, tlsConfig.KeyFile); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// startAutocertTLS starts the server with Let's Encrypt autocert
func (s *Server) startAutocertTLS(tlsConfig *config.TLSConfig, addr string) error {
if tlsConfig.Domain == "" {
return fmt.Errorf("autocert mode requires domain to be specified")
}
logging.Info("Setting up Let's Encrypt autocert",
"domain", tlsConfig.Domain,
"email", tlsConfig.Email,
"cache_dir", tlsConfig.CacheDir,
"production", tlsConfig.Production)
// Create autocert manager
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(tlsConfig.Domain),
Cache: autocert.DirCache(tlsConfig.CacheDir),
Email: tlsConfig.Email,
}
// Configure TLS
s.httpServer.TLSConfig = &tls.Config{
GetCertificate: certManager.GetCertificate,
MinVersion: tls.VersionTLS12,
}
// Start HTTP-01 challenge server on port 80 if we're listening on 443
if s.wh.config.Server.Port == 443 {
go func() {
httpAddr := fmt.Sprintf("%s:80", s.wh.config.Server.Host)
logging.Info("Starting HTTP server for ACME challenges", "address", httpAddr)
if err := http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil)); err != nil {
logging.Error("Failed to start HTTP challenge server", "error", err)
}
}()
}
logging.Info("Starting HTTPS server with Let's Encrypt",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"domain", tlsConfig.Domain)
if err := s.httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// Stop stops the HTTP server gracefully
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
return s.httpServer.Shutdown(ctx)
}
return nil
}
// setupRoutes configures all HTTP routes
func (s *Server) setupRoutes() *http.ServeMux {
mux := http.NewServeMux()
h := s.wh.Handlers()
// Health check (no auth required)
mux.HandleFunc("/health", h.Health)
// Hook management (with auth)
mux.HandleFunc("/api/hooks", h.Auth(h.Hooks))
mux.HandleFunc("/api/hooks/add", h.Auth(h.AddHook))
mux.HandleFunc("/api/hooks/remove", h.Auth(h.RemoveHook))
// Account management (with auth)
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
// Send messages (with auth)
mux.HandleFunc("/api/send", h.Auth(h.SendMessage))
mux.HandleFunc("/api/send/image", h.Auth(h.SendImage))
mux.HandleFunc("/api/send/video", h.Auth(h.SendVideo))
mux.HandleFunc("/api/send/document", h.Auth(h.SendDocument))
// Serve media files (with auth)
mux.HandleFunc("/api/media/", h.ServeMedia)
// Serve QR codes (no auth - needed during pairing)
mux.HandleFunc("/api/qr/", h.ServeQRCode)
// Business API webhooks (no auth - Meta validates via verify_token)
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
logging.Info("HTTP server endpoints configured",
"health", "/health",
"hooks", "/api/hooks",
"accounts", "/api/accounts",
"send", "/api/send",
"qr", "/api/qr")
return mux
}
// handleHookResponse processes hook success events for two-way communication
func (s *Server) handleHookResponse(event events.Event) {
// Use event context for sending message
ctx := event.Context
if ctx == nil {
ctx = context.Background()
}
// Extract response from event data
responseData, ok := event.Data["response"]
if !ok || responseData == nil {
return
}
// Try to cast to HookResponse
resp, ok := responseData.(hooks.HookResponse)
if !ok {
return
}
if !resp.SendMessage {
return
}
// Determine which account to use - default to first available if not specified
targetAccountID := resp.AccountID
if targetAccountID == "" && len(s.wh.config.WhatsApp) > 0 {
targetAccountID = s.wh.config.WhatsApp[0].ID
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(resp.To, s.wh.config.Server.DefaultCountryCode)
// Parse JID
jid, err := types.ParseJID(formattedJID)
if err != nil {
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
return
}
// Send message with context
if err := s.wh.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
logging.Error("Failed to send message from hook response", "error", err)
} else {
logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To)
}
}

View File

@@ -0,0 +1,169 @@
package whatshooked
import (
"context"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/eventlogger"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/handlers"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
)
// WhatsHooked is the main library instance
type WhatsHooked struct {
config *config.Config
configPath string
eventBus *events.EventBus
whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager
eventLogger *eventlogger.Logger
handlers *handlers.Handlers
server *Server // Optional built-in server
}
// NewFromFile creates a WhatsHooked instance from a config file
func NewFromFile(configPath string) (*WhatsHooked, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, err
}
// Initialize logging from config
logging.Init(cfg.LogLevel)
return newWithConfig(cfg, configPath)
}
// New creates a WhatsHooked instance with programmatic config
func New(opts ...Option) (*WhatsHooked, error) {
cfg := &config.Config{
Server: config.ServerConfig{
Host: "localhost",
Port: 8080,
},
Media: config.MediaConfig{
DataPath: "./data/media",
Mode: "link",
},
LogLevel: "info",
}
// Apply options
for _, opt := range opts {
opt(cfg)
}
// Initialize logging from config
logging.Init(cfg.LogLevel)
return newWithConfig(cfg, "")
}
// newWithConfig is the internal constructor
func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error) {
wh := &WhatsHooked{
config: cfg,
configPath: configPath,
eventBus: events.NewEventBus(),
}
// Initialize WhatsApp manager
wh.whatsappMgr = whatsapp.NewManager(
wh.eventBus,
cfg.Media,
cfg,
configPath,
func(updatedCfg *config.Config) error {
if configPath != "" {
return config.Save(configPath, updatedCfg)
}
return nil
},
)
// Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus)
wh.hookMgr.LoadHooks(cfg.Hooks)
wh.hookMgr.Start()
// Initialize event logger if enabled
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database, wh.whatsappMgr)
if err == nil {
wh.eventLogger = logger
wh.eventBus.SubscribeAll(func(event events.Event) {
wh.eventLogger.Log(event)
})
logging.Info("Event logger initialized", "targets", cfg.EventLogger.Targets)
} else {
logging.Error("Failed to initialize event logger", "error", err)
}
}
// Create handlers
wh.handlers = handlers.New(wh.whatsappMgr, wh.hookMgr, cfg, configPath)
return wh, nil
}
// ConnectAll connects to all configured WhatsApp accounts
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
for _, waCfg := range wh.config.WhatsApp {
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
// Continue connecting to other accounts even if one fails
}
}
return nil
}
// Handlers returns the HTTP handlers instance
func (wh *WhatsHooked) Handlers() *handlers.Handlers {
return wh.handlers
}
// Manager returns the WhatsApp manager
func (wh *WhatsHooked) Manager() *whatsapp.Manager {
return wh.whatsappMgr
}
// EventBus returns the event bus
func (wh *WhatsHooked) EventBus() *events.EventBus {
return wh.eventBus
}
// HookManager returns the hook manager
func (wh *WhatsHooked) HookManager() *hooks.Manager {
return wh.hookMgr
}
// Config returns the configuration
func (wh *WhatsHooked) Config() *config.Config {
return wh.config
}
// Close shuts down all components gracefully
func (wh *WhatsHooked) Close() error {
wh.whatsappMgr.DisconnectAll()
if wh.eventLogger != nil {
return wh.eventLogger.Close()
}
return nil
}
// StartServer starts the built-in HTTP server (convenience method)
func (wh *WhatsHooked) StartServer() error {
wh.server = NewServer(wh)
return wh.server.Start()
}
// StopServer stops the built-in HTTP server
func (wh *WhatsHooked) StopServer(ctx context.Context) error {
if wh.server != nil {
return wh.server.Stop(ctx)
}
return nil
}