From bf3c4a48e4268ad173291950e7eefe9009129452 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 27 Feb 2026 02:58:34 -0700 Subject: [PATCH] copilot: add CSV and JSON endpoints to smsapid with csvauth role-based auth --- cmd/smsapid/go.mod | 14 +- cmd/smsapid/go.sum | 14 +- cmd/smsapid/main.go | 401 +++++++++++++++++++++++++++-- http/androidsmsgateway/webhooks.go | 58 +++-- 4 files changed, 443 insertions(+), 44 deletions(-) diff --git a/cmd/smsapid/go.mod b/cmd/smsapid/go.mod index c1b33cd..0964a7d 100644 --- a/cmd/smsapid/go.mod +++ b/cmd/smsapid/go.mod @@ -3,14 +3,26 @@ module github.com/therootcompany/golib/cmd/smsapid go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/jszwec/csvutil v1.10.0 github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 + github.com/therootcompany/golib/auth v1.0.0 + github.com/therootcompany/golib/auth/csvauth v0.0.0 github.com/therootcompany/golib/colorjson v1.0.1 github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d + github.com/therootcompany/golib/http/middleware/v2 v2.0.0 ) require ( github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) + +replace ( + github.com/therootcompany/golib/auth/csvauth => ../../auth/csvauth + github.com/therootcompany/golib/http/androidsmsgateway => ../../http/androidsmsgateway + github.com/therootcompany/golib/http/middleware/v2 => ../../http/middleware ) diff --git a/cmd/smsapid/go.sum b/cmd/smsapid/go.sum index 81d2478..3ebcc12 100644 --- a/cmd/smsapid/go.sum +++ b/cmd/smsapid/go.sum @@ -1,5 +1,9 @@ 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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= +github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -7,11 +11,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 h1:CXJI+lliMiiEwzfgE8yt/38K0heYDgQ0L3f/3fxRnQU= github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740/go.mod h1:G4w16caPmc6at7u4fmkj/8OAoOnM9mkmJr2fvL0vhaw= +github.com/therootcompany/golib/auth v1.0.0 h1:17hfwcJO/Efc22/8RcCTKUD49mhCc5tyoHiKonA3Slg= +github.com/therootcompany/golib/auth v1.0.0/go.mod h1:DSw8llmDkMtvMZWrzrTRtcaLPpPMsT6Sg+qwGf5O2U8= github.com/therootcompany/golib/colorjson v1.0.1 h1:AfBeVr9GX9xMvlJNFmFYzkWFy62yWwwXjX2LLA/Afto= github.com/therootcompany/golib/colorjson v1.0.1/go.mod h1:bE0wCyOsRFQnz22+TnQu4D0+FPl+ZugaaE79bjgDqRw= -github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d h1:jKf9QQUiGAHsrjkfpoo4FTQnFJu4UkDkPreZLll7tdE= -github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d/go.mod h1:2O9+uXPc1VAJvveK9eqm9X4e4pTJmFWV6vtJa3sI/CA= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/cmd/smsapid/main.go b/cmd/smsapid/main.go index f3e8845..5f3e4ad 100644 --- a/cmd/smsapid/main.go +++ b/cmd/smsapid/main.go @@ -2,50 +2,76 @@ package main import ( "context" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log" "net/http" "os" + "slices" "strconv" "strings" "sync" + "time" + chiMiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/jszwec/csvutil" "github.com/simonfrey/jsonl" + "github.com/therootcompany/golib/auth" + "github.com/therootcompany/golib/auth/csvauth" "github.com/therootcompany/golib/colorjson" "github.com/therootcompany/golib/http/androidsmsgateway" + mwpkg "github.com/therootcompany/golib/http/middleware/v2" ) var jsonf = colorjson.NewFormatter() +// webhookMux protects webhookWriter, pingWriter, webhookEvents, and pingEvents. +var webhookMux = sync.Mutex{} var webhookEvents []androidsmsgateway.WebhookEvent var webhookWriter jsonl.Writer -var webhookMux = sync.Mutex{} +var pingEvents []*androidsmsgateway.WebhookPing +var pingWriter jsonl.Writer + +var smsAuth *csvauth.Auth + +// pingRow is a flat CSV row for a ping event (x-* headers omitted, health fields inlined). +type pingRow struct { + DeviceID string `csv:"deviceId"` + Event string `csv:"event"` + ID string `csv:"id"` + WebhookID string `csv:"webhookId"` + PingedAt time.Time `csv:"pingedAt"` + Status string `csv:"status"` + Version string `csv:"version"` + BatteryLevel float64 `csv:"batteryLevel"` + BatteryCharging float64 `csv:"batteryCharging"` + ConnectionStatus float64 `csv:"connectionStatus"` + ConnectionTransport float64 `csv:"connectionTransport"` + ConnectionCellular float64 `csv:"connectionCellular"` + MessagesFailed float64 `csv:"messagesFailed"` +} func main() { jsonf.Indent = 3 - // TODO manual override via flags - // color.NoColor = false - - filePath := "./messages.jsonl" + messagesPath := "./messages.jsonl" { - file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0644) + file, err := os.OpenFile(messagesPath, os.O_RDONLY|os.O_CREATE, 0644) if err != nil { - log.Fatalf("failed to open file '%s': %v", filePath, err) + log.Fatalf("failed to open file '%s': %v", messagesPath, err) } defer func() { _ = file.Close() }() - // buf := bufio.NewReader(file) - buf := file - webhookEvents, err = readWebhooks(buf) + webhookEvents, err = readWebhooks(file) if err != nil { - log.Fatalf("failed to read jsonl file '%s': %v", filePath, err) + log.Fatalf("failed to read jsonl file '%s': %v", messagesPath, err) } } { - file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + file, err := os.OpenFile(messagesPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { panic(fmt.Errorf("failed to open file: %v", err)) } @@ -54,6 +80,45 @@ func main() { webhookWriter = jsonl.NewWriter(file) } + pingsPath := "./pings.jsonl" + { + file, err := os.OpenFile(pingsPath, os.O_RDONLY|os.O_CREATE, 0644) + if err != nil { + log.Fatalf("failed to open file '%s': %v", pingsPath, err) + } + defer func() { _ = file.Close() }() + + pingEvents, err = readPings(file) + if err != nil { + log.Fatalf("failed to read jsonl file '%s': %v", pingsPath, err) + } + } + { + file, err := os.OpenFile(pingsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + panic(fmt.Errorf("failed to open file: %v", err)) + } + defer func() { _ = file.Close() }() + + pingWriter = jsonl.NewWriter(file) + } + + // Load credentials for /api/smsgw routes. + credPath := "./credentials.tsv" + if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" { + credPath = v + } + aesKey := getAESKey("CSVAUTH_AES_128_KEY") + if f, err := os.Open(credPath); err == nil { + defer func() { _ = f.Close() }() + smsAuth = csvauth.New(aesKey) + if err := smsAuth.LoadCSV(f, '\t'); err != nil { + log.Fatalf("failed to load credentials from %q: %v", credPath, err) + } + } else { + log.Printf("Warning: credentials file %q not found; /api/smsgw routes will return 401: %v", credPath, err) + } + mux := http.NewServeMux() mux.HandleFunc("GET /api/webhooks", handlerWebhooks) mux.Handle("GET /", LogRequest(http.HandlerFunc(HandleOK))) @@ -62,9 +127,285 @@ func main() { mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK))) mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK))) + // Protected routes under /api/smsgw, authenticated via csvauth with the "sms" role. + smsgw := mwpkg.WithMux(mux, smsAuthMiddleware) + smsgw.HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV) + smsgw.HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON) + smsgw.HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV) + smsgw.HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON) + smsgw.HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV) + smsgw.HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON) + addr := "localhost:8088" fmt.Printf("Listening on %s...\n\n", addr) - log.Fatal(http.ListenAndServe(addr, mux)) + log.Fatal(http.ListenAndServe(addr, chiMiddleware.Logger(chiMiddleware.Compress(5)(mux)))) +} + +// smsAuthMiddleware requires csvauth credentials with the "sms" role. +func smsAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if smsAuth == nil { + w.Header().Set("WWW-Authenticate", `Basic`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + cred, err := authenticateSMS(r) + if err != nil || !slices.Contains(cred.Permissions(), "sms") { + w.Header().Set("WWW-Authenticate", `Basic`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// authenticateSMS extracts and verifies credentials from Basic Auth, Authorization header, or API-Key header. +func authenticateSMS(r *http.Request) (auth.BasicPrinciple, error) { + // 1. Try Basic Auth + if username, password, ok := r.BasicAuth(); ok { + cred, err := smsAuth.Authenticate(username, password) + if !errors.Is(err, csvauth.ErrNotFound) { + if err == nil { + return cred, err + } + return nil, err + } + // Basic Auth was used to carry a token (username is a token name like "", "api", "apikey"). + token := password + if password == "" { + token = username + } + return smsAuth.Authenticate("", token) + } + + // 2. Try Authorization: + if authHeader := r.Header.Get("Authorization"); authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 { + token := strings.TrimSpace(parts[1]) + return smsAuth.Authenticate("", token) + } + return nil, csvauth.ErrUnauthorized + } + + // 3. Try API-Key / X-API-Key headers + for _, h := range []string{"API-Key", "X-API-Key"} { + if key := r.Header.Get(h); key != "" { + return smsAuth.Authenticate("", key) + } + } + + return nil, csvauth.ErrNotFound +} + +// getAESKey reads an AES-128 key (32 hex chars) from an environment variable. +// Returns a zero key if the variable is absent or invalid. +func getAESKey(envname string) []byte { + if v := os.Getenv(envname); v != "" { + key, err := hex.DecodeString(strings.TrimSpace(v)) + if err == nil && len(key) == 16 { + return key + } + log.Printf("Warning: %s is invalid (must be 32 hex chars); using zero key", envname) + } + return make([]byte, 16) +} + +// parseSinceLimit extracts the "since" (ISO datetime) and "limit" query parameters. +func parseSinceLimit(r *http.Request) (time.Time, int) { + var since time.Time + if s := r.URL.Query().Get("since"); s != "" { + for _, format := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02"} { + if t, err := time.Parse(format, s); err == nil { + since = t + break + } + } + } + + limit := 10000 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(strings.ReplaceAll(l, "_", "")); err == nil && n > 0 { + limit = n + } + } + + return since, limit +} + +func handlerReceivedCSV(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]*androidsmsgateway.WebhookReceived, 0, min(len(webhookEvents), limit)) + for _, event := range webhookEvents { + recv, ok := event.(*androidsmsgateway.WebhookReceived) + if !ok { + continue + } + if !since.IsZero() && !recv.Payload.ReceivedAt.After(since) { + continue + } + rows = append(rows, recv) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + b, err := csvutil.Marshal(rows) + if err != nil { + http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + _, _ = w.Write(b) +} + +func handlerReceivedJSON(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]*androidsmsgateway.WebhookReceived, 0, min(len(webhookEvents), limit)) + for _, event := range webhookEvents { + recv, ok := event.(*androidsmsgateway.WebhookReceived) + if !ok { + continue + } + if !since.IsZero() && !recv.Payload.ReceivedAt.After(since) { + continue + } + rows = append(rows, recv) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(rows) +} + +func handlerSentCSV(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]*androidsmsgateway.WebhookSent, 0, min(len(webhookEvents), limit)) + for _, event := range webhookEvents { + sent, ok := event.(*androidsmsgateway.WebhookSent) + if !ok { + continue + } + if !since.IsZero() && !sent.Payload.SentAt.After(since) { + continue + } + rows = append(rows, sent) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + b, err := csvutil.Marshal(rows) + if err != nil { + http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + _, _ = w.Write(b) +} + +func handlerSentJSON(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]*androidsmsgateway.WebhookSent, 0, min(len(webhookEvents), limit)) + for _, event := range webhookEvents { + sent, ok := event.(*androidsmsgateway.WebhookSent) + if !ok { + continue + } + if !since.IsZero() && !sent.Payload.SentAt.After(since) { + continue + } + rows = append(rows, sent) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(rows) +} + +func handlerPingCSV(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]pingRow, 0, min(len(pingEvents), limit)) + for _, ping := range pingEvents { + pingedAt := time.UnixMilli(ping.XTimestamp).UTC() + if !since.IsZero() && !pingedAt.After(since) { + continue + } + h := ping.Payload.Health + row := pingRow{ + DeviceID: ping.DeviceID, + Event: ping.Event, + ID: ping.ID, + WebhookID: ping.WebhookID, + PingedAt: pingedAt, + Status: h.Status, + Version: h.Version, + BatteryLevel: h.Checks["battery:level"].ObservedValue, + BatteryCharging: h.Checks["battery:charging"].ObservedValue, + ConnectionStatus: h.Checks["connection:status"].ObservedValue, + ConnectionTransport: h.Checks["connection:transport"].ObservedValue, + ConnectionCellular: h.Checks["connection:cellular"].ObservedValue, + MessagesFailed: h.Checks["messages:failed"].ObservedValue, + } + rows = append(rows, row) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + b, err := csvutil.Marshal(rows) + if err != nil { + http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + _, _ = w.Write(b) +} + +func handlerPingJSON(w http.ResponseWriter, r *http.Request) { + since, limit := parseSinceLimit(r) + + webhookMux.Lock() + rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit)) + for _, ping := range pingEvents { + pingedAt := time.UnixMilli(ping.XTimestamp).UTC() + if !since.IsZero() && !pingedAt.After(since) { + continue + } + rows = append(rows, ping) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(rows) } func HandleOK(w http.ResponseWriter, r *http.Request) { @@ -118,7 +459,7 @@ func LogBody(next http.Handler) http.Handler { fmt.Fprintf(os.Stderr, "Unexpected body:\n%q\n", string(body)) } case "POST", "PATCH", "PUT": - // known + // known default: fmt.Fprintf(os.Stderr, "Unexpected method %s\n", r.Method) } @@ -256,6 +597,15 @@ func handler(w http.ResponseWriter, r *http.Request) { } switch h.GetEvent() { + case "system:ping": + ping := h.(*androidsmsgateway.WebhookPing) + webhookMux.Lock() + defer webhookMux.Unlock() + if err := pingWriter.Write(ping); err != nil { + http.Error(w, `{"error":"failed to save ping"}`, http.StatusOK) + return + } + pingEvents = append(pingEvents, ping) case "mms:received", "sms:received", "sms:data-received", "sms:sent", "sms:delivered", "sms:failed": webhookMux.Lock() defer webhookMux.Unlock() @@ -264,8 +614,6 @@ func handler(w http.ResponseWriter, r *http.Request) { return } webhookEvents = append(webhookEvents, h) - case "system:ping": - // nothing to do yet default: http.Error(w, `{"error":"unknown webhook event"}`, http.StatusOK) return @@ -325,3 +673,24 @@ func readWebhooks(f io.Reader) ([]androidsmsgateway.WebhookEvent, error) { } return webhooks, nil } + +func readPings(f io.Reader) ([]*androidsmsgateway.WebhookPing, error) { + var pings []*androidsmsgateway.WebhookPing + r := jsonl.NewReader(f) + err := r.ReadLines(func(line []byte) error { + if len(line) == 0 { + return nil + } + var ping androidsmsgateway.WebhookPing + if err := json.Unmarshal(line, &ping); err != nil { + return fmt.Errorf("could not unmarshal into WebhookPing: %w", err) + } + pings = append(pings, &ping) + return nil + }) + + if err != nil { + return pings, fmt.Errorf("failed to read JSONL lines: %w", err) + } + return pings, nil +} diff --git a/http/androidsmsgateway/webhooks.go b/http/androidsmsgateway/webhooks.go index 2598006..0ef94cb 100644 --- a/http/androidsmsgateway/webhooks.go +++ b/http/androidsmsgateway/webhooks.go @@ -24,13 +24,13 @@ type Webhook struct { // WebhookSent represents a webhook notification for an SMS sent event. type WebhookSent struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookSentPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookSentPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookSent as part of the WebhookEvent interface. @@ -40,10 +40,10 @@ func (w *WebhookSent) GetEvent() string { // WebhookSentPayload contains details about the sent SMS. type WebhookSentPayload struct { - MessageID string `json:"messageId"` - PartsCount int `json:"partsCount"` - PhoneNumber string `json:"phoneNumber"` - SentAt time.Time `json:"sentAt"` + MessageID string `json:"messageId" csv:"messageId"` + PartsCount int `json:"partsCount" csv:"partsCount"` + PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"` + SentAt time.Time `json:"sentAt" csv:"sentAt"` } // WebhookDelivered represents a webhook notification for an SMS delivered event. @@ -71,13 +71,13 @@ type WebhookDeliveredPayload struct { // WebhookReceived represents a webhook notification for an SMS received event. type WebhookReceived struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookReceivedPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookReceivedPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookDelivered as part of the WebhookEvent interface. @@ -87,11 +87,11 @@ func (w *WebhookReceived) GetEvent() string { // WebhookReceivedPayload contains details about the received SMS. type WebhookReceivedPayload struct { - Message string `json:"message"` - MessageID string `json:"messageId"` - PhoneNumber string `json:"phoneNumber"` - ReceivedAt time.Time `json:"receivedAt"` - SimNumber int `json:"simNumber"` + Message string `json:"message" csv:"message"` + MessageID string `json:"messageId" csv:"messageId"` + PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"` + ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"` + SimNumber int `json:"simNumber" csv:"simNumber"` } // WebhookPing represents a system:ping webhook event. @@ -134,6 +134,18 @@ type HealthCheck struct { // Decode decodes the raw Payload based on the Event field and returns the appropriate WebhookEvent. func Decode(webhook *Webhook) (WebhookEvent, error) { switch webhook.Event { + case "system:ping": + var ping WebhookPing + ping.DeviceID = webhook.DeviceID + ping.Event = webhook.Event + ping.ID = webhook.ID + ping.WebhookID = webhook.WebhookID + ping.XSignature = webhook.XSignature + ping.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &ping.Payload); err != nil { + return nil, errors.New("failed to decode system:ping payload: " + err.Error()) + } + return &ping, nil case "sms:sent": var sent WebhookSent sent.DeviceID = webhook.DeviceID