diff --git a/.claude/readme b/.claude/readme new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index adf8f72..7c00427 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.whatshooked-cli.example.json b/.whatshooked-cli.example.json new file mode 100644 index 0000000..1018bd5 --- /dev/null +++ b/.whatshooked-cli.example.json @@ -0,0 +1,3 @@ +{ + "server_url": "http://localhost:8080" +} diff --git a/AI_USE.md b/AI_USE.md new file mode 100644 index 0000000..397857c --- /dev/null +++ b/AI_USE.md @@ -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 | + \_____________/ + \___________/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e22e24 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d8bd043 --- /dev/null +++ b/Makefile @@ -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" diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0343c60 --- /dev/null +++ b/PLAN.md @@ -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. + + + \ No newline at end of file diff --git a/README.md b/README.md index 396badd..429495e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,450 @@ -# whatshooked +# WhatsHooked -whatshooked \ No newline at end of file +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 +``` + +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. diff --git a/assets/image/whatshooked.jpg b/assets/image/whatshooked.jpg new file mode 100644 index 0000000..eec7f73 Binary files /dev/null and b/assets/image/whatshooked.jpg differ diff --git a/assets/image/whatshooked_tp.png b/assets/image/whatshooked_tp.png new file mode 100644 index 0000000..1607a9b Binary files /dev/null and b/assets/image/whatshooked_tp.png differ diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli b/cli new file mode 100755 index 0000000..045db5a Binary files /dev/null and b/cli differ diff --git a/cmd/cli/config.go b/cmd/cli/config.go new file mode 100644 index 0000000..89d8bf4 --- /dev/null +++ b/cmd/cli/config.go @@ -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 +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..aaf1b77 --- /dev/null +++ b/cmd/cli/main.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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") +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4403847 --- /dev/null +++ b/cmd/server/main.go @@ -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) +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..cce6f16 --- /dev/null +++ b/config.example.json @@ -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" +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8412b7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..18acdc6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ba8173f --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/events/builders.go b/internal/events/builders.go new file mode 100644 index 0000000..ccf0292 --- /dev/null +++ b/internal/events/builders.go @@ -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(), + }) +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..79c1af2 --- /dev/null +++ b/internal/events/events.go @@ -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, + } +} diff --git a/internal/hooks/manager.go b/internal/hooks/manager.go new file mode 100644 index 0000000..3f29e4a --- /dev/null +++ b/internal/hooks/manager.go @@ -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 +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..7802956 --- /dev/null +++ b/internal/logging/logger.go @@ -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() +} diff --git a/internal/utils/phone.go b/internal/utils/phone.go new file mode 100644 index 0000000..e33a1cc --- /dev/null +++ b/internal/utils/phone.go @@ -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")) +} diff --git a/internal/utils/phone_test.go b/internal/utils/phone_test.go new file mode 100644 index 0000000..9b72052 --- /dev/null +++ b/internal/utils/phone_test.go @@ -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) + } + }) + } +} diff --git a/internal/whatsapp/client.go b/internal/whatsapp/client.go new file mode 100644 index 0000000..261f58e --- /dev/null +++ b/internal/whatsapp/client.go @@ -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 +} diff --git a/server b/server new file mode 100755 index 0000000..1a302f3 Binary files /dev/null and b/server differ