CLI refactor

This commit is contained in:
2025-12-29 05:29:53 +02:00
parent d54b0eaddf
commit 16aaf1919d
9 changed files with 1111 additions and 585 deletions

View File

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

466
CLI.md Normal file
View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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