From 16aaf1919d9a68ff33d50f42a85bb856def11829 Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 29 Dec 2025 05:29:53 +0200 Subject: [PATCH] CLI refactor --- .whatshooked-cli.example.json | 5 +- CLI.md | 466 +++++++++++++++++++++++++++ cmd/cli/client.go | 111 +++++++ cmd/cli/commands_accounts.go | 83 +++++ cmd/cli/commands_health.go | 28 ++ cmd/cli/commands_hooks.go | 155 +++++++++ cmd/cli/commands_send.go | 254 +++++++++++++++ cmd/cli/config.go | 9 + cmd/cli/main.go | 585 +--------------------------------- 9 files changed, 1111 insertions(+), 585 deletions(-) create mode 100644 CLI.md create mode 100644 cmd/cli/client.go create mode 100644 cmd/cli/commands_accounts.go create mode 100644 cmd/cli/commands_health.go create mode 100644 cmd/cli/commands_hooks.go create mode 100644 cmd/cli/commands_send.go diff --git a/.whatshooked-cli.example.json b/.whatshooked-cli.example.json index 1018bd5..f8815b1 100644 --- a/.whatshooked-cli.example.json +++ b/.whatshooked-cli.example.json @@ -1,3 +1,6 @@ { - "server_url": "http://localhost:8080" + "server_url": "http://localhost:8080", + "auth_key": "", + "username": "", + "password": "" } diff --git a/CLI.md b/CLI.md new file mode 100644 index 0000000..f925324 --- /dev/null +++ b/CLI.md @@ -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 to configuration file +- `--server `: 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 +``` + +### 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 +``` + +Supported formats: JPG, PNG, GIF, WebP + +#### Send Video + +Send a video with optional caption: + +```bash +./bin/whatshook-cli send video +``` + +Supported formats: MP4, MOV, AVI, WebM, 3GP + +#### Send Document + +Send a document with optional caption: + +```bash +./bin/whatshook-cli send document +``` + +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 <= 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) + } +} diff --git a/cmd/cli/commands_accounts.go b/cmd/cli/commands_accounts.go new file mode 100644 index 0000000..c454b70 --- /dev/null +++ b/cmd/cli/commands_accounts.go @@ -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") +} diff --git a/cmd/cli/commands_health.go b/cmd/cli/commands_health.go new file mode 100644 index 0000000..4679dec --- /dev/null +++ b/cmd/cli/commands_health.go @@ -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"]) +} diff --git a/cmd/cli/commands_hooks.go b/cmd/cli/commands_hooks.go new file mode 100644 index 0000000..90e92cb --- /dev/null +++ b/cmd/cli/commands_hooks.go @@ -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 ", + 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") +} diff --git a/cmd/cli/commands_send.go b/cmd/cli/commands_send.go new file mode 100644 index 0000000..5d8c7cd --- /dev/null +++ b/cmd/cli/commands_send.go @@ -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 ", + 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 ", + 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 ", + 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") +} diff --git a/cmd/cli/config.go b/cmd/cli/config.go index 89d8bf4..dc8b61e 100644 --- a/cmd/cli/config.go +++ b/cmd/cli/config.go @@ -10,6 +10,9 @@ import ( // CLIConfig holds the CLI configuration type CLIConfig struct { ServerURL string + AuthKey string + Username string + Password string } // LoadCLIConfig loads configuration with priority: config file → ENV → flag @@ -18,6 +21,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) { // Set defaults v.SetDefault("server_url", "http://localhost:8080") + v.SetDefault("auth_key", "") + v.SetDefault("username", "") + v.SetDefault("password", "") // 1. Load from config file (lowest priority) if configFile != "" { @@ -50,6 +56,9 @@ func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) { cfg := &CLIConfig{ ServerURL: v.GetString("server_url"), + AuthKey: v.GetString("auth_key"), + Username: v.GetString("username"), + Password: v.GetString("password"), } return cfg, nil diff --git a/cmd/cli/main.go b/cmd/cli/main.go index aaf1b77..bd4e71e 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,17 +1,9 @@ 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" ) @@ -46,584 +38,9 @@ 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)") + // Add all command groups 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") -}