diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..295c154 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI +run-name: "CI Pipeline" +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.22', '1.23'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run unit tests + run: make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run linter + run: make lint + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Build binary + run: make build + + - name: Verify binaries exist + run: | + if [ ! -f bin/whatshook-server ]; then + echo "Error: Server binary not found at bin/whatshook-server" + exit 1 + fi + if [ ! -f bin/whatshook-cli ]; then + echo "Error: CLI binary not found at bin/whatshook-cli" + exit 1 + fi + echo "Build successful!" + ls -lh bin/ + + - name: Check mod tidiness + run: | + go mod tidy + git diff --exit-code go.mod go.sum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..be4d878 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release +run-name: "Making Release" +on: + push: + tags: + - 'v*.*.*' + +jobs: + build-and-release: + name: Build and Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Get version from tag + id: get_version + run: | + echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + echo "Version: ${GITHUB_REF#refs/tags/}" + + - name: Build binaries for multiple platforms + run: | + mkdir -p dist + + # Linux AMD64 + GOOS=linux GOARCH=amd64 go build -o dist/relspec-linux-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec + + # Linux ARM64 + GOOS=linux GOARCH=arm64 go build -o dist/relspec-linux-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec + + # macOS AMD64 + GOOS=darwin GOARCH=amd64 go build -o dist/relspec-darwin-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec + + # macOS ARM64 (Apple Silicon) + GOOS=darwin GOARCH=arm64 go build -o dist/relspec-darwin-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec + + # Windows AMD64 + GOOS=windows GOARCH=amd64 go build -o dist/relspec-windows-amd64.exe -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec + + # Create checksums + cd dist + sha256sum * > checksums.txt + cd .. + + - name: Generate release notes + id: release_notes + run: | + # Get the previous tag + previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + if [ -z "$previous_tag" ]; then + # No previous tag, get all commits + commits=$(git log --pretty=format:"- %s (%h)" --no-merges) + else + # Get commits since the previous tag + commits=$(git log "${previous_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges) + fi + + # Create release notes + cat > release_notes.md << EOF + # Release ${{ steps.get_version.outputs.VERSION }} + + ## Changes + + ${commits} + + ## Installation + + Download the appropriate binary for your platform: + + - **Linux (AMD64)**: \`relspec-linux-amd64\` + - **Linux (ARM64)**: \`relspec-linux-arm64\` + - **macOS (Intel)**: \`relspec-darwin-amd64\` + - **macOS (Apple Silicon)**: \`relspec-darwin-arm64\` + - **Windows (AMD64)**: \`relspec-windows-amd64.exe\` + + Make the binary executable (Linux/macOS): + \`\`\`bash + chmod +x relspec-* + \`\`\` + + Verify the download with the provided checksums. + EOF + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + body_path: release_notes.md + files: | + dist/relspec-linux-amd64 + dist/relspec-linux-arm64 + dist/relspec-darwin-amd64 + dist/relspec-darwin-arm64 + dist/relspec-windows-amd64.exe + dist/checksums.txt + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "Release ${{ steps.get_version.outputs.VERSION }} created successfully!" + echo "Binaries built for:" + echo " - Linux (amd64, arm64)" + echo " - macOS (amd64, arm64)" + echo " - Windows (amd64)" diff --git a/.golangci.json b/.golangci.json new file mode 100644 index 0000000..436536b --- /dev/null +++ b/.golangci.json @@ -0,0 +1,114 @@ +{ + "formatters": { + "enable": [ + "gofmt", + "goimports" + ], + "exclusions": { + "generated": "lax", + "paths": [ + "third_party$", + "builtin$", + "examples$" + ] + }, + "settings": { + "gofmt": { + "simplify": true + }, + "goimports": { + "local-prefixes": [ + "git.warky.dev/wdevs/relspecgo" + ] + } + } + }, + "issues": { + "max-issues-per-linter": 0, + "max-same-issues": 0 + }, + "linters": { + "enable": [ + "gocritic", + "misspell", + "revive" + ], + "exclusions": { + "generated": "lax", + "paths": [ + "third_party$", + "builtin$", + "examples$", + "mocks?", + "tests?" + ], + "rules": [ + { + "linters": [ + "dupl", + "errcheck", + "gocritic", + "gosec" + ], + "path": "_test\\.go" + }, + { + "linters": [ + "errcheck" + ], + "text": "Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*print(f|ln)?|os\\.(Un)?Setenv). is not checked" + }, + { + "path": "_test\\.go", + "text": "cognitive complexity|cyclomatic complexity" + } + ] + }, + "settings": { + "errcheck": { + "check-blank": false, + "check-type-assertions": false + }, + "gocritic": { + "enabled-checks": [ + "boolExprSimplify", + "builtinShadow", + "emptyFallthrough", + "equalFold", + "indexAlloc", + "initClause", + "methodExprCall", + "nilValReturn", + "rangeExprCopy", + "rangeValCopy", + "stringXbytes", + "typeAssertChain", + "unlabelStmt", + "unnamedResult", + "unnecessaryBlock", + "weakCond", + "yodaStyleExpr" + ], + "disabled-checks": [ + "ifElseChain" + ] + }, + "revive": { + "rules": [ + { + "disabled": true, + "name": "exported" + }, + { + "disabled": true, + "name": "package-comments" + } + ] + } + } + }, + "run": { + "tests": true + }, + "version": "2" +} \ No newline at end of file diff --git a/Makefile b/Makefile index d8bd043..fafa67c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean test run-server run-cli help +.PHONY: build clean test lint lintfix run-server run-cli help # Build both server and CLI build: @@ -29,10 +29,11 @@ clean: @rm -f bin/whatshook* @echo "Clean complete!" -# Run tests +# Run tests with coverage test: @echo "Running tests..." - @go test ./... + @go test -v -coverprofile=coverage.out -covermode=atomic ./... + @echo "Coverage report saved to coverage.out" # Run server (requires config.json) run-server: @@ -49,6 +50,51 @@ deps: @go mod tidy @echo "Dependencies installed!" + +release-version: ## Create and push a release with specific version (use: make release-version VERSION=v1.2.3) + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make release-version VERSION=v1.2.3"; \ + exit 1; \ + fi + @version="$(VERSION)"; \ + if ! echo "$$version" | grep -q "^v"; then \ + version="v$$version"; \ + fi; \ + echo "Creating release: $$version"; \ + latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \ + if [ -z "$$latest_tag" ]; then \ + commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \ + else \ + commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \ + fi; \ + if [ -z "$$commit_logs" ]; then \ + tag_message="Release $$version"; \ + else \ + tag_message="Release $$version\n\n$$commit_logs"; \ + fi; \ + git tag -a "$$version" -m "$$tag_message"; \ + git push origin "$$version"; \ + echo "Tag $$version created and pushed to remote repository." + + +lint: ## Run linter + @echo "Running linter..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run --config=.golangci.json; \ + else \ + echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + fi + +lintfix: ## Run linter + @echo "Running linter..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run --config=.golangci.json --fix; \ + else \ + echo "golangci-lint not installed. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + fi + # Help help: @echo "WhatsHooked Makefile" @@ -58,7 +104,9 @@ help: @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 test - Run tests with coverage" + @echo " make lint - Run linter" + @echo " make lintfix - Run linter with auto-fix" @echo " make run-server - Run server (requires config.json)" @echo " make run-cli ARGS='health' - Run CLI with arguments" @echo " make deps - Install dependencies" diff --git a/cmd/cli/commands_accounts.go b/cmd/cli/commands_accounts.go index 3bf09a9..3f29287 100644 --- a/cmd/cli/commands_accounts.go +++ b/cmd/cli/commands_accounts.go @@ -66,13 +66,19 @@ func addAccount(client *Client) { var account config.WhatsAppConfig fmt.Print("Account ID: ") - fmt.Scanln(&account.ID) + if _, err := fmt.Scanln(&account.ID); err != nil { + checkError(fmt.Errorf("error reading account ID: %v", err)) + } fmt.Print("Phone Number (with country code): ") - fmt.Scanln(&account.PhoneNumber) + if _, err := fmt.Scanln(&account.PhoneNumber); err != nil { + checkError(fmt.Errorf("error reading phone number: %v", err)) + } fmt.Print("Session Path: ") - fmt.Scanln(&account.SessionPath) + if _, err := fmt.Scanln(&account.SessionPath); err != nil { + checkError(fmt.Errorf("error reading session path: %v", err)) + } resp, err := client.Post("/api/accounts/add", account) checkError(err) diff --git a/cmd/cli/commands_hooks.go b/cmd/cli/commands_hooks.go index 9f6d8c7..88d5567 100644 --- a/cmd/cli/commands_hooks.go +++ b/cmd/cli/commands_hooks.go @@ -95,16 +95,25 @@ func addHook(client *Client) { scanner := bufio.NewScanner(os.Stdin) fmt.Print("Hook ID: ") - fmt.Scanln(&hook.ID) + if _, err := fmt.Scanln(&hook.ID); err != nil { + checkError(fmt.Errorf("error reading hook ID: %v", err)) + } fmt.Print("Hook Name: ") - fmt.Scanln(&hook.Name) + if _, err := fmt.Scanln(&hook.Name); err != nil { + checkError(fmt.Errorf("error reading hook name: %v", err)) + } fmt.Print("Webhook URL: ") - fmt.Scanln(&hook.URL) + if _, err := fmt.Scanln(&hook.URL); err != nil { + checkError(fmt.Errorf("error reading webhook URL: %v", err)) + } fmt.Print("HTTP Method (POST): ") - fmt.Scanln(&hook.Method) + if _, err := fmt.Scanln(&hook.Method); err == nil { + // Successfully read input + fmt.Printf("Selected Method %s", hook.Method) + } if hook.Method == "" { hook.Method = "POST" } diff --git a/cmd/cli/commands_send.go b/cmd/cli/commands_send.go index 5d8c7cd..ce70c94 100644 --- a/cmd/cli/commands_send.go +++ b/cmd/cli/commands_send.go @@ -70,10 +70,14 @@ func sendMessage(client *Client) { } fmt.Print("Account ID: ") - fmt.Scanln(&req.AccountID) + if _, err := fmt.Scanln(&req.AccountID); err != nil { + checkError(fmt.Errorf("error reading account ID: %v", err)) + } fmt.Print("Recipient (phone number or JID, e.g., 0834606792 or 1234567890@s.whatsapp.net): ") - fmt.Scanln(&req.To) + if _, err := fmt.Scanln(&req.To); err != nil { + checkError(fmt.Errorf("error reading recipient: %v", err)) + } fmt.Print("Message text: ") reader := os.Stdin @@ -101,10 +105,14 @@ func sendImage(client *Client, filePath string) { } fmt.Print("Account ID: ") - fmt.Scanln(&req.AccountID) + if _, err := fmt.Scanln(&req.AccountID); err != nil { + checkError(fmt.Errorf("error reading account ID: %v", err)) + } fmt.Print("Recipient (phone number): ") - fmt.Scanln(&req.To) + if _, err := fmt.Scanln(&req.To); err != nil { + checkError(fmt.Errorf("error reading recipient: %v", err)) + } fmt.Print("Caption (optional): ") reader := os.Stdin @@ -151,10 +159,14 @@ func sendVideo(client *Client, filePath string) { } fmt.Print("Account ID: ") - fmt.Scanln(&req.AccountID) + if _, err := fmt.Scanln(&req.AccountID); err != nil { + checkError(fmt.Errorf("error reading account ID: %v", err)) + } fmt.Print("Recipient (phone number): ") - fmt.Scanln(&req.To) + if _, err := fmt.Scanln(&req.To); err != nil { + checkError(fmt.Errorf("error reading recipient: %v", err)) + } fmt.Print("Caption (optional): ") reader := os.Stdin @@ -204,10 +216,14 @@ func sendDocument(client *Client, filePath string) { } fmt.Print("Account ID: ") - fmt.Scanln(&req.AccountID) + if _, err := fmt.Scanln(&req.AccountID); err != nil { + checkError(fmt.Errorf("error reading account ID: %v", err)) + } fmt.Print("Recipient (phone number): ") - fmt.Scanln(&req.To) + if _, err := fmt.Scanln(&req.To); err != nil { + checkError(fmt.Errorf("error reading recipient: %v", err)) + } fmt.Print("Caption (optional): ") reader := os.Stdin diff --git a/pkg/config/config.go b/pkg/config/config.go index 4b157a8..97470d2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -28,12 +28,12 @@ type ServerConfig struct { // WhatsAppConfig holds configuration for a WhatsApp account type WhatsAppConfig struct { - ID string `json:"id"` - Type string `json:"type"` // "whatsmeow" or "business-api" - PhoneNumber string `json:"phone_number"` - SessionPath string `json:"session_path,omitempty"` - ShowQR bool `json:"show_qr,omitempty"` - BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"` + ID string `json:"id"` + Type string `json:"type"` // "whatsmeow" or "business-api" + PhoneNumber string `json:"phone_number"` + SessionPath string `json:"session_path,omitempty"` + ShowQR bool `json:"show_qr,omitempty"` + BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"` } // BusinessAPIConfig holds configuration for WhatsApp Business API @@ -72,7 +72,7 @@ type DatabaseConfig struct { // MediaConfig holds media storage and delivery configuration type MediaConfig struct { DataPath string `json:"data_path"` - Mode string `json:"mode"` // "base64", "link", or "both" + Mode string `json:"mode"` // "base64", "link", or "both" BaseURL string `json:"base_url,omitempty"` // Base URL for media links } diff --git a/pkg/events/events.go b/pkg/events/events.go index 79c1af2..72fa1ad 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -35,10 +35,10 @@ const ( // 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:"-"` + 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 diff --git a/pkg/handlers/accounts.go b/pkg/handlers/accounts.go index e85fe38..203b62f 100644 --- a/pkg/handlers/accounts.go +++ b/pkg/handlers/accounts.go @@ -12,7 +12,7 @@ import ( // Accounts returns the list of all configured WhatsApp accounts func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(h.config.WhatsApp) + writeJSON(w, h.config.WhatsApp) } // AddAccount adds a new WhatsApp account to the system @@ -43,5 +43,5 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } diff --git a/pkg/handlers/businessapi.go b/pkg/handlers/businessapi.go index c7d1734..495e475 100644 --- a/pkg/handlers/businessapi.go +++ b/pkg/handlers/businessapi.go @@ -77,7 +77,7 @@ func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Reque if mode == "subscribe" && token == accountConfig.VerifyToken { logging.Info("Webhook verification successful", "account_id", accountID) w.WriteHeader(http.StatusOK) - w.Write([]byte(challenge)) + writeBytes(w, []byte(challenge)) return } @@ -130,7 +130,7 @@ func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Reques // Return 200 OK to acknowledge receipt w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + writeBytes(w, []byte("OK")) } // extractAccountIDFromPath extracts the account ID from the URL path diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index cc40cd9..3c1d512 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -1,10 +1,12 @@ package handlers import ( + "encoding/json" "net/http" "git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/hooks" + "git.warky.dev/wdevs/whatshooked/pkg/logging" "git.warky.dev/wdevs/whatshooked/pkg/whatsapp" ) @@ -54,3 +56,17 @@ func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers { h.authConfig = cfg return h } + +// writeJSON is a helper that writes JSON response and logs errors +func writeJSON(w http.ResponseWriter, v interface{}) { + if err := json.NewEncoder(w).Encode(v); err != nil { + logging.Error("Failed to encode JSON response", "error", err) + } +} + +// writeBytes is a helper that writes bytes and logs errors +func writeBytes(w http.ResponseWriter, data []byte) { + if _, err := w.Write(data); err != nil { + logging.Error("Failed to write response", "error", err) + } +} diff --git a/pkg/handlers/health.go b/pkg/handlers/health.go index 1fb715f..a844ad4 100644 --- a/pkg/handlers/health.go +++ b/pkg/handlers/health.go @@ -1,11 +1,10 @@ package handlers import ( - "encoding/json" "net/http" ) // Health handles health check requests func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } diff --git a/pkg/handlers/hooks.go b/pkg/handlers/hooks.go index d251a09..316d967 100644 --- a/pkg/handlers/hooks.go +++ b/pkg/handlers/hooks.go @@ -12,7 +12,7 @@ import ( func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) { hooks := h.hookMgr.ListHooks() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(hooks) + writeJSON(w, hooks) } // AddHook adds a new hook to the system @@ -39,7 +39,7 @@ func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } // RemoveHook removes a hook from the system @@ -70,5 +70,5 @@ func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) { } } - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } diff --git a/pkg/handlers/send.go b/pkg/handlers/send.go index 39a9c9b..4a36717 100644 --- a/pkg/handlers/send.go +++ b/pkg/handlers/send.go @@ -40,7 +40,7 @@ func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } // SendImage sends an image via WhatsApp @@ -87,7 +87,7 @@ func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } // SendVideo sends a video via WhatsApp @@ -134,7 +134,7 @@ func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } // SendDocument sends a document via WhatsApp @@ -185,5 +185,5 @@ func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok"}) } diff --git a/pkg/whatsapp/businessapi/client.go b/pkg/whatsapp/businessapi/client.go index c3d5de7..1e82dec 100644 --- a/pkg/whatsapp/businessapi/client.go +++ b/pkg/whatsapp/businessapi/client.go @@ -340,15 +340,3 @@ func jidToPhoneNumber(jid types.JID) string { return phone } - -// phoneNumberToJID converts an E.164 phone number to WhatsApp JID -func phoneNumberToJID(phoneNumber string) types.JID { - // Remove + if present - phone := strings.TrimPrefix(phoneNumber, "+") - - // Create JID - return types.JID{ - User: phone, - Server: types.DefaultUserServer, // "s.whatsapp.net" - } -} diff --git a/pkg/whatsapp/businessapi/events.go b/pkg/whatsapp/businessapi/events.go index b008845..50138ba 100644 --- a/pkg/whatsapp/businessapi/events.go +++ b/pkg/whatsapp/businessapi/events.go @@ -32,8 +32,8 @@ func (c *Client) HandleWebhook(r *http.Request) error { // Process each entry for _, entry := range payload.Entry { - for _, change := range entry.Changes { - c.processChange(change) + for i := range entry.Changes { + c.processChange(entry.Changes[i]) } } @@ -198,10 +198,8 @@ func (c *Client) parseTimestamp(ts string) time.Time { } // processMediaData processes media based on the configured mode -func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { +func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (filename string, mediaURL string) { mode := c.mediaConfig.Mode - var filename string - var mediaURL string // Generate filename ext := getExtensionFromMimeType(mimeType) @@ -262,23 +260,23 @@ func (c *Client) generateMediaURL(messageID, filename string) string { // getExtensionFromMimeType returns the file extension for a given MIME type func getExtensionFromMimeType(mimeType string) string { extensions := map[string]string{ - "image/jpeg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "video/mp4": ".mp4", - "video/mpeg": ".mpeg", - "video/webm": ".webm", - "video/3gpp": ".3gp", - "application/pdf": ".pdf", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "video/mp4": ".mp4", + "video/mpeg": ".mpeg", + "video/webm": ".webm", + "video/3gpp": ".3gp", + "application/pdf": ".pdf", "application/msword": ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", - "application/vnd.ms-excel": ".xls", + "application/vnd.ms-excel": ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", - "text/plain": ".txt", + "text/plain": ".txt", "application/json": ".json", - "audio/mpeg": ".mp3", - "audio/ogg": ".ogg", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", } if ext, ok := extensions[mimeType]; ok { diff --git a/pkg/whatsapp/businessapi/media.go b/pkg/whatsapp/businessapi/media.go index 1a4e0f9..1ce9f87 100644 --- a/pkg/whatsapp/businessapi/media.go +++ b/pkg/whatsapp/businessapi/media.go @@ -82,7 +82,7 @@ func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) } // downloadMedia downloads media from the Business API using the media ID -func (c *Client) downloadMedia(ctx context.Context, mediaID string) ([]byte, string, error) { +func (c *Client) downloadMedia(ctx context.Context, mediaID string) (data []byte, mimeType string, err error) { // Step 1: Get the media URL url := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, @@ -129,10 +129,11 @@ func (c *Client) downloadMedia(ctx context.Context, mediaID string) ([]byte, str return nil, "", fmt.Errorf("failed to download media, status %d", downloadResp.StatusCode) } - data, err := io.ReadAll(downloadResp.Body) + data, err = io.ReadAll(downloadResp.Body) if err != nil { return nil, "", fmt.Errorf("failed to read media data: %w", err) } - return data, mediaResp.MimeType, nil + mimeType = mediaResp.MimeType + return } diff --git a/pkg/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go index ae3c000..2b127ca 100644 --- a/pkg/whatsapp/businessapi/types.go +++ b/pkg/whatsapp/businessapi/types.go @@ -2,13 +2,13 @@ package businessapi // SendMessageRequest represents a request to send a text message via Business API type SendMessageRequest struct { - MessagingProduct string `json:"messaging_product"` // Always "whatsapp" - RecipientType string `json:"recipient_type,omitempty"` // "individual" - To string `json:"to"` // Phone number in E.164 format - Type string `json:"type"` // "text", "image", "video", "document" - Text *TextObject `json:"text,omitempty"` - Image *MediaObject `json:"image,omitempty"` - Video *MediaObject `json:"video,omitempty"` + MessagingProduct string `json:"messaging_product"` // Always "whatsapp" + RecipientType string `json:"recipient_type,omitempty"` // "individual" + To string `json:"to"` // Phone number in E.164 format + Type string `json:"type"` // "text", "image", "video", "document" + Text *TextObject `json:"text,omitempty"` + Image *MediaObject `json:"image,omitempty"` + Video *MediaObject `json:"video,omitempty"` Document *DocumentObject `json:"document,omitempty"` } @@ -19,14 +19,14 @@ type TextObject struct { // MediaObject represents media (image/video) message type MediaObject struct { - ID string `json:"id,omitempty"` // Media ID (from upload) + ID string `json:"id,omitempty"` // Media ID (from upload) Link string `json:"link,omitempty"` // Or direct URL Caption string `json:"caption,omitempty"` } // DocumentObject represents a document message type DocumentObject struct { - ID string `json:"id,omitempty"` // Media ID (from upload) + ID string `json:"id,omitempty"` // Media ID (from upload) Link string `json:"link,omitempty"` // Or direct URL Caption string `json:"caption,omitempty"` Filename string `json:"filename,omitempty"` @@ -51,11 +51,11 @@ type MediaUploadResponse struct { // MediaURLResponse represents the response when getting media URL type MediaURLResponse struct { - URL string `json:"url"` // CDN URL to download media - MimeType string `json:"mime_type"` - SHA256 string `json:"sha256"` - FileSize int64 `json:"file_size"` - ID string `json:"id"` + URL string `json:"url"` // CDN URL to download media + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + FileSize int64 `json:"file_size"` + ID string `json:"id"` MessagingProduct string `json:"messaging_product"` } @@ -90,11 +90,11 @@ type WebhookChange struct { // WebhookValue contains the actual webhook data type WebhookValue struct { - MessagingProduct string `json:"messaging_product"` - Metadata WebhookMetadata `json:"metadata"` - Contacts []WebhookContact `json:"contacts,omitempty"` - Messages []WebhookMessage `json:"messages,omitempty"` - Statuses []WebhookStatus `json:"statuses,omitempty"` + MessagingProduct string `json:"messaging_product"` + Metadata WebhookMetadata `json:"metadata"` + Contacts []WebhookContact `json:"contacts,omitempty"` + Messages []WebhookMessage `json:"messages,omitempty"` + Statuses []WebhookStatus `json:"statuses,omitempty"` } // WebhookMetadata contains metadata about the phone number @@ -116,15 +116,15 @@ type WebhookProfile struct { // WebhookMessage represents a message in the webhook type WebhookMessage struct { - From string `json:"from"` // Sender phone number - ID string `json:"id"` // Message ID - Timestamp string `json:"timestamp"` // Unix timestamp as string - Type string `json:"type"` // "text", "image", "video", "document", etc. - Text *WebhookText `json:"text,omitempty"` - Image *WebhookMediaMessage `json:"image,omitempty"` - Video *WebhookMediaMessage `json:"video,omitempty"` + From string `json:"from"` // Sender phone number + ID string `json:"id"` // Message ID + Timestamp string `json:"timestamp"` // Unix timestamp as string + Type string `json:"type"` // "text", "image", "video", "document", etc. + Text *WebhookText `json:"text,omitempty"` + Image *WebhookMediaMessage `json:"image,omitempty"` + Video *WebhookMediaMessage `json:"video,omitempty"` Document *WebhookDocumentMessage `json:"document,omitempty"` - Context *WebhookContext `json:"context,omitempty"` // Reply context + Context *WebhookContext `json:"context,omitempty"` // Reply context } // WebhookText represents a text message @@ -158,20 +158,20 @@ type WebhookContext struct { // WebhookStatus represents a message status update type WebhookStatus struct { - ID string `json:"id"` // Message ID - Status string `json:"status"` // "sent", "delivered", "read", "failed" - Timestamp string `json:"timestamp"` // Unix timestamp as string - RecipientID string `json:"recipient_id"` - Conversation *WebhookConversation `json:"conversation,omitempty"` - Pricing *WebhookPricing `json:"pricing,omitempty"` - Errors []WebhookError `json:"errors,omitempty"` + ID string `json:"id"` // Message ID + Status string `json:"status"` // "sent", "delivered", "read", "failed" + Timestamp string `json:"timestamp"` // Unix timestamp as string + RecipientID string `json:"recipient_id"` + Conversation *WebhookConversation `json:"conversation,omitempty"` + Pricing *WebhookPricing `json:"pricing,omitempty"` + Errors []WebhookError `json:"errors,omitempty"` } // WebhookConversation contains conversation details type WebhookConversation struct { - ID string `json:"id"` - ExpirationTimestamp string `json:"expiration_timestamp,omitempty"` - Origin WebhookOrigin `json:"origin"` + ID string `json:"id"` + ExpirationTimestamp string `json:"expiration_timestamp,omitempty"` + Origin WebhookOrigin `json:"origin"` } // WebhookOrigin contains conversation origin diff --git a/pkg/whatsapp/whatsmeow/client.go b/pkg/whatsapp/whatsmeow/client.go index 85e1a18..9685622 100644 --- a/pkg/whatsapp/whatsmeow/client.go +++ b/pkg/whatsapp/whatsmeow/client.go @@ -395,7 +395,7 @@ func (c *Client) handleEvent(evt interface{}) { // Extract message content based on type var text string - var messageType string = "text" + var messageType = "text" var mimeType string var filename string var mediaBase64 string @@ -516,12 +516,13 @@ func (c *Client) handleEvent(evt interface{}) { case *waEvents.Receipt: // Handle delivery and read receipts - if v.Type == types.ReceiptTypeDelivered { + switch v.Type { + case types.ReceiptTypeDelivered: for _, messageID := range v.MessageIDs { logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) } - } else if v.Type == types.ReceiptTypeRead { + case types.ReceiptTypeRead: for _, messageID := range v.MessageIDs { logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) @@ -561,10 +562,8 @@ func (c *Client) startKeepAlive() { } // processMediaData processes media based on the configured mode -func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { +func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (filename string, mediaURL string) { mode := c.mediaConfig.Mode - var filename string - var mediaURL string // Generate filename ext := getExtensionFromMimeType(mimeType)