From f6731efbe462370a9f0a47f8dd36ea1152ce9abc Mon Sep 17 00:00:00 2001 From: anthonynsimon Date: Mon, 2 Mar 2026 21:38:56 +0100 Subject: [PATCH 1/3] Switch to websocket based consumer --- .gitignore | 1 + Makefile | 6 +++ go.mod | 1 + go.sum | 2 + main.go | 140 +++++++++++++++++++++++++++++++++++++++-------------- 5 files changed, 113 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 0124fe5..b1b54f0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ _testmain.go # Dist dist usewebhook +usewebhook-cli *.tar.gz # Tmp files diff --git a/Makefile b/Makefile index 491d25e..aef3b09 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,12 @@ PKG = github.com/figstra/usewebhook-cli VERSION ?= dev LDFLAGS = -ldflags "-X 'main.Version=$(VERSION)'" +build: + go build $(LDFLAGS) -o dist/usewebhook . + +run: build + ./dist/usewebhook + deps: go get ./... diff --git a/go.mod b/go.mod index 2f5b847..74f321e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/fatih/color v1.18.0 + github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.1 ) diff --git a/go.sum b/go.sum index 9afc24e..36e260b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/main.go b/main.go index 024c4d4..6f36c2f 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "time" "github.com/fatih/color" + "github.com/gorilla/websocket" "github.com/spf13/cobra" ) @@ -26,25 +27,39 @@ var ( Version = "dev" APIURL = "https://usewebhook.com/api/webhooks/" BaseURL = "https://usewebhook.com" + WSURL = "wss://usewebhook.com/ws/webhook/" SettingsFilename = ".usewebhook" ) // WebhookRequest represents a single webhook request type WebhookRequest struct { - RequestID string `json:"request_id"` - Timestamp string `json:"timestamp"` - IP string `json:"ip"` - Method string `json:"method"` - Query string `json:"query"` - Headers map[string]string `json:"headers"` - Body string `json:"body"` + RequestID string `json:"request_id"` + Timestamp string `json:"timestamp"` + IP string `json:"ip"` + CountryCode string `json:"country_code"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Hostname string `json:"hostname"` + Path string `json:"path"` + Query string `json:"query"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` } -// WebhookResponse represents the response from the webhook API +// WebhookResponse represents the response from the webhook HTTP API type WebhookResponse struct { Requests []WebhookRequest `json:"requests"` } +// WSMessage is the envelope for WebSocket messages from the server. +// type "webhook.init" contains historical Requests; type "webhook.new" contains a single Request. +type WSMessage struct { + Type string `json:"type"` + Requests []WebhookRequest `json:"requests"` + Request *WebhookRequest `json:"request"` +} + // Config represents the user's configuration type Config struct { WebhookHistory []string `json:"webhook_history"` @@ -57,7 +72,6 @@ type AppConfig struct { ForwardTo string WebhookID string RequestID string - PollSleep time.Duration InitialSleep time.Duration } @@ -196,43 +210,95 @@ func decodeBase64Body(encodedBody string) (string, string, error) { return string(decoded), originalContentType, nil } -// pollWebhook continuously polls the webhook API for new requests -func pollWebhook(config AppConfig) { - lastPollTime := time.Now().UTC() +// fetchSingleRequest fetches a specific request by ID from the HTTP API and exits +func fetchSingleRequest(config AppConfig) { + params := url.Values{} + params.Set("request_id", config.RequestID) - for { - params := url.Values{} - if config.RequestID != "" { - params.Set("request_id", config.RequestID) - } else { - params.Set("since", lastPollTime.Format(time.RFC3339)) + webhookData, err := fetchWebhookData(config.WebhookID, params) + if err != nil { + color.Red("Error fetching webhook data: %v", err) + os.Exit(1) + } + + if len(webhookData.Requests) == 0 { + color.Red("No requests found for request ID: %s", config.RequestID) + os.Exit(1) + } + + for _, request := range webhookData.Requests { + logRequest(request, config.FullLog) + if config.ForwardTo != "" { + forwardRequest(request, config.ForwardTo) } + } + os.Exit(0) +} - webhookData, err := fetchWebhookData(config.WebhookID, params) +// connectAndListen opens a WebSocket connection and dispatches incoming requests until an error occurs. +// seen tracks request IDs already processed; isFirstConnect suppresses the history batch on the initial connection. +func connectAndListen(config AppConfig, seen map[string]bool, isFirstConnect *bool) error { + conn, _, err := websocket.DefaultDialer.Dial(WSURL+config.WebhookID, http.Header{ + "Origin": []string{BaseURL}, + }) + if err != nil { + return err + } + defer conn.Close() + + for { + _, message, err := conn.ReadMessage() if err != nil { - color.Red("Error fetching webhook data: %v", err) - time.Sleep(config.InitialSleep) - continue + return err } - for _, request := range webhookData.Requests { - logRequest(request, config.FullLog) - if config.ForwardTo != "" { - forwardRequest(request, config.ForwardTo) - } + var msg WSMessage + if err := json.Unmarshal(message, &msg); err != nil { + color.Yellow("Warning: failed to parse message: %v", err) + continue } - // if single request mode, exit after the first request - if config.RequestID != "" { - if len(webhookData.Requests) <= 0 { - color.Red("No requests found for request ID: %s", config.RequestID) - os.Exit(1) + switch msg.Type { + case "webhook.init": + for _, req := range msg.Requests { + if *isFirstConnect { + // Mark historical requests as seen without displaying them + seen[req.RequestID] = true + } else if !seen[req.RequestID] { + // Requests that arrived while we were disconnected + seen[req.RequestID] = true + logRequest(req, config.FullLog) + if config.ForwardTo != "" { + forwardRequest(req, config.ForwardTo) + } + } + } + *isFirstConnect = false + + case "webhook.new": + if msg.Request != nil && !seen[msg.Request.RequestID] { + seen[msg.Request.RequestID] = true + logRequest(*msg.Request, config.FullLog) + if config.ForwardTo != "" { + forwardRequest(*msg.Request, config.ForwardTo) + } } - os.Exit(0) } + } +} - lastPollTime = time.Now().UTC() - time.Sleep(config.PollSleep) +// listenWebSocket connects via WebSocket and reconnects automatically on disconnect. +// The seen map and isFirstConnect flag persist across reconnects to avoid replaying requests. +func listenWebSocket(config AppConfig) { + seen := make(map[string]bool) + isFirstConnect := true + + for { + err := connectAndListen(config, seen, &isFirstConnect) + if err != nil { + color.Red("WebSocket error: %v. Reconnecting...", err) + time.Sleep(config.InitialSleep) + } } } @@ -311,7 +377,6 @@ func saveConfig(config *Config) error { // createRootCommand creates and returns the root command for the CLI func createRootCommand() *cobra.Command { appConfig := AppConfig{ - PollSleep: 3 * time.Second, InitialSleep: 1 * time.Second, } @@ -378,6 +443,7 @@ func runRootCommand(cmd *cobra.Command, args []string, appConfig *AppConfig) { if appConfig.RequestID != "" { color.Green("Single request mode. Retrieving webhook=%s request=%s\n\n", appConfig.WebhookID, appConfig.RequestID) + fetchSingleRequest(*appConfig) } else { color.Green("Dashboard: %s/?id=%s", BaseURL, appConfig.WebhookID) color.Green("Webhook URL: %s/%s", BaseURL, appConfig.WebhookID) @@ -385,8 +451,8 @@ func runRootCommand(cmd *cobra.Command, args []string, appConfig *AppConfig) { color.Green("Forwarding to: %s", appConfig.ForwardTo) } color.HiBlack("\nPress Ctrl+C to stop\n\n") + listenWebSocket(*appConfig) } - pollWebhook(*appConfig) } // contains checks if a slice contains a specific item From 1591a898dab4eaa6bfe02946cfd5eca6f78ea65a Mon Sep 17 00:00:00 2001 From: anthonynsimon Date: Mon, 2 Mar 2026 21:43:12 +0100 Subject: [PATCH 2/3] Add tests --- main_test.go | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 main_test.go diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c79858c --- /dev/null +++ b/main_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// sharedWebhookID is a fixed test webhook — reused across all live tests +const sharedWebhookID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + +func dialTestWS(t *testing.T) *websocket.Conn { + t.Helper() + conn, _, err := websocket.DefaultDialer.Dial(WSURL+sharedWebhookID, http.Header{ + "Origin": []string{BaseURL}, + }) + if err != nil { + t.Fatalf("failed to connect to WebSocket: %v", err) + } + return conn +} + +func readWSMessage(t *testing.T, conn *websocket.Conn, timeout time.Duration) WSMessage { + t.Helper() + conn.SetReadDeadline(time.Now().Add(timeout)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read WebSocket message: %v", err) + } + var msg WSMessage + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("failed to unmarshal message: %v\nraw: %s", err, raw) + } + return msg +} + +func sendWebhookRequest(t *testing.T, method, body string) { + t.Helper() + url := fmt.Sprintf("%s/%s", BaseURL, sharedWebhookID) + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("failed to create HTTP request: %v", err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to send webhook request: %v", err) + } + defer resp.Body.Close() +} + +// TestWSConnectReceivesInit verifies that connecting to the WebSocket returns a webhook.init message +func TestWSConnectReceivesInit(t *testing.T) { + conn := dialTestWS(t) + defer conn.Close() + + msg := readWSMessage(t, conn, 5*time.Second) + + if msg.Type != "webhook.init" { + t.Errorf("expected type webhook.init, got %q", msg.Type) + } +} + +// TestWSReceivesNewRequestOnHTTPPost verifies that sending an HTTP request triggers a webhook.new message +func TestWSReceivesNewRequestOnHTTPPost(t *testing.T) { + conn := dialTestWS(t) + defer conn.Close() + + // Consume the init message + readWSMessage(t, conn, 5*time.Second) + + // Send a POST request to the webhook URL + payload := `{"test": "live-integration"}` + sendWebhookRequest(t, http.MethodPost, payload) + + msg := readWSMessage(t, conn, 10*time.Second) + + if msg.Type != "webhook.new" { + t.Errorf("expected type webhook.new, got %q", msg.Type) + } + if msg.Request == nil { + t.Fatal("expected request to be non-nil") + } + if msg.Request.Method != http.MethodPost { + t.Errorf("expected method POST, got %q", msg.Request.Method) + } + if !strings.Contains(msg.Request.Body, "live-integration") { + t.Errorf("expected body to contain 'live-integration', got %q", msg.Request.Body) + } +} + +// TestWSReceivesNewRequestOnHTTPGet verifies a GET request also triggers webhook.new +func TestWSReceivesNewRequestOnHTTPGet(t *testing.T) { + conn := dialTestWS(t) + defer conn.Close() + + // Consume the init message + readWSMessage(t, conn, 5*time.Second) + + sendWebhookRequest(t, http.MethodGet, "") + + msg := readWSMessage(t, conn, 10*time.Second) + + if msg.Type != "webhook.new" { + t.Errorf("expected type webhook.new, got %q", msg.Type) + } + if msg.Request == nil { + t.Fatal("expected request to be non-nil") + } + if msg.Request.Method != http.MethodGet { + t.Errorf("expected method GET, got %q", msg.Request.Method) + } +} + +// TestExtractIdsFromURLOrArgs covers the various input formats accepted by the CLI +func TestExtractIdsFromURLOrArgs(t *testing.T) { + id := "409bdb1f81abfa826c2022d18ddff2e5" + + cases := []struct { + input string + wantID string + wantReqID string + wantErr bool + }{ + {id, id, "", false}, + {"https://usewebhook.com/" + id, id, "", false}, + {"http://usewebhook.com/" + id, id, "", false}, + {"usewebhook.com/" + id, id, "", false}, + {"https://usewebhook.com/?id=" + id + "&req=abc123", id, "abc123", false}, + {"not-a-valid-id", "", "", true}, + {"", "", "", true}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + gotID, gotReqID, err := extractIdsFromURLOrArgs(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got id=%q", gotID) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if gotID != tc.wantID { + t.Errorf("webhook ID: want %q, got %q", tc.wantID, gotID) + } + if gotReqID != tc.wantReqID { + t.Errorf("request ID: want %q, got %q", tc.wantReqID, gotReqID) + } + }) + } +} From c3220dc87cba16a64e5e981063116933884c2e6f Mon Sep 17 00:00:00 2001 From: anthonynsimon Date: Mon, 2 Mar 2026 21:45:06 +0100 Subject: [PATCH 3/3] Enable ci tests --- .github/workflows/check.yml | 19 +++++++++++++++++++ .github/workflows/release.yml | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..4de58b8 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Test + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e45090..21d419f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ jobs: with: go-version: '1.22.4' + - name: Test + run: make test + - name: Build run: VERSION=${{ github.ref_name }} make release