initial commit

This commit is contained in:
2025-12-28 21:34:45 +02:00
parent dbffadf0d3
commit 499104c69c
27 changed files with 4043 additions and 2 deletions

0
.claude/readme Normal file
View File

25
.gitignore vendored
View File

@@ -21,3 +21,28 @@
# Go workspace file
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

View File

@@ -0,0 +1,3 @@
{
"server_url": "http://localhost:8080"
}

35
AI_USE.md Normal file
View 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
View 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
View 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
View 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
View File

@@ -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.
![1.00](./assets/image/whatshooked.jpg)
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

0
bin/.gitkeep Normal file
View File

BIN
cli Executable file

Binary file not shown.

56
cmd/cli/config.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

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

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

BIN
server Executable file

Binary file not shown.