Lint fixes and testing workflow actions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Test (1.23) (push) Has been cancelled
CI / Test (1.22) (push) Has been cancelled

This commit is contained in:
2025-12-29 10:24:18 +02:00
parent 767a9e211f
commit a3eca09502
20 changed files with 532 additions and 119 deletions

103
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

116
.github/workflows/release.yml vendored Normal file
View File

@@ -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)"

114
.golangci.json Normal file
View File

@@ -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"
}

View File

@@ -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 both server and CLI
build: build:
@@ -29,10 +29,11 @@ clean:
@rm -f bin/whatshook* @rm -f bin/whatshook*
@echo "Clean complete!" @echo "Clean complete!"
# Run tests # Run tests with coverage
test: test:
@echo "Running tests..." @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 (requires config.json)
run-server: run-server:
@@ -49,6 +50,51 @@ deps:
@go mod tidy @go mod tidy
@echo "Dependencies installed!" @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
help: help:
@echo "WhatsHooked Makefile" @echo "WhatsHooked Makefile"
@@ -58,7 +104,9 @@ help:
@echo " make build-server - Build server only" @echo " make build-server - Build server only"
@echo " make build-cli - Build CLI only" @echo " make build-cli - Build CLI only"
@echo " make clean - Remove build artifacts (preserves bin directory)" @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-server - Run server (requires config.json)"
@echo " make run-cli ARGS='health' - Run CLI with arguments" @echo " make run-cli ARGS='health' - Run CLI with arguments"
@echo " make deps - Install dependencies" @echo " make deps - Install dependencies"

View File

@@ -66,13 +66,19 @@ func addAccount(client *Client) {
var account config.WhatsAppConfig var account config.WhatsAppConfig
fmt.Print("Account ID: ") 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.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.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) resp, err := client.Post("/api/accounts/add", account)
checkError(err) checkError(err)

View File

@@ -95,16 +95,25 @@ func addHook(client *Client) {
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Hook ID: ") 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.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.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.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 == "" { if hook.Method == "" {
hook.Method = "POST" hook.Method = "POST"
} }

View File

@@ -70,10 +70,14 @@ func sendMessage(client *Client) {
} }
fmt.Print("Account ID: ") 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.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: ") fmt.Print("Message text: ")
reader := os.Stdin reader := os.Stdin
@@ -101,10 +105,14 @@ func sendImage(client *Client, filePath string) {
} }
fmt.Print("Account ID: ") 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.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): ") fmt.Print("Caption (optional): ")
reader := os.Stdin reader := os.Stdin
@@ -151,10 +159,14 @@ func sendVideo(client *Client, filePath string) {
} }
fmt.Print("Account ID: ") 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.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): ") fmt.Print("Caption (optional): ")
reader := os.Stdin reader := os.Stdin
@@ -204,10 +216,14 @@ func sendDocument(client *Client, filePath string) {
} }
fmt.Print("Account ID: ") 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.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): ") fmt.Print("Caption (optional): ")
reader := os.Stdin reader := os.Stdin

View File

@@ -28,12 +28,12 @@ type ServerConfig struct {
// WhatsAppConfig holds configuration for a WhatsApp account // WhatsAppConfig holds configuration for a WhatsApp account
type WhatsAppConfig struct { type WhatsAppConfig struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // "whatsmeow" or "business-api" Type string `json:"type"` // "whatsmeow" or "business-api"
PhoneNumber string `json:"phone_number"` PhoneNumber string `json:"phone_number"`
SessionPath string `json:"session_path,omitempty"` SessionPath string `json:"session_path,omitempty"`
ShowQR bool `json:"show_qr,omitempty"` ShowQR bool `json:"show_qr,omitempty"`
BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"` BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"`
} }
// BusinessAPIConfig holds configuration for WhatsApp Business API // BusinessAPIConfig holds configuration for WhatsApp Business API
@@ -72,7 +72,7 @@ type DatabaseConfig struct {
// MediaConfig holds media storage and delivery configuration // MediaConfig holds media storage and delivery configuration
type MediaConfig struct { type MediaConfig struct {
DataPath string `json:"data_path"` 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 BaseURL string `json:"base_url,omitempty"` // Base URL for media links
} }

View File

@@ -35,10 +35,10 @@ const (
// Event represents an event in the system // Event represents an event in the system
type Event struct { type Event struct {
Type EventType `json:"type"` Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
Context context.Context `json:"-"` Context context.Context `json:"-"`
} }
// Subscriber is a function that handles events // Subscriber is a function that handles events

View File

@@ -12,7 +12,7 @@ import (
// Accounts returns the list of all configured WhatsApp accounts // Accounts returns the list of all configured WhatsApp accounts
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) { func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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 // 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) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }

View File

@@ -77,7 +77,7 @@ func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Reque
if mode == "subscribe" && token == accountConfig.VerifyToken { if mode == "subscribe" && token == accountConfig.VerifyToken {
logging.Info("Webhook verification successful", "account_id", accountID) logging.Info("Webhook verification successful", "account_id", accountID)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(challenge)) writeBytes(w, []byte(challenge))
return return
} }
@@ -130,7 +130,7 @@ func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Reques
// Return 200 OK to acknowledge receipt // Return 200 OK to acknowledge receipt
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) writeBytes(w, []byte("OK"))
} }
// extractAccountIDFromPath extracts the account ID from the URL path // extractAccountIDFromPath extracts the account ID from the URL path

View File

@@ -1,10 +1,12 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/hooks" "git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp" "git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
) )
@@ -54,3 +56,17 @@ func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers {
h.authConfig = cfg h.authConfig = cfg
return h 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)
}
}

View File

@@ -1,11 +1,10 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
) )
// Health handles health check requests // Health handles health check requests
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { 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"})
} }

View File

@@ -12,7 +12,7 @@ import (
func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) { func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) {
hooks := h.hookMgr.ListHooks() hooks := h.hookMgr.ListHooks()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hooks) writeJSON(w, hooks)
} }
// AddHook adds a new hook to the system // 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) 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 // 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"})
} }

View File

@@ -40,7 +40,7 @@ func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
// SendImage sends an image via WhatsApp // SendImage sends an image via WhatsApp
@@ -87,7 +87,7 @@ func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
// SendVideo sends a video via WhatsApp // SendVideo sends a video via WhatsApp
@@ -134,7 +134,7 @@ func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
// SendDocument sends a document via WhatsApp // SendDocument sends a document via WhatsApp
@@ -185,5 +185,5 @@ func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }

View File

@@ -340,15 +340,3 @@ func jidToPhoneNumber(jid types.JID) string {
return phone 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"
}
}

View File

@@ -32,8 +32,8 @@ func (c *Client) HandleWebhook(r *http.Request) error {
// Process each entry // Process each entry
for _, entry := range payload.Entry { for _, entry := range payload.Entry {
for _, change := range entry.Changes { for i := range entry.Changes {
c.processChange(change) 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 // 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 mode := c.mediaConfig.Mode
var filename string
var mediaURL string
// Generate filename // Generate filename
ext := getExtensionFromMimeType(mimeType) 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 // getExtensionFromMimeType returns the file extension for a given MIME type
func getExtensionFromMimeType(mimeType string) string { func getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{ extensions := map[string]string{
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
"image/gif": ".gif", "image/gif": ".gif",
"image/webp": ".webp", "image/webp": ".webp",
"video/mp4": ".mp4", "video/mp4": ".mp4",
"video/mpeg": ".mpeg", "video/mpeg": ".mpeg",
"video/webm": ".webm", "video/webm": ".webm",
"video/3gpp": ".3gp", "video/3gpp": ".3gp",
"application/pdf": ".pdf", "application/pdf": ".pdf",
"application/msword": ".doc", "application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls", "application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"text/plain": ".txt", "text/plain": ".txt",
"application/json": ".json", "application/json": ".json",
"audio/mpeg": ".mp3", "audio/mpeg": ".mp3",
"audio/ogg": ".ogg", "audio/ogg": ".ogg",
} }
if ext, ok := extensions[mimeType]; ok { if ext, ok := extensions[mimeType]; ok {

View File

@@ -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 // 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 // Step 1: Get the media URL
url := fmt.Sprintf("https://graph.facebook.com/%s/%s", url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion, 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) 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 { if err != nil {
return nil, "", fmt.Errorf("failed to read media data: %w", err) return nil, "", fmt.Errorf("failed to read media data: %w", err)
} }
return data, mediaResp.MimeType, nil mimeType = mediaResp.MimeType
return
} }

View File

@@ -2,13 +2,13 @@ package businessapi
// SendMessageRequest represents a request to send a text message via Business API // SendMessageRequest represents a request to send a text message via Business API
type SendMessageRequest struct { type SendMessageRequest struct {
MessagingProduct string `json:"messaging_product"` // Always "whatsapp" MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
RecipientType string `json:"recipient_type,omitempty"` // "individual" RecipientType string `json:"recipient_type,omitempty"` // "individual"
To string `json:"to"` // Phone number in E.164 format To string `json:"to"` // Phone number in E.164 format
Type string `json:"type"` // "text", "image", "video", "document" Type string `json:"type"` // "text", "image", "video", "document"
Text *TextObject `json:"text,omitempty"` Text *TextObject `json:"text,omitempty"`
Image *MediaObject `json:"image,omitempty"` Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"` Video *MediaObject `json:"video,omitempty"`
Document *DocumentObject `json:"document,omitempty"` Document *DocumentObject `json:"document,omitempty"`
} }
@@ -19,14 +19,14 @@ type TextObject struct {
// MediaObject represents media (image/video) message // MediaObject represents media (image/video) message
type MediaObject struct { 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 Link string `json:"link,omitempty"` // Or direct URL
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
} }
// DocumentObject represents a document message // DocumentObject represents a document message
type DocumentObject struct { 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 Link string `json:"link,omitempty"` // Or direct URL
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
@@ -51,11 +51,11 @@ type MediaUploadResponse struct {
// MediaURLResponse represents the response when getting media URL // MediaURLResponse represents the response when getting media URL
type MediaURLResponse struct { type MediaURLResponse struct {
URL string `json:"url"` // CDN URL to download media URL string `json:"url"` // CDN URL to download media
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
FileSize int64 `json:"file_size"` FileSize int64 `json:"file_size"`
ID string `json:"id"` ID string `json:"id"`
MessagingProduct string `json:"messaging_product"` MessagingProduct string `json:"messaging_product"`
} }
@@ -90,11 +90,11 @@ type WebhookChange struct {
// WebhookValue contains the actual webhook data // WebhookValue contains the actual webhook data
type WebhookValue struct { type WebhookValue struct {
MessagingProduct string `json:"messaging_product"` MessagingProduct string `json:"messaging_product"`
Metadata WebhookMetadata `json:"metadata"` Metadata WebhookMetadata `json:"metadata"`
Contacts []WebhookContact `json:"contacts,omitempty"` Contacts []WebhookContact `json:"contacts,omitempty"`
Messages []WebhookMessage `json:"messages,omitempty"` Messages []WebhookMessage `json:"messages,omitempty"`
Statuses []WebhookStatus `json:"statuses,omitempty"` Statuses []WebhookStatus `json:"statuses,omitempty"`
} }
// WebhookMetadata contains metadata about the phone number // WebhookMetadata contains metadata about the phone number
@@ -116,15 +116,15 @@ type WebhookProfile struct {
// WebhookMessage represents a message in the webhook // WebhookMessage represents a message in the webhook
type WebhookMessage struct { type WebhookMessage struct {
From string `json:"from"` // Sender phone number From string `json:"from"` // Sender phone number
ID string `json:"id"` // Message ID ID string `json:"id"` // Message ID
Timestamp string `json:"timestamp"` // Unix timestamp as string Timestamp string `json:"timestamp"` // Unix timestamp as string
Type string `json:"type"` // "text", "image", "video", "document", etc. Type string `json:"type"` // "text", "image", "video", "document", etc.
Text *WebhookText `json:"text,omitempty"` Text *WebhookText `json:"text,omitempty"`
Image *WebhookMediaMessage `json:"image,omitempty"` Image *WebhookMediaMessage `json:"image,omitempty"`
Video *WebhookMediaMessage `json:"video,omitempty"` Video *WebhookMediaMessage `json:"video,omitempty"`
Document *WebhookDocumentMessage `json:"document,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 // WebhookText represents a text message
@@ -158,20 +158,20 @@ type WebhookContext struct {
// WebhookStatus represents a message status update // WebhookStatus represents a message status update
type WebhookStatus struct { type WebhookStatus struct {
ID string `json:"id"` // Message ID ID string `json:"id"` // Message ID
Status string `json:"status"` // "sent", "delivered", "read", "failed" Status string `json:"status"` // "sent", "delivered", "read", "failed"
Timestamp string `json:"timestamp"` // Unix timestamp as string Timestamp string `json:"timestamp"` // Unix timestamp as string
RecipientID string `json:"recipient_id"` RecipientID string `json:"recipient_id"`
Conversation *WebhookConversation `json:"conversation,omitempty"` Conversation *WebhookConversation `json:"conversation,omitempty"`
Pricing *WebhookPricing `json:"pricing,omitempty"` Pricing *WebhookPricing `json:"pricing,omitempty"`
Errors []WebhookError `json:"errors,omitempty"` Errors []WebhookError `json:"errors,omitempty"`
} }
// WebhookConversation contains conversation details // WebhookConversation contains conversation details
type WebhookConversation struct { type WebhookConversation struct {
ID string `json:"id"` ID string `json:"id"`
ExpirationTimestamp string `json:"expiration_timestamp,omitempty"` ExpirationTimestamp string `json:"expiration_timestamp,omitempty"`
Origin WebhookOrigin `json:"origin"` Origin WebhookOrigin `json:"origin"`
} }
// WebhookOrigin contains conversation origin // WebhookOrigin contains conversation origin

View File

@@ -395,7 +395,7 @@ func (c *Client) handleEvent(evt interface{}) {
// Extract message content based on type // Extract message content based on type
var text string var text string
var messageType string = "text" var messageType = "text"
var mimeType string var mimeType string
var filename string var filename string
var mediaBase64 string var mediaBase64 string
@@ -516,12 +516,13 @@ func (c *Client) handleEvent(evt interface{}) {
case *waEvents.Receipt: case *waEvents.Receipt:
// Handle delivery and read receipts // Handle delivery and read receipts
if v.Type == types.ReceiptTypeDelivered { switch v.Type {
case types.ReceiptTypeDelivered:
for _, messageID := range v.MessageIDs { for _, messageID := range v.MessageIDs {
logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) 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)) 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 { for _, messageID := range v.MessageIDs {
logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) 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)) 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 // 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 mode := c.mediaConfig.Mode
var filename string
var mediaURL string
// Generate filename // Generate filename
ext := getExtensionFromMimeType(mimeType) ext := getExtensionFromMimeType(mimeType)