initial commit

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

56
cmd/cli/config.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"os"
"path/filepath"
"github.com/spf13/viper"
)
// CLIConfig holds the CLI configuration
type CLIConfig struct {
ServerURL string
}
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
v := viper.New()
// Set defaults
v.SetDefault("server_url", "http://localhost:8080")
// 1. Load from config file (lowest priority)
if configFile != "" {
v.SetConfigFile(configFile)
} else {
// Look for config in home directory
home, err := os.UserHomeDir()
if err == nil {
v.AddConfigPath(filepath.Join(home, ".whatshooked"))
v.SetConfigName("cli")
v.SetConfigType("json")
}
// Also look in current directory
v.AddConfigPath(".")
v.SetConfigName(".whatshooked-cli")
v.SetConfigType("json")
}
// Read config file if it exists (don't error if it doesn't)
_ = v.ReadInConfig()
// 2. Override with environment variables (medium priority)
v.SetEnvPrefix("WHATSHOOKED")
v.AutomaticEnv()
// 3. Override with command-line flag (highest priority)
if serverFlag != "" {
v.Set("server_url", serverFlag)
}
cfg := &CLIConfig{
ServerURL: v.GetString("server_url"),
}
return cfg, nil
}

629
cmd/cli/main.go Normal file
View File

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