Compare commits

...

20 Commits

Author SHA1 Message Date
Hein
98fc28fc5f docs(whatsapp): Update WhatsApp Business API setup guide
Some checks failed
CI / Test (1.22) (push) Failing after -24m48s
CI / Test (1.23) (push) Failing after -24m45s
CI / Build (push) Successful in -27m0s
CI / Lint (push) Successful in -26m50s
* Add registration process for new phone numbers
* Clarify importance of registration for sending messages and 2FA
* Improve formatting and clarity throughout the document
2026-01-30 17:06:04 +02:00
Hein
c5e121de4a chore: 🔧 clean up code structure and remove unused files
Some checks failed
CI / Test (1.22) (push) Failing after -24m45s
CI / Test (1.23) (push) Failing after -24m42s
CI / Build (push) Successful in -26m57s
CI / Lint (push) Successful in -26m48s
* Refactored several modules for better readability
* Removed deprecated functions and variables
* Improved overall project organization
2026-01-30 16:59:22 +02:00
Hein
c4d974d6ce feat(cache): 🎉 add message caching functionality
Some checks failed
CI / Test (1.23) (push) Failing after -27m1s
CI / Lint (push) Successful in -26m31s
CI / Build (push) Successful in -27m3s
CI / Test (1.22) (push) Failing after -24m58s
* Implement MessageCache to store events when no webhooks are available.
* Add configuration options for enabling cache, setting data path, max age, and max events.
* Create API endpoints for managing cached events, including listing, replaying, and deleting.
* Integrate caching into the hooks manager to store events when no active webhooks are found.
* Enhance logging for better traceability of cached events and operations.
2026-01-30 16:00:34 +02:00
Hein
3901bbb668 feat(whatsapp): Enhance webhook message handling
Some checks failed
CI / Test (1.22) (push) Failing after -24m5s
CI / Test (1.23) (push) Failing after -23m59s
CI / Build (push) Successful in -24m25s
CI / Lint (push) Failing after -24m11s
* Add support for new message types: audio, sticker, location, contacts, interactive, button, reaction, order, system, and unknown.
* Implement logging for various webhook events for better visibility.
* Update WebhookMessage struct to include new fields for enhanced message processing.
2026-01-30 11:30:10 +02:00
147dac9b60 Whatsapp Business enhancements
Some checks failed
CI / Test (1.22) (push) Failing after -22m39s
CI / Test (1.23) (push) Failing after -22m40s
CI / Build (push) Successful in -25m42s
CI / Lint (push) Failing after -25m28s
2025-12-30 11:35:10 +02:00
d80a6433b9 Fixed mqtt bug where phone number is not formatted
Some checks failed
CI / Test (1.23) (push) Failing after -22m52s
CI / Test (1.22) (push) Failing after -22m44s
CI / Build (push) Successful in -25m59s
CI / Lint (push) Successful in -25m47s
2025-12-30 01:00:42 +02:00
7b2390cbf6 Mqtt logging
Some checks failed
CI / Test (1.22) (push) Failing after -23m38s
CI / Test (1.23) (push) Failing after -23m28s
CI / Build (push) Successful in -25m46s
CI / Lint (push) Successful in -25m30s
2025-12-30 00:19:49 +02:00
eb788f903a update: deps
Some checks failed
CI / Test (1.22) (push) Failing after -22m58s
CI / Test (1.23) (push) Failing after -22m50s
CI / Lint (push) Successful in -23m30s
CI / Build (push) Successful in -23m41s
2025-12-29 23:42:23 +02:00
4d083b0bd9 Better tests
Some checks failed
CI / Test (1.23) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Test (1.22) (push) Has been cancelled
2025-12-29 23:40:49 +02:00
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
16aaf1919d CLI refactor 2025-12-29 05:29:53 +02:00
d54b0eaddf Docker added 2025-12-29 05:19:08 +02:00
75 changed files with 12024 additions and 2178 deletions

46
.dockerignore Normal file
View File

@@ -0,0 +1,46 @@
# Git files
.git
.gitignore
# Documentation
*.md
!README.md
PLAN.md
TODO.md
# Build artifacts
bin/
*.exe
*.dll
*.so
*.dylib
# Test files
*_test.go
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Data directories
sessions/
data/
*.db
*.db-shm
*.db-wal
# Config files (will be mounted as volumes)
config.json
*.example.json
.whatshooked-cli.example.json
# Assets
assets/
# Claude
.claude/
CLAUDE.md
AI_USE.md

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

@@ -0,0 +1,108 @@
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: Clean build cache
run: |
go clean -cache
go mod tidy
- 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)"

4
.gitignore vendored
View File

@@ -46,3 +46,7 @@ sessions/
# OS
.DS_Store
Thumbs.db
/server
# Web directory (files are embedded in pkg/handlers/static/)
web/

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,3 +0,0 @@
{
"server_url": "http://localhost:8080"
}

430
ACCOUNT_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,430 @@
# Account Management API
This document describes the API endpoints for managing WhatsApp accounts in WhatsHooked.
## Authentication
All account management endpoints require authentication using one of the following methods:
- **API Key**: `X-API-Key: your-api-key-here`
- **Basic Auth**: `Authorization: Basic <base64(username:password)>`
## Endpoints
### 1. List All Accounts
Get a list of all configured WhatsApp accounts.
**Endpoint:** `GET /api/accounts`
**Response:**
```json
[
{
"id": "business",
"type": "business-api",
"phone_number": "+27663602295",
"disabled": false,
"business_api": {
"phone_number_id": "889966060876308",
"access_token": "...",
"business_account_id": "1602055757491196",
"api_version": "v21.0",
"verify_token": "..."
}
}
]
```
---
### 2. Add Account
Add a new WhatsApp account to the system.
**Endpoint:** `POST /api/accounts/add`
**Request Body (WhatsApp Web/WhatsMe ow):**
```json
{
"id": "my-account",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/my-account",
"show_qr": true,
"disabled": false
}
```
**Request Body (Business API):**
```json
{
"id": "business-account",
"type": "business-api",
"phone_number": "+1234567890",
"disabled": false,
"business_api": {
"phone_number_id": "123456789",
"access_token": "YOUR_ACCESS_TOKEN",
"business_account_id": "987654321",
"api_version": "v21.0",
"verify_token": "your-verify-token"
}
}
```
**Response:**
```json
{
"status": "ok",
"account_id": "my-account"
}
```
**Status Codes:**
- `201 Created` - Account added successfully
- `400 Bad Request` - Invalid request body
- `500 Internal Server Error` - Failed to connect or save config
---
### 3. Update Account
Update an existing WhatsApp account configuration.
**Endpoint:** `POST /api/accounts/update` or `PUT /api/accounts/update`
**Request Body:**
```json
{
"id": "business-account",
"type": "business-api",
"phone_number": "+1234567890",
"disabled": false,
"business_api": {
"phone_number_id": "123456789",
"access_token": "NEW_ACCESS_TOKEN",
"business_account_id": "987654321",
"api_version": "v21.0",
"verify_token": "new-verify-token"
}
}
```
**Response:**
```json
{
"status": "ok",
"account_id": "business-account"
}
```
**Notes:**
- The `id` field is required to identify which account to update
- The `type` field cannot be changed (preserved from original)
- If the account is enabled, it will be disconnected and reconnected with new settings
- Configuration is saved to disk after successful update
**Status Codes:**
- `200 OK` - Account updated successfully
- `400 Bad Request` - Invalid request or missing ID
- `404 Not Found` - Account not found
- `500 Internal Server Error` - Failed to reconnect or save config
---
### 4. Disable Account
Disable a WhatsApp account (disconnect and prevent auto-connect on restart).
**Endpoint:** `POST /api/accounts/disable`
**Request Body:**
```json
{
"id": "my-account"
}
```
**Response:**
```json
{
"status": "ok",
"account_id": "my-account"
}
```
**Behavior:**
- Disconnects the account immediately
- Sets `disabled: true` in config
- Account will not auto-connect on server restart
- Account remains in config (can be re-enabled)
**Status Codes:**
- `200 OK` - Account disabled successfully
- `400 Bad Request` - Invalid request body
- `404 Not Found` - Account not found
- `500 Internal Server Error` - Failed to save config
---
### 5. Enable Account
Enable a previously disabled WhatsApp account.
**Endpoint:** `POST /api/accounts/enable`
**Request Body:**
```json
{
"id": "my-account"
}
```
**Response:**
```json
{
"status": "ok",
"account_id": "my-account"
}
```
**Behavior:**
- Sets `disabled: false` in config
- Connects the account immediately
- Account will auto-connect on server restart
- If connection fails, account remains disabled
**Status Codes:**
- `200 OK` - Account enabled and connected successfully
- `400 Bad Request` - Invalid request body
- `404 Not Found` - Account not found
- `500 Internal Server Error` - Failed to connect or save config
---
### 6. Remove Account
Permanently remove a WhatsApp account from the system.
**Endpoint:** `POST /api/accounts/remove` or `DELETE /api/accounts/remove`
**Request Body:**
```json
{
"id": "my-account"
}
```
**Response:**
```json
{
"status": "ok"
}
```
**Behavior:**
- Disconnects the account
- Removes from config permanently
- Session data is NOT deleted (manual cleanup required)
**Status Codes:**
- `200 OK` - Account removed successfully
- `400 Bad Request` - Invalid request body
- `404 Not Found` - Account not found
- `500 Internal Server Error` - Failed to save config
---
## Configuration File
Account settings are stored in `config.json`:
```json
{
"whatsapp": [
{
"id": "business",
"type": "business-api",
"phone_number": "+27663602295",
"disabled": false,
"business_api": {
"phone_number_id": "889966060876308",
"access_token": "...",
"business_account_id": "1602055757491196",
"api_version": "v21.0",
"verify_token": "..."
}
},
{
"id": "personal",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/personal",
"show_qr": true,
"disabled": true
}
]
}
```
### Configuration Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique identifier for the account |
| `type` | string | Yes | Account type: `whatsmeow` or `business-api` |
| `phone_number` | string | Yes | Phone number with country code |
| `disabled` | boolean | No | If `true`, account won't connect (default: `false`) |
| `session_path` | string | No | Session storage path (whatsmeow only) |
| `show_qr` | boolean | No | Display QR code in logs (whatsmeow only) |
| `business_api` | object | Conditional | Required for `business-api` type |
### Business API Configuration
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `phone_number_id` | string | Yes | WhatsApp Business phone number ID |
| `access_token` | string | Yes | Meta Graph API access token |
| `business_account_id` | string | No | Business account ID |
| `api_version` | string | No | API version (default: `v21.0`) |
| `verify_token` | string | No | Webhook verification token |
---
## Usage Examples
### cURL Examples
**List accounts:**
```bash
curl -u username:password http://localhost:8080/api/accounts
```
**Add account:**
```bash
curl -u username:password \
-X POST \
-H "Content-Type: application/json" \
-d '{
"id": "new-account",
"type": "whatsmeow",
"phone_number": "+1234567890",
"session_path": "./sessions/new-account",
"show_qr": true
}' \
http://localhost:8080/api/accounts/add
```
**Update account:**
```bash
curl -u username:password \
-X POST \
-H "Content-Type: application/json" \
-d '{
"id": "business",
"type": "business-api",
"phone_number": "+27663602295",
"business_api": {
"phone_number_id": "889966060876308",
"access_token": "NEW_TOKEN_HERE",
"api_version": "v21.0"
}
}' \
http://localhost:8080/api/accounts/update
```
**Disable account:**
```bash
curl -u username:password \
-X POST \
-H "Content-Type: application/json" \
-d '{"id": "my-account"}' \
http://localhost:8080/api/accounts/disable
```
**Enable account:**
```bash
curl -u username:password \
-X POST \
-H "Content-Type: application/json" \
-d '{"id": "my-account"}' \
http://localhost:8080/api/accounts/enable
```
**Remove account:**
```bash
curl -u username:password \
-X POST \
-H "Content-Type: application/json" \
-d '{"id": "my-account"}' \
http://localhost:8080/api/accounts/remove
```
---
## Server Logs
When managing accounts, you'll see these log messages:
```log
INFO Account disabled account_id=business
INFO Config saved after disabling account account_id=business
INFO Account enabled and connected account_id=business
INFO Config saved after enabling account account_id=business
INFO Account configuration updated account_id=business
INFO Account reconnected with new settings account_id=business
INFO Skipping disabled account account_id=business
```
---
## Best Practices
1. **Disable vs Remove**:
- Use **disable** for temporary disconnection (maintenance, testing)
- Use **remove** for permanent deletion
2. **Update Process**:
- Always provide complete configuration when updating
- Account will be reconnected automatically if enabled
3. **Session Management**:
- WhatsMe ow sessions are stored in `session_path`
- Removing an account doesn't delete session files
- Clean up manually if needed
4. **Server Restart**:
- Only enabled accounts (`disabled: false`) connect on startup
- Disabled accounts remain in config but stay disconnected
5. **Configuration Backup**:
- Config is automatically saved after each change
- Keep backups before making bulk changes
- Test changes with one account first
---
## Error Handling
All endpoints return proper HTTP status codes and JSON error messages:
```json
{
"error": "Account not found"
}
```
Or plain text for simple errors:
```
Account ID required in path
```
Common error scenarios:
- Account already exists (when adding)
- Account not found (when updating/removing)
- Connection failed (when enabling)
- Configuration save failed (any operation)

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

466
CLI.md Normal file
View File

@@ -0,0 +1,466 @@
# WhatsHooked CLI Documentation
The WhatsHooked CLI provides a command-line interface for managing the WhatsHooked server, webhooks, and WhatsApp accounts.
## Table of Contents
- [Installation](#installation)
- [Authentication](#authentication)
- [Configuration](#configuration)
- [Commands](#commands)
- [Examples](#examples)
## Installation
Build the CLI using the provided Makefile:
```bash
make build
```
The binary will be available at `./bin/whatshook-cli`.
## Authentication
The CLI supports multiple authentication methods to secure communication with the WhatsHooked server.
### Authentication Methods
The CLI supports two authentication methods (in priority order):
1. **API Key Authentication** (Recommended)
- Uses Bearer token or x-api-key header
- Most secure and simple to use
2. **Basic Authentication**
- Uses username and password
- HTTP Basic Auth
### Configuration Priority
Authentication credentials are loaded in the following priority order (highest to lowest):
1. **Command-line flags** (highest priority)
2. **Environment variables**
3. **Configuration file** (lowest priority)
### Setting Up Authentication
#### Option 1: Configuration File (Recommended)
Create a configuration file at `~/.whatshooked/cli.json`:
```json
{
"server_url": "http://localhost:8080",
"auth_key": "your-api-key-here",
"username": "",
"password": ""
}
```
Or use API key authentication:
```json
{
"server_url": "http://localhost:8080",
"auth_key": "your-secure-api-key"
}
```
Or use username/password authentication:
```json
{
"server_url": "http://localhost:8080",
"username": "admin",
"password": "your-secure-password"
}
```
You can also create a local configuration file in your project directory:
```bash
cp .whatshooked-cli.example.json .whatshooked-cli.json
# Edit the file with your credentials
```
#### Option 2: Environment Variables
Set environment variables (useful for CI/CD):
```bash
export WHATSHOOKED_SERVER_URL="http://localhost:8080"
export WHATSHOOKED_AUTH_KEY="your-api-key-here"
```
Or with username/password:
```bash
export WHATSHOOKED_SERVER_URL="http://localhost:8080"
export WHATSHOOKED_USERNAME="admin"
export WHATSHOOKED_PASSWORD="your-password"
```
#### Option 3: Command-Line Flags
Pass credentials via command-line flags:
```bash
./bin/whatshook-cli --server http://localhost:8080 health
```
Note: Currently, authentication credentials can only be set via config file or environment variables. Command-line flags for auth credentials may be added in future versions.
### No Authentication
If your server doesn't require authentication, simply omit the authentication fields:
```json
{
"server_url": "http://localhost:8080"
}
```
## Configuration
### Configuration File Locations
The CLI looks for configuration files in the following locations (in order):
1. File specified by `--config` flag
2. `$HOME/.whatshooked/cli.json`
3. `./.whatshooked-cli.json` (current directory)
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `server_url` | string | `http://localhost:8080` | WhatsHooked server URL |
| `auth_key` | string | `""` | API key for authentication |
| `username` | string | `""` | Username for Basic Auth |
| `password` | string | `""` | Password for Basic Auth |
## Commands
### Global Flags
All commands support the following global flags:
- `--config <path>`: Path to configuration file
- `--server <url>`: Server URL (overrides config file)
### Health Check
Check if the server is running and healthy:
```bash
./bin/whatshook-cli health
```
### Hooks Management
#### List Hooks
List all configured webhooks:
```bash
./bin/whatshook-cli hooks
./bin/whatshook-cli hooks list
```
#### Add Hook
Add a new webhook interactively:
```bash
./bin/whatshook-cli hooks add
```
You'll be prompted for:
- Hook ID
- Hook Name
- Webhook URL
- HTTP Method (default: POST)
- Events to subscribe to (optional, comma-separated)
- Description (optional)
**Available Event Types:**
WhatsApp Connection Events:
- `whatsapp.connected` - WhatsApp client connected
- `whatsapp.disconnected` - WhatsApp client disconnected
- `whatsapp.qr.code` - QR code generated for pairing
- `whatsapp.qr.timeout` - QR code expired
- `whatsapp.qr.error` - QR code generation error
- `whatsapp.pair.success` - Device paired successfully
- `whatsapp.pair.failed` - Device pairing failed
- `whatsapp.pair.event` - Generic pairing event
Message Events:
- `message.received` - Message received from WhatsApp
- `message.sent` - Message sent successfully
- `message.failed` - Message sending failed
- `message.delivered` - Message delivered to recipient
- `message.read` - Message read by recipient
Hook Events:
- `hook.triggered` - Hook was triggered
- `hook.success` - Hook executed successfully
- `hook.failed` - Hook execution failed
If no events are specified, the hook will receive all events.
#### Remove Hook
Remove a webhook by ID:
```bash
./bin/whatshook-cli hooks remove <hook_id>
```
### Accounts Management
#### List Accounts
List all configured WhatsApp accounts:
```bash
./bin/whatshook-cli accounts
./bin/whatshook-cli accounts list
```
#### Add Account
Add a new WhatsApp account interactively:
```bash
./bin/whatshook-cli accounts add
```
You'll be prompted for:
- Account ID
- Phone Number (with country code, e.g., +1234567890)
- Session Path (where to store session data)
After adding, check the server logs for a QR code to scan with WhatsApp.
### Send Messages
#### Send Text Message
Send a text message interactively:
```bash
./bin/whatshook-cli send text
```
You'll be prompted for:
- Account ID
- Recipient (phone number or JID)
- Message text
#### Send Image
Send an image with optional caption:
```bash
./bin/whatshook-cli send image <file_path>
```
Supported formats: JPG, PNG, GIF, WebP
#### Send Video
Send a video with optional caption:
```bash
./bin/whatshook-cli send video <file_path>
```
Supported formats: MP4, MOV, AVI, WebM, 3GP
#### Send Document
Send a document with optional caption:
```bash
./bin/whatshook-cli send document <file_path>
```
Supported formats: PDF, DOC, DOCX, XLS, XLSX, TXT, ZIP, and more
## Examples
### Example 1: Basic Setup
```bash
# Create config file
mkdir -p ~/.whatshooked
cat > ~/.whatshooked/cli.json <<EOF
{
"server_url": "http://localhost:8080",
"auth_key": "my-secure-api-key"
}
EOF
# Check server health
./bin/whatshook-cli health
# List hooks
./bin/whatshook-cli hooks list
```
### Example 2: Using Environment Variables
```bash
# Set environment variables
export WHATSHOOKED_SERVER_URL="https://my-server.com:8080"
export WHATSHOOKED_AUTH_KEY="production-api-key"
# Use CLI without config file
./bin/whatshook-cli health
./bin/whatshook-cli accounts list
```
### Example 3: Managing Hooks
```bash
# Add a new hook for all events
./bin/whatshook-cli hooks add
# Hook ID: my_hook
# Hook Name: My Webhook
# Webhook URL: https://example.com/webhook
# HTTP Method (POST): POST
# Events (comma-separated, or press Enter for all): [press Enter]
# Description (optional): My webhook handler
# Add a hook for specific events only
./bin/whatshook-cli hooks add
# Hook ID: message_hook
# Hook Name: Message Handler
# Webhook URL: https://example.com/messages
# HTTP Method (POST): POST
# Events (comma-separated, or press Enter for all): message.received, message.sent
# Description (optional): Handle incoming and outgoing messages
# List all hooks
./bin/whatshook-cli hooks
# Remove a hook
./bin/whatshook-cli hooks remove message_hook
```
### Example 4: Sending Messages
```bash
# Send text message
./bin/whatshook-cli send text
# Enter account ID, recipient, and message when prompted
# Send image
./bin/whatshook-cli send image /path/to/photo.jpg
# Send video
./bin/whatshook-cli send video /path/to/video.mp4
# Send document
./bin/whatshook-cli send document /path/to/report.pdf
```
### Example 5: Production Setup with Authentication
```bash
# Server config.json (enable authentication)
{
"server": {
"host": "0.0.0.0",
"port": 8080,
"auth_key": "super-secret-production-key"
}
}
# CLI config (~/.whatshooked/cli.json)
{
"server_url": "https://whatshooked.mycompany.com",
"auth_key": "super-secret-production-key"
}
# Now all CLI commands will be authenticated
./bin/whatshook-cli hooks list
./bin/whatshook-cli accounts add
```
## Error Handling
The CLI provides clear error messages for common issues:
### Authentication Errors
```
Error: HTTP 401: Unauthorized
```
**Solution**: Check your authentication credentials in the config file or environment variables.
### Connection Errors
```
Error: dial tcp: connect: connection refused
```
**Solution**: Ensure the server is running and the URL is correct.
### Invalid Credentials
```
Error: HTTP 403: Forbidden
```
**Solution**: Verify your API key or username/password are correct and match the server configuration.
## Best Practices
1. **Use API Key Authentication**: More secure than username/password, easier to rotate
2. **Store Config Securely**: Don't commit config files with credentials to version control
3. **Use Environment Variables in CI/CD**: Safer than storing credentials in files
4. **Enable Authentication in Production**: Always use authentication for production servers
5. **Use HTTPS**: In production, always use HTTPS for the server URL
## Security Notes
- Never commit configuration files containing credentials to version control
- Use restrictive file permissions for config files: `chmod 600 ~/.whatshooked/cli.json`
- Rotate API keys regularly
- Use different credentials for development and production
- In production, always use HTTPS to encrypt traffic
## Troubleshooting
### Config File Not Found
If you see warnings about config file not being found, create one:
```bash
mkdir -p ~/.whatshooked
cp .whatshooked-cli.example.json ~/.whatshooked/cli.json
# Edit the file with your settings
```
### Server Unreachable
Verify the server is running:
```bash
curl http://localhost:8080/health
```
### Authentication Required
If the server requires authentication but you haven't configured it:
```
Error: HTTP 401: Unauthorized
```
Add authentication to your config file as shown in the [Authentication](#authentication) section.

419
DOCKER.md Normal file
View File

@@ -0,0 +1,419 @@
# Docker Deployment Guide
This guide explains how to run WhatsHooked using Docker and Docker Compose.
## Prerequisites
- Docker installed (version 20.10 or later)
- Docker Compose installed (version 1.29 or later)
## Quick Start
1. **Copy the example configuration file:**
```bash
cp config.example.json config.json
```
2. **Edit the configuration file:**
Open `config.json` and update:
- WhatsApp phone numbers
- Webhook URLs and authentication
- Server settings (port, authentication)
3. **Create required directories:**
```bash
mkdir -p sessions data/media
```
4. **Build and start the server:**
```bash
docker-compose up -d
```
5. **View logs to scan QR code (first run):**
```bash
docker-compose logs -f whatshooked
```
Scan the QR code with WhatsApp on your phone to authenticate.
6. **Check server health:**
```bash
curl http://localhost:8080/health
```
## Docker Commands
### Build the image
```bash
docker-compose build
```
### Start the service
```bash
docker-compose up -d
```
### Stop the service
```bash
docker-compose down
```
### View logs
```bash
docker-compose logs -f
```
### Restart the service
```bash
docker-compose restart
```
### Access the container shell
```bash
docker-compose exec whatshooked sh
```
## Volume Mounts
The docker-compose.yml file mounts three important volumes:
1. **config.json** - Server configuration (read-only)
2. **sessions/** - WhatsApp session data (persistent authentication)
3. **data/media/** - Downloaded media files
These volumes ensure your data persists across container restarts.
## Configuration
### Port Mapping
By default, the server runs on port 8080. To change the port:
**Option 1: Update config.json**
```json
{
"server": {
"port": 9090
}
}
```
Then update docker-compose.yml:
```yaml
ports:
- "9090:9090"
```
**Option 2: Map to different host port**
```yaml
ports:
- "3000:8080" # Access via localhost:3000, server still runs on 8080 internally
```
### Authentication
Set authentication in config.json:
**Option 1: API Key**
```json
{
"server": {
"auth_key": "your-secure-api-key-here"
}
}
```
**Option 2: Username/Password**
```json
{
"server": {
"username": "admin",
"password": "secure-password"
}
}
```
### Resource Limits
Uncomment the deploy section in docker-compose.yml to set resource limits:
```yaml
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
```
## QR Code Scanning
When you first start the server, you'll need to scan a QR code to authenticate with WhatsApp.
### View QR Code in Logs
```bash
docker-compose logs -f whatshooked
```
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:
```bash
./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
Check logs for errors:
```bash
docker-compose logs
```
### Config file not found
Ensure config.json exists in the project root:
```bash
ls -la config.json
```
### Permission issues with volumes
Fix ownership of mounted directories:
```bash
sudo chown -R $(id -u):$(id -g) sessions data
```
### QR code not displaying
Ensure `show_qr: true` is set in config.json:
```json
{
"whatsapp": [
{
"show_qr": true
}
]
}
```
### Cannot connect to server
Check if the container is running:
```bash
docker-compose ps
```
Check health status:
```bash
docker inspect whatshooked-server | grep -A 10 Health
```
## Production Deployment
### Security Best Practices
1. **Use secrets for sensitive data:**
- Don't commit config.json with real credentials
- Use Docker secrets or environment variables
- Enable authentication (auth_key or username/password)
2. **Use HTTPS:**
- Put the server behind a reverse proxy (nginx, Traefik, Caddy)
- Enable SSL/TLS certificates
- Update webhook base_url to use https://
3. **Network isolation:**
- Use Docker networks to isolate the service
- Only expose necessary ports
- Consider using a VPN for webhook endpoints
4. **Regular backups:**
- Backup the sessions/ directory regularly
- Backup config.json (securely)
- Backup media files if needed
### Example with Reverse Proxy (nginx)
Create a network:
```bash
docker network create whatshooked-net
```
Update docker-compose.yml:
```yaml
services:
whatshooked:
networks:
- whatshooked-net
# Don't expose ports to host, only to network
# ports:
# - "8080:8080"
networks:
whatshooked-net:
external: true
```
Configure nginx to proxy to the container:
```nginx
server {
listen 443 ssl;
server_name whatshooked.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://whatshooked:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## Monitoring
### Health Checks
The compose file includes a health check that runs every 30 seconds:
```bash
docker inspect --format='{{.State.Health.Status}}' whatshooked-server
```
### Log Management
Limit log size to prevent disk space issues:
```yaml
services:
whatshooked:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
## Updates
To update to the latest version:
1. Pull latest code:
```bash
git pull
```
2. Rebuild the image:
```bash
docker-compose build --no-cache
```
3. Restart the service:
```bash
docker-compose down
docker-compose up -d
```
## Multi-Platform Builds
To build for different architectures (e.g., ARM for Raspberry Pi):
```bash
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t whatshooked:latest .
```

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM golang:1.25-alpine AS builder
# Install build dependencies (SQLite requires CGO)
RUN apk add --no-cache gcc musl-dev sqlite-dev
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the binaries
# CGO is required for mattn/go-sqlite3
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
# Install runtime dependencies
RUN apk add --no-cache ca-certificates sqlite-libs tzdata
WORKDIR /app
# Copy binaries from builder
COPY --from=builder /build/bin ./bin
# Create necessary directories
RUN mkdir -p /app/sessions /app/data/media /app/data/certs
# Expose the default server port
EXPOSE 8080
# Run the 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"

720
README.md
View File

@@ -1,17 +1,30 @@
# 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
- [WhatsApp Business API Setup](WHATSAPP_BUSINESS.md) - Complete guide for configuring WhatsApp Business API credentials
- [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 +32,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 +159,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 +361,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 +384,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 +432,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 +454,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 +672,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 +990,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,15 +1022,41 @@ The server accepts both full JID format and plain phone numbers. When using plai
```
whatshooked/
├── cmd/
│ ├── server/ # Main server application
│ ├── server/ # Standalone server (thin wrapper)
│ │ └── main.go
│ └── cli/ # CLI tool
├── internal/
├── config/ # Configuration management
│ ├── events/ # Event bus and event types
│ ├── logging/ # Structured logging
│ ├── 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
── utils/ # Utility functions (phone formatting, etc.)
── eventlogger/ # Event persistence
│ └── utils/ # Utility functions
├── config.example.json # Example configuration
└── go.mod # Go module definition
```
@@ -442,9 +1097,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.

1040
WHATSAPP_BUSINESS.md Normal file

File diff suppressed because it is too large Load Diff

111
cmd/cli/client.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// Client wraps the HTTP client with authentication support
type Client struct {
baseURL string
authKey string
username string
password string
client *http.Client
}
// NewClient creates a new authenticated HTTP client
func NewClient(cfg *CLIConfig) *Client {
return &Client{
baseURL: cfg.ServerURL,
authKey: cfg.AuthKey,
username: cfg.Username,
password: cfg.Password,
client: &http.Client{},
}
}
// addAuth adds authentication headers to the request
func (c *Client) addAuth(req *http.Request) {
// Priority 1: API Key (as Bearer token or x-api-key header)
if c.authKey != "" {
req.Header.Set("Authorization", "Bearer "+c.authKey)
req.Header.Set("x-api-key", c.authKey)
return
}
// Priority 2: Basic Auth (username/password)
if c.username != "" && c.password != "" {
req.SetBasicAuth(c.username, c.password)
}
}
// Get performs an authenticated GET request
func (c *Client) Get(path string) (*http.Response, error) {
req, err := http.NewRequest("GET", c.baseURL+path, nil)
if err != nil {
return nil, err
}
c.addAuth(req)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
// Check for HTTP error status codes
if resp.StatusCode >= 400 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return resp, nil
}
// Post performs an authenticated POST request with JSON data
func (c *Client) Post(path string, data interface{}) (*http.Response, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
c.addAuth(req)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
// Check for HTTP error status codes
if resp.StatusCode >= 400 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return resp, nil
}
// decodeJSON decodes JSON response into target
func decodeJSON(resp *http.Response, target interface{}) error {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(target)
}
// checkError prints error and exits if error is not nil
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,89 @@
package main
import (
"fmt"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"github.com/spf13/cobra"
)
// accountsCmd is the parent command for account management
var accountsCmd = &cobra.Command{
Use: "accounts",
Short: "Manage WhatsApp accounts",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
listAccounts(client)
},
}
var accountsListCmd = &cobra.Command{
Use: "list",
Short: "List all accounts",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
listAccounts(client)
},
}
var accountsAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new WhatsApp account",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
addAccount(client)
},
}
func init() {
accountsCmd.AddCommand(accountsListCmd)
accountsCmd.AddCommand(accountsAddCmd)
}
func listAccounts(client *Client) {
resp, err := client.Get("/api/accounts")
checkError(err)
defer resp.Body.Close()
var accounts []config.WhatsAppConfig
checkError(decodeJSON(resp, &accounts))
if len(accounts) == 0 {
fmt.Println("No accounts configured")
return
}
fmt.Printf("Configured accounts (%d):\n\n", len(accounts))
for _, acc := range accounts {
fmt.Printf("ID: %s\n", acc.ID)
fmt.Printf("Phone Number: %s\n", acc.PhoneNumber)
fmt.Printf("Session Path: %s\n", acc.SessionPath)
fmt.Println()
}
}
func addAccount(client *Client) {
var account config.WhatsAppConfig
fmt.Print("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): ")
if _, err := fmt.Scanln(&account.PhoneNumber); err != nil {
checkError(fmt.Errorf("error reading phone number: %v", err))
}
fmt.Print("Session Path: ")
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)
defer resp.Body.Close()
fmt.Println("Account added successfully")
fmt.Println("Check server logs for QR code to pair the device")
}

View File

@@ -0,0 +1,28 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
// healthCmd checks server health
var healthCmd = &cobra.Command{
Use: "health",
Short: "Check server health",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
checkHealth(client)
},
}
func checkHealth(client *Client) {
resp, err := client.Get("/health")
checkError(err)
defer resp.Body.Close()
var result map[string]string
checkError(decodeJSON(resp, &result))
fmt.Printf("Server status: %s\n", result["status"])
}

164
cmd/cli/commands_hooks.go Normal file
View File

@@ -0,0 +1,164 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"github.com/spf13/cobra"
)
// hooksCmd is the parent command for hook management
var hooksCmd = &cobra.Command{
Use: "hooks",
Short: "Manage webhooks",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
listHooks(client)
},
}
var hooksListCmd = &cobra.Command{
Use: "list",
Short: "List all hooks",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
listHooks(client)
},
}
var hooksAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new hook",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
addHook(client)
},
}
var hooksRemoveCmd = &cobra.Command{
Use: "remove <hook_id>",
Short: "Remove a hook",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
removeHook(client, args[0])
},
}
func init() {
hooksCmd.AddCommand(hooksListCmd)
hooksCmd.AddCommand(hooksAddCmd)
hooksCmd.AddCommand(hooksRemoveCmd)
}
func listHooks(client *Client) {
resp, err := client.Get("/api/hooks")
checkError(err)
defer resp.Body.Close()
var hooks []config.Hook
checkError(decodeJSON(resp, &hooks))
if len(hooks) == 0 {
fmt.Println("No hooks configured")
return
}
fmt.Printf("Configured hooks (%d):\n\n", len(hooks))
for _, hook := range hooks {
status := "inactive"
if hook.Active {
status = "active"
}
fmt.Printf("ID: %s\n", hook.ID)
fmt.Printf("Name: %s\n", hook.Name)
fmt.Printf("URL: %s\n", hook.URL)
fmt.Printf("Method: %s\n", hook.Method)
fmt.Printf("Status: %s\n", status)
if len(hook.Events) > 0 {
fmt.Printf("Events: %v\n", hook.Events)
} else {
fmt.Printf("Events: all (no filter)\n")
}
if hook.Description != "" {
fmt.Printf("Description: %s\n", hook.Description)
}
fmt.Println()
}
}
func addHook(client *Client) {
var hook config.Hook
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Hook ID: ")
if _, err := fmt.Scanln(&hook.ID); err != nil {
checkError(fmt.Errorf("error reading hook ID: %v", err))
}
fmt.Print("Hook Name: ")
if _, err := fmt.Scanln(&hook.Name); err != nil {
checkError(fmt.Errorf("error reading hook name: %v", err))
}
fmt.Print("Webhook URL: ")
if _, err := fmt.Scanln(&hook.URL); err != nil {
checkError(fmt.Errorf("error reading webhook URL: %v", err))
}
fmt.Print("HTTP Method (POST): ")
if _, err := fmt.Scanln(&hook.Method); err == nil {
// Successfully read input
fmt.Printf("Selected Method %s", hook.Method)
}
if hook.Method == "" {
hook.Method = "POST"
}
// Prompt for events with helpful examples
fmt.Println("\nAvailable events:")
fmt.Println(" WhatsApp: whatsapp.connected, whatsapp.disconnected, whatsapp.qr.code")
fmt.Println(" Messages: message.received, message.sent, message.delivered, message.read")
fmt.Println(" Hooks: hook.triggered, hook.success, hook.failed")
fmt.Print("\nEvents (comma-separated, or press Enter for all): ")
scanner.Scan()
eventsInput := strings.TrimSpace(scanner.Text())
if eventsInput != "" {
// Split by comma and trim whitespace
eventsList := strings.Split(eventsInput, ",")
hook.Events = make([]string, 0, len(eventsList))
for _, event := range eventsList {
trimmed := strings.TrimSpace(event)
if trimmed != "" {
hook.Events = append(hook.Events, trimmed)
}
}
}
fmt.Print("\nDescription (optional): ")
scanner.Scan()
hook.Description = strings.TrimSpace(scanner.Text())
hook.Active = true
resp, err := client.Post("/api/hooks/add", hook)
checkError(err)
defer resp.Body.Close()
fmt.Println("Hook added successfully")
}
func removeHook(client *Client, id string) {
req := map[string]string{"id": id}
resp, err := client.Post("/api/hooks/remove", req)
checkError(err)
defer resp.Body.Close()
fmt.Println("Hook removed successfully")
}

270
cmd/cli/commands_send.go Normal file
View File

@@ -0,0 +1,270 @@
package main
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// sendCmd is the parent command for sending messages
var sendCmd = &cobra.Command{
Use: "send",
Short: "Send messages",
}
var sendTextCmd = &cobra.Command{
Use: "text",
Short: "Send a text message",
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
sendMessage(client)
},
}
var sendImageCmd = &cobra.Command{
Use: "image <file_path>",
Short: "Send an image",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
sendImage(client, args[0])
},
}
var sendVideoCmd = &cobra.Command{
Use: "video <file_path>",
Short: "Send a video",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
sendVideo(client, args[0])
},
}
var sendDocumentCmd = &cobra.Command{
Use: "document <file_path>",
Short: "Send a document",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
client := NewClient(cliConfig)
sendDocument(client, args[0])
},
}
func init() {
sendCmd.AddCommand(sendTextCmd)
sendCmd.AddCommand(sendImageCmd)
sendCmd.AddCommand(sendVideoCmd)
sendCmd.AddCommand(sendDocumentCmd)
}
func sendMessage(client *Client) {
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
fmt.Print("Account ID: ")
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): ")
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Message text: ")
reader := os.Stdin
buf := make([]byte, 1024)
n, err := reader.Read(buf)
if err != nil {
checkError(fmt.Errorf("error reading input: %v", err))
}
req.Text = string(buf[:n])
resp, err := client.Post("/api/send", req)
checkError(err)
defer resp.Body.Close()
fmt.Println("Message sent successfully")
}
func sendImage(client *Client, filePath string) {
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"`
}
fmt.Print("Account ID: ")
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read image file
imageData, err := os.ReadFile(filePath)
checkError(err)
// Encode to base64
req.ImageData = base64.StdEncoding.EncodeToString(imageData)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".jpg", ".jpeg":
req.MimeType = "image/jpeg"
case ".png":
req.MimeType = "image/png"
case ".gif":
req.MimeType = "image/gif"
case ".webp":
req.MimeType = "image/webp"
default:
req.MimeType = "image/jpeg"
}
resp, err := client.Post("/api/send/image", req)
checkError(err)
defer resp.Body.Close()
fmt.Println("Image sent successfully")
}
func sendVideo(client *Client, filePath string) {
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"`
}
fmt.Print("Account ID: ")
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read video file
videoData, err := os.ReadFile(filePath)
checkError(err)
// Encode to base64
req.VideoData = base64.StdEncoding.EncodeToString(videoData)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".mp4":
req.MimeType = "video/mp4"
case ".mov":
req.MimeType = "video/quicktime"
case ".avi":
req.MimeType = "video/x-msvideo"
case ".webm":
req.MimeType = "video/webm"
case ".3gp":
req.MimeType = "video/3gpp"
default:
req.MimeType = "video/mp4"
}
resp, err := client.Post("/api/send/video", req)
checkError(err)
defer resp.Body.Close()
fmt.Println("Video sent successfully")
}
func sendDocument(client *Client, filePath string) {
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"`
}
fmt.Print("Account ID: ")
if _, err := fmt.Scanln(&req.AccountID); err != nil {
checkError(fmt.Errorf("error reading account ID: %v", err))
}
fmt.Print("Recipient (phone number): ")
if _, err := fmt.Scanln(&req.To); err != nil {
checkError(fmt.Errorf("error reading recipient: %v", err))
}
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read document file
documentData, err := os.ReadFile(filePath)
checkError(err)
// Encode to base64
req.DocumentData = base64.StdEncoding.EncodeToString(documentData)
// Use the original filename
req.Filename = filepath.Base(filePath)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".pdf":
req.MimeType = "application/pdf"
case ".doc":
req.MimeType = "application/msword"
case ".docx":
req.MimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
req.MimeType = "application/vnd.ms-excel"
case ".xlsx":
req.MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".txt":
req.MimeType = "text/plain"
case ".zip":
req.MimeType = "application/zip"
default:
req.MimeType = "application/octet-stream"
}
resp, err := client.Post("/api/send/document", req)
checkError(err)
defer resp.Body.Close()
fmt.Println("Document sent successfully")
}

View File

@@ -10,6 +10,9 @@ import (
// CLIConfig holds the CLI configuration
type CLIConfig struct {
ServerURL string
AuthKey string
Username string
Password string
}
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
@@ -18,6 +21,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
// Set defaults
v.SetDefault("server_url", "http://localhost:8080")
v.SetDefault("auth_key", "")
v.SetDefault("username", "")
v.SetDefault("password", "")
// 1. Load from config file (lowest priority)
if configFile != "" {
@@ -50,6 +56,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
cfg := &CLIConfig{
ServerURL: v.GetString("server_url"),
AuthKey: v.GetString("auth_key"),
Username: v.GetString("username"),
Password: v.GetString("password"),
}
return cfg, nil

View File

@@ -1,17 +1,9 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"git.warky.dev/wdevs/whatshooked/internal/config"
"github.com/spf13/cobra"
)
@@ -46,584 +38,9 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.whatshooked/cli.json)")
rootCmd.PersistentFlags().StringVar(&serverURL, "server", "", "server URL (default: http://localhost:8080)")
// Add all command groups
rootCmd.AddCommand(healthCmd)
rootCmd.AddCommand(hooksCmd)
rootCmd.AddCommand(accountsCmd)
rootCmd.AddCommand(sendCmd)
}
// Health command
var healthCmd = &cobra.Command{
Use: "health",
Short: "Check server health",
Run: func(cmd *cobra.Command, args []string) {
checkHealth(cliConfig.ServerURL)
},
}
// Hooks command group
var hooksCmd = &cobra.Command{
Use: "hooks",
Short: "Manage webhooks",
Run: func(cmd *cobra.Command, args []string) {
listHooks(cliConfig.ServerURL)
},
}
var hooksListCmd = &cobra.Command{
Use: "list",
Short: "List all hooks",
Run: func(cmd *cobra.Command, args []string) {
listHooks(cliConfig.ServerURL)
},
}
var hooksAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new hook",
Run: func(cmd *cobra.Command, args []string) {
addHook(cliConfig.ServerURL)
},
}
var hooksRemoveCmd = &cobra.Command{
Use: "remove <hook_id>",
Short: "Remove a hook",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
removeHook(cliConfig.ServerURL, args[0])
},
}
func init() {
hooksCmd.AddCommand(hooksListCmd)
hooksCmd.AddCommand(hooksAddCmd)
hooksCmd.AddCommand(hooksRemoveCmd)
}
// Accounts command group
var accountsCmd = &cobra.Command{
Use: "accounts",
Short: "Manage WhatsApp accounts",
Run: func(cmd *cobra.Command, args []string) {
listAccounts(cliConfig.ServerURL)
},
}
var accountsListCmd = &cobra.Command{
Use: "list",
Short: "List all accounts",
Run: func(cmd *cobra.Command, args []string) {
listAccounts(cliConfig.ServerURL)
},
}
var accountsAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new WhatsApp account",
Run: func(cmd *cobra.Command, args []string) {
addAccount(cliConfig.ServerURL)
},
}
func init() {
accountsCmd.AddCommand(accountsListCmd)
accountsCmd.AddCommand(accountsAddCmd)
}
// Send command group
var sendCmd = &cobra.Command{
Use: "send",
Short: "Send messages",
}
var sendTextCmd = &cobra.Command{
Use: "text",
Short: "Send a text message",
Run: func(cmd *cobra.Command, args []string) {
sendMessage(cliConfig.ServerURL)
},
}
var sendImageCmd = &cobra.Command{
Use: "image <file_path>",
Short: "Send an image",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
sendImage(cliConfig.ServerURL, args[0])
},
}
var sendVideoCmd = &cobra.Command{
Use: "video <file_path>",
Short: "Send a video",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
sendVideo(cliConfig.ServerURL, args[0])
},
}
var sendDocumentCmd = &cobra.Command{
Use: "document <file_path>",
Short: "Send a document",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
sendDocument(cliConfig.ServerURL, args[0])
},
}
func init() {
sendCmd.AddCommand(sendTextCmd)
sendCmd.AddCommand(sendImageCmd)
sendCmd.AddCommand(sendVideoCmd)
sendCmd.AddCommand(sendDocumentCmd)
}
// Helper functions
func checkHealth(serverURL string) {
resp, err := http.Get(serverURL + "/health")
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("Error decoding response: %v\n", err)
os.Exit(1)
}
fmt.Printf("Server status: %s\n", result["status"])
}
func listHooks(serverURL string) {
resp, err := http.Get(serverURL + "/api/hooks")
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
var hooks []config.Hook
if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil {
fmt.Printf("Error decoding response: %v\n", err)
os.Exit(1)
}
if len(hooks) == 0 {
fmt.Println("No hooks configured")
return
}
fmt.Printf("Configured hooks (%d):\n\n", len(hooks))
for _, hook := range hooks {
status := "inactive"
if hook.Active {
status = "active"
}
fmt.Printf("ID: %s\n", hook.ID)
fmt.Printf("Name: %s\n", hook.Name)
fmt.Printf("URL: %s\n", hook.URL)
fmt.Printf("Method: %s\n", hook.Method)
fmt.Printf("Status: %s\n", status)
if hook.Description != "" {
fmt.Printf("Description: %s\n", hook.Description)
}
fmt.Println()
}
}
func addHook(serverURL string) {
var hook config.Hook
fmt.Print("Hook ID: ")
fmt.Scanln(&hook.ID)
fmt.Print("Hook Name: ")
fmt.Scanln(&hook.Name)
fmt.Print("Webhook URL: ")
fmt.Scanln(&hook.URL)
fmt.Print("HTTP Method (POST): ")
fmt.Scanln(&hook.Method)
if hook.Method == "" {
hook.Method = "POST"
}
fmt.Print("Description (optional): ")
fmt.Scanln(&hook.Description)
hook.Active = true
data, err := json.Marshal(hook)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/hooks/add", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Hook added successfully")
}
func removeHook(serverURL string, id string) {
req := map[string]string{"id": id}
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/hooks/remove", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Hook removed successfully")
}
func listAccounts(serverURL string) {
resp, err := http.Get(serverURL + "/api/accounts")
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
var accounts []config.WhatsAppConfig
if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
fmt.Printf("Error decoding response: %v\n", err)
os.Exit(1)
}
if len(accounts) == 0 {
fmt.Println("No accounts configured")
return
}
fmt.Printf("Configured accounts (%d):\n\n", len(accounts))
for _, acc := range accounts {
fmt.Printf("ID: %s\n", acc.ID)
fmt.Printf("Phone Number: %s\n", acc.PhoneNumber)
fmt.Printf("Session Path: %s\n", acc.SessionPath)
fmt.Println()
}
}
func addAccount(serverURL string) {
var account config.WhatsAppConfig
fmt.Print("Account ID: ")
fmt.Scanln(&account.ID)
fmt.Print("Phone Number (with country code): ")
fmt.Scanln(&account.PhoneNumber)
fmt.Print("Session Path: ")
fmt.Scanln(&account.SessionPath)
data, err := json.Marshal(account)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/accounts/add", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Account added successfully")
fmt.Println("Check server logs for QR code to pair the device")
}
func sendMessage(serverURL string) {
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
fmt.Print("Recipient (phone number or JID, e.g., 0834606792 or 1234567890@s.whatsapp.net): ")
fmt.Scanln(&req.To)
fmt.Print("Message text: ")
reader := os.Stdin
buf := make([]byte, 1024)
n, err := reader.Read(buf)
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
os.Exit(1)
}
req.Text = string(buf[:n])
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/send", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Message sent successfully")
}
func sendImage(serverURL string, filePath string) {
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"`
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read image file
imageData, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf("Error reading image file: %v\n", err)
os.Exit(1)
}
// Encode to base64
req.ImageData = base64.StdEncoding.EncodeToString(imageData)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".jpg", ".jpeg":
req.MimeType = "image/jpeg"
case ".png":
req.MimeType = "image/png"
case ".gif":
req.MimeType = "image/gif"
case ".webp":
req.MimeType = "image/webp"
default:
req.MimeType = "image/jpeg"
}
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/send/image", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Image sent successfully")
}
func sendVideo(serverURL string, filePath string) {
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"`
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read video file
videoData, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf("Error reading video file: %v\n", err)
os.Exit(1)
}
// Encode to base64
req.VideoData = base64.StdEncoding.EncodeToString(videoData)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".mp4":
req.MimeType = "video/mp4"
case ".mov":
req.MimeType = "video/quicktime"
case ".avi":
req.MimeType = "video/x-msvideo"
case ".webm":
req.MimeType = "video/webm"
case ".3gp":
req.MimeType = "video/3gpp"
default:
req.MimeType = "video/mp4"
}
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/send/video", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Video sent successfully")
}
func sendDocument(serverURL string, filePath string) {
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"`
}
fmt.Print("Account ID: ")
fmt.Scanln(&req.AccountID)
fmt.Print("Recipient (phone number): ")
fmt.Scanln(&req.To)
fmt.Print("Caption (optional): ")
reader := os.Stdin
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
req.Caption = strings.TrimSpace(string(buf[:n]))
// Read document file
documentData, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf("Error reading document file: %v\n", err)
os.Exit(1)
}
// Encode to base64
req.DocumentData = base64.StdEncoding.EncodeToString(documentData)
// Use the original filename
req.Filename = filepath.Base(filePath)
// Detect mime type from extension
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".pdf":
req.MimeType = "application/pdf"
case ".doc":
req.MimeType = "application/msword"
case ".docx":
req.MimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
req.MimeType = "application/vnd.ms-excel"
case ".xlsx":
req.MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".txt":
req.MimeType = "text/plain"
case ".zip":
req.MimeType = "application/zip"
default:
req.MimeType = "application/octet-stream"
}
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
resp, err := http.Post(serverURL+"/api/send/document", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Error: %s\n", string(body))
os.Exit(1)
}
fmt.Println("Document sent successfully")
}

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

@@ -0,0 +1,6 @@
{
"server_url": "http://localhost:8080",
"auth_key": "",
"username": "",
"password": ""
}

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

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
version: '3.8'
services:
whatshooked:
build:
context: .
dockerfile: Dockerfile
container_name: whatshooked-server
ports:
- "8080:8080"
volumes:
# Mount config file
- ./bin/config.json:/app/config.json:ro
# Mount sessions directory for WhatsApp authentication persistence
- ./bin/sessions:/app/sessions
# Mount media directory for storing downloaded media files
- ./bin/data/media:/app/data/media
restart: unless-stopped
# Health check
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Environment variables (optional - can override config.json settings)
# environment:
# - LOG_LEVEL=info
# Use host network mode if you need QR code scanning via terminal
# network_mode: "host"
# Resource limits (optional)
# deploy:
# resources:
# limits:
# cpus: '1.0'
# memory: 512M
# reservations:
# cpus: '0.25'
# memory: 128M
# Optional: Add networks for more complex setups
# networks:
# whatshooked-network:
# driver: bridge

16
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,27 +23,27 @@ 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
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vektah/gqlparser/v2 v2.5.31 // indirect
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/exp v0.0.0-20251219203646-944ab1f22d93 // 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
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

22
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=
@@ -57,12 +63,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -78,8 +82,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
@@ -90,10 +94,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/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
}

394
pkg/cache/message_cache.go vendored Normal file
View File

@@ -0,0 +1,394 @@
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// CachedEvent represents an event stored in cache
type CachedEvent struct {
ID string `json:"id"`
Event events.Event `json:"event"`
Timestamp time.Time `json:"timestamp"`
Reason string `json:"reason"`
Attempts int `json:"attempts"`
LastAttempt *time.Time `json:"last_attempt,omitempty"`
}
// MessageCache manages cached events when no webhooks are available
type MessageCache struct {
events map[string]*CachedEvent
mu sync.RWMutex
dataPath string
enabled bool
maxAge time.Duration // Maximum age before events are purged
maxEvents int // Maximum number of events to keep
}
// Config holds cache configuration
type Config struct {
Enabled bool `json:"enabled"`
DataPath string `json:"data_path"`
MaxAge time.Duration `json:"max_age"` // Default: 7 days
MaxEvents int `json:"max_events"` // Default: 10000
}
// NewMessageCache creates a new message cache
func NewMessageCache(cfg Config) (*MessageCache, error) {
if !cfg.Enabled {
return &MessageCache{
enabled: false,
}, nil
}
if cfg.DataPath == "" {
cfg.DataPath = "./data/cache"
}
if cfg.MaxAge == 0 {
cfg.MaxAge = 7 * 24 * time.Hour // 7 days
}
if cfg.MaxEvents == 0 {
cfg.MaxEvents = 10000
}
// Create cache directory
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
cache := &MessageCache{
events: make(map[string]*CachedEvent),
dataPath: cfg.DataPath,
enabled: true,
maxAge: cfg.MaxAge,
maxEvents: cfg.MaxEvents,
}
// Load existing cached events
if err := cache.loadFromDisk(); err != nil {
logging.Warn("Failed to load cached events from disk", "error", err)
}
// Start cleanup goroutine
go cache.cleanupLoop()
logging.Info("Message cache initialized",
"enabled", cfg.Enabled,
"data_path", cfg.DataPath,
"max_age", cfg.MaxAge,
"max_events", cfg.MaxEvents)
return cache, nil
}
// Store adds an event to the cache
func (c *MessageCache) Store(event events.Event, reason string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
// Check if we're at capacity
if len(c.events) >= c.maxEvents {
// Remove oldest event
c.removeOldest()
}
// Generate unique ID
id := fmt.Sprintf("%d-%s", time.Now().UnixNano(), event.Type)
cached := &CachedEvent{
ID: id,
Event: event,
Timestamp: time.Now(),
Reason: reason,
Attempts: 0,
}
c.events[id] = cached
// Save to disk asynchronously
go c.saveToDisk(cached)
logging.Debug("Event cached",
"event_id", id,
"event_type", event.Type,
"reason", reason,
"cache_size", len(c.events))
return nil
}
// Get retrieves a cached event by ID
func (c *MessageCache) Get(id string) (*CachedEvent, bool) {
if !c.enabled {
return nil, false
}
c.mu.RLock()
defer c.mu.RUnlock()
event, exists := c.events[id]
return event, exists
}
// List returns all cached events
func (c *MessageCache) List() []*CachedEvent {
if !c.enabled {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*CachedEvent, 0, len(c.events))
for _, event := range c.events {
result = append(result, event)
}
return result
}
// ListByEventType returns cached events filtered by event type
func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEvent {
if !c.enabled {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*CachedEvent, 0)
for _, cached := range c.events {
if cached.Event.Type == eventType {
result = append(result, cached)
}
}
return result
}
// Remove deletes an event from the cache
func (c *MessageCache) Remove(id string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.events[id]; !exists {
return fmt.Errorf("cached event not found: %s", id)
}
delete(c.events, id)
// Remove from disk
go c.removeFromDisk(id)
logging.Debug("Event removed from cache", "event_id", id)
return nil
}
// IncrementAttempts increments the delivery attempt counter
func (c *MessageCache) IncrementAttempts(id string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
cached, exists := c.events[id]
if !exists {
return fmt.Errorf("cached event not found: %s", id)
}
now := time.Now()
cached.Attempts++
cached.LastAttempt = &now
// Update on disk
go c.saveToDisk(cached)
return nil
}
// Clear removes all cached events
func (c *MessageCache) Clear() error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.events = make(map[string]*CachedEvent)
// Clear disk cache
go c.clearDisk()
logging.Info("Message cache cleared")
return nil
}
// Count returns the number of cached events
func (c *MessageCache) Count() int {
if !c.enabled {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.events)
}
// IsEnabled returns whether the cache is enabled
func (c *MessageCache) IsEnabled() bool {
return c.enabled
}
// removeOldest removes the oldest event from the cache
func (c *MessageCache) removeOldest() {
var oldestID string
var oldestTime time.Time
for id, cached := range c.events {
if oldestID == "" || cached.Timestamp.Before(oldestTime) {
oldestID = id
oldestTime = cached.Timestamp
}
}
if oldestID != "" {
delete(c.events, oldestID)
go c.removeFromDisk(oldestID)
logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID)
}
}
// cleanupLoop periodically removes expired events
func (c *MessageCache) cleanupLoop() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
c.cleanup()
}
}
// cleanup removes expired events
func (c *MessageCache) cleanup() {
if !c.enabled {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
expiredIDs := make([]string, 0)
for id, cached := range c.events {
if now.Sub(cached.Timestamp) > c.maxAge {
expiredIDs = append(expiredIDs, id)
}
}
for _, id := range expiredIDs {
delete(c.events, id)
go c.removeFromDisk(id)
}
if len(expiredIDs) > 0 {
logging.Info("Cleaned up expired cached events", "count", len(expiredIDs))
}
}
// saveToDisk saves a cached event to disk
func (c *MessageCache) saveToDisk(cached *CachedEvent) {
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID))
data, err := json.MarshalIndent(cached, "", " ")
if err != nil {
logging.Error("Failed to marshal cached event", "event_id", cached.ID, "error", err)
return
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
logging.Error("Failed to save cached event to disk", "event_id", cached.ID, "error", err)
}
}
// loadFromDisk loads all cached events from disk
func (c *MessageCache) loadFromDisk() error {
files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json"))
if err != nil {
return fmt.Errorf("failed to list cache files: %w", err)
}
loaded := 0
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
logging.Warn("Failed to read cache file", "file", file, "error", err)
continue
}
var cached CachedEvent
if err := json.Unmarshal(data, &cached); err != nil {
logging.Warn("Failed to unmarshal cache file", "file", file, "error", err)
continue
}
// Skip expired events
if time.Since(cached.Timestamp) > c.maxAge {
os.Remove(file)
continue
}
c.events[cached.ID] = &cached
loaded++
}
if loaded > 0 {
logging.Info("Loaded cached events from disk", "count", loaded)
}
return nil
}
// removeFromDisk removes a cached event file from disk
func (c *MessageCache) removeFromDisk(id string) {
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", id))
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
logging.Error("Failed to remove cached event from disk", "event_id", id, "error", err)
}
}
// clearDisk removes all cache files from disk
func (c *MessageCache) clearDisk() {
files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json"))
if err != nil {
logging.Error("Failed to list cache files for clearing", "error", err)
return
}
for _, file := range files {
if err := os.Remove(file); err != nil {
logging.Error("Failed to remove cache file", "file", file, "error", err)
}
}
}

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

@@ -0,0 +1,221 @@
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"`
MessageCache MessageCacheConfig `json:"message_cache,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"`
Disabled bool `json:"disabled,omitempty"` // If true, account won't be connected
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
}
// MessageCacheConfig holds message cache configuration
type MessageCacheConfig struct {
Enabled bool `json:"enabled"` // Enable message caching
DataPath string `json:"data_path,omitempty"` // Directory to store cached events
MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7)
MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000)
}
// 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"
}
}
// Set message cache defaults
if cfg.MessageCache.DataPath == "" {
cfg.MessageCache.DataPath = "./data/message_cache"
}
if cfg.MessageCache.MaxAgeDays == 0 {
cfg.MessageCache.MaxAgeDays = 7
}
if cfg.MessageCache.MaxEvents == 0 {
cfg.MessageCache.MaxEvents = 10000
}
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,122 @@
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, defaultCountryCode string) (*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":
logging.Info("Initializing MQTT event logger target", "broker", cfg.MQTT.Broker)
mqttTarget, err := NewMQTTTarget(cfg.MQTT, waManager, defaultCountryCode)
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,304 @@
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
defaultCountryCode string
}
// NewMQTTTarget creates a new MQTT target
func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager, defaultCountryCode string) (*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),
defaultCountryCode: defaultCountryCode,
}
// 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)
logging.Info("Starting MQTT subscription", "topic", topic, "qos", cfg.QoS)
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("Successfully subscribed to MQTT send topic", "topic", topic)
}
}
})
// Create and connect the client
client := mqtt.NewClient(opts)
logging.Info("Starting MQTT connection", "broker", cfg.Broker, "client_id", cfg.ClientID)
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"
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(sendReq.To, m.defaultCountryCode)
// Parse JID
jid, err := types.ParseJID(formattedJID)
if err != nil {
logging.Error("Failed to parse JID", "to", sendReq.To, "formatted", formattedJID, "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

@@ -79,6 +79,10 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
EventWhatsAppDisconnected,
EventWhatsAppPairSuccess,
EventWhatsAppPairFailed,
EventWhatsAppQRCode,
EventWhatsAppQRTimeout,
EventWhatsAppQRError,
EventWhatsAppPairEvent,
EventMessageReceived,
EventMessageSent,
EventMessageFailed,

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

@@ -0,0 +1,294 @@
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"})
}
// DisableAccount disables a WhatsApp account
func (h *Handlers) DisableAccount(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
}
// Find the account
found := false
for i := range h.config.WhatsApp {
if h.config.WhatsApp[i].ID == req.ID {
found = true
// Check if already disabled
if h.config.WhatsApp[i].Disabled {
logging.Info("Account already disabled", "account_id", req.ID)
writeJSON(w, map[string]string{"status": "ok", "message": "account already disabled"})
return
}
// Disconnect the account
if err := h.whatsappMgr.Disconnect(req.ID); err != nil {
logging.Warn("Failed to disconnect account during disable", "account_id", req.ID, "error", err)
// Continue with disabling even if disconnect fails
}
// Mark as disabled
h.config.WhatsApp[i].Disabled = true
logging.Info("Account disabled", "account_id", req.ID)
break
}
}
if !found {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Save config
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after disabling account", "account_id", req.ID, "error", err)
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
logging.Info("Config saved after disabling account", "account_id", req.ID)
}
writeJSON(w, map[string]string{"status": "ok", "account_id": req.ID})
}
// EnableAccount enables a WhatsApp account
func (h *Handlers) EnableAccount(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
}
// Find the account
var accountConfig *config.WhatsAppConfig
for i := range h.config.WhatsApp {
if h.config.WhatsApp[i].ID == req.ID {
accountConfig = &h.config.WhatsApp[i]
break
}
}
if accountConfig == nil {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Check if already enabled
if !accountConfig.Disabled {
logging.Info("Account already enabled", "account_id", req.ID)
writeJSON(w, map[string]string{"status": "ok", "message": "account already enabled"})
return
}
// Mark as enabled
accountConfig.Disabled = false
// Connect the account
if err := h.whatsappMgr.Connect(context.Background(), *accountConfig); err != nil {
logging.Error("Failed to connect account during enable", "account_id", req.ID, "error", err)
// Revert the disabled flag
accountConfig.Disabled = true
http.Error(w, "Failed to connect account: "+err.Error(), http.StatusInternalServerError)
return
}
logging.Info("Account enabled and connected", "account_id", req.ID)
// Save config
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after enabling account", "account_id", req.ID, "error", err)
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
logging.Info("Config saved after enabling account", "account_id", req.ID)
}
writeJSON(w, map[string]string{"status": "ok", "account_id": req.ID})
}
// UpdateAccount updates an existing WhatsApp account configuration
func (h *Handlers) UpdateAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var updates config.WhatsAppConfig
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if updates.ID == "" {
http.Error(w, "Account ID is required", http.StatusBadRequest)
return
}
// Find the account
found := false
var oldConfig config.WhatsAppConfig
for i := range h.config.WhatsApp {
if h.config.WhatsApp[i].ID == updates.ID {
found = true
oldConfig = h.config.WhatsApp[i]
// Update fields (preserve ID and Type)
updates.ID = oldConfig.ID
if updates.Type == "" {
updates.Type = oldConfig.Type
}
h.config.WhatsApp[i] = updates
logging.Info("Account configuration updated", "account_id", updates.ID)
break
}
}
if !found {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// If the account was enabled and settings changed, reconnect it
if !updates.Disabled {
// Disconnect old connection
if err := h.whatsappMgr.Disconnect(updates.ID); err != nil {
logging.Warn("Failed to disconnect account during update", "account_id", updates.ID, "error", err)
}
// Reconnect with new settings
if err := h.whatsappMgr.Connect(context.Background(), updates); err != nil {
logging.Error("Failed to reconnect account after update", "account_id", updates.ID, "error", err)
http.Error(w, "Failed to reconnect account: "+err.Error(), http.StatusInternalServerError)
return
}
logging.Info("Account reconnected with new settings", "account_id", updates.ID)
}
// Save config
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config after updating account", "account_id", updates.ID, "error", err)
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
logging.Info("Config saved after updating account", "account_id", updates.ID)
}
writeJSON(w, map[string]string{"status": "ok", "account_id": updates.ID})
}

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

@@ -0,0 +1,162 @@
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) {
accountID := extractAccountIDFromPath(r.URL.Path)
if r.Method == http.MethodGet {
logging.Info("WhatsApp webhook verification request", "account_id", accountID, "method", "GET")
h.businessAPIWebhookVerify(w, r)
return
}
if r.Method == http.MethodPost {
logging.Info("WhatsApp webhook event received", "account_id", accountID, "method", "POST")
h.businessAPIWebhookEvent(w, r)
return
}
logging.Warn("WhatsApp webhook invalid method", "account_id", accountID, "method", r.Method)
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 == "" {
logging.Warn("WhatsApp webhook verification missing account ID")
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 == "" {
logging.Warn("WhatsApp webhook event missing account ID")
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
logging.Info("WhatsApp webhook processing started", "account_id", accountID)
// 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
}
logging.Info("WhatsApp webhook processed successfully", "account_id", accountID)
// 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 ""
}

254
pkg/handlers/cache.go Normal file
View File

@@ -0,0 +1,254 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// GetCachedEvents returns all cached events
// GET /api/cache
func (h *Handlers) GetCachedEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Optional event_type filter
eventType := r.URL.Query().Get("event_type")
var cachedEvents interface{}
if eventType != "" {
cachedEvents = cache.ListByEventType(events.EventType(eventType))
} else {
cachedEvents = cache.List()
}
writeJSON(w, map[string]interface{}{
"cached_events": cachedEvents,
"count": cache.Count(),
})
}
// GetCachedEvent returns a specific cached event by ID
// GET /api/cache/{id}
func (h *Handlers) GetCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Extract ID from path
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
cached, exists := cache.Get(id)
if !exists {
http.Error(w, "Cached event not found", http.StatusNotFound)
return
}
writeJSON(w, cached)
}
// ReplayCachedEvents replays all cached events
// POST /api/cache/replay
func (h *Handlers) ReplayCachedEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
logging.Info("Replaying all cached events via API")
successCount, failCount, err := h.hookMgr.ReplayCachedEvents()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"replayed": successCount + failCount,
"delivered": successCount,
"failed": failCount,
"remaining_cached": cache.Count(),
})
}
// ReplayCachedEvent replays a specific cached event
// POST /api/cache/replay/{id}
func (h *Handlers) ReplayCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Extract ID from request body or query param
var req struct {
ID string `json:"id"`
}
// Try query param first
id := r.URL.Query().Get("id")
if id == "" {
// Try JSON body
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
id = req.ID
}
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
logging.Info("Replaying cached event via API", "event_id", id)
if err := h.hookMgr.ReplayCachedEvent(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"event_id": id,
"message": "Event replayed successfully",
})
}
// DeleteCachedEvent removes a specific cached event
// DELETE /api/cache/{id}
func (h *Handlers) DeleteCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
logging.Info("Deleting cached event via API", "event_id", id)
if err := cache.Remove(id); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"event_id": id,
"message": "Cached event deleted successfully",
})
}
// ClearCache removes all cached events
// DELETE /api/cache
func (h *Handlers) ClearCache(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Optional confirmation parameter
confirm := r.URL.Query().Get("confirm")
confirmInt, _ := strconv.ParseBool(confirm)
if !confirmInt {
http.Error(w, "Add ?confirm=true to confirm cache clearing", http.StatusBadRequest)
return
}
count := cache.Count()
logging.Warn("Clearing all cached events via API", "count", count)
if err := cache.Clear(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"cleared": count,
"message": "Cache cleared successfully",
})
}
// GetCacheStats returns cache statistics
// GET /api/cache/stats
func (h *Handlers) GetCacheStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
writeJSON(w, map[string]interface{}{
"enabled": false,
"count": 0,
})
return
}
// Group by event type
cachedEvents := cache.List()
eventTypeCounts := make(map[string]int)
for _, cached := range cachedEvents {
eventTypeCounts[string(cached.Event.Type)]++
}
writeJSON(w, map[string]interface{}{
"enabled": true,
"total_count": cache.Count(),
"by_event_type": eventTypeCounts,
})
}

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"})
}

66
pkg/handlers/static.go Normal file
View File

@@ -0,0 +1,66 @@
package handlers
import (
"embed"
"net/http"
"path/filepath"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
//go:embed static/*
var staticFiles embed.FS
// ServeIndex serves the landing page
func (h *Handlers) ServeIndex(w http.ResponseWriter, r *http.Request) {
// Only serve index on root path
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
content, err := staticFiles.ReadFile("static/index.html")
if err != nil {
logging.Error("Failed to read index.html", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
writeBytes(w, content)
}
// ServeStatic serves static files (logo, etc.)
func (h *Handlers) ServeStatic(w http.ResponseWriter, r *http.Request) {
// Get the file path from URL
filename := filepath.Base(r.URL.Path)
filePath := filepath.Join("static", filename)
content, err := staticFiles.ReadFile(filePath)
if err != nil {
logging.Error("Failed to read static file", "path", filePath, "error", err)
http.NotFound(w, r)
return
}
// Set content type based on file extension
ext := filepath.Ext(filePath)
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".css":
w.Header().Set("Content-Type", "text/css")
case ".js":
w.Header().Set("Content-Type", "application/javascript")
}
// Cache static assets for 1 hour
w.Header().Set("Cache-Control", "public, max-age=3600")
w.WriteHeader(http.StatusOK)
writeBytes(w, content)
}

View File

@@ -0,0 +1,82 @@
# Static Files
This directory contains the embedded static files for the WhatsHooked landing page.
## Files
- `index.html` - Landing page with API documentation
- `logo.png` - WhatsHooked logo (from `assets/image/whatshooked_tp.png`)
## How It Works
These files are embedded into the Go binary using `go:embed` directive in `static.go`.
When you build the server:
```bash
go build ./cmd/server/
```
The files in this directory are compiled directly into the binary, so the server can run without any external files.
## Updating the Landing Page
1. **Edit the HTML:**
```bash
vim pkg/handlers/static/index.html
```
2. **Rebuild the server:**
```bash
go build ./cmd/server/
```
3. **Restart the server:**
```bash
./server -config bin/config.json
```
The changes will be embedded in the new binary.
## Updating the Logo
1. **Replace the logo:**
```bash
cp path/to/new-logo.png pkg/handlers/static/logo.png
```
2. **Rebuild:**
```bash
go build ./cmd/server/
```
## Routes
- `GET /` - Serves `index.html`
- `GET /static/logo.png` - Serves `logo.png`
- `GET /static/*` - Serves any file in this directory
## Development Tips
- Files are cached with `Cache-Control: public, max-age=3600` (1 hour)
- Force refresh in browser: `Ctrl+Shift+R` or `Cmd+Shift+R`
- Changes require rebuild - no hot reload
- Keep files small - they're embedded in the binary
## File Structure
```
pkg/handlers/
├── static.go # Handler with go:embed directive
├── static/
│ ├── index.html # Landing page
│ ├── logo.png # Logo image
│ └── README.md # This file
```
## Benefits of Embedded Files
**Single binary deployment** - No external dependencies
**Fast serving** - Files loaded from memory
**No file system access** - Works in restricted environments
**Portable** - Binary includes everything
**Version controlled** - Static assets tracked with code

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsHooked - WhatsApp Webhook Bridge</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #333;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 800px;
width: 100%;
padding: 60px 40px;
text-align: center;
}
.logo-container {
margin-bottom: 40px;
animation: fadeInDown 0.8s ease;
}
.logo {
width: 200px;
height: 200px;
margin: 0 auto 20px;
}
.logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
h1 {
font-size: 3em;
margin-bottom: 20px;
animation: fadeInDown 0.8s ease 0.2s both;
}
h1 .whats {
color: #1e88e5;
}
h1 .hooked {
color: #1a237e;
}
.tagline {
font-size: 1.3em;
color: #666;
margin-bottom: 40px;
animation: fadeInUp 0.8s ease 0.4s both;
}
.status {
background: #e8f5e9;
border: 2px solid #4caf50;
border-radius: 10px;
padding: 20px;
margin-bottom: 40px;
animation: fadeInUp 0.8s ease 0.6s both;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
background: #4caf50;
border-radius: 50%;
margin-right: 10px;
animation: pulse 2s infinite;
}
.status-text {
color: #2e7d32;
font-weight: 600;
font-size: 1.1em;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
animation: fadeInUp 0.8s ease 0.8s both;
}
.feature {
background: #f5f5f5;
padding: 20px;
border-radius: 10px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.feature:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.feature-icon {
font-size: 2em;
margin-bottom: 10px;
}
.feature-title {
font-weight: 600;
margin-bottom: 8px;
color: #1a237e;
}
.feature-text {
font-size: 0.9em;
color: #666;
}
.endpoints {
background: #f8f9fa;
border-radius: 10px;
padding: 30px;
text-align: left;
animation: fadeInUp 0.8s ease 1s both;
}
.endpoints h2 {
color: #1a237e;
margin-bottom: 20px;
text-align: center;
}
.endpoint-group {
margin-bottom: 20px;
}
.endpoint-group h3 {
color: #1e88e5;
font-size: 1em;
margin-bottom: 10px;
}
.endpoint {
background: white;
padding: 12px 15px;
margin-bottom: 8px;
border-radius: 6px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 10px;
}
.endpoint-method {
background: #1e88e5;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
font-size: 0.85em;
min-width: 60px;
text-align: center;
}
.endpoint-method.post {
background: #4caf50;
}
.endpoint-method.delete {
background: #f44336;
}
.endpoint-path {
color: #666;
}
.footer {
margin-top: 40px;
padding-top: 30px;
border-top: 2px solid #eee;
color: #999;
font-size: 0.9em;
animation: fadeIn 0.8s ease 1.2s both;
}
.footer a {
color: #1e88e5;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@media (max-width: 600px) {
.container {
padding: 40px 20px;
}
h1 {
font-size: 2em;
}
.tagline {
font-size: 1.1em;
}
.logo {
width: 150px;
height: 150px;
}
.endpoint {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<div class="logo-container">
<div class="logo">
<img src="/static/logo.png" alt="WhatsHooked Logo">
</div>
<h1><span class="whats">Whats</span><span class="hooked">Hooked</span></h1>
<p class="tagline">Bridge your WhatsApp messages to webhooks</p>
</div>
<div class="status">
<span class="status-indicator"></span>
<span class="status-text">Server is running</span>
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">📱</div>
<div class="feature-title">WhatsApp Integration</div>
<div class="feature-text">Connect via WhatsApp Web or Business API</div>
</div>
<div class="feature">
<div class="feature-icon">🔗</div>
<div class="feature-title">Webhook Bridge</div>
<div class="feature-text">Forward messages to your custom endpoints</div>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-title">Real-time Events</div>
<div class="feature-text">Instant message delivery and status updates</div>
</div>
<div class="feature">
<div class="feature-icon">💾</div>
<div class="feature-title">Message Cache</div>
<div class="feature-text">Never lose messages with persistent storage</div>
</div>
</div>
<div class="endpoints">
<h2>Available API Endpoints</h2>
<div class="endpoint-group">
<h3>📊 Status & Health</h3>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/health</span>
</div>
</div>
<div class="endpoint-group">
<h3>🔌 Webhooks</h3>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/hooks</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/hooks/add</span>
</div>
<div class="endpoint">
<span class="endpoint-method delete">DELETE</span>
<span class="endpoint-path">/api/hooks/remove</span>
</div>
</div>
<div class="endpoint-group">
<h3>👤 Accounts</h3>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/accounts</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/accounts/add</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/accounts/update</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/accounts/disable</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/accounts/enable</span>
</div>
<div class="endpoint">
<span class="endpoint-method delete">DELETE</span>
<span class="endpoint-path">/api/accounts/remove</span>
</div>
</div>
<div class="endpoint-group">
<h3>💬 Send Messages</h3>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/send</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/send/image</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/send/video</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/send/document</span>
</div>
</div>
<div class="endpoint-group">
<h3>💾 Message Cache</h3>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/cache</span>
</div>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/cache/stats</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/api/cache/replay</span>
</div>
</div>
<div class="endpoint-group">
<h3>🔔 WhatsApp Business API</h3>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/webhooks/whatsapp/{account_id}</span>
</div>
<div class="endpoint">
<span class="endpoint-method post">POST</span>
<span class="endpoint-path">/webhooks/whatsapp/{account_id}</span>
</div>
</div>
</div>
<div class="footer">
<p>Need help? Check out the <a href="https://git.warky.dev/wdevs/whatshooked" target="_blank">documentation</a></p>
<p style="margin-top: 10px;">Made with ❤️ for developers</p>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -11,9 +11,10 @@ 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/cache"
"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
@@ -54,13 +55,15 @@ type Manager struct {
mu sync.RWMutex
client *http.Client
eventBus *events.EventBus
cache *cache.MessageCache
}
// NewManager creates a new hook manager
func NewManager(eventBus *events.EventBus) *Manager {
func NewManager(eventBus *events.EventBus, messageCache *cache.MessageCache) *Manager {
return &Manager{
hooks: make(map[string]config.Hook),
eventBus: eventBus,
cache: messageCache,
client: &http.Client{
Timeout: 30 * time.Second,
},
@@ -90,20 +93,26 @@ func (m *Manager) Start() {
for _, eventType := range allEventTypes {
m.eventBus.Subscribe(eventType, m.handleEvent)
}
logging.Info("Hook manager started and subscribed to events", "event_types", len(allEventTypes))
}
// handleEvent processes any event and triggers relevant hooks
func (m *Manager) handleEvent(event events.Event) {
logging.Debug("Hook manager received event", "event_type", event.Type)
// Get hooks that are subscribed to this event type
m.mu.RLock()
relevantHooks := make([]config.Hook, 0)
for _, hook := range m.hooks {
if !hook.Active {
logging.Debug("Skipping inactive hook", "hook_id", hook.ID)
continue
}
// If hook has no events specified, subscribe to all events
if len(hook.Events) == 0 {
logging.Debug("Hook subscribes to all events", "hook_id", hook.ID)
relevantHooks = append(relevantHooks, hook)
continue
}
@@ -112,6 +121,7 @@ func (m *Manager) handleEvent(event events.Event) {
eventTypeStr := string(event.Type)
for _, subscribedEvent := range hook.Events {
if subscribedEvent == eventTypeStr {
logging.Debug("Hook matches event", "hook_id", hook.ID, "event_type", eventTypeStr)
relevantHooks = append(relevantHooks, hook)
break
}
@@ -119,14 +129,50 @@ func (m *Manager) handleEvent(event events.Event) {
}
m.mu.RUnlock()
logging.Debug("Found relevant hooks for event", "event_type", event.Type, "hook_count", len(relevantHooks))
// If no relevant hooks found, cache the event
if len(relevantHooks) == 0 {
if m.cache != nil && m.cache.IsEnabled() {
reason := fmt.Sprintf("No active webhooks configured for event type: %s", event.Type)
if err := m.cache.Store(event, reason); err != nil {
logging.Error("Failed to cache event", "event_type", event.Type, "error", err)
} else {
logging.Info("Event cached due to no active webhooks",
"event_type", event.Type,
"cache_size", m.cache.Count())
}
} else {
logging.Warn("No active webhooks for event and caching is disabled",
"event_type", event.Type)
}
return
}
// Trigger each relevant hook
if len(relevantHooks) > 0 {
m.triggerHooksForEvent(event, relevantHooks)
success := m.triggerHooksForEvent(event, relevantHooks)
// If event was successfully delivered and it was previously cached, remove it from cache
if success && m.cache != nil && m.cache.IsEnabled() {
// Try to find and remove this event from cache
// (This handles the case where a cached event is being replayed)
cachedEvents := m.cache.List()
for _, cached := range cachedEvents {
if cached.Event.Type == event.Type &&
cached.Event.Timestamp.Equal(event.Timestamp) {
if err := m.cache.Remove(cached.ID); err == nil {
logging.Info("Cached event successfully delivered and removed from cache",
"event_id", cached.ID,
"event_type", event.Type)
}
break
}
}
}
}
// triggerHooksForEvent sends event data to specific hooks
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) {
// triggerHooksForEvent sends event data to specific hooks and returns success status
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) bool {
ctx := event.Context
if ctx == nil {
ctx = context.Background()
@@ -175,14 +221,26 @@ func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook)
// Send to each hook with the event type
var wg sync.WaitGroup
successCount := 0
mu := sync.Mutex{}
for _, hook := range hooks {
wg.Add(1)
go func(h config.Hook, et events.EventType) {
defer wg.Done()
_ = m.sendToHook(ctx, h, payload, et)
resp := m.sendToHook(ctx, h, payload, et)
if resp != nil || ctx.Err() == nil {
// Count as success if we got a response or context is still valid
mu.Lock()
successCount++
mu.Unlock()
}
}(hook, event.Type)
}
wg.Wait()
// Return true if at least one hook was successfully triggered
return successCount > 0
}
// Helper functions to extract data from event map
@@ -265,17 +323,24 @@ func (m *Manager) ListHooks() []config.Hook {
// sendToHook sends any payload to a specific hook with explicit event type
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
if ctx == nil {
ctx = context.Background()
// Create a new context detached from the incoming context to prevent cancellation
// when the original HTTP request completes. Use a 30-second timeout to match client timeout.
hookCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Use the original context for event publishing (if available)
eventCtx := ctx
if eventCtx == nil {
eventCtx = context.Background()
}
// Publish hook triggered event
m.eventBus.Publish(events.HookTriggeredEvent(ctx, hook.ID, hook.Name, hook.URL, payload))
m.eventBus.Publish(events.HookTriggeredEvent(eventCtx, hook.ID, hook.Name, hook.URL, payload))
data, err := json.Marshal(payload)
if err != nil {
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
return nil
}
@@ -283,7 +348,7 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
parsedURL, err := url.Parse(hook.URL)
if err != nil {
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
return nil
}
@@ -311,10 +376,10 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
}
parsedURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, hook.Method, parsedURL.String(), bytes.NewReader(data))
req, err := http.NewRequestWithContext(hookCtx, hook.Method, parsedURL.String(), bytes.NewReader(data))
if err != nil {
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
return nil
}
@@ -328,14 +393,14 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
resp, err := m.client.Do(req)
if err != nil {
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
return nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
return nil
}
@@ -343,23 +408,97 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
body, err := io.ReadAll(resp.Body)
if err != nil {
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
return nil
}
if len(body) == 0 {
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, nil))
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, nil))
return nil
}
var hookResp HookResponse
if err := json.Unmarshal(body, &hookResp); err != nil {
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, string(body)))
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, string(body)))
return nil
}
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, hookResp))
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, hookResp))
return &hookResp
}
// ReplayCachedEvents attempts to replay all cached events
func (m *Manager) ReplayCachedEvents() (successCountResult int, failCountResult int, err error) {
if m.cache == nil || !m.cache.IsEnabled() {
return 0, 0, fmt.Errorf("message cache is not enabled")
}
cachedEvents := m.cache.List()
if len(cachedEvents) == 0 {
return 0, 0, nil
}
logging.Info("Replaying cached events", "count", len(cachedEvents))
successCount := 0
failCount := 0
for _, cached := range cachedEvents {
// Try to process the event again
m.handleEvent(cached.Event)
// Increment attempt counter
if err := m.cache.IncrementAttempts(cached.ID); err != nil {
logging.Error("Failed to increment attempt counter", "event_id", cached.ID, "error", err)
}
// Check if event was successfully delivered by seeing if it's still cached
// (handleEvent will remove it from cache if successfully delivered)
time.Sleep(100 * time.Millisecond) // Give time for async delivery
if _, exists := m.cache.Get(cached.ID); !exists {
successCount++
logging.Debug("Cached event successfully replayed", "event_id", cached.ID)
} else {
failCount++
}
}
logging.Info("Cached event replay complete",
"success", successCount,
"failed", failCount,
"remaining_cached", m.cache.Count())
return successCount, failCount, nil
}
// ReplayCachedEvent attempts to replay a single cached event by ID
func (m *Manager) ReplayCachedEvent(id string) error {
if m.cache == nil || !m.cache.IsEnabled() {
return fmt.Errorf("message cache is not enabled")
}
cached, exists := m.cache.Get(id)
if !exists {
return fmt.Errorf("cached event not found: %s", id)
}
logging.Info("Replaying cached event", "event_id", id, "event_type", cached.Event.Type)
// Process the event
m.handleEvent(cached.Event)
// Increment attempt counter
if err := m.cache.IncrementAttempts(id); err != nil {
logging.Error("Failed to increment attempt counter", "event_id", id, "error", err)
}
return nil
}
// GetCache returns the message cache (for external access)
func (m *Manager) GetCache() *cache.MessageCache {
return m.cache
}

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,542 @@
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 {
logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id)
// Step 1: Validate token and check permissions
tokenInfo, err := c.validateToken(ctx)
if err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("token validation failed: %w", err)
}
// Log token information
logging.Info("Access token validated",
"account_id", c.id,
"token_type", tokenInfo.Type,
"app", tokenInfo.Application,
"app_id", tokenInfo.AppID,
"expires", c.formatExpiry(tokenInfo.ExpiresAt),
"scopes", strings.Join(tokenInfo.Scopes, ", "))
// Check for required permissions
requiredScopes := []string{"whatsapp_business_management", "whatsapp_business_messaging"}
missingScopes := c.checkMissingScopes(tokenInfo.Scopes, requiredScopes)
if len(missingScopes) > 0 {
err := fmt.Errorf("token missing required permissions: %s", strings.Join(missingScopes, ", "))
logging.Error("Insufficient token permissions",
"account_id", c.id,
"missing_scopes", strings.Join(missingScopes, ", "),
"current_scopes", strings.Join(tokenInfo.Scopes, ", "))
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return err
}
// Step 2: Get phone number details
phoneDetails, err := c.getPhoneNumberDetails(ctx)
if err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("failed to get phone number details: %w", err)
}
// Log phone number information
logging.Info("Phone number details retrieved",
"account_id", c.id,
"phone_number_id", phoneDetails.ID,
"display_number", phoneDetails.DisplayPhoneNumber,
"verified_name", phoneDetails.VerifiedName,
"verification_status", phoneDetails.CodeVerificationStatus,
"quality_rating", phoneDetails.QualityRating,
"throughput_level", phoneDetails.Throughput.Level)
// Warn if phone number is not verified
if phoneDetails.CodeVerificationStatus != "VERIFIED" {
logging.Warn("Phone number is not verified - messaging capabilities may be limited",
"account_id", c.id,
"status", phoneDetails.CodeVerificationStatus)
}
// Step 3: Get business account details (if business_account_id is provided)
if c.config.BusinessAccountID != "" {
businessDetails, err := c.getBusinessAccountDetails(ctx)
if err != nil {
logging.Warn("Failed to get business account details (non-critical)",
"account_id", c.id,
"business_account_id", c.config.BusinessAccountID,
"error", err)
} else {
logging.Info("Business account details retrieved",
"account_id", c.id,
"business_account_id", businessDetails.ID,
"business_name", businessDetails.Name,
"timezone_id", businessDetails.TimezoneID)
}
}
c.mu.Lock()
c.connected = true
c.mu.Unlock()
logging.Info("Business API client connected successfully",
"account_id", c.id,
"phone", phoneDetails.DisplayPhoneNumber,
"verified_name", phoneDetails.VerifiedName)
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber))
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
}
// validateToken validates the access token and returns token information
func (c *Client) validateToken(ctx context.Context) (*TokenDebugData, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/debug_token?input_token=%s",
c.config.APIVersion,
c.config.AccessToken)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create token validation 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 validate token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token validation response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("token validation failed: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return nil, fmt.Errorf("token validation returned status %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenDebugResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token validation response: %w", err)
}
if !tokenResp.Data.IsValid {
return nil, fmt.Errorf("access token is invalid or expired")
}
return &tokenResp.Data, nil
}
// getPhoneNumberDetails retrieves details about the phone number
func (c *Client) getPhoneNumberDetails(ctx context.Context) (*PhoneNumberDetails, error) {
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 nil, fmt.Errorf("failed to create phone number details 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 phone number details: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read phone number details response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("API error: %s (code: %d, subcode: %d)",
errResp.Error.Message, errResp.Error.Code, errResp.Error.ErrorSubcode)
}
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var phoneDetails PhoneNumberDetails
if err := json.Unmarshal(body, &phoneDetails); err != nil {
return nil, fmt.Errorf("failed to parse phone number details: %w", err)
}
return &phoneDetails, nil
}
// getBusinessAccountDetails retrieves details about the business account
func (c *Client) getBusinessAccountDetails(ctx context.Context) (*BusinessAccountDetails, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion,
c.config.BusinessAccountID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create business account details 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 business account details: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read business account details response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var businessDetails BusinessAccountDetails
if err := json.Unmarshal(body, &businessDetails); err != nil {
return nil, fmt.Errorf("failed to parse business account details: %w", err)
}
return &businessDetails, nil
}
// checkMissingScopes checks which required scopes are missing from the token
func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []string) []string {
scopeMap := make(map[string]bool)
for _, scope := range currentScopes {
scopeMap[scope] = true
}
var missing []string
for _, required := range requiredScopes {
if !scopeMap[required] {
missing = append(missing, required)
}
}
return missing
}
// formatExpiry formats the expiry timestamp for logging
func (c *Client) formatExpiry(expiresAt int64) string {
if expiresAt == 0 {
return "never"
}
expiryTime := time.Unix(expiresAt, 0)
return expiryTime.Format("2006-01-02 15:04:05 MST")
}

View File

@@ -0,0 +1,449 @@
package businessapi
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"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)
}
logging.Info("Processing webhook payload",
"account_id", c.id,
"entries", len(payload.Entry))
// Process each entry
changeCount := 0
for _, entry := range payload.Entry {
changeCount += len(entry.Changes)
for i := range entry.Changes {
c.processChange(entry.Changes[i])
}
}
logging.Info("Webhook payload processed",
"account_id", c.id,
"entries", len(payload.Entry),
"changes", changeCount)
return nil
}
// processChange processes a webhook change
func (c *Client) processChange(change WebhookChange) {
ctx := context.Background()
logging.Info("Processing webhook change",
"account_id", c.id,
"field", change.Field,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
// Handle different field types
switch change.Field {
case "messages":
// Process messages
for i := range change.Value.Messages {
msg := change.Value.Messages[i]
c.processMessage(ctx, msg, change.Value.Contacts)
}
// Process statuses
for _, status := range change.Value.Statuses {
c.processStatus(ctx, status)
}
case "message_template_status_update":
// Log template status updates for visibility
logging.Info("Message template status update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "account_update":
// Log account updates
logging.Info("Account update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "phone_number_quality_update":
// Log quality updates
logging.Info("Phone number quality update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "phone_number_name_update":
// Log name updates
logging.Info("Phone number name update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "account_alerts":
// Log account alerts
logging.Warn("Account alert received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
default:
logging.Debug("Unknown webhook field type",
"account_id", c.id,
"field", change.Field)
}
}
// 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)
}
}
case "audio":
if msg.Audio != nil {
messageType = "audio"
mimeType = msg.Audio.MimeType
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Audio.ID)
if err != nil {
logging.Error("Failed to download audio", "account_id", c.id, "media_id", msg.Audio.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "sticker":
if msg.Sticker != nil {
messageType = "sticker"
mimeType = msg.Sticker.MimeType
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Sticker.ID)
if err != nil {
logging.Error("Failed to download sticker", "account_id", c.id, "media_id", msg.Sticker.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "location":
if msg.Location != nil {
messageType = "location"
// Format location as text
text = fmt.Sprintf("Location: %s (%s) - %.6f, %.6f",
msg.Location.Name, msg.Location.Address,
msg.Location.Latitude, msg.Location.Longitude)
}
case "contacts":
if len(msg.Contacts) > 0 {
messageType = "contacts"
// Format contacts as text
var contactNames []string
for i := range msg.Contacts {
contact := msg.Contacts[i]
contactNames = append(contactNames, contact.Name.FormattedName)
}
text = fmt.Sprintf("Shared %d contact(s): %s", len(msg.Contacts), strings.Join(contactNames, ", "))
}
case "interactive":
if msg.Interactive != nil {
messageType = "interactive"
switch msg.Interactive.Type {
case "button_reply":
if msg.Interactive.ButtonReply != nil {
text = msg.Interactive.ButtonReply.Title
}
case "list_reply":
if msg.Interactive.ListReply != nil {
text = msg.Interactive.ListReply.Title
}
case "nfm_reply":
if msg.Interactive.NfmReply != nil {
text = msg.Interactive.NfmReply.Body
}
}
}
case "button":
if msg.Button != nil {
messageType = "button"
text = msg.Button.Text
}
case "reaction":
if msg.Reaction != nil {
messageType = "reaction"
text = msg.Reaction.Emoji
}
case "order":
if msg.Order != nil {
messageType = "order"
text = fmt.Sprintf("Order with %d item(s): %s", len(msg.Order.ProductItems), msg.Order.Text)
}
case "system":
if msg.System != nil {
messageType = "system"
text = msg.System.Body
}
case "unknown":
// messageType = "unknown"
logging.Warn("Received unknown message type", "account_id", c.id, "message_id", msg.ID)
return
default:
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
return
}
// Publish message received event
logging.Info("Message received via WhatsApp",
"account_id", c.id,
"message_id", msg.ID,
"from", msg.From,
"type", messageType)
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.Info("Message status: sent", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
case "delivered":
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Info("Message status: delivered", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
case "read":
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Info("Message status: read", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
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/mp4": ".m4a",
"audio/ogg": ".ogg",
"audio/amr": ".amr",
"audio/opus": ".opus",
}
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,396 @@
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", "audio", "sticker", "location", "contacts", "interactive", "button", "order", "system", "unknown", "reaction"
Text *WebhookText `json:"text,omitempty"`
Image *WebhookMediaMessage `json:"image,omitempty"`
Video *WebhookMediaMessage `json:"video,omitempty"`
Document *WebhookDocumentMessage `json:"document,omitempty"`
Audio *WebhookMediaMessage `json:"audio,omitempty"`
Sticker *WebhookMediaMessage `json:"sticker,omitempty"`
Location *WebhookLocation `json:"location,omitempty"`
Contacts []WebhookContactCard `json:"contacts,omitempty"`
Interactive *WebhookInteractive `json:"interactive,omitempty"`
Button *WebhookButton `json:"button,omitempty"`
Reaction *WebhookReaction `json:"reaction,omitempty"`
Order *WebhookOrder `json:"order,omitempty"`
System *WebhookSystem `json:"system,omitempty"`
Context *WebhookContext `json:"context,omitempty"` // Reply context
Identity *WebhookIdentity `json:"identity,omitempty"`
Referral *WebhookReferral `json:"referral,omitempty"`
}
// 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"`
}
// WebhookLocation represents a location message
type WebhookLocation struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
}
// WebhookContactCard represents a contact card
type WebhookContactCard struct {
Addresses []WebhookContactAddress `json:"addresses,omitempty"`
Birthday string `json:"birthday,omitempty"`
Emails []WebhookContactEmail `json:"emails,omitempty"`
Name WebhookContactName `json:"name"`
Org WebhookContactOrg `json:"org,omitempty"`
Phones []WebhookContactPhone `json:"phones,omitempty"`
URLs []WebhookContactURL `json:"urls,omitempty"`
}
// WebhookContactAddress represents a contact address
type WebhookContactAddress struct {
City string `json:"city,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
State string `json:"state,omitempty"`
Street string `json:"street,omitempty"`
Type string `json:"type,omitempty"`
Zip string `json:"zip,omitempty"`
}
// WebhookContactEmail represents a contact email
type WebhookContactEmail struct {
Email string `json:"email,omitempty"`
Type string `json:"type,omitempty"`
}
// WebhookContactName represents a contact name
type WebhookContactName struct {
FormattedName string `json:"formatted_name"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
Suffix string `json:"suffix,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
// WebhookContactOrg represents a contact organization
type WebhookContactOrg struct {
Company string `json:"company,omitempty"`
Department string `json:"department,omitempty"`
Title string `json:"title,omitempty"`
}
// WebhookContactPhone represents a contact phone
type WebhookContactPhone struct {
Phone string `json:"phone,omitempty"`
Type string `json:"type,omitempty"`
WaID string `json:"wa_id,omitempty"`
}
// WebhookContactURL represents a contact URL
type WebhookContactURL struct {
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
}
// WebhookInteractive represents an interactive message response
type WebhookInteractive struct {
Type string `json:"type"` // "button_reply", "list_reply"
ButtonReply *WebhookButtonReply `json:"button_reply,omitempty"`
ListReply *WebhookListReply `json:"list_reply,omitempty"`
NfmReply *WebhookNfmReply `json:"nfm_reply,omitempty"`
}
// WebhookButtonReply represents a button reply
type WebhookButtonReply struct {
ID string `json:"id"`
Title string `json:"title"`
}
// WebhookListReply represents a list reply
type WebhookListReply struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
// WebhookNfmReply represents a native flow message reply
type WebhookNfmReply struct {
ResponseJSON string `json:"response_json"`
Body string `json:"body"`
Name string `json:"name"`
}
// WebhookButton represents a quick reply button
type WebhookButton struct {
Text string `json:"text"`
Payload string `json:"payload"`
}
// WebhookReaction represents a reaction to a message
type WebhookReaction struct {
MessageID string `json:"message_id"`
Emoji string `json:"emoji"`
}
// WebhookOrder represents an order
type WebhookOrder struct {
CatalogID string `json:"catalog_id"`
ProductItems []WebhookProductItem `json:"product_items"`
Text string `json:"text,omitempty"`
}
// WebhookProductItem represents a product in an order
type WebhookProductItem struct {
ProductRetailerID string `json:"product_retailer_id"`
Quantity int `json:"quantity"`
ItemPrice float64 `json:"item_price"`
Currency string `json:"currency"`
}
// WebhookSystem represents a system message
type WebhookSystem struct {
Body string `json:"body,omitempty"`
Type string `json:"type,omitempty"` // "customer_changed_number", "customer_identity_changed", etc.
Identity string `json:"identity,omitempty"`
NewWaID string `json:"new_wa_id,omitempty"`
WaID string `json:"wa_id,omitempty"`
}
// WebhookIdentity represents identity information
type WebhookIdentity struct {
Acknowledged bool `json:"acknowledged"`
CreatedTimestamp string `json:"created_timestamp"`
Hash string `json:"hash"`
}
// WebhookReferral represents referral information
type WebhookReferral struct {
SourceURL string `json:"source_url"`
SourceID string `json:"source_id,omitempty"`
SourceType string `json:"source_type"`
Headline string `json:"headline,omitempty"`
Body string `json:"body,omitempty"`
MediaType string `json:"media_type,omitempty"`
ImageURL string `json:"image_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
ThumbnailURL string `json:"thumbnail_url,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"`
}
// TokenDebugResponse represents the response from debug_token endpoint
type TokenDebugResponse struct {
Data TokenDebugData `json:"data"`
}
// TokenDebugData contains token validation information
type TokenDebugData struct {
AppID string `json:"app_id"`
Type string `json:"type"`
Application string `json:"application"`
DataAccessExpiresAt int64 `json:"data_access_expires_at"`
ExpiresAt int64 `json:"expires_at"`
IsValid bool `json:"is_valid"`
IssuedAt int64 `json:"issued_at,omitempty"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
}
// PhoneNumberDetails represents phone number information from the API
type PhoneNumberDetails struct {
ID string `json:"id"`
VerifiedName string `json:"verified_name"`
CodeVerificationStatus string `json:"code_verification_status"`
DisplayPhoneNumber string `json:"display_phone_number"`
QualityRating string `json:"quality_rating"`
PlatformType string `json:"platform_type"`
Throughput ThroughputInfo `json:"throughput"`
}
// ThroughputInfo contains throughput information
type ThroughputInfo struct {
Level string `json:"level"`
}
// BusinessAccountDetails represents business account information
type BusinessAccountDetails struct {
ID string `json:"id"`
Name string `json:"name"`
TimezoneID string `json:"timezone_id"`
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
}

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

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

@@ -0,0 +1,313 @@
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()
// Landing page (no auth required)
mux.HandleFunc("/", h.ServeIndex)
// Static files (no auth required)
mux.HandleFunc("/static/", h.ServeStatic)
// 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/update", h.Auth(h.UpdateAccount))
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
mux.HandleFunc("/api/accounts/disable", h.Auth(h.DisableAccount))
mux.HandleFunc("/api/accounts/enable", h.Auth(h.EnableAccount))
// 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)
// Message cache management (with auth)
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
mux.HandleFunc("/api/cache/replay", h.Auth(h.ReplayCachedEvents)) // POST - replay all
mux.HandleFunc("/api/cache/event", h.Auth(h.GetCachedEvent)) // GET with ?id=
mux.HandleFunc("/api/cache/event/replay", h.Auth(h.ReplayCachedEvent)) // POST with ?id=
mux.HandleFunc("/api/cache/event/delete", h.Auth(h.DeleteCachedEvent)) // DELETE with ?id=
mux.HandleFunc("/api/cache/clear", h.Auth(h.ClearCache)) // DELETE with ?confirm=true
logging.Info("HTTP server endpoints configured",
"index", "/",
"static", "/static/*",
"health", "/health",
"hooks", "/api/hooks",
"accounts", "/api/accounts",
"send", "/api/send",
"cache", "/api/cache",
"webhooks", "/webhooks/whatsapp/*",
"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,194 @@
package whatshooked
import (
"context"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/cache"
"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
messageCache *cache.MessageCache
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 message cache
cacheConfig := cache.Config{
Enabled: cfg.MessageCache.Enabled,
DataPath: cfg.MessageCache.DataPath,
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
MaxEvents: cfg.MessageCache.MaxEvents,
}
messageCache, err := cache.NewMessageCache(cacheConfig)
if err != nil {
logging.Error("Failed to initialize message cache", "error", err)
// Continue without cache rather than failing
messageCache = &cache.MessageCache{}
}
wh.messageCache = messageCache
// Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus, wh.messageCache)
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, cfg.Server.DefaultCountryCode)
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 {
// Skip disabled accounts
if waCfg.Disabled {
logging.Info("Skipping disabled account", "account_id", waCfg.ID)
continue
}
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
}