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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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