initial commit
This commit is contained in:
0
.claude/readme
Normal file
0
.claude/readme
Normal file
25
.gitignore
vendored
25
.gitignore
vendored
@@ -21,3 +21,28 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# WhatsHooked specific
|
||||||
|
# Binaries (keep directory structure)
|
||||||
|
bin/*
|
||||||
|
!bin/.gitkeep
|
||||||
|
|
||||||
|
# Config files (keep only example)
|
||||||
|
config.json
|
||||||
|
.whatshooked-cli.json
|
||||||
|
|
||||||
|
# Session data
|
||||||
|
sessions/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
3
.whatshooked-cli.example.json
Normal file
3
.whatshooked-cli.example.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://localhost:8080"
|
||||||
|
}
|
||||||
35
AI_USE.md
Normal file
35
AI_USE.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# AI Usage Declaration
|
||||||
|
|
||||||
|
This Go project utilizes AI tools for the following purposes:
|
||||||
|
|
||||||
|
- Generating and improving documentation
|
||||||
|
- Writing and enhancing tests
|
||||||
|
- Refactoring and optimizing existing code
|
||||||
|
|
||||||
|
AI is **not** used for core design or architecture decisions.
|
||||||
|
All design decisions are deferred to human discussion.
|
||||||
|
AI is employed only for enhancements to human-written code.
|
||||||
|
|
||||||
|
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
|
||||||
|
|
||||||
|
|
||||||
|
.-""""""-.
|
||||||
|
.' '.
|
||||||
|
/ O O \
|
||||||
|
: ` :
|
||||||
|
| |
|
||||||
|
: .------. :
|
||||||
|
\ ' ' /
|
||||||
|
'. .'
|
||||||
|
'-......-'
|
||||||
|
MEGAMIND AI
|
||||||
|
[============]
|
||||||
|
|
||||||
|
___________
|
||||||
|
/___________\
|
||||||
|
/_____________\
|
||||||
|
| ASSIMILATE |
|
||||||
|
| RESISTANCE |
|
||||||
|
| IS FUTILE |
|
||||||
|
\_____________/
|
||||||
|
\___________/
|
||||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 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
|
||||||
65
Makefile
Normal file
65
Makefile
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
.PHONY: build clean test run-server run-cli help
|
||||||
|
|
||||||
|
# Build both server and CLI
|
||||||
|
build:
|
||||||
|
@echo "Building WhatsHooked..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@go build -o bin/whatshook-server ./cmd/server
|
||||||
|
@go build -o bin/whatshook-cli ./cmd/cli
|
||||||
|
@echo "Build complete! Binaries in bin/"
|
||||||
|
|
||||||
|
# Build server only
|
||||||
|
build-server:
|
||||||
|
@echo "Building server..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@go build -o bin/whatshook-server ./cmd/server
|
||||||
|
@echo "Server built: bin/whatshook-server"
|
||||||
|
|
||||||
|
# Build CLI only
|
||||||
|
build-cli:
|
||||||
|
@echo "Building CLI..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@go build -o bin/whatshook-cli ./cmd/cli
|
||||||
|
@echo "CLI built: bin/whatshook-cli"
|
||||||
|
|
||||||
|
# Clean build artifacts (preserves bin directory)
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@rm -f bin/whatshook*
|
||||||
|
@echo "Clean complete!"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
@go test ./...
|
||||||
|
|
||||||
|
# Run server (requires config.json)
|
||||||
|
run-server:
|
||||||
|
@go run ./cmd/server -config config.json
|
||||||
|
|
||||||
|
# Run CLI
|
||||||
|
run-cli:
|
||||||
|
@go run ./cmd/cli $(ARGS)
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
deps:
|
||||||
|
@echo "Installing dependencies..."
|
||||||
|
@go mod download
|
||||||
|
@go mod tidy
|
||||||
|
@echo "Dependencies installed!"
|
||||||
|
|
||||||
|
# Help
|
||||||
|
help:
|
||||||
|
@echo "WhatsHooked Makefile"
|
||||||
|
@echo ""
|
||||||
|
@echo "Usage:"
|
||||||
|
@echo " make build - Build server and CLI"
|
||||||
|
@echo " make build-server - Build server only"
|
||||||
|
@echo " make build-cli - Build CLI only"
|
||||||
|
@echo " make clean - Remove build artifacts (preserves bin directory)"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo " make run-server - Run server (requires config.json)"
|
||||||
|
@echo " make run-cli ARGS='health' - Run CLI with arguments"
|
||||||
|
@echo " make deps - Install dependencies"
|
||||||
|
@echo " make help - Show this help message"
|
||||||
37
PLAN.md
Normal file
37
PLAN.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
Lets create a new project. I've already created the go.mod file.
|
||||||
|
Be pricise and summaritive.
|
||||||
|
The project is written in modern go.mod
|
||||||
|
|
||||||
|
The project must do the following:
|
||||||
|
A service that connects to whatsapp via the whatsmeow api. When new message are received, it will send the message to all registered hooks.
|
||||||
|
The service must have a list of registered web hooks.
|
||||||
|
Whet a hook is called, it must send a message to whatsapp.
|
||||||
|
Name the hooks and enpoints correctly.
|
||||||
|
Two way communication is needed.
|
||||||
|
|
||||||
|
First Phase:
|
||||||
|
Instance / Config level hooks and whatsapp accounts.
|
||||||
|
Text/HTML messages only for a start.
|
||||||
|
- config package: That contains all configuration data for the application, including database connection information, API keys, etc.
|
||||||
|
- logging package: This package should handle logging in a structured way. It should be able to log errors, warnings, and other messages with different levels of severity.
|
||||||
|
- whatsapp package: This package must use https://github.com/tulir/whatsmeow to connect to multiple whatsapp accounts.
|
||||||
|
- whatsapp api package: This package must use the official whatsapp business api for sending messages to whatsapp accounts.
|
||||||
|
- whatshook server command: The server should start and connect to a given whatsapp accounts via config. New message must be pushed to all created hooks.
|
||||||
|
- whatshook cli command: Do connection via server, and push new message to hooks via server. Check server health. Add accounts. Add hooks.
|
||||||
|
|
||||||
|
events system: Whatsapp api events must be sent / received via the event system. The events system must publish to the hooks and whatsapp apis.
|
||||||
|
For example, whatsapp notifies of connect,disconnect message received events etc.
|
||||||
|
Web handlers for hooks to send whatsapp messages. Events must then be publish on successfull or failes sends.
|
||||||
|
Document/Images message and other types of messages.
|
||||||
|
|
||||||
|
|
||||||
|
Second Phase:
|
||||||
|
User level hooks and whatsapp accounts.
|
||||||
|
|
||||||
|
- webserver package: Must provide a web server that can serve the application's frontend and API endpoints. based on https://github.com/bitechdev/ResolveSpec
|
||||||
|
- webserver template subpackage: Must contain all templates for the application.
|
||||||
|
- api subpackage: Must contain all API endpoints and implement https://github.com/bitechdev/ResolveSpec
|
||||||
|
- auth package: This package should handle authentication in a secure way. It should be able to authenticate users, generate tokens, and verify user credentials.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
451
README.md
451
README.md
@@ -1,3 +1,450 @@
|
|||||||
# whatshooked
|
# 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Phase 1 Features
|
||||||
|
|
||||||
|
- **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously
|
||||||
|
- **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages
|
||||||
|
- **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp
|
||||||
|
- **Instance/Config Level Hooks**: Global hooks that receive all messages from all accounts
|
||||||
|
- **Media Support**: Send and receive images, videos, and documents
|
||||||
|
- **CLI Management**: Command-line tool for managing accounts and hooks
|
||||||
|
- **Structured Logging**: JSON-based logging with configurable log levels
|
||||||
|
- **Authentication**: HTTP Basic Auth and API key authentication for server endpoints
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project uses an event-driven architecture with the following packages:
|
||||||
|
|
||||||
|
- **internal/config**: Configuration management and persistence
|
||||||
|
- **internal/logging**: Structured logging using Go's slog package
|
||||||
|
- **internal/events**: Event bus for publish/subscribe messaging between components
|
||||||
|
- **internal/whatsapp**: WhatsApp client management using whatsmeow
|
||||||
|
- **internal/hooks**: Webhook management and message forwarding
|
||||||
|
- **cmd/server**: Main server application
|
||||||
|
- **cmd/cli**: Command-line interface for management
|
||||||
|
|
||||||
|
### Event-Driven Architecture
|
||||||
|
|
||||||
|
The system uses a central event bus to decouple components:
|
||||||
|
|
||||||
|
1. **WhatsApp Events** → Event Bus → Hook Manager
|
||||||
|
- Connection/disconnection events
|
||||||
|
- Message received events
|
||||||
|
- Message sent/failed events
|
||||||
|
|
||||||
|
2. **Hook Events** → Event Bus → WhatsApp Manager
|
||||||
|
- Hook triggered events
|
||||||
|
- Hook success/failure events
|
||||||
|
- Webhook responses trigger message sends
|
||||||
|
|
||||||
|
This architecture enables:
|
||||||
|
- Loose coupling between WhatsApp and webhooks
|
||||||
|
- Easy addition of new event subscribers
|
||||||
|
- Centralized event logging and monitoring
|
||||||
|
- Two-way communication through event responses
|
||||||
|
- Context propagation for cancellation and timeout handling
|
||||||
|
- Proper request lifecycle management across components
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```bash
|
||||||
|
mkdir -p bin
|
||||||
|
go build -o bin/whatshook-server ./cmd/server
|
||||||
|
go build -o bin/whatshook-cli ./cmd/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `config.json` file based on the example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the configuration file to add your WhatsApp accounts and webhooks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080,
|
||||||
|
"default_country_code": "27"
|
||||||
|
},
|
||||||
|
"whatsapp": [
|
||||||
|
{
|
||||||
|
"id": "account1",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"session_path": "./sessions/account1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"id": "hook1",
|
||||||
|
"name": "My Webhook",
|
||||||
|
"url": "https://example.com/webhook",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer token"
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"description": "Webhook description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
**Server Configuration:**
|
||||||
|
- `host`: Server hostname (default: "localhost")
|
||||||
|
- `port`: Server port (default: 8080)
|
||||||
|
- `default_country_code`: Default country code for phone number formatting (e.g., "27" for South Africa, "1" for US/Canada)
|
||||||
|
- `username`: Username for HTTP Basic Authentication (optional)
|
||||||
|
- `password`: Password for HTTP Basic Authentication (optional)
|
||||||
|
- `auth_key`: API key for x-api-key header or Authorization Bearer token authentication (optional)
|
||||||
|
|
||||||
|
**WhatsApp Account Configuration:**
|
||||||
|
- `id`: Unique identifier for this account
|
||||||
|
- `phone_number`: Phone number with country code
|
||||||
|
- `session_path`: Path to store session data
|
||||||
|
|
||||||
|
**Hook Configuration:**
|
||||||
|
- `id`: Unique identifier for this hook
|
||||||
|
- `name`: Human-readable name
|
||||||
|
- `url`: Webhook URL to call
|
||||||
|
- `method`: HTTP method (usually "POST")
|
||||||
|
- `headers`: Optional HTTP headers
|
||||||
|
- `active`: Whether this hook is enabled
|
||||||
|
- `description`: Optional description
|
||||||
|
|
||||||
|
### Server Authentication
|
||||||
|
|
||||||
|
The server supports two authentication methods to protect API endpoints:
|
||||||
|
|
||||||
|
#### 1. HTTP Basic Authentication
|
||||||
|
Set both `username` and `password` in the server configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secure_password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients must provide credentials in the Authorization header:
|
||||||
|
```bash
|
||||||
|
curl -u admin:secure_password http://localhost:8080/api/hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. API Key Authentication
|
||||||
|
Set `auth_key` in the server configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080,
|
||||||
|
"auth_key": "your-secret-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients can provide the API key using either:
|
||||||
|
- **x-api-key header**:
|
||||||
|
```bash
|
||||||
|
curl -H "x-api-key: your-secret-api-key" http://localhost:8080/api/hooks
|
||||||
|
```
|
||||||
|
- **Authorization Bearer token**:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer your-secret-api-key" http://localhost:8080/api/hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Authentication Notes
|
||||||
|
|
||||||
|
- If no authentication is configured (all fields empty), the server operates without authentication
|
||||||
|
- The `/health` endpoint is always accessible without authentication
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Starting the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-server -config config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run, you'll need to pair your WhatsApp account. The QR code will be displayed directly in the terminal for easy scanning:
|
||||||
|
|
||||||
|
```
|
||||||
|
========================================
|
||||||
|
WhatsApp QR Code for account: account1
|
||||||
|
Phone: +1234567890
|
||||||
|
========================================
|
||||||
|
Scan this QR code with WhatsApp on your phone:
|
||||||
|
[QR CODE DISPLAYED HERE]
|
||||||
|
========================================
|
||||||
|
```
|
||||||
|
|
||||||
|
The QR code is also published as an event (`whatsapp.qr.code`) so you can handle it programmatically if needed.
|
||||||
|
|
||||||
|
### Using the CLI
|
||||||
|
|
||||||
|
The CLI uses Cobra and supports configuration from multiple sources with the following priority:
|
||||||
|
1. Command-line flags (highest priority)
|
||||||
|
2. Environment variables
|
||||||
|
3. Configuration file (lowest priority)
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
Create a CLI configuration file (optional):
|
||||||
|
```bash
|
||||||
|
cp .whatshooked-cli.example.json .whatshooked-cli.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set via environment variable:
|
||||||
|
```bash
|
||||||
|
export WHATSHOOKED_SERVER_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use command-line flag:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli --server http://localhost:8080 health
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Commands
|
||||||
|
|
||||||
|
Get help:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli --help
|
||||||
|
./bin/whatshook-cli hooks --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Check server health:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli health
|
||||||
|
```
|
||||||
|
|
||||||
|
List all hooks:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli hooks list
|
||||||
|
# or just
|
||||||
|
./bin/whatshook-cli hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new hook:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli hooks add
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove a hook:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli hooks remove <hook_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
List WhatsApp accounts:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli accounts list
|
||||||
|
# or just
|
||||||
|
./bin/whatshook-cli accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a WhatsApp account:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli accounts add
|
||||||
|
```
|
||||||
|
|
||||||
|
Send a message:
|
||||||
|
```bash
|
||||||
|
./bin/whatshook-cli send
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Configuration Priority
|
||||||
|
|
||||||
|
The CLI loads configuration with the following priority (highest to lowest):
|
||||||
|
|
||||||
|
1. **Command-line flags**: `--server http://example.com:8080`
|
||||||
|
2. **Environment variables**: `WHATSHOOKED_SERVER_URL=http://example.com:8080`
|
||||||
|
3. **Config file**: `.whatshooked-cli.json` in current directory or `$HOME/.whatshooked/cli.json`
|
||||||
|
4. **Defaults**: `http://localhost:8080`
|
||||||
|
|
||||||
|
## Webhook Integration
|
||||||
|
|
||||||
|
### Incoming Message Format
|
||||||
|
|
||||||
|
When a WhatsApp message is received, all active webhooks receive a POST request with the following JSON payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_id": "account1",
|
||||||
|
"message_id": "3EB0123456789ABCDEF",
|
||||||
|
"from": "1234567890@s.whatsapp.net",
|
||||||
|
"to": "9876543210@s.whatsapp.net",
|
||||||
|
"text": "Hello, World!",
|
||||||
|
"timestamp": "2025-12-28T10:30:00Z",
|
||||||
|
"is_group": false,
|
||||||
|
"group_name": "",
|
||||||
|
"sender_name": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Response Format
|
||||||
|
|
||||||
|
Webhooks can respond with a JSON payload to send a message back to WhatsApp:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"send_message": true,
|
||||||
|
"to": "0834606792",
|
||||||
|
"text": "This is a response message",
|
||||||
|
"account_id": "account1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using full JID format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"send_message": true,
|
||||||
|
"to": "27834606792@s.whatsapp.net",
|
||||||
|
"text": "This is a response message",
|
||||||
|
"account_id": "account1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `send_message`: Set to `true` to send a message
|
||||||
|
- `to`: Recipient phone number or JID. Can be:
|
||||||
|
- Plain phone number (e.g., `"0834606792"`) - will be formatted using `default_country_code`
|
||||||
|
- Phone number with country code (e.g., `"27834606792"`)
|
||||||
|
- Full JID format (e.g., `"27834606792@s.whatsapp.net"`)
|
||||||
|
- `text`: Message text to send
|
||||||
|
- `account_id`: (Optional) Which WhatsApp account to use. If not specified, uses the account that received the original message
|
||||||
|
|
||||||
|
#### Phone Number Formatting
|
||||||
|
|
||||||
|
The server automatically formats phone numbers to WhatsApp JID format:
|
||||||
|
|
||||||
|
1. If the number contains `@`, it's used as-is (already in JID format)
|
||||||
|
2. Otherwise, formatting rules apply:
|
||||||
|
- Removes all non-digit characters (spaces, dashes, parentheses, etc.)
|
||||||
|
- **If starts with 0**: Assumes no country code and replaces the 0 with the `default_country_code`
|
||||||
|
- **If starts with +**: Assumes it already has a country code
|
||||||
|
- **Otherwise**: Adds country code if configured and not already present
|
||||||
|
- Appends `@s.whatsapp.net` suffix
|
||||||
|
|
||||||
|
Examples with `default_country_code: "27"`:
|
||||||
|
- `0834606792` → `27834606792@s.whatsapp.net` (replaces leading 0 with 27)
|
||||||
|
- `083-460-6792` → `27834606792@s.whatsapp.net` (removes dashes, replaces 0)
|
||||||
|
- `27834606792` → `27834606792@s.whatsapp.net` (already has country code)
|
||||||
|
- `+27834606792` → `27834606792@s.whatsapp.net` (+ indicates country code present)
|
||||||
|
- `27834606792@s.whatsapp.net` → `27834606792@s.whatsapp.net` (unchanged, already JID)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The server exposes the following HTTP endpoints:
|
||||||
|
|
||||||
|
- `GET /health` - Health check (no authentication required)
|
||||||
|
- `GET /api/hooks` - List all hooks (requires authentication if enabled)
|
||||||
|
- `POST /api/hooks/add` - Add a new hook (requires authentication if enabled)
|
||||||
|
- `POST /api/hooks/remove` - Remove a hook (requires authentication if enabled)
|
||||||
|
- `GET /api/accounts` - List all WhatsApp accounts (requires authentication if enabled)
|
||||||
|
- `POST /api/accounts/add` - Add a new WhatsApp account (requires authentication if enabled)
|
||||||
|
- `POST /api/send` - Send a message (requires authentication if enabled)
|
||||||
|
- `POST /api/send/image` - Send an image (requires authentication if enabled)
|
||||||
|
- `POST /api/send/video` - Send a video (requires authentication if enabled)
|
||||||
|
- `POST /api/send/document` - Send a document (requires authentication if enabled)
|
||||||
|
- `GET /api/media/{accountID}/{filename}` - Serve media files (requires authentication if enabled)
|
||||||
|
|
||||||
|
## WhatsApp JID Format
|
||||||
|
|
||||||
|
WhatsApp uses JID (Jabber ID) format for addressing:
|
||||||
|
|
||||||
|
- Individual: `1234567890@s.whatsapp.net`
|
||||||
|
- Group: `123456789-1234567890@g.us`
|
||||||
|
|
||||||
|
The server accepts both full JID format and plain phone numbers. When using plain phone numbers, they are automatically formatted to JID format based on the `default_country_code` configuration. See [Phone Number Formatting](#phone-number-formatting) for details.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
whatshooked/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── server/ # Main server application
|
||||||
|
│ └── cli/ # CLI tool
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Configuration management
|
||||||
|
│ ├── events/ # Event bus and event types
|
||||||
|
│ ├── logging/ # Structured logging
|
||||||
|
│ ├── whatsapp/ # WhatsApp client management
|
||||||
|
│ ├── hooks/ # Webhook management
|
||||||
|
│ └── utils/ # Utility functions (phone formatting, etc.)
|
||||||
|
├── config.example.json # Example configuration
|
||||||
|
└── go.mod # Go module definition
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
The system publishes the following event types:
|
||||||
|
|
||||||
|
**WhatsApp Events:**
|
||||||
|
- `whatsapp.connected` - WhatsApp client connected
|
||||||
|
- `whatsapp.disconnected` - WhatsApp client disconnected
|
||||||
|
- `whatsapp.pair.success` - Device pairing successful
|
||||||
|
- `whatsapp.pair.failed` - Device pairing failed
|
||||||
|
- `whatsapp.qr.code` - QR code generated for pairing (includes qr_code data)
|
||||||
|
- `whatsapp.qr.timeout` - QR code expired
|
||||||
|
- `whatsapp.qr.error` - QR code generation error
|
||||||
|
- `whatsapp.pair.event` - Generic pairing event
|
||||||
|
|
||||||
|
**Message Events:**
|
||||||
|
- `message.received` - New message received from WhatsApp
|
||||||
|
- `message.sent` - Message successfully sent to WhatsApp
|
||||||
|
- `message.failed` - Message send failed
|
||||||
|
|
||||||
|
**Hook Events:**
|
||||||
|
- `hook.triggered` - Webhook is being called
|
||||||
|
- `hook.success` - Webhook responded successfully
|
||||||
|
- `hook.failed` - Webhook call failed
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Phases
|
||||||
|
|
||||||
|
### Phase 2 (Planned)
|
||||||
|
- User level hooks and WhatsApp accounts
|
||||||
|
- Web server with frontend UI
|
||||||
|
- Enhanced authentication with user roles and permissions
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See LICENSE file for details.
|
||||||
|
|||||||
BIN
assets/image/whatshooked.jpg
Normal file
BIN
assets/image/whatshooked.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
assets/image/whatshooked_tp.png
Normal file
BIN
assets/image/whatshooked_tp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
0
bin/.gitkeep
Normal file
0
bin/.gitkeep
Normal file
56
cmd/cli/config.go
Normal file
56
cmd/cli/config.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CLIConfig holds the CLI configuration
|
||||||
|
type CLIConfig struct {
|
||||||
|
ServerURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
|
||||||
|
func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
|
||||||
|
v := viper.New()
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
v.SetDefault("server_url", "http://localhost:8080")
|
||||||
|
|
||||||
|
// 1. Load from config file (lowest priority)
|
||||||
|
if configFile != "" {
|
||||||
|
v.SetConfigFile(configFile)
|
||||||
|
} else {
|
||||||
|
// Look for config in home directory
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
v.AddConfigPath(filepath.Join(home, ".whatshooked"))
|
||||||
|
v.SetConfigName("cli")
|
||||||
|
v.SetConfigType("json")
|
||||||
|
}
|
||||||
|
// Also look in current directory
|
||||||
|
v.AddConfigPath(".")
|
||||||
|
v.SetConfigName(".whatshooked-cli")
|
||||||
|
v.SetConfigType("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read config file if it exists (don't error if it doesn't)
|
||||||
|
_ = v.ReadInConfig()
|
||||||
|
|
||||||
|
// 2. Override with environment variables (medium priority)
|
||||||
|
v.SetEnvPrefix("WHATSHOOKED")
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
// 3. Override with command-line flag (highest priority)
|
||||||
|
if serverFlag != "" {
|
||||||
|
v.Set("server_url", serverFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &CLIConfig{
|
||||||
|
ServerURL: v.GetString("server_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
629
cmd/cli/main.go
Normal file
629
cmd/cli/main.go
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfgFile string
|
||||||
|
serverURL string
|
||||||
|
cliConfig *CLIConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "whatshook-cli",
|
||||||
|
Short: "WhatsHooked CLI - Manage WhatsApp webhooks",
|
||||||
|
Long: `A command-line interface for managing WhatsHooked server, hooks, and WhatsApp accounts.`,
|
||||||
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
var err error
|
||||||
|
cliConfig, err = LoadCLIConfig(cfgFile, serverURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.whatshooked/cli.json)")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&serverURL, "server", "", "server URL (default: http://localhost:8080)")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(healthCmd)
|
||||||
|
rootCmd.AddCommand(hooksCmd)
|
||||||
|
rootCmd.AddCommand(accountsCmd)
|
||||||
|
rootCmd.AddCommand(sendCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health command
|
||||||
|
var healthCmd = &cobra.Command{
|
||||||
|
Use: "health",
|
||||||
|
Short: "Check server health",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHealth(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks command group
|
||||||
|
var hooksCmd = &cobra.Command{
|
||||||
|
Use: "hooks",
|
||||||
|
Short: "Manage webhooks",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
listHooks(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var hooksListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all hooks",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
listHooks(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var hooksAddCmd = &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a new hook",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
addHook(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var hooksRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <hook_id>",
|
||||||
|
Short: "Remove a hook",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
removeHook(cliConfig.ServerURL, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hooksCmd.AddCommand(hooksListCmd)
|
||||||
|
hooksCmd.AddCommand(hooksAddCmd)
|
||||||
|
hooksCmd.AddCommand(hooksRemoveCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts command group
|
||||||
|
var accountsCmd = &cobra.Command{
|
||||||
|
Use: "accounts",
|
||||||
|
Short: "Manage WhatsApp accounts",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
listAccounts(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountsListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all accounts",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
listAccounts(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountsAddCmd = &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a new WhatsApp account",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
addAccount(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
accountsCmd.AddCommand(accountsListCmd)
|
||||||
|
accountsCmd.AddCommand(accountsAddCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command group
|
||||||
|
var sendCmd = &cobra.Command{
|
||||||
|
Use: "send",
|
||||||
|
Short: "Send messages",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendTextCmd = &cobra.Command{
|
||||||
|
Use: "text",
|
||||||
|
Short: "Send a text message",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
sendMessage(cliConfig.ServerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendImageCmd = &cobra.Command{
|
||||||
|
Use: "image <file_path>",
|
||||||
|
Short: "Send an image",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
sendImage(cliConfig.ServerURL, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendVideoCmd = &cobra.Command{
|
||||||
|
Use: "video <file_path>",
|
||||||
|
Short: "Send a video",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
sendVideo(cliConfig.ServerURL, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendDocumentCmd = &cobra.Command{
|
||||||
|
Use: "document <file_path>",
|
||||||
|
Short: "Send a document",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
sendDocument(cliConfig.ServerURL, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sendCmd.AddCommand(sendTextCmd)
|
||||||
|
sendCmd.AddCommand(sendImageCmd)
|
||||||
|
sendCmd.AddCommand(sendVideoCmd)
|
||||||
|
sendCmd.AddCommand(sendDocumentCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func checkHealth(serverURL string) {
|
||||||
|
resp, err := http.Get(serverURL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result map[string]string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
fmt.Printf("Error decoding response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Server status: %s\n", result["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func listHooks(serverURL string) {
|
||||||
|
resp, err := http.Get(serverURL + "/api/hooks")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var hooks []config.Hook
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil {
|
||||||
|
fmt.Printf("Error decoding response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hooks) == 0 {
|
||||||
|
fmt.Println("No hooks configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Configured hooks (%d):\n\n", len(hooks))
|
||||||
|
for _, hook := range hooks {
|
||||||
|
status := "inactive"
|
||||||
|
if hook.Active {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
fmt.Printf("ID: %s\n", hook.ID)
|
||||||
|
fmt.Printf("Name: %s\n", hook.Name)
|
||||||
|
fmt.Printf("URL: %s\n", hook.URL)
|
||||||
|
fmt.Printf("Method: %s\n", hook.Method)
|
||||||
|
fmt.Printf("Status: %s\n", status)
|
||||||
|
if hook.Description != "" {
|
||||||
|
fmt.Printf("Description: %s\n", hook.Description)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addHook(serverURL string) {
|
||||||
|
var hook config.Hook
|
||||||
|
|
||||||
|
fmt.Print("Hook ID: ")
|
||||||
|
fmt.Scanln(&hook.ID)
|
||||||
|
|
||||||
|
fmt.Print("Hook Name: ")
|
||||||
|
fmt.Scanln(&hook.Name)
|
||||||
|
|
||||||
|
fmt.Print("Webhook URL: ")
|
||||||
|
fmt.Scanln(&hook.URL)
|
||||||
|
|
||||||
|
fmt.Print("HTTP Method (POST): ")
|
||||||
|
fmt.Scanln(&hook.Method)
|
||||||
|
if hook.Method == "" {
|
||||||
|
hook.Method = "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Description (optional): ")
|
||||||
|
fmt.Scanln(&hook.Description)
|
||||||
|
|
||||||
|
hook.Active = true
|
||||||
|
|
||||||
|
data, err := json.Marshal(hook)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/hooks/add", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Hook added successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeHook(serverURL string, id string) {
|
||||||
|
req := map[string]string{"id": id}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/hooks/remove", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Hook removed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAccounts(serverURL string) {
|
||||||
|
resp, err := http.Get(serverURL + "/api/accounts")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var accounts []config.WhatsAppConfig
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
|
||||||
|
fmt.Printf("Error decoding response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
fmt.Println("No accounts configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Configured accounts (%d):\n\n", len(accounts))
|
||||||
|
for _, acc := range accounts {
|
||||||
|
fmt.Printf("ID: %s\n", acc.ID)
|
||||||
|
fmt.Printf("Phone Number: %s\n", acc.PhoneNumber)
|
||||||
|
fmt.Printf("Session Path: %s\n", acc.SessionPath)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAccount(serverURL string) {
|
||||||
|
var account config.WhatsAppConfig
|
||||||
|
|
||||||
|
fmt.Print("Account ID: ")
|
||||||
|
fmt.Scanln(&account.ID)
|
||||||
|
|
||||||
|
fmt.Print("Phone Number (with country code): ")
|
||||||
|
fmt.Scanln(&account.PhoneNumber)
|
||||||
|
|
||||||
|
fmt.Print("Session Path: ")
|
||||||
|
fmt.Scanln(&account.SessionPath)
|
||||||
|
|
||||||
|
data, err := json.Marshal(account)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/accounts/add", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Account added successfully")
|
||||||
|
fmt.Println("Check server logs for QR code to pair the device")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(serverURL string) {
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Account ID: ")
|
||||||
|
fmt.Scanln(&req.AccountID)
|
||||||
|
|
||||||
|
fmt.Print("Recipient (phone number or JID, e.g., 0834606792 or 1234567890@s.whatsapp.net): ")
|
||||||
|
fmt.Scanln(&req.To)
|
||||||
|
|
||||||
|
fmt.Print("Message text: ")
|
||||||
|
reader := os.Stdin
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := reader.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading input: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
req.Text = string(buf[:n])
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/send", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Message sent successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendImage(serverURL string, filePath string) {
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
ImageData string `json:"image_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Account ID: ")
|
||||||
|
fmt.Scanln(&req.AccountID)
|
||||||
|
|
||||||
|
fmt.Print("Recipient (phone number): ")
|
||||||
|
fmt.Scanln(&req.To)
|
||||||
|
|
||||||
|
fmt.Print("Caption (optional): ")
|
||||||
|
reader := os.Stdin
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := reader.Read(buf)
|
||||||
|
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||||
|
|
||||||
|
// Read image file
|
||||||
|
imageData, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading image file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
req.ImageData = base64.StdEncoding.EncodeToString(imageData)
|
||||||
|
|
||||||
|
// Detect mime type from extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
req.MimeType = "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
req.MimeType = "image/png"
|
||||||
|
case ".gif":
|
||||||
|
req.MimeType = "image/gif"
|
||||||
|
case ".webp":
|
||||||
|
req.MimeType = "image/webp"
|
||||||
|
default:
|
||||||
|
req.MimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/send/image", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Image sent successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVideo(serverURL string, filePath string) {
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
VideoData string `json:"video_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Account ID: ")
|
||||||
|
fmt.Scanln(&req.AccountID)
|
||||||
|
|
||||||
|
fmt.Print("Recipient (phone number): ")
|
||||||
|
fmt.Scanln(&req.To)
|
||||||
|
|
||||||
|
fmt.Print("Caption (optional): ")
|
||||||
|
reader := os.Stdin
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := reader.Read(buf)
|
||||||
|
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||||
|
|
||||||
|
// Read video file
|
||||||
|
videoData, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading video file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
req.VideoData = base64.StdEncoding.EncodeToString(videoData)
|
||||||
|
|
||||||
|
// Detect mime type from extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4":
|
||||||
|
req.MimeType = "video/mp4"
|
||||||
|
case ".mov":
|
||||||
|
req.MimeType = "video/quicktime"
|
||||||
|
case ".avi":
|
||||||
|
req.MimeType = "video/x-msvideo"
|
||||||
|
case ".webm":
|
||||||
|
req.MimeType = "video/webm"
|
||||||
|
case ".3gp":
|
||||||
|
req.MimeType = "video/3gpp"
|
||||||
|
default:
|
||||||
|
req.MimeType = "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/send/video", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Video sent successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDocument(serverURL string, filePath string) {
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
DocumentData string `json:"document_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Account ID: ")
|
||||||
|
fmt.Scanln(&req.AccountID)
|
||||||
|
|
||||||
|
fmt.Print("Recipient (phone number): ")
|
||||||
|
fmt.Scanln(&req.To)
|
||||||
|
|
||||||
|
fmt.Print("Caption (optional): ")
|
||||||
|
reader := os.Stdin
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := reader.Read(buf)
|
||||||
|
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||||
|
|
||||||
|
// Read document file
|
||||||
|
documentData, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading document file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
req.DocumentData = base64.StdEncoding.EncodeToString(documentData)
|
||||||
|
|
||||||
|
// Use the original filename
|
||||||
|
req.Filename = filepath.Base(filePath)
|
||||||
|
|
||||||
|
// Detect mime type from extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
switch ext {
|
||||||
|
case ".pdf":
|
||||||
|
req.MimeType = "application/pdf"
|
||||||
|
case ".doc":
|
||||||
|
req.MimeType = "application/msword"
|
||||||
|
case ".docx":
|
||||||
|
req.MimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
case ".xls":
|
||||||
|
req.MimeType = "application/vnd.ms-excel"
|
||||||
|
case ".xlsx":
|
||||||
|
req.MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
case ".txt":
|
||||||
|
req.MimeType = "text/plain"
|
||||||
|
case ".zip":
|
||||||
|
req.MimeType = "application/zip"
|
||||||
|
default:
|
||||||
|
req.MimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(serverURL+"/api/send/document", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Error: %s\n", string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Document sent successfully")
|
||||||
|
}
|
||||||
573
cmd/server/main.go
Normal file
573
cmd/server/main.go
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/config"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/events"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/hooks"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/logging"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/utils"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/whatsapp"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configPath = flag.String("config", "config.json", "Path to configuration file")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
config *config.Config
|
||||||
|
whatsappMgr *whatsapp.Manager
|
||||||
|
hookMgr *hooks.Manager
|
||||||
|
httpServer *http.Server
|
||||||
|
eventBus *events.EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
logging.Init(cfg.LogLevel)
|
||||||
|
logging.Info("Starting WhatsHooked server")
|
||||||
|
|
||||||
|
// Create event bus
|
||||||
|
eventBus := events.NewEventBus()
|
||||||
|
|
||||||
|
// Create server with config update callback
|
||||||
|
srv := &Server{
|
||||||
|
config: cfg,
|
||||||
|
eventBus: eventBus,
|
||||||
|
whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, *configPath, func(updatedCfg *config.Config) error {
|
||||||
|
return config.Save(*configPath, updatedCfg)
|
||||||
|
}),
|
||||||
|
hookMgr: hooks.NewManager(eventBus),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hooks
|
||||||
|
srv.hookMgr.LoadHooks(cfg.Hooks)
|
||||||
|
|
||||||
|
// Start hook manager to listen for events
|
||||||
|
srv.hookMgr.Start()
|
||||||
|
|
||||||
|
// Subscribe to hook success events to handle webhook responses
|
||||||
|
srv.eventBus.Subscribe(events.EventHookSuccess, srv.handleHookResponse)
|
||||||
|
|
||||||
|
// Start HTTP server for CLI BEFORE connecting to WhatsApp
|
||||||
|
// This ensures all infrastructure is ready before events start flowing
|
||||||
|
srv.startHTTPServer()
|
||||||
|
|
||||||
|
// Give HTTP server a moment to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
|
||||||
|
|
||||||
|
// Connect to WhatsApp accounts
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, waCfg := range cfg.WhatsApp {
|
||||||
|
if err := srv.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
||||||
|
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for interrupt signal
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
logging.Info("Shutting down server")
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if srv.httpServer != nil {
|
||||||
|
srv.httpServer.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.whatsappMgr.DisconnectAll()
|
||||||
|
logging.Info("Server stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHookResponse processes hook success events to handle two-way communication
|
||||||
|
func (s *Server) handleHookResponse(event events.Event) {
|
||||||
|
// Use event context for sending message
|
||||||
|
ctx := event.Context
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response from event data
|
||||||
|
responseData, ok := event.Data["response"]
|
||||||
|
if !ok || responseData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to cast to HookResponse
|
||||||
|
resp, ok := responseData.(hooks.HookResponse)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.SendMessage {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which account to use - default to first available if not specified
|
||||||
|
targetAccountID := resp.AccountID
|
||||||
|
if targetAccountID == "" && len(s.config.WhatsApp) > 0 {
|
||||||
|
targetAccountID = s.config.WhatsApp[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(resp.To, s.config.Server.DefaultCountryCode)
|
||||||
|
|
||||||
|
// Parse JID
|
||||||
|
jid, err := types.ParseJID(formattedJID)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message with context
|
||||||
|
if err := s.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
|
||||||
|
logging.Error("Failed to send message from hook response", "error", err)
|
||||||
|
} else {
|
||||||
|
logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware validates authentication credentials
|
||||||
|
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if any authentication is configured
|
||||||
|
hasAuth := s.config.Server.Username != "" || s.config.Server.Password != "" || s.config.Server.AuthKey != ""
|
||||||
|
|
||||||
|
if !hasAuth {
|
||||||
|
// No authentication configured, allow access
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated := false
|
||||||
|
|
||||||
|
// Check for API key authentication (x-api-key header or Authorization bearer token)
|
||||||
|
if s.config.Server.AuthKey != "" {
|
||||||
|
// Check x-api-key header
|
||||||
|
apiKey := r.Header.Get("x-api-key")
|
||||||
|
if apiKey == s.config.Server.AuthKey {
|
||||||
|
authenticated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Authorization header for bearer token
|
||||||
|
if !authenticated {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
token := authHeader[7:]
|
||||||
|
if token == s.config.Server.AuthKey {
|
||||||
|
authenticated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for username/password authentication (HTTP Basic Auth)
|
||||||
|
if !authenticated && s.config.Server.Username != "" && s.config.Server.Password != "" {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if ok && username == s.config.Server.Username && password == s.config.Server.Password {
|
||||||
|
authenticated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked Server"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHTTPServer starts the HTTP server for CLI communication
|
||||||
|
func (s *Server) startHTTPServer() {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Health check (no auth required)
|
||||||
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
|
|
||||||
|
// Hook management (with auth)
|
||||||
|
mux.HandleFunc("/api/hooks", s.authMiddleware(s.handleHooks))
|
||||||
|
mux.HandleFunc("/api/hooks/add", s.authMiddleware(s.handleAddHook))
|
||||||
|
mux.HandleFunc("/api/hooks/remove", s.authMiddleware(s.handleRemoveHook))
|
||||||
|
|
||||||
|
// Account management (with auth)
|
||||||
|
mux.HandleFunc("/api/accounts", s.authMiddleware(s.handleAccounts))
|
||||||
|
mux.HandleFunc("/api/accounts/add", s.authMiddleware(s.handleAddAccount))
|
||||||
|
|
||||||
|
// Send messages (with auth)
|
||||||
|
mux.HandleFunc("/api/send", s.authMiddleware(s.handleSendMessage))
|
||||||
|
mux.HandleFunc("/api/send/image", s.authMiddleware(s.handleSendImage))
|
||||||
|
mux.HandleFunc("/api/send/video", s.authMiddleware(s.handleSendVideo))
|
||||||
|
mux.HandleFunc("/api/send/document", s.authMiddleware(s.handleSendDocument))
|
||||||
|
|
||||||
|
// Serve media files (with auth)
|
||||||
|
mux.HandleFunc("/api/media/", s.authMiddleware(s.handleServeMedia))
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
|
||||||
|
s.httpServer = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logging.Info("Starting HTTP server",
|
||||||
|
"host", s.config.Server.Host,
|
||||||
|
"port", s.config.Server.Port,
|
||||||
|
"address", addr,
|
||||||
|
)
|
||||||
|
logging.Info("HTTP server endpoints available",
|
||||||
|
"health", "/health",
|
||||||
|
"hooks", "/api/hooks",
|
||||||
|
"accounts", "/api/accounts",
|
||||||
|
"send", "/api/send",
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logging.Error("HTTP server error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP Handlers
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hooks := s.hookMgr.ListHooks()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var hook config.Hook
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.hookMgr.AddHook(hook)
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
s.config.Hooks = s.hookMgr.ListHooks()
|
||||||
|
if err := config.Save(*configPath, s.config); err != nil {
|
||||||
|
logging.Error("Failed to save config", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.hookMgr.RemoveHook(req.ID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
s.config.Hooks = s.hookMgr.ListHooks()
|
||||||
|
if err := config.Save(*configPath, s.config); err != nil {
|
||||||
|
logging.Error("Failed to save config", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(s.config.WhatsApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var account config.WhatsAppConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&account); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the account
|
||||||
|
if err := s.whatsappMgr.Connect(context.Background(), account); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
s.config.WhatsApp = append(s.config.WhatsApp, account)
|
||||||
|
if err := config.Save(*configPath, s.config); err != nil {
|
||||||
|
logging.Error("Failed to save config", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
|
||||||
|
|
||||||
|
jid, err := types.ParseJID(formattedJID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
ImageData string `json:"image_data"` // base64 encoded
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 image data
|
||||||
|
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
|
||||||
|
jid, err := types.ParseJID(formattedJID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default mime type if not provided
|
||||||
|
if req.MimeType == "" {
|
||||||
|
req.MimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
VideoData string `json:"video_data"` // base64 encoded
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 video data
|
||||||
|
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
|
||||||
|
jid, err := types.ParseJID(formattedJID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default mime type if not provided
|
||||||
|
if req.MimeType == "" {
|
||||||
|
req.MimeType = "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
DocumentData string `json:"document_data"` // base64 encoded
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 document data
|
||||||
|
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
|
||||||
|
jid, err := types.ParseJID(formattedJID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid JID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values if not provided
|
||||||
|
if req.MimeType == "" {
|
||||||
|
req.MimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
if req.Filename == "" {
|
||||||
|
req.Filename = "document"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Expected path format: /api/media/{accountID}/{filename}
|
||||||
|
path := r.URL.Path[len("/api/media/"):]
|
||||||
|
|
||||||
|
// Split path into accountID and filename
|
||||||
|
var accountID, filename string
|
||||||
|
for i, ch := range path {
|
||||||
|
if ch == '/' {
|
||||||
|
accountID = path[:i]
|
||||||
|
filename = path[i+1:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountID == "" || filename == "" {
|
||||||
|
http.Error(w, "Invalid media path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full file path
|
||||||
|
filePath := filepath.Join(s.config.Media.DataPath, accountID, filename)
|
||||||
|
|
||||||
|
// Security check: ensure the resolved path is within the media directory
|
||||||
|
mediaDir := filepath.Join(s.config.Media.DataPath, accountID)
|
||||||
|
absFilePath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
absMediaDir, err := filepath.Abs(mediaDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file path is within media directory (prevent directory traversal)
|
||||||
|
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
http.ServeFile(w, r, absFilePath)
|
||||||
|
}
|
||||||
82
config.example.json
Normal file
82
config.example.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080,
|
||||||
|
"default_country_code": "27",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"auth_key": ""
|
||||||
|
},
|
||||||
|
"whatsapp": [
|
||||||
|
{
|
||||||
|
"id": "acc1",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"session_path": "./sessions/account1",
|
||||||
|
"show_qr": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"id": "message_hook",
|
||||||
|
"name": "Message Handler",
|
||||||
|
"url": "https://6e808bc4802f4ae89db5c7eacba24083.api.mockbin.io/messages",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer your-token-here"
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"events": [
|
||||||
|
"message.received",
|
||||||
|
"message.sent",
|
||||||
|
"message.delivered",
|
||||||
|
"message.read"
|
||||||
|
],
|
||||||
|
"description": "Receives message events (incoming, outgoing, delivery receipts, and read receipts)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "connection_hook",
|
||||||
|
"name": "Connection Monitor",
|
||||||
|
"url": "https://6e808bc4802f4ae89db5c7eacba24083.api.mockbin.io/status",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer your-token-here"
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"events": [
|
||||||
|
"whatsapp.connected",
|
||||||
|
"whatsapp.disconnected"
|
||||||
|
],
|
||||||
|
"description": "Monitors WhatsApp connection status changes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qr_hook",
|
||||||
|
"name": "QR Code Handler",
|
||||||
|
"url": "https://6e808bc4802f4ae89db5c7eacba24083.api.mockbin.io/qr",
|
||||||
|
"method": "POST",
|
||||||
|
"active": false,
|
||||||
|
"events": [
|
||||||
|
"whatsapp.qr.code",
|
||||||
|
"whatsapp.qr.timeout",
|
||||||
|
"whatsapp.qr.error"
|
||||||
|
],
|
||||||
|
"description": "Handles QR code events during pairing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "all_events_logger",
|
||||||
|
"name": "All Events Logger",
|
||||||
|
"url": "https://6e808bc4802f4ae89db5c7eacba24083.api.mockbin.io/all",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer your-token-here"
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"description": "Receives ALL events (no events field means subscribe to everything)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"data_path": "./data/media",
|
||||||
|
"mode": "link",
|
||||||
|
"base_url": "http://localhost:8080"
|
||||||
|
},
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
45
go.mod
Normal file
45
go.mod
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
module git.warky.dev/wdevs/whatshooked
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||||
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.mau.fi/util v0.9.4 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/term v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
|
)
|
||||||
114
go.sum
Normal file
114
go.sum
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
|
||||||
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
|
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
||||||
|
go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32 h1:NeE9eEYY4kEJVCfCXaAU27LgAPugPHRHJdC9IpXFPzI=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32/go.mod h1:S4OWR9+hTx+54+jRzl+NfRBXnGpPm5IRPyhXB7haSd0=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||||
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
105
internal/config/config.go
Normal file
105
internal/config/config.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
161
internal/events/builders.go
Normal file
161
internal/events/builders.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WhatsAppConnectedEvent creates a WhatsApp connected event
|
||||||
|
func WhatsAppConnectedEvent(ctx context.Context, accountID string, phoneNumber string) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppConnected, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"phone_number": phoneNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppDisconnectedEvent creates a WhatsApp disconnected event
|
||||||
|
func WhatsAppDisconnectedEvent(ctx context.Context, accountID string, reason string) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppDisconnected, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppPairSuccessEvent creates a WhatsApp pair success event
|
||||||
|
func WhatsAppPairSuccessEvent(ctx context.Context, accountID string) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppPairSuccess, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppPairFailedEvent creates a WhatsApp pair failed event
|
||||||
|
func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppPairFailed, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
|
||||||
|
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"qr_code": qrCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppQRTimeoutEvent creates a WhatsApp QR timeout event
|
||||||
|
func WhatsAppQRTimeoutEvent(ctx context.Context, accountID string) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppQRTimeout, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppQRErrorEvent creates a WhatsApp QR error event
|
||||||
|
func WhatsAppQRErrorEvent(ctx context.Context, accountID string, err error) Event {
|
||||||
|
return NewEvent(ctx, EventWhatsAppQRError, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsAppPairEventGeneric creates a generic WhatsApp pairing event
|
||||||
|
func WhatsAppPairEventGeneric(ctx context.Context, accountID string, eventName string, data map[string]any) Event {
|
||||||
|
eventData := map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"event": eventName,
|
||||||
|
}
|
||||||
|
for k, v := range data {
|
||||||
|
eventData[k] = v
|
||||||
|
}
|
||||||
|
return NewEvent(ctx, EventWhatsAppPairEvent, eventData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReceivedEvent creates a message received event
|
||||||
|
func MessageReceivedEvent(ctx context.Context, accountID, messageID, from, to, text string, timestamp time.Time, isGroup bool, groupName, senderName, messageType, mimeType, filename, mediaBase64, mediaURL string) Event {
|
||||||
|
return NewEvent(ctx, EventMessageReceived, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
"text": text,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"is_group": isGroup,
|
||||||
|
"group_name": groupName,
|
||||||
|
"sender_name": senderName,
|
||||||
|
"message_type": messageType,
|
||||||
|
"mime_type": mimeType,
|
||||||
|
"filename": filename,
|
||||||
|
"media_base64": mediaBase64,
|
||||||
|
"media_url": mediaURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageSentEvent creates a message sent event
|
||||||
|
func MessageSentEvent(ctx context.Context, accountID, messageID, to, text string) Event {
|
||||||
|
return NewEvent(ctx, EventMessageSent, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"to": to,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageFailedEvent creates a message failed event
|
||||||
|
func MessageFailedEvent(ctx context.Context, accountID, to, text string, err error) Event {
|
||||||
|
return NewEvent(ctx, EventMessageFailed, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"to": to,
|
||||||
|
"text": text,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageDeliveredEvent creates a message delivered event
|
||||||
|
func MessageDeliveredEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
|
||||||
|
return NewEvent(ctx, EventMessageDelivered, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"from": from,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReadEvent creates a message read event
|
||||||
|
func MessageReadEvent(ctx context.Context, accountID, messageID, from string, timestamp time.Time) Event {
|
||||||
|
return NewEvent(ctx, EventMessageRead, map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"from": from,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookTriggeredEvent creates a hook triggered event
|
||||||
|
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
|
||||||
|
return NewEvent(ctx, EventHookTriggered, map[string]any{
|
||||||
|
"hook_id": hookID,
|
||||||
|
"hook_name": hookName,
|
||||||
|
"url": url,
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookSuccessEvent creates a hook success event
|
||||||
|
func HookSuccessEvent(ctx context.Context, hookID, hookName string, statusCode int, response any) Event {
|
||||||
|
return NewEvent(ctx, EventHookSuccess, map[string]any{
|
||||||
|
"hook_id": hookID,
|
||||||
|
"hook_name": hookName,
|
||||||
|
"status_code": statusCode,
|
||||||
|
"response": response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookFailedEvent creates a hook failed event
|
||||||
|
func HookFailedEvent(ctx context.Context, hookID, hookName string, err error) Event {
|
||||||
|
return NewEvent(ctx, EventHookFailed, map[string]any{
|
||||||
|
"hook_id": hookID,
|
||||||
|
"hook_name": hookName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
161
internal/events/events.go
Normal file
161
internal/events/events.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventType represents the type of event
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WhatsApp connection events
|
||||||
|
EventWhatsAppConnected EventType = "whatsapp.connected"
|
||||||
|
EventWhatsAppDisconnected EventType = "whatsapp.disconnected"
|
||||||
|
EventWhatsAppPairSuccess EventType = "whatsapp.pair.success"
|
||||||
|
EventWhatsAppPairFailed EventType = "whatsapp.pair.failed"
|
||||||
|
EventWhatsAppQRCode EventType = "whatsapp.qr.code"
|
||||||
|
EventWhatsAppQRTimeout EventType = "whatsapp.qr.timeout"
|
||||||
|
EventWhatsAppQRError EventType = "whatsapp.qr.error"
|
||||||
|
EventWhatsAppPairEvent EventType = "whatsapp.pair.event"
|
||||||
|
|
||||||
|
// WhatsApp message events
|
||||||
|
EventMessageReceived EventType = "message.received"
|
||||||
|
EventMessageSent EventType = "message.sent"
|
||||||
|
EventMessageFailed EventType = "message.failed"
|
||||||
|
EventMessageDelivered EventType = "message.delivered"
|
||||||
|
EventMessageRead EventType = "message.read"
|
||||||
|
|
||||||
|
// Hook events
|
||||||
|
EventHookTriggered EventType = "hook.triggered"
|
||||||
|
EventHookSuccess EventType = "hook.success"
|
||||||
|
EventHookFailed EventType = "hook.failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents an event in the system
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
Context context.Context `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriber is a function that handles events
|
||||||
|
type Subscriber func(event Event)
|
||||||
|
|
||||||
|
// EventBus manages event publishing and subscription
|
||||||
|
type EventBus struct {
|
||||||
|
subscribers map[EventType][]Subscriber
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventBus creates a new event bus
|
||||||
|
func NewEventBus() *EventBus {
|
||||||
|
return &EventBus{
|
||||||
|
subscribers: make(map[EventType][]Subscriber),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a subscriber for a specific event type
|
||||||
|
func (eb *EventBus) Subscribe(eventType EventType, subscriber Subscriber) {
|
||||||
|
eb.mu.Lock()
|
||||||
|
defer eb.mu.Unlock()
|
||||||
|
|
||||||
|
if eb.subscribers[eventType] == nil {
|
||||||
|
eb.subscribers[eventType] = make([]Subscriber, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeAll registers a subscriber for all event types
|
||||||
|
func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
|
||||||
|
eb.mu.Lock()
|
||||||
|
defer eb.mu.Unlock()
|
||||||
|
|
||||||
|
allTypes := []EventType{
|
||||||
|
EventWhatsAppConnected,
|
||||||
|
EventWhatsAppDisconnected,
|
||||||
|
EventWhatsAppPairSuccess,
|
||||||
|
EventWhatsAppPairFailed,
|
||||||
|
EventMessageReceived,
|
||||||
|
EventMessageSent,
|
||||||
|
EventMessageFailed,
|
||||||
|
EventMessageDelivered,
|
||||||
|
EventMessageRead,
|
||||||
|
EventHookTriggered,
|
||||||
|
EventHookSuccess,
|
||||||
|
EventHookFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, eventType := range allTypes {
|
||||||
|
if eb.subscribers[eventType] == nil {
|
||||||
|
eb.subscribers[eventType] = make([]Subscriber, 0)
|
||||||
|
}
|
||||||
|
eb.subscribers[eventType] = append(eb.subscribers[eventType], subscriber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish publishes an event to all subscribers asynchronously
|
||||||
|
func (eb *EventBus) Publish(event Event) {
|
||||||
|
eb.mu.RLock()
|
||||||
|
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
|
||||||
|
copy(subscribers, eb.subscribers[event.Type])
|
||||||
|
eb.mu.RUnlock()
|
||||||
|
|
||||||
|
// Use event context if available, otherwise background
|
||||||
|
ctx := event.Context
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subscriber := range subscribers {
|
||||||
|
go func(sub Subscriber, evt Event) {
|
||||||
|
// Check if context is already cancelled
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
sub(evt)
|
||||||
|
}
|
||||||
|
}(subscriber, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishSync publishes an event to all subscribers synchronously
|
||||||
|
func (eb *EventBus) PublishSync(event Event) {
|
||||||
|
eb.mu.RLock()
|
||||||
|
subscribers := make([]Subscriber, len(eb.subscribers[event.Type]))
|
||||||
|
copy(subscribers, eb.subscribers[event.Type])
|
||||||
|
eb.mu.RUnlock()
|
||||||
|
|
||||||
|
// Use event context if available, otherwise background
|
||||||
|
ctx := event.Context
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subscriber := range subscribers {
|
||||||
|
// Check if context is already cancelled
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
subscriber(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEvent creates a new event with the current timestamp and context
|
||||||
|
func NewEvent(ctx context.Context, eventType EventType, data map[string]any) Event {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
Type: eventType,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Data: data,
|
||||||
|
Context: ctx,
|
||||||
|
}
|
||||||
|
}
|
||||||
365
internal/hooks/manager.go
Normal file
365
internal/hooks/manager.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/config"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/events"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaInfo represents media attachment information
|
||||||
|
type MediaInfo struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Base64 string `json:"base64,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagePayload represents a message sent to webhooks
|
||||||
|
type MessagePayload struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
IsGroup bool `json:"is_group"`
|
||||||
|
GroupName string `json:"group_name,omitempty"`
|
||||||
|
SenderName string `json:"sender_name,omitempty"`
|
||||||
|
MessageType string `json:"message_type"`
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookResponse represents a response from a webhook
|
||||||
|
type HookResponse struct {
|
||||||
|
SendMessage bool `json:"send_message"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager manages webhooks
|
||||||
|
type Manager struct {
|
||||||
|
hooks map[string]config.Hook
|
||||||
|
mu sync.RWMutex
|
||||||
|
client *http.Client
|
||||||
|
eventBus *events.EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new hook manager
|
||||||
|
func NewManager(eventBus *events.EventBus) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
hooks: make(map[string]config.Hook),
|
||||||
|
eventBus: eventBus,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening for events
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
// Get all possible event types
|
||||||
|
allEventTypes := []events.EventType{
|
||||||
|
events.EventWhatsAppConnected,
|
||||||
|
events.EventWhatsAppDisconnected,
|
||||||
|
events.EventWhatsAppPairSuccess,
|
||||||
|
events.EventWhatsAppPairFailed,
|
||||||
|
events.EventWhatsAppQRCode,
|
||||||
|
events.EventWhatsAppQRTimeout,
|
||||||
|
events.EventWhatsAppQRError,
|
||||||
|
events.EventWhatsAppPairEvent,
|
||||||
|
events.EventMessageReceived,
|
||||||
|
events.EventMessageSent,
|
||||||
|
events.EventMessageFailed,
|
||||||
|
events.EventMessageDelivered,
|
||||||
|
events.EventMessageRead,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all event types with a generic handler
|
||||||
|
for _, eventType := range allEventTypes {
|
||||||
|
m.eventBus.Subscribe(eventType, m.handleEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEvent processes any event and triggers relevant hooks
|
||||||
|
func (m *Manager) handleEvent(event events.Event) {
|
||||||
|
// Get hooks that are subscribed to this event type
|
||||||
|
m.mu.RLock()
|
||||||
|
relevantHooks := make([]config.Hook, 0)
|
||||||
|
for _, hook := range m.hooks {
|
||||||
|
if !hook.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hook has no events specified, subscribe to all events
|
||||||
|
if len(hook.Events) == 0 {
|
||||||
|
relevantHooks = append(relevantHooks, hook)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this hook is subscribed to this event type
|
||||||
|
eventTypeStr := string(event.Type)
|
||||||
|
for _, subscribedEvent := range hook.Events {
|
||||||
|
if subscribedEvent == eventTypeStr {
|
||||||
|
relevantHooks = append(relevantHooks, hook)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Trigger each relevant hook
|
||||||
|
if len(relevantHooks) > 0 {
|
||||||
|
m.triggerHooksForEvent(event, relevantHooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerHooksForEvent sends event data to specific hooks
|
||||||
|
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) {
|
||||||
|
ctx := event.Context
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payload based on event type
|
||||||
|
var payload interface{}
|
||||||
|
|
||||||
|
// For message events, create MessagePayload
|
||||||
|
if event.Type == events.EventMessageReceived || event.Type == events.EventMessageSent {
|
||||||
|
messageType := getStringFromEvent(event.Data, "message_type")
|
||||||
|
|
||||||
|
msgPayload := MessagePayload{
|
||||||
|
AccountID: getStringFromEvent(event.Data, "account_id"),
|
||||||
|
MessageID: getStringFromEvent(event.Data, "message_id"),
|
||||||
|
From: getStringFromEvent(event.Data, "from"),
|
||||||
|
To: getStringFromEvent(event.Data, "to"),
|
||||||
|
Text: getStringFromEvent(event.Data, "text"),
|
||||||
|
Timestamp: getTimeFromEvent(event.Data, "timestamp"),
|
||||||
|
IsGroup: getBoolFromEvent(event.Data, "is_group"),
|
||||||
|
GroupName: getStringFromEvent(event.Data, "group_name"),
|
||||||
|
SenderName: getStringFromEvent(event.Data, "sender_name"),
|
||||||
|
MessageType: messageType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add media info if message has media content
|
||||||
|
if messageType != "" && messageType != "text" {
|
||||||
|
msgPayload.Media = &MediaInfo{
|
||||||
|
Type: messageType,
|
||||||
|
MimeType: getStringFromEvent(event.Data, "mime_type"),
|
||||||
|
Filename: getStringFromEvent(event.Data, "filename"),
|
||||||
|
URL: getStringFromEvent(event.Data, "media_url"),
|
||||||
|
Base64: getStringFromEvent(event.Data, "media_base64"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = msgPayload
|
||||||
|
} else {
|
||||||
|
// For other events, create generic payload with event type and data
|
||||||
|
payload = map[string]interface{}{
|
||||||
|
"event_type": string(event.Type),
|
||||||
|
"timestamp": event.Timestamp,
|
||||||
|
"data": event.Data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to each hook with the event type
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, hook := range hooks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(h config.Hook, et events.EventType) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = m.sendToHook(ctx, h, payload, et)
|
||||||
|
}(hook, event.Type)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to extract data from event map
|
||||||
|
func getStringFromEvent(data map[string]interface{}, key string) string {
|
||||||
|
if val, ok := data[key].(string); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeFromEvent(data map[string]interface{}, key string) time.Time {
|
||||||
|
if val, ok := data[key].(time.Time); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoolFromEvent(data map[string]interface{}, key string) bool {
|
||||||
|
if val, ok := data[key].(bool); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadHooks loads hooks from configuration
|
||||||
|
func (m *Manager) LoadHooks(hooks []config.Hook) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, hook := range hooks {
|
||||||
|
m.hooks[hook.ID] = hook
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("Hooks loaded", "count", len(hooks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddHook adds a new hook
|
||||||
|
func (m *Manager) AddHook(hook config.Hook) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.hooks[hook.ID] = hook
|
||||||
|
logging.Info("Hook added", "id", hook.ID, "name", hook.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHook removes a hook
|
||||||
|
func (m *Manager) RemoveHook(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := m.hooks[id]; !exists {
|
||||||
|
return fmt.Errorf("hook %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m.hooks, id)
|
||||||
|
logging.Info("Hook removed", "id", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHook returns a hook by ID
|
||||||
|
func (m *Manager) GetHook(id string) (config.Hook, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
hook, exists := m.hooks[id]
|
||||||
|
return hook, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHooks returns all hooks
|
||||||
|
func (m *Manager) ListHooks() []config.Hook {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
hooks := make([]config.Hook, 0, len(m.hooks))
|
||||||
|
for _, hook := range m.hooks {
|
||||||
|
hooks = append(hooks, hook)
|
||||||
|
}
|
||||||
|
return hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendToHook sends any payload to a specific hook with explicit event type
|
||||||
|
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish hook triggered event
|
||||||
|
m.eventBus.Publish(events.HookTriggeredEvent(ctx, hook.ID, hook.Name, hook.URL, payload))
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL with query parameters
|
||||||
|
parsedURL, err := url.Parse(hook.URL)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract account_id from payload
|
||||||
|
var accountID string
|
||||||
|
|
||||||
|
switch p := payload.(type) {
|
||||||
|
case MessagePayload:
|
||||||
|
accountID = p.AccountID
|
||||||
|
case map[string]interface{}:
|
||||||
|
if data, ok := p["data"].(map[string]interface{}); ok {
|
||||||
|
if aid, ok := data["account_id"].(string); ok {
|
||||||
|
accountID = aid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
query := parsedURL.Query()
|
||||||
|
if eventType != "" {
|
||||||
|
query.Set("event", string(eventType))
|
||||||
|
}
|
||||||
|
if accountID != "" {
|
||||||
|
query.Set("account_id", accountID)
|
||||||
|
}
|
||||||
|
parsedURL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, hook.Method, parsedURL.String(), bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for key, value := range hook.Headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Debug("Sending to hook", "hook_id", hook.ID, "url", hook.URL)
|
||||||
|
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse response
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
|
||||||
|
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, nil))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hookResp HookResponse
|
||||||
|
if err := json.Unmarshal(body, &hookResp); err != nil {
|
||||||
|
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
|
||||||
|
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, string(body)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
|
||||||
|
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, hookResp))
|
||||||
|
return &hookResp
|
||||||
|
}
|
||||||
70
internal/logging/logger.go
Normal file
70
internal/logging/logger.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
72
internal/utils/phone.go
Normal file
72
internal/utils/phone.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatPhoneToJID converts a phone number to WhatsApp JID format
|
||||||
|
// If the number already contains @, it returns as-is
|
||||||
|
// Otherwise, applies formatting rules:
|
||||||
|
// - If starts with 0, assumes no country code and replaces 0 with country code
|
||||||
|
// - If starts with +, assumes it already has country code
|
||||||
|
// - Otherwise adds country code if not present
|
||||||
|
// - Adds @s.whatsapp.net suffix
|
||||||
|
func FormatPhoneToJID(phone string, defaultCountryCode string) string {
|
||||||
|
// If already in JID format, return as-is
|
||||||
|
if strings.Contains(phone, "@") {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all non-digit characters
|
||||||
|
cleaned := strings.Map(func(r rune) rune {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, phone)
|
||||||
|
|
||||||
|
// If empty after cleaning, return original
|
||||||
|
if cleaned == "" {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
|
||||||
|
// If number starts with 0, it definitely doesn't have a country code
|
||||||
|
// Replace the leading 0 with the country code
|
||||||
|
if strings.HasPrefix(cleaned, "0") && defaultCountryCode != "" {
|
||||||
|
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
|
||||||
|
cleaned = countryCode + strings.TrimLeft(cleaned, "0")
|
||||||
|
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all leading zeros
|
||||||
|
cleaned = strings.TrimLeft(cleaned, "0")
|
||||||
|
|
||||||
|
// If original phone started with +, it already has country code
|
||||||
|
if strings.HasPrefix(phone, "+") {
|
||||||
|
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add country code if provided and number doesn't start with it
|
||||||
|
if defaultCountryCode != "" {
|
||||||
|
countryCode := strings.TrimPrefix(defaultCountryCode, "+")
|
||||||
|
if !strings.HasPrefix(cleaned, countryCode) {
|
||||||
|
cleaned = countryCode + cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s@s.whatsapp.net", cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGroupJID checks if a JID is a group JID
|
||||||
|
func IsGroupJID(jid string) bool {
|
||||||
|
return strings.HasSuffix(jid, "@g.us")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidJID checks if a string is a valid WhatsApp JID
|
||||||
|
func IsValidJID(jid string) bool {
|
||||||
|
return strings.Contains(jid, "@") &&
|
||||||
|
(strings.HasSuffix(jid, "@s.whatsapp.net") ||
|
||||||
|
strings.HasSuffix(jid, "@g.us") ||
|
||||||
|
strings.HasSuffix(jid, "@broadcast"))
|
||||||
|
}
|
||||||
200
internal/utils/phone_test.go
Normal file
200
internal/utils/phone_test.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatPhoneToJID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
phone string
|
||||||
|
defaultCountryCode string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Already in JID format",
|
||||||
|
phone: "27834606792@s.whatsapp.net",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plain number with leading zero",
|
||||||
|
phone: "0834606792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number with country code",
|
||||||
|
phone: "27834606792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number with plus sign",
|
||||||
|
phone: "+27834606792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number without country code config",
|
||||||
|
phone: "0834606792",
|
||||||
|
defaultCountryCode: "",
|
||||||
|
want: "834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number with spaces and dashes",
|
||||||
|
phone: "083-460-6792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number with parentheses",
|
||||||
|
phone: "(083) 460 6792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "US number with leading 1",
|
||||||
|
phone: "12025551234",
|
||||||
|
defaultCountryCode: "1",
|
||||||
|
want: "12025551234@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "US number with area code",
|
||||||
|
phone: "202-555-1234",
|
||||||
|
defaultCountryCode: "1",
|
||||||
|
want: "12025551234@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Group JID unchanged",
|
||||||
|
phone: "123456789-1234567890@g.us",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "123456789-1234567890@g.us",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Number with different country code via plus sign",
|
||||||
|
phone: "+12025551234",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "12025551234@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Country code with plus in config",
|
||||||
|
phone: "0834606792",
|
||||||
|
defaultCountryCode: "+27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty phone number",
|
||||||
|
phone: "",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple leading zeros",
|
||||||
|
phone: "00834606792",
|
||||||
|
defaultCountryCode: "27",
|
||||||
|
want: "27834606792@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := FormatPhoneToJID(tt.phone, tt.defaultCountryCode)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("FormatPhoneToJID(%q, %q) = %q, want %q",
|
||||||
|
tt.phone, tt.defaultCountryCode, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsGroupJID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jid string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Individual JID",
|
||||||
|
jid: "27834606792@s.whatsapp.net",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Group JID",
|
||||||
|
jid: "123456789-1234567890@g.us",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
jid: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid JID",
|
||||||
|
jid: "notajid",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := IsGroupJID(tt.jid)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsGroupJID(%q) = %v, want %v", tt.jid, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidJID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jid string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid individual JID",
|
||||||
|
jid: "27834606792@s.whatsapp.net",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid group JID",
|
||||||
|
jid: "123456789-1234567890@g.us",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid broadcast JID",
|
||||||
|
jid: "123456789@broadcast",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid - no @ symbol",
|
||||||
|
jid: "27834606792",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid - wrong suffix",
|
||||||
|
jid: "27834606792@invalid.com",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
jid: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Just @ symbol",
|
||||||
|
jid: "@",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := IsValidJID(tt.jid)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsValidJID(%q) = %v, want %v", tt.jid, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
767
internal/whatsapp/client.go
Normal file
767
internal/whatsapp/client.go
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user