Compare commits
20 Commits
75cff3699f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98fc28fc5f | ||
|
|
c5e121de4a | ||
|
|
c4d974d6ce | ||
|
|
3901bbb668 | ||
| 147dac9b60 | |||
| d80a6433b9 | |||
| 7b2390cbf6 | |||
| eb788f903a | |||
| 4d083b0bd9 | |||
| ea1209c84c | |||
| fd2527219e | |||
| 94fc899bab | |||
| bb9aa01519 | |||
| a3eca09502 | |||
| 767a9e211f | |||
| ae169f81e4 | |||
| 09a12560d3 | |||
| 2b1b77334a | |||
| 16aaf1919d | |||
| d54b0eaddf |
46
.dockerignore
Normal file
46
.dockerignore
Normal 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
108
.github/workflows/ci.yml
vendored
Normal 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
150
.github/workflows/release.yml
vendored
Normal 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)"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,4 +45,8 @@ sessions/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/server
|
||||||
|
|
||||||
|
# Web directory (files are embedded in pkg/handlers/static/)
|
||||||
|
web/
|
||||||
|
|||||||
114
.golangci.json
Normal file
114
.golangci.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"server_url": "http://localhost:8080"
|
|
||||||
}
|
|
||||||
430
ACCOUNT_MANAGEMENT.md
Normal file
430
ACCOUNT_MANAGEMENT.md
Normal 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)
|
||||||
29
CLAUDE.md
29
CLAUDE.md
@@ -1,29 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**whatshooked** is a Go project currently in its initial setup phase. The repository structure and development workflow will be established as the project evolves.
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **Language**: Go
|
|
||||||
- **Version Control**: Git
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
This is a new Go project. Standard Go development commands will apply once code is added:
|
|
||||||
|
|
||||||
- `go build` - Build the project
|
|
||||||
- `go test ./...` - Run all tests
|
|
||||||
- `go test -v ./path/to/package` - Run tests for a specific package
|
|
||||||
- `go run .` - Run the main application (once a main package exists)
|
|
||||||
- `go mod tidy` - Clean up dependencies
|
|
||||||
|
|
||||||
## Project Status
|
|
||||||
|
|
||||||
The repository has been initialized but does not yet contain application code. When adding initial code:
|
|
||||||
- Follow standard Go project layout conventions
|
|
||||||
- Use `go mod init` if a go.mod file needs to be created
|
|
||||||
- Consider the intended purpose of "whatshooked" when designing the architecture
|
|
||||||
466
CLI.md
Normal file
466
CLI.md
Normal 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
419
DOCKER.md
Normal 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
41
Dockerfile
Normal 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
249
EVENT_LOGGER.md
Normal 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
370
MQTT_CONFIG_EXAMPLE.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# MQTT Configuration Example
|
||||||
|
|
||||||
|
This document provides examples of how to configure MQTT support in whatshooked.
|
||||||
|
|
||||||
|
## Configuration Structure
|
||||||
|
|
||||||
|
Add the following to your `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_logger": {
|
||||||
|
"enabled": true,
|
||||||
|
"targets": ["mqtt"],
|
||||||
|
"mqtt": {
|
||||||
|
"broker": "tcp://localhost:1883",
|
||||||
|
"client_id": "whatshooked-mqtt",
|
||||||
|
"username": "your_mqtt_username",
|
||||||
|
"password": "your_mqtt_password",
|
||||||
|
"topic_prefix": "whatshooked",
|
||||||
|
"qos": 1,
|
||||||
|
"retained": false,
|
||||||
|
"events": [
|
||||||
|
"message.received",
|
||||||
|
"message.sent",
|
||||||
|
"whatsapp.connected",
|
||||||
|
"whatsapp.disconnected"
|
||||||
|
],
|
||||||
|
"subscribe": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Fields
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
- **broker**: MQTT broker URL (e.g., `tcp://localhost:1883`, `ssl://broker.example.com:8883`)
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
- **client_id**: MQTT client identifier (auto-generated if not specified)
|
||||||
|
- **username**: Username for MQTT broker authentication
|
||||||
|
- **password**: Password for MQTT broker authentication
|
||||||
|
- **topic_prefix**: Prefix for all MQTT topics (default: `whatshooked`)
|
||||||
|
- **qos**: Quality of Service level (0, 1, or 2; default: 1)
|
||||||
|
- 0: At most once delivery
|
||||||
|
- 1: At least once delivery
|
||||||
|
- 2: Exactly once delivery
|
||||||
|
- **retained**: Whether messages should be retained by the broker (default: false)
|
||||||
|
- **events**: Array of event types to publish. If empty or omitted, all events will be published.
|
||||||
|
- **subscribe**: Enable subscription for sending WhatsApp messages via MQTT (default: false)
|
||||||
|
|
||||||
|
## Topic Structure
|
||||||
|
|
||||||
|
### Published Events
|
||||||
|
|
||||||
|
Events are published to topics in the format:
|
||||||
|
```
|
||||||
|
{topic_prefix}/{account_id}/{event_type}
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `whatshooked/my-account/message.received`
|
||||||
|
- `whatshooked/my-account/whatsapp.connected`
|
||||||
|
- `whatshooked/business-account/message.sent`
|
||||||
|
|
||||||
|
### Sending Messages (Subscribe Mode)
|
||||||
|
|
||||||
|
When `subscribe: true` is enabled, you can send WhatsApp messages by publishing to:
|
||||||
|
```
|
||||||
|
{topic_prefix}/{account_id}/send
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text Messages
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"text": "Hello from MQTT!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"caption": "Check out this image!",
|
||||||
|
"mime_type": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Messages (via Base64)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"caption": "A small test image",
|
||||||
|
"mime_type": "image/png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Video Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/video.mp4",
|
||||||
|
"caption": "Check out this video!",
|
||||||
|
"mime_type": "video/mp4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Document Messages (via URL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/report.pdf",
|
||||||
|
"filename": "monthly-report.pdf",
|
||||||
|
"caption": "Here's the monthly report",
|
||||||
|
"mime_type": "application/pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Document Messages (via Base64)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...",
|
||||||
|
"filename": "document.pdf",
|
||||||
|
"caption": "Important document",
|
||||||
|
"mime_type": "application/pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Payload Fields:**
|
||||||
|
- `type`: Message type - "text", "image", "video", or "document" (default: "text")
|
||||||
|
- `to`: Phone number in JID format (required)
|
||||||
|
- `text`: Message text (required for text messages)
|
||||||
|
- `caption`: Optional caption for media messages
|
||||||
|
- `mime_type`: MIME type of the media (defaults: image/jpeg, video/mp4, application/pdf)
|
||||||
|
- `filename`: Filename for documents (required for document type)
|
||||||
|
- `base64`: Base64 encoded media data (mutually exclusive with `url`)
|
||||||
|
- `url`: URL to download media from (mutually exclusive with `base64`)
|
||||||
|
|
||||||
|
**Note:** For media messages, you must provide either `base64` or `url`, but not both.
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
Available event types for filtering:
|
||||||
|
|
||||||
|
### WhatsApp Connection Events
|
||||||
|
- `whatsapp.connected`
|
||||||
|
- `whatsapp.disconnected`
|
||||||
|
- `whatsapp.pair.success`
|
||||||
|
- `whatsapp.pair.failed`
|
||||||
|
- `whatsapp.qr.code`
|
||||||
|
- `whatsapp.qr.timeout`
|
||||||
|
- `whatsapp.qr.error`
|
||||||
|
- `whatsapp.pair.event`
|
||||||
|
|
||||||
|
### Message Events
|
||||||
|
- `message.received`
|
||||||
|
- `message.sent`
|
||||||
|
- `message.failed`
|
||||||
|
- `message.delivered`
|
||||||
|
- `message.read`
|
||||||
|
|
||||||
|
### Hook Events
|
||||||
|
- `hook.triggered`
|
||||||
|
- `hook.success`
|
||||||
|
- `hook.failed`
|
||||||
|
|
||||||
|
## Home Assistant Integration Example
|
||||||
|
|
||||||
|
To integrate with Home Assistant, you can use the MQTT integration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# configuration.yaml
|
||||||
|
mqtt:
|
||||||
|
sensor:
|
||||||
|
- name: "WhatsApp Status"
|
||||||
|
state_topic: "whatshooked/my-account/whatsapp.connected"
|
||||||
|
value_template: "{{ value_json.type }}"
|
||||||
|
|
||||||
|
- name: "Last WhatsApp Message"
|
||||||
|
state_topic: "whatshooked/my-account/message.received"
|
||||||
|
value_template: "{{ value_json.data.text }}"
|
||||||
|
json_attributes_topic: "whatshooked/my-account/message.received"
|
||||||
|
json_attributes_template: "{{ value_json.data | tojson }}"
|
||||||
|
|
||||||
|
# Send messages from Home Assistant
|
||||||
|
script:
|
||||||
|
send_whatsapp_text:
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: '{"type": "text", "to": "27821234567@s.whatsapp.net", "text": "Alert from Home Assistant!"}'
|
||||||
|
|
||||||
|
send_whatsapp_image:
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/camera-snapshot.jpg",
|
||||||
|
"caption": "Motion detected at front door"
|
||||||
|
}
|
||||||
|
|
||||||
|
send_whatsapp_camera_snapshot:
|
||||||
|
sequence:
|
||||||
|
# Take a snapshot
|
||||||
|
- service: camera.snapshot
|
||||||
|
target:
|
||||||
|
entity_id: camera.front_door
|
||||||
|
data:
|
||||||
|
filename: /tmp/snapshot.jpg
|
||||||
|
# Convert to base64 and send via MQTT
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": "{{ state_attr('camera.front_door', 'entity_picture') | base64_encode }}",
|
||||||
|
"caption": "Front door snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
send_whatsapp_document:
|
||||||
|
sequence:
|
||||||
|
- service: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "whatshooked/my-account/send"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"url": "https://example.com/daily-report.pdf",
|
||||||
|
"filename": "daily-report.pdf",
|
||||||
|
"caption": "Today's energy usage report"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Configuration Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"whatsapp": [
|
||||||
|
{
|
||||||
|
"id": "my-account",
|
||||||
|
"type": "whatsmeow",
|
||||||
|
"phone_number": "27821234567",
|
||||||
|
"session_path": "./data/sessions/my-account",
|
||||||
|
"show_qr": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"data_path": "./data/media",
|
||||||
|
"mode": "link"
|
||||||
|
},
|
||||||
|
"event_logger": {
|
||||||
|
"enabled": true,
|
||||||
|
"targets": ["file", "mqtt"],
|
||||||
|
"file_dir": "./data/events",
|
||||||
|
"mqtt": {
|
||||||
|
"broker": "tcp://homeassistant.local:1883",
|
||||||
|
"username": "mqtt_user",
|
||||||
|
"password": "mqtt_password",
|
||||||
|
"topic_prefix": "whatshooked",
|
||||||
|
"qos": 1,
|
||||||
|
"retained": false,
|
||||||
|
"events": [
|
||||||
|
"message.received",
|
||||||
|
"message.sent",
|
||||||
|
"whatsapp.connected",
|
||||||
|
"whatsapp.disconnected"
|
||||||
|
],
|
||||||
|
"subscribe": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing MQTT Connection
|
||||||
|
|
||||||
|
You can test your MQTT connection using `mosquitto_sub` and `mosquitto_pub`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Subscribe to all whatshooked events
|
||||||
|
mosquitto_sub -h localhost -t "whatshooked/#" -v
|
||||||
|
|
||||||
|
# Subscribe to specific account events
|
||||||
|
mosquitto_sub -h localhost -t "whatshooked/my-account/#" -v
|
||||||
|
|
||||||
|
# Send a test text message
|
||||||
|
mosquitto_pub -h localhost -t "whatshooked/my-account/send" \
|
||||||
|
-m '{"type": "text", "to": "27821234567@s.whatsapp.net", "text": "Test message"}'
|
||||||
|
|
||||||
|
# Send an image from URL
|
||||||
|
mosquitto_pub -h localhost -t "whatshooked/my-account/send" \
|
||||||
|
-m '{"type": "image", "to": "27821234567@s.whatsapp.net", "url": "https://picsum.photos/200", "caption": "Random image"}'
|
||||||
|
|
||||||
|
# Send an image from base64 (1x1 red pixel example)
|
||||||
|
mosquitto_pub -h localhost -t "whatshooked/my-account/send" \
|
||||||
|
-m '{"type": "image", "to": "27821234567@s.whatsapp.net", "base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", "caption": "Red pixel"}'
|
||||||
|
|
||||||
|
# Send a document from URL
|
||||||
|
mosquitto_pub -h localhost -t "whatshooked/my-account/send" \
|
||||||
|
-m '{"type": "document", "to": "27821234567@s.whatsapp.net", "url": "https://example.com/document.pdf", "filename": "test.pdf", "caption": "Test document"}'
|
||||||
|
|
||||||
|
# Send a video from URL
|
||||||
|
mosquitto_pub -h localhost -t "whatshooked/my-account/send" \
|
||||||
|
-m '{"type": "video", "to": "27821234567@s.whatsapp.net", "url": "https://example.com/video.mp4", "caption": "Test video"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Python for Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Connect to broker
|
||||||
|
client = mqtt.Client()
|
||||||
|
client.connect("localhost", 1883, 60)
|
||||||
|
|
||||||
|
# Send text message
|
||||||
|
payload = {
|
||||||
|
"type": "text",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"text": "Hello from Python!"
|
||||||
|
}
|
||||||
|
client.publish("whatshooked/my-account/send", json.dumps(payload))
|
||||||
|
|
||||||
|
# Send image from file
|
||||||
|
with open("image.jpg", "rb") as f:
|
||||||
|
image_data = base64.b64encode(f.read()).decode()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "image",
|
||||||
|
"to": "27821234567@s.whatsapp.net",
|
||||||
|
"base64": image_data,
|
||||||
|
"caption": "Image from Python",
|
||||||
|
"mime_type": "image/jpeg"
|
||||||
|
}
|
||||||
|
client.publish("whatshooked/my-account/send", json.dumps(payload))
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
```
|
||||||
56
Makefile
56
Makefile
@@ -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 both server and CLI
|
||||||
build:
|
build:
|
||||||
@@ -29,10 +29,11 @@ clean:
|
|||||||
@rm -f bin/whatshook*
|
@rm -f bin/whatshook*
|
||||||
@echo "Clean complete!"
|
@echo "Clean complete!"
|
||||||
|
|
||||||
# Run tests
|
# Run tests with coverage
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@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 (requires config.json)
|
||||||
run-server:
|
run-server:
|
||||||
@@ -49,6 +50,51 @@ deps:
|
|||||||
@go mod tidy
|
@go mod tidy
|
||||||
@echo "Dependencies installed!"
|
@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
|
||||||
help:
|
help:
|
||||||
@echo "WhatsHooked Makefile"
|
@echo "WhatsHooked Makefile"
|
||||||
@@ -58,7 +104,9 @@ help:
|
|||||||
@echo " make build-server - Build server only"
|
@echo " make build-server - Build server only"
|
||||||
@echo " make build-cli - Build CLI only"
|
@echo " make build-cli - Build CLI only"
|
||||||
@echo " make clean - Remove build artifacts (preserves bin directory)"
|
@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-server - Run server (requires config.json)"
|
||||||
@echo " make run-cli ARGS='health' - Run CLI with arguments"
|
@echo " make run-cli ARGS='health' - Run CLI with arguments"
|
||||||
@echo " make deps - Install dependencies"
|
@echo " make deps - Install dependencies"
|
||||||
|
|||||||
730
README.md
730
README.md
@@ -1,17 +1,30 @@
|
|||||||
# WhatsHooked
|
# 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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 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)
|
## Features
|
||||||
|
|
||||||
## Phase 1 Features
|
|
||||||
|
|
||||||
- **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously
|
- **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
|
- **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages
|
||||||
- **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp
|
- **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
|
- **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
|
- **CLI Management**: Command-line tool for managing accounts and hooks
|
||||||
- **Structured Logging**: JSON-based logging with configurable log levels
|
- **Structured Logging**: JSON-based logging with configurable log levels
|
||||||
- **Authentication**: HTTP Basic Auth and API key authentication for server endpoints
|
- **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
|
## Architecture
|
||||||
|
|
||||||
The project uses an event-driven architecture with the following packages:
|
The project uses an event-driven architecture with the following packages:
|
||||||
|
|
||||||
- **internal/config**: Configuration management and persistence
|
### Library Packages (pkg/)
|
||||||
- **internal/logging**: Structured logging using Go's slog package
|
|
||||||
- **internal/events**: Event bus for publish/subscribe messaging between components
|
- **pkg/whatshooked**: Main library entry point with NewFromFile() and New() constructors
|
||||||
- **internal/whatsapp**: WhatsApp client management using whatsmeow
|
- **pkg/config**: Configuration management and persistence
|
||||||
- **internal/hooks**: Webhook management and message forwarding
|
- **pkg/logging**: Pluggable structured logging interface
|
||||||
- **cmd/server**: Main server application
|
- **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
|
- **cmd/cli**: Command-line interface for management
|
||||||
|
|
||||||
### Event-Driven Architecture
|
### Event-Driven Architecture
|
||||||
@@ -54,7 +159,186 @@ This architecture enables:
|
|||||||
- Context propagation for cancellation and timeout handling
|
- Context propagation for cancellation and timeout handling
|
||||||
- Proper request lifecycle management across components
|
- 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
|
### Build from source
|
||||||
|
|
||||||
@@ -77,6 +361,18 @@ Create a `config.json` file based on the example:
|
|||||||
cp config.example.json config.json
|
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:
|
Edit the configuration file to add your WhatsApp accounts and webhooks:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -88,9 +384,23 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
|
|||||||
},
|
},
|
||||||
"whatsapp": [
|
"whatsapp": [
|
||||||
{
|
{
|
||||||
"id": "account1",
|
"id": "personal",
|
||||||
|
"type": "whatsmeow",
|
||||||
"phone_number": "+1234567890",
|
"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": [
|
"hooks": [
|
||||||
@@ -122,8 +432,20 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
|
|||||||
|
|
||||||
**WhatsApp Account Configuration:**
|
**WhatsApp Account Configuration:**
|
||||||
- `id`: Unique identifier for this account
|
- `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
|
- `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:**
|
**Hook Configuration:**
|
||||||
- `id`: Unique identifier for this hook
|
- `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")
|
- `method`: HTTP method (usually "POST")
|
||||||
- `headers`: Optional HTTP headers
|
- `headers`: Optional HTTP headers
|
||||||
- `active`: Whether this hook is enabled
|
- `active`: Whether this hook is enabled
|
||||||
|
- `events`: List of event types to subscribe to (optional, defaults to all)
|
||||||
- `description`: Optional description
|
- `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
|
### Server Authentication
|
||||||
|
|
||||||
The server supports two authentication methods to protect API endpoints:
|
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
|
- 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
|
- 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
|
## Usage
|
||||||
|
|
||||||
### Starting the Server
|
### Starting the Server
|
||||||
@@ -365,17 +990,21 @@ Examples with `default_country_code: "27"`:
|
|||||||
|
|
||||||
The server exposes the following HTTP endpoints:
|
The server exposes the following HTTP endpoints:
|
||||||
|
|
||||||
|
**Public Endpoints:**
|
||||||
- `GET /health` - Health check (no authentication required)
|
- `GET /health` - Health check (no authentication required)
|
||||||
- `GET /api/hooks` - List all hooks (requires authentication if enabled)
|
- `GET/POST /webhooks/whatsapp/{accountID}` - Business API webhook verification and events (no authentication, validated by Meta's verify_token)
|
||||||
- `POST /api/hooks/add` - Add a new hook (requires authentication if enabled)
|
|
||||||
- `POST /api/hooks/remove` - Remove a hook (requires authentication if enabled)
|
**Protected Endpoints (require authentication if enabled):**
|
||||||
- `GET /api/accounts` - List all WhatsApp accounts (requires authentication if enabled)
|
- `GET /api/hooks` - List all hooks
|
||||||
- `POST /api/accounts/add` - Add a new WhatsApp account (requires authentication if enabled)
|
- `POST /api/hooks/add` - Add a new hook
|
||||||
- `POST /api/send` - Send a message (requires authentication if enabled)
|
- `POST /api/hooks/remove` - Remove a hook
|
||||||
- `POST /api/send/image` - Send an image (requires authentication if enabled)
|
- `GET /api/accounts` - List all WhatsApp accounts
|
||||||
- `POST /api/send/video` - Send a video (requires authentication if enabled)
|
- `POST /api/accounts/add` - Add a new WhatsApp account
|
||||||
- `POST /api/send/document` - Send a document (requires authentication if enabled)
|
- `POST /api/send` - Send a message
|
||||||
- `GET /api/media/{accountID}/{filename}` - Serve media files (requires authentication if enabled)
|
- `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
|
## WhatsApp JID Format
|
||||||
|
|
||||||
@@ -393,17 +1022,43 @@ The server accepts both full JID format and plain phone numbers. When using plai
|
|||||||
```
|
```
|
||||||
whatshooked/
|
whatshooked/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── server/ # Main server application
|
│ ├── server/ # Standalone server (thin wrapper)
|
||||||
│ └── cli/ # CLI tool
|
│ │ └── main.go
|
||||||
├── internal/
|
│ └── cli/ # CLI tool
|
||||||
│ ├── config/ # Configuration management
|
│ ├── main.go
|
||||||
│ ├── events/ # Event bus and event types
|
│ └── commands_*.go
|
||||||
│ ├── logging/ # Structured logging
|
├── pkg/ # Public library packages
|
||||||
│ ├── whatsapp/ # WhatsApp client management
|
│ ├── whatshooked/ # Main library entry point
|
||||||
│ ├── hooks/ # Webhook management
|
│ │ ├── whatshooked.go # NewFromFile(), New()
|
||||||
│ └── utils/ # Utility functions (phone formatting, etc.)
|
│ │ ├── options.go # Functional options
|
||||||
├── config.example.json # Example configuration
|
│ │ └── server.go # Built-in HTTP server
|
||||||
└── go.mod # Go module definition
|
│ ├── handlers/ # HTTP handlers
|
||||||
|
│ │ ├── handlers.go # Handler struct
|
||||||
|
│ │ ├── middleware.go # Auth middleware
|
||||||
|
│ │ ├── send.go # Send handlers
|
||||||
|
│ │ ├── accounts.go # Account handlers
|
||||||
|
│ │ ├── hooks.go # Hook handlers
|
||||||
|
│ │ ├── media.go # Media handlers
|
||||||
|
│ │ ├── health.go # Health handler
|
||||||
|
│ │ └── businessapi.go # Business API webhook
|
||||||
|
│ ├── config/ # Configuration types
|
||||||
|
│ ├── events/ # Event bus
|
||||||
|
│ ├── logging/ # Pluggable logging
|
||||||
|
│ ├── whatsapp/ # WhatsApp client management
|
||||||
|
│ │ ├── interface.go # Client interface
|
||||||
|
│ │ ├── manager.go # Multi-client manager
|
||||||
|
│ │ ├── whatsmeow/ # Personal WhatsApp
|
||||||
|
│ │ │ └── client.go
|
||||||
|
│ │ └── businessapi/ # WhatsApp Business API
|
||||||
|
│ │ ├── client.go
|
||||||
|
│ │ ├── types.go
|
||||||
|
│ │ ├── events.go
|
||||||
|
│ │ └── media.go
|
||||||
|
│ ├── hooks/ # Webhook management
|
||||||
|
│ ├── eventlogger/ # Event persistence
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
├── config.example.json # Example configuration
|
||||||
|
└── go.mod # Go module definition
|
||||||
```
|
```
|
||||||
|
|
||||||
### Event Types
|
### Event Types
|
||||||
@@ -442,9 +1097,8 @@ go test ./...
|
|||||||
go build ./...
|
go build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Phases
|
## Future Plans
|
||||||
|
|
||||||
### Phase 2 (Planned)
|
|
||||||
- User level hooks and WhatsApp accounts
|
- User level hooks and WhatsApp accounts
|
||||||
- Web server with frontend UI
|
- Web server with frontend UI
|
||||||
- Enhanced authentication with user roles and permissions
|
- Enhanced authentication with user roles and permissions
|
||||||
|
|||||||
14
TODO.md
14
TODO.md
@@ -1,11 +1,13 @@
|
|||||||
# Todo List
|
# Todo List
|
||||||
|
|
||||||
## General todo
|
## General todo
|
||||||
- [ ] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
|
- [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
|
||||||
- [ ] Authentication options for cli
|
- [✔️] 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.)
|
- [✔️] **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
|
- [✔️] Whatsapp Business API support add
|
||||||
- [ ] Optional Postgres server connection for Whatsmeo
|
- [ ] Optional Postgres server connection for Whatsmeo
|
||||||
- [ ] Optional Postgres server,database for event saving and hook registration
|
- [ ] Optional Postgres server,database for event saving and hook registration
|
||||||
- [ ] Optional Event logging into directory for each type
|
- [✔️] Optional Event logging into directory for each type
|
||||||
- [ ] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages)
|
- [✔️] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages)
|
||||||
|
- [✔️] Refactor into pkg to be able to use the system as a client library instead of starting a server
|
||||||
|
- [✔️] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths.
|
||||||
1040
WHATSAPP_BUSINESS.md
Normal file
1040
WHATSAPP_BUSINESS.md
Normal file
File diff suppressed because it is too large
Load Diff
111
cmd/cli/client.go
Normal file
111
cmd/cli/client.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
cmd/cli/commands_accounts.go
Normal file
89
cmd/cli/commands_accounts.go
Normal 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")
|
||||||
|
}
|
||||||
28
cmd/cli/commands_health.go
Normal file
28
cmd/cli/commands_health.go
Normal 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
164
cmd/cli/commands_hooks.go
Normal 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
270
cmd/cli/commands_send.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
// CLIConfig holds the CLI configuration
|
// CLIConfig holds the CLI configuration
|
||||||
type CLIConfig struct {
|
type CLIConfig struct {
|
||||||
ServerURL string
|
ServerURL string
|
||||||
|
AuthKey string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
|
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
|
||||||
@@ -18,6 +21,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
|
|||||||
|
|
||||||
// Set defaults
|
// Set defaults
|
||||||
v.SetDefault("server_url", "http://localhost:8080")
|
v.SetDefault("server_url", "http://localhost:8080")
|
||||||
|
v.SetDefault("auth_key", "")
|
||||||
|
v.SetDefault("username", "")
|
||||||
|
v.SetDefault("password", "")
|
||||||
|
|
||||||
// 1. Load from config file (lowest priority)
|
// 1. Load from config file (lowest priority)
|
||||||
if configFile != "" {
|
if configFile != "" {
|
||||||
@@ -50,6 +56,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
|
|||||||
|
|
||||||
cfg := &CLIConfig{
|
cfg := &CLIConfig{
|
||||||
ServerURL: v.GetString("server_url"),
|
ServerURL: v.GetString("server_url"),
|
||||||
|
AuthKey: v.GetString("auth_key"),
|
||||||
|
Username: v.GetString("username"),
|
||||||
|
Password: v.GetString("password"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
585
cmd/cli/main.go
585
cmd/cli/main.go
@@ -1,17 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
"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(&cfgFile, "config", "", "config file (default: $HOME/.whatshooked/cli.json)")
|
||||||
rootCmd.PersistentFlags().StringVar(&serverURL, "server", "", "server URL (default: http://localhost:8080)")
|
rootCmd.PersistentFlags().StringVar(&serverURL, "server", "", "server URL (default: http://localhost:8080)")
|
||||||
|
|
||||||
|
// Add all command groups
|
||||||
rootCmd.AddCommand(healthCmd)
|
rootCmd.AddCommand(healthCmd)
|
||||||
rootCmd.AddCommand(hooksCmd)
|
rootCmd.AddCommand(hooksCmd)
|
||||||
rootCmd.AddCommand(accountsCmd)
|
rootCmd.AddCommand(accountsCmd)
|
||||||
rootCmd.AddCommand(sendCmd)
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,89 +2,80 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/config"
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/whatshooked"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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 {
|
// resolveConfigPath determines the config file path to use
|
||||||
config *config.Config
|
// Priority: 1) provided path (if exists), 2) config.json in current dir, 3) .whatshooked/config.json in user home
|
||||||
whatsappMgr *whatsapp.Manager
|
func resolveConfigPath(providedPath string) (string, error) {
|
||||||
hookMgr *hooks.Manager
|
// If a path was explicitly provided, check if it exists
|
||||||
httpServer *http.Server
|
if providedPath != "" {
|
||||||
eventBus *events.EventBus
|
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() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Load configuration
|
// Resolve config path
|
||||||
cfg, err := config.Load(*configPath)
|
cfgPath, err := resolveConfigPath(*configPath)
|
||||||
if err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize logging
|
// Create WhatsHooked instance from config file
|
||||||
logging.Init(cfg.LogLevel)
|
wh, err := whatshooked.NewFromFile(cfgPath)
|
||||||
logging.Info("Starting WhatsHooked server")
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to initialize WhatsHooked from %s: %v\n", cfgPath, err)
|
||||||
// Create event bus
|
os.Exit(1)
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load hooks
|
logging.Info("Starting WhatsHooked server", "config_path", cfgPath)
|
||||||
srv.hookMgr.LoadHooks(cfg.Hooks)
|
|
||||||
|
|
||||||
// Start hook manager to listen for events
|
// Start the built-in HTTP server (non-blocking goroutine)
|
||||||
srv.hookMgr.Start()
|
go func() {
|
||||||
|
if err := wh.StartServer(); err != nil {
|
||||||
// Subscribe to hook success events to handle webhook responses
|
logging.Error("HTTP server error", "error", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
// Wait for interrupt signal
|
// Wait for interrupt signal
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
@@ -97,477 +88,15 @@ func main() {
|
|||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if srv.httpServer != nil {
|
// Stop server
|
||||||
srv.httpServer.Shutdown(shutdownCtx)
|
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")
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
6
config/.whatshooked-cli.example.json
Normal file
6
config/.whatshooked-cli.example.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://localhost:8080",
|
||||||
|
"auth_key": "",
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
@@ -5,14 +5,38 @@
|
|||||||
"default_country_code": "27",
|
"default_country_code": "27",
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"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": [
|
"whatsapp": [
|
||||||
{
|
{
|
||||||
"id": "acc1",
|
"id": "personal",
|
||||||
|
"type": "whatsmeow",
|
||||||
"phone_number": "+1234567890",
|
"phone_number": "+1234567890",
|
||||||
"session_path": "./sessions/account1",
|
"session_path": "./sessions/personal",
|
||||||
"show_qr": true
|
"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": [
|
"hooks": [
|
||||||
@@ -78,5 +102,23 @@
|
|||||||
"mode": "link",
|
"mode": "link",
|
||||||
"base_url": "http://localhost:8080"
|
"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"
|
"log_level": "info"
|
||||||
}
|
}
|
||||||
39
config/config.https-custom.example.json
Normal file
39
config/config.https-custom.example.json
Normal 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"
|
||||||
|
}
|
||||||
41
config/config.https-letsencrypt.example.json
Normal file
41
config/config.https-letsencrypt.example.json
Normal 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"
|
||||||
|
}
|
||||||
38
config/config.https-self-signed.example.json
Normal file
38
config/config.https-self-signed.example.json
Normal 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
51
docker-compose.yml
Normal 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
16
go.mod
@@ -3,12 +3,16 @@ module git.warky.dev/wdevs/whatshooked
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -19,27 +23,27 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.4 // indirect
|
go.mau.fi/util v0.9.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.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-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/term v0.38.0 // indirect
|
golang.org/x/term v0.38.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
22
go.sum
22
go.sum
@@ -14,6 +14,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
@@ -27,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/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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/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.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
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 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
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 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
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=
|
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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
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-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
394
pkg/cache/message_cache.go
vendored
Normal 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
221
pkg/config/config.go
Normal 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)
|
||||||
|
}
|
||||||
122
pkg/eventlogger/eventlogger.go
Normal file
122
pkg/eventlogger/eventlogger.go
Normal 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
|
||||||
|
}
|
||||||
69
pkg/eventlogger/file_target.go
Normal file
69
pkg/eventlogger/file_target.go
Normal 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
|
||||||
|
}
|
||||||
304
pkg/eventlogger/mqtt_target.go
Normal file
304
pkg/eventlogger/mqtt_target.go
Normal 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
|
||||||
|
}
|
||||||
120
pkg/eventlogger/postgres_target.go
Normal file
120
pkg/eventlogger/postgres_target.go
Normal 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()
|
||||||
|
}
|
||||||
111
pkg/eventlogger/sqlite_target.go
Normal file
111
pkg/eventlogger/sqlite_target.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -37,10 +37,11 @@ func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) E
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
|
// 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{
|
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"qr_code": qrCode,
|
"qr_code": qrCode,
|
||||||
|
"qr_url": qrURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@ const (
|
|||||||
|
|
||||||
// Event represents an event in the system
|
// Event represents an event in the system
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventType `json:"type"`
|
Type EventType `json:"type"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Data map[string]any `json:"data"`
|
Data map[string]any `json:"data"`
|
||||||
Context context.Context `json:"-"`
|
Context context.Context `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscriber is a function that handles events
|
// Subscriber is a function that handles events
|
||||||
@@ -79,6 +79,10 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
|
|||||||
EventWhatsAppDisconnected,
|
EventWhatsAppDisconnected,
|
||||||
EventWhatsAppPairSuccess,
|
EventWhatsAppPairSuccess,
|
||||||
EventWhatsAppPairFailed,
|
EventWhatsAppPairFailed,
|
||||||
|
EventWhatsAppQRCode,
|
||||||
|
EventWhatsAppQRTimeout,
|
||||||
|
EventWhatsAppQRError,
|
||||||
|
EventWhatsAppPairEvent,
|
||||||
EventMessageReceived,
|
EventMessageReceived,
|
||||||
EventMessageSent,
|
EventMessageSent,
|
||||||
EventMessageFailed,
|
EventMessageFailed,
|
||||||
294
pkg/handlers/accounts.go
Normal file
294
pkg/handlers/accounts.go
Normal 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
162
pkg/handlers/businessapi.go
Normal 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
254
pkg/handlers/cache.go
Normal 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
72
pkg/handlers/handlers.go
Normal 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
10
pkg/handlers/health.go
Normal 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
78
pkg/handlers/hooks.go
Normal 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
52
pkg/handlers/media.go
Normal 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)
|
||||||
|
}
|
||||||
71
pkg/handlers/middleware.go
Normal file
71
pkg/handlers/middleware.go
Normal 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
57
pkg/handlers/qr.go
Normal 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
189
pkg/handlers/send.go
Normal 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
66
pkg/handlers/static.go
Normal 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)
|
||||||
|
}
|
||||||
82
pkg/handlers/static/README.md
Normal file
82
pkg/handlers/static/README.md
Normal 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
|
||||||
424
pkg/handlers/static/index.html
Normal file
424
pkg/handlers/static/index.html
Normal 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>
|
||||||
BIN
pkg/handlers/static/logo.png
Normal file
BIN
pkg/handlers/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -11,9 +11,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/config"
|
"git.warky.dev/wdevs/whatshooked/pkg/cache"
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||||
"git.warky.dev/wdevs/whatshooked/internal/logging"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MediaInfo represents media attachment information
|
// MediaInfo represents media attachment information
|
||||||
@@ -54,13 +55,15 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
client *http.Client
|
client *http.Client
|
||||||
eventBus *events.EventBus
|
eventBus *events.EventBus
|
||||||
|
cache *cache.MessageCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new hook manager
|
// NewManager creates a new hook manager
|
||||||
func NewManager(eventBus *events.EventBus) *Manager {
|
func NewManager(eventBus *events.EventBus, messageCache *cache.MessageCache) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
hooks: make(map[string]config.Hook),
|
hooks: make(map[string]config.Hook),
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
|
cache: messageCache,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -90,20 +93,26 @@ func (m *Manager) Start() {
|
|||||||
for _, eventType := range allEventTypes {
|
for _, eventType := range allEventTypes {
|
||||||
m.eventBus.Subscribe(eventType, m.handleEvent)
|
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
|
// handleEvent processes any event and triggers relevant hooks
|
||||||
func (m *Manager) handleEvent(event events.Event) {
|
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
|
// Get hooks that are subscribed to this event type
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
relevantHooks := make([]config.Hook, 0)
|
relevantHooks := make([]config.Hook, 0)
|
||||||
for _, hook := range m.hooks {
|
for _, hook := range m.hooks {
|
||||||
if !hook.Active {
|
if !hook.Active {
|
||||||
|
logging.Debug("Skipping inactive hook", "hook_id", hook.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// If hook has no events specified, subscribe to all events
|
// If hook has no events specified, subscribe to all events
|
||||||
if len(hook.Events) == 0 {
|
if len(hook.Events) == 0 {
|
||||||
|
logging.Debug("Hook subscribes to all events", "hook_id", hook.ID)
|
||||||
relevantHooks = append(relevantHooks, hook)
|
relevantHooks = append(relevantHooks, hook)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,7 @@ func (m *Manager) handleEvent(event events.Event) {
|
|||||||
eventTypeStr := string(event.Type)
|
eventTypeStr := string(event.Type)
|
||||||
for _, subscribedEvent := range hook.Events {
|
for _, subscribedEvent := range hook.Events {
|
||||||
if subscribedEvent == eventTypeStr {
|
if subscribedEvent == eventTypeStr {
|
||||||
|
logging.Debug("Hook matches event", "hook_id", hook.ID, "event_type", eventTypeStr)
|
||||||
relevantHooks = append(relevantHooks, hook)
|
relevantHooks = append(relevantHooks, hook)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -119,14 +129,50 @@ func (m *Manager) handleEvent(event events.Event) {
|
|||||||
}
|
}
|
||||||
m.mu.RUnlock()
|
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
|
// Trigger each relevant hook
|
||||||
if len(relevantHooks) > 0 {
|
success := m.triggerHooksForEvent(event, relevantHooks)
|
||||||
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
|
// triggerHooksForEvent sends event data to specific hooks and returns success status
|
||||||
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) {
|
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) bool {
|
||||||
ctx := event.Context
|
ctx := event.Context
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
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
|
// Send to each hook with the event type
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
successCount := 0
|
||||||
|
mu := sync.Mutex{}
|
||||||
|
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(h config.Hook, et events.EventType) {
|
go func(h config.Hook, et events.EventType) {
|
||||||
defer wg.Done()
|
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)
|
}(hook, event.Type)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
|
// Return true if at least one hook was successfully triggered
|
||||||
|
return successCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions to extract data from event map
|
// 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
|
// 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 {
|
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
|
||||||
if ctx == nil {
|
// Create a new context detached from the incoming context to prevent cancellation
|
||||||
ctx = context.Background()
|
// 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
|
// 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)
|
data, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +348,7 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
parsedURL, err := url.Parse(hook.URL)
|
parsedURL, err := url.Parse(hook.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,10 +376,10 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
}
|
}
|
||||||
parsedURL.RawQuery = query.Encode()
|
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 {
|
if err != nil {
|
||||||
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,14 +393,14 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
resp, err := m.client.Do(req)
|
resp, err := m.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,23 +408,97 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hookResp HookResponse
|
var hookResp HookResponse
|
||||||
if err := json.Unmarshal(body, &hookResp); err != nil {
|
if err := json.Unmarshal(body, &hookResp); err != nil {
|
||||||
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
|
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
|
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
59
pkg/logging/logging.go
Normal 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
42
pkg/utils/media.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadMedia downloads media from a URL and returns the data
|
||||||
|
func DownloadMedia(url string) ([]byte, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to download media: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to download media: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read media data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeBase64 decodes a base64 string and returns the data
|
||||||
|
func DecodeBase64(encoded string) ([]byte, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
207
pkg/utils/tls.go
Normal file
207
pkg/utils/tls.go
Normal 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
|
||||||
|
}
|
||||||
542
pkg/whatsapp/businessapi/client.go
Normal file
542
pkg/whatsapp/businessapi/client.go
Normal 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")
|
||||||
|
}
|
||||||
449
pkg/whatsapp/businessapi/events.go
Normal file
449
pkg/whatsapp/businessapi/events.go
Normal 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 ""
|
||||||
|
}
|
||||||
139
pkg/whatsapp/businessapi/media.go
Normal file
139
pkg/whatsapp/businessapi/media.go
Normal 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
|
||||||
|
}
|
||||||
396
pkg/whatsapp/businessapi/types.go
Normal file
396
pkg/whatsapp/businessapi/types.go
Normal 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
34
pkg/whatsapp/interface.go
Normal 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
214
pkg/whatsapp/manager.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
772
pkg/whatsapp/whatsmeow/client.go
Normal file
772
pkg/whatsapp/whatsmeow/client.go
Normal 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
178
pkg/whatshooked/options.go
Normal 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
313
pkg/whatshooked/server.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
194
pkg/whatshooked/whatshooked.go
Normal file
194
pkg/whatshooked/whatshooked.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user