diff --git a/cmd/smsapid/go.mod b/cmd/smsapid/go.mod new file mode 100644 index 0000000..c1b33cd --- /dev/null +++ b/cmd/smsapid/go.mod @@ -0,0 +1,16 @@ +module github.com/therootcompany/golib/cmd/smsapid + +go 1.25.0 + +require ( + github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 + github.com/therootcompany/golib/colorjson v1.0.1 + github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d +) + +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 +) diff --git a/cmd/smsapid/go.sum b/cmd/smsapid/go.sum new file mode 100644 index 0000000..81d2478 --- /dev/null +++ b/cmd/smsapid/go.sum @@ -0,0 +1,17 @@ +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/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= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +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/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/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= diff --git a/cmd/smsapid/main.go b/cmd/smsapid/main.go new file mode 100644 index 0000000..f3e8845 --- /dev/null +++ b/cmd/smsapid/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + + "github.com/simonfrey/jsonl" + "github.com/therootcompany/golib/colorjson" + "github.com/therootcompany/golib/http/androidsmsgateway" +) + +var jsonf = colorjson.NewFormatter() + +var webhookEvents []androidsmsgateway.WebhookEvent +var webhookWriter jsonl.Writer +var webhookMux = sync.Mutex{} + +func main() { + jsonf.Indent = 3 + + // TODO manual override via flags + // color.NoColor = false + + filePath := "./messages.jsonl" + { + file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0644) + if err != nil { + log.Fatalf("failed to open file '%s': %v", filePath, err) + } + defer func() { _ = file.Close() }() + + // buf := bufio.NewReader(file) + buf := file + webhookEvents, err = readWebhooks(buf) + if err != nil { + log.Fatalf("failed to read jsonl file '%s': %v", filePath, err) + } + } + { + file, err := os.OpenFile(filePath, 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() }() + + webhookWriter = jsonl.NewWriter(file) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/webhooks", handlerWebhooks) + mux.Handle("GET /", LogRequest(http.HandlerFunc(HandleOK))) + mux.Handle("POST /", LogRequest(http.HandlerFunc(handler))) + mux.Handle("PATCH /", LogRequest(http.HandlerFunc(HandleOK))) + mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK))) + mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK))) + + addr := "localhost:8088" + fmt.Printf("Listening on %s...\n\n", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func HandleOK(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +type ctxKey struct{} + +var CtxKeyBody = ctxKey{} + +func LogRequest(next http.Handler) http.Handler { + return LogHeaders(LogBody(next)) +} + +func LogHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Log method, path, and query + var query string + if len(r.URL.RawQuery) > 0 { + query = "?" + r.URL.RawQuery + } + log.Printf("%s %s%s", r.Method, r.URL.Path, query) + + // Find max header name length for alignment + maxLen := len("HOST") + for name := range r.Header { + if len(name) > maxLen { + maxLen = len(name) + } + } + maxLen += 1 + + fmt.Printf(" %-"+fmt.Sprintf("%d", maxLen+1)+"s %s\n", "HOST", r.Host) + for name, values := range r.Header { + for _, value := range values { + fmt.Printf(" %-"+fmt.Sprintf("%d", maxLen+1)+"s %s\n", name+":", value) + } + } + fmt.Fprintf(os.Stderr, "\n") + + next.ServeHTTP(w, r) + }) +} + +func LogBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + switch strings.ToUpper(r.Method) { + case "HEAD", "GET", "DELETE", "OPTIONS": + if len(body) > 0 { + fmt.Fprintf(os.Stderr, "Unexpected body:\n%q\n", string(body)) + } + case "POST", "PATCH", "PUT": + // known + default: + fmt.Fprintf(os.Stderr, "Unexpected method %s\n", r.Method) + } + defer fmt.Println() + + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read body:\n%q\n", string(body)) + return + } + + // Parse and pretty-print JSON, or raw body + textBytes := body + var text string + var data any + if err := json.Unmarshal(body, &data); err == nil { + textBytes, _ = jsonf.Marshal(data) + } + text = string(textBytes) + text = prefixLines(text, " ") + text = strings.TrimSpace(text) + fmt.Printf(" %s\n", text) + + ctx := context.WithValue(r.Context(), CtxKeyBody, body) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func handlerWebhooks(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + next := r.URL.Query().Get("next") + previous := r.URL.Query().Get("previous") + limitStr := r.URL.Query().Get("limit") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = 1000 + } + + var startIdx, endIdx int + if next != "" { + for i, event := range webhookEvents { + switch e := event.(type) { + case *androidsmsgateway.WebhookSent: + if e.ID == next { + startIdx = i + 1 + break + } + case *androidsmsgateway.WebhookDelivered: + if e.ID == next { + startIdx = i + 1 + break + } + case *androidsmsgateway.WebhookReceived: + if e.ID == next { + startIdx = i + 1 + break + } + } + } + } else if previous != "" { + for i, event := range webhookEvents { + switch e := event.(type) { + case *androidsmsgateway.WebhookSent: + if e.ID == previous && i >= limit { + startIdx = i - limit + break + } + case *androidsmsgateway.WebhookDelivered: + if e.ID == previous && i >= limit { + startIdx = i - limit + break + } + case *androidsmsgateway.WebhookReceived: + if e.ID == previous && i >= limit { + startIdx = i - limit + break + } + } + } + } else { + if len(webhookEvents) > limit { + startIdx = len(webhookEvents) - limit + } else { + startIdx = 0 + } + } + + endIdx = min(startIdx+limit, len(webhookEvents)) + + if _, err := w.Write([]byte("[")); err != nil { + http.Error(w, `{"error":"failed to write response"}`, http.StatusInternalServerError) + return + } + for i, event := range webhookEvents[startIdx:endIdx] { + if i > 0 { + if _, err := w.Write([]byte(",")); err != nil { + http.Error(w, `{"error":"failed to write response"}`, http.StatusInternalServerError) + return + } + } + if err := enc.Encode(event); err != nil { + http.Error(w, `{"error":"failed to encode webhook"}`, http.StatusInternalServerError) + return + } + } + if _, err := w.Write([]byte("]")); err != nil { + http.Error(w, `{"error":"failed to write response"}`, http.StatusInternalServerError) + return + } +} + +func handler(w http.ResponseWriter, r *http.Request) { + // this will return OK unless a retry is needed (e.g. internal error) + + body, ok := r.Context().Value(CtxKeyBody).([]byte) + if !ok { + return + } + + var webhook androidsmsgateway.Webhook + if err := json.Unmarshal(body, &webhook); err != nil { + http.Error(w, `{"error":"failed to parse webhook"}`, http.StatusOK) + return + } + ts, _ := strconv.Atoi(r.Header.Get("X-Timestamp")) + webhook.XTimestamp = int64(ts) + webhook.XSignature = r.Header.Get("X-Signature") + + h, err := androidsmsgateway.Decode(&webhook) + if err != nil { + http.Error(w, `{"error":"failed to parse webhook as a specific event"}`, http.StatusOK) + return + } + + switch h.GetEvent() { + case "mms:received", "sms:received", "sms:data-received", "sms:sent", "sms:delivered", "sms:failed": + webhookMux.Lock() + defer webhookMux.Unlock() + if err := webhookWriter.Write(h); err != nil { + http.Error(w, `{"error":"failed to save webhook"}`, http.StatusOK) + return + } + webhookEvents = append(webhookEvents, h) + case "system:ping": + // nothing to do yet + default: + http.Error(w, `{"error":"unknown webhook event"}`, http.StatusOK) + return + } + + _, _ = w.Write([]byte(`{"message": "ok"}`)) +} + +func prefixLines(text, prefix string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +func readWebhooks(f io.Reader) ([]androidsmsgateway.WebhookEvent, error) { + var webhooks []androidsmsgateway.WebhookEvent + r := jsonl.NewReader(f) + err := r.ReadLines(func(line []byte) error { + if len(line) == 0 { + return nil + } + + var webhook androidsmsgateway.Webhook + if err := json.Unmarshal(line, &webhook); err != nil { + return fmt.Errorf("could not unmarshal into Webhook: %w", err) + } + + switch webhook.Event { + case "sms:sent": + var sent androidsmsgateway.WebhookSent + if err := json.Unmarshal(line, &sent); err != nil { + return fmt.Errorf("could not unmarshal into WebhookSent: %w", err) + } + webhooks = append(webhooks, &sent) + case "sms:delivered": + var delivered androidsmsgateway.WebhookDelivered + if err := json.Unmarshal(line, &delivered); err != nil { + return fmt.Errorf("could not unmarshal into WebhookDelivered: %w", err) + } + webhooks = append(webhooks, &delivered) + case "sms:received": + var received androidsmsgateway.WebhookReceived + if err := json.Unmarshal(line, &received); err != nil { + return fmt.Errorf("could not unmarshal into WebhookReceived: %w", err) + } + webhooks = append(webhooks, &received) + default: + return fmt.Errorf("unknown event type: %s", webhook.Event) + } + return nil + }) + + if err != nil { + return webhooks, fmt.Errorf("failed to read JSONL lines: %w", err) + } + return webhooks, nil +} diff --git a/cmd/smsapid/webhook-list.sh b/cmd/smsapid/webhook-list.sh new file mode 100755 index 0000000..a8b3d65 --- /dev/null +++ b/cmd/smsapid/webhook-list.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e +set -u + +. ./.env + +# https://docs.sms-gate.app/features/webhooks/ + +cmd_curl="curl --fail-with-body -sS" + +printf '\nExisting webhooks\n' +$cmd_curl "${SMSGW_BASEURL}/webhooks" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" | jq diff --git a/cmd/smsapid/webhook-register.sh b/cmd/smsapid/webhook-register.sh new file mode 100755 index 0000000..efb4f7b --- /dev/null +++ b/cmd/smsapid/webhook-register.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -e +set -u + +. ./.env + +# https://docs.sms-gate.app/features/webhooks/ + +cmd_curl="curl --fail-with-body -sS" +g_events="sms:sent sms:delivered sms:failed sms:received mms:received sms:data-received system:ping" + +fn_delete_all() { ( + b_json="$( + $cmd_curl "${SMSGW_BASEURL}/webhooks" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" + )" + echo "${b_json}" | jq + + echo "${b_json}" | jq -r '.[] | "\(.id) \(.event) \(.url)"' | while read -r b_id b_event b_url; do + echo >&2 "Deleting webhook ${b_id} ${b_url} ${b_event}" + $cmd_curl -X DELETE "${SMSGW_BASEURL}/webhooks/${b_id}" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" + done +); } + +fn_subscribe_all() { ( + for b_event in $g_events; do + echo >&2 "Subscribing to ${b_event}" + $cmd_curl "${SMSGW_BASEURL}/webhooks" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" \ + -H 'Content-Type: application/json' \ + -d '{ + "url": "https://smsgateway.lab1.therootcompany.com/api/log", + "event": "'"${b_event}"'" + }' | jq + printf '\n' + sleep 0.1 + done +); } + +printf '\nPurging all existing webooks\n' +fn_delete_all + +printf '\nExisting webhooks\n' +$cmd_curl "${SMSGW_BASEURL}/webhooks" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" | jq + +printf '\nSubscribe to all webooks\n' +fn_subscribe_all + +printf 'Current webooks\n' +$cmd_curl "${SMSGW_BASEURL}/webhooks" \ + -u "${SMSGW_USER}:${SMSGW_PASSWORD}" | jq + +printf 'OK\n' diff --git a/http/androidsmsgateway/webhooks.go b/http/androidsmsgateway/webhooks.go new file mode 100644 index 0000000..2598006 --- /dev/null +++ b/http/androidsmsgateway/webhooks.go @@ -0,0 +1,176 @@ +package androidsmsgateway + +import ( + "encoding/json" + "errors" + "time" +) + +// WebhookEvent is an interface for all webhook event types. +type WebhookEvent interface { + GetEvent() string +} + +// Webhook represents a webhook notification for an SMS sent event. +type Webhook struct { + DeviceID string `json:"deviceId"` + Event string `json:"event"` + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` + WebhookID string `json:"webhookId"` + XSignature string `json:"x-signature"` + XTimestamp int64 `json:"x-timestamp"` +} + +// 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"` +} + +// GetEvent marks WebhookSent as part of the WebhookEvent interface. +func (w *WebhookSent) GetEvent() string { + return w.Event +} + +// 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"` +} + +// WebhookDelivered represents a webhook notification for an SMS delivered event. +type WebhookDelivered struct { + DeviceID string `json:"deviceId"` + Event string `json:"event"` + ID string `json:"id"` + Payload WebhookDeliveredPayload `json:"payload"` + WebhookID string `json:"webhookId"` + XSignature string `json:"x-signature"` + XTimestamp int64 `json:"x-timestamp"` +} + +// GetEvent marks WebhookDelivered as part of the WebhookEvent interface. +func (w *WebhookDelivered) GetEvent() string { + return w.Event +} + +// WebhookDeliveredPayload contains details about the delivered SMS. +type WebhookDeliveredPayload struct { + DeliveredAt time.Time `json:"deliveredAt"` + MessageID string `json:"messageId"` + PhoneNumber string `json:"phoneNumber"` +} + +// 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"` +} + +// GetEvent marks WebhookDelivered as part of the WebhookEvent interface. +func (w *WebhookReceived) GetEvent() string { + return w.Event +} + +// 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"` +} + +// WebhookPing represents a system:ping webhook event. +type WebhookPing struct { + DeviceID string `json:"deviceId"` + Event string `json:"event"` + ID string `json:"id"` + Payload WebhookPingPayload `json:"payload"` + WebhookID string `json:"webhookId"` + XSignature string `json:"x-signature"` + XTimestamp int64 `json:"x-timestamp"` +} + +// GetEvent marks WebhookPing as part of the WebhookEvent interface. +func (w *WebhookPing) GetEvent() string { + return w.Event +} + +// WebhookPingPayload contains the health data reported by a system:ping event. +type WebhookPingPayload struct { + Health DeviceHealth `json:"health"` +} + +// DeviceHealth is the top-level health object inside a system:ping payload. +type DeviceHealth struct { + Checks map[string]HealthCheck `json:"checks"` + ReleaseID int `json:"releaseId"` + Status string `json:"status"` + Version string `json:"version"` +} + +// HealthCheck represents a single named health check result. +type HealthCheck struct { + Description string `json:"description"` + ObservedUnit string `json:"observedUnit"` + ObservedValue float64 `json:"observedValue"` + Status string `json:"status"` +} + +// 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 "sms:sent": + var sent WebhookSent + sent.DeviceID = webhook.DeviceID + sent.Event = webhook.Event + sent.ID = webhook.ID + sent.WebhookID = webhook.WebhookID + sent.XSignature = webhook.XSignature + sent.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &sent.Payload); err != nil { + return nil, errors.New("failed to decode sms:sent payload: " + err.Error()) + } + return &sent, nil + case "sms:delivered": + var delivered WebhookDelivered + delivered.DeviceID = webhook.DeviceID + delivered.Event = webhook.Event + delivered.ID = webhook.ID + delivered.WebhookID = webhook.WebhookID + delivered.XSignature = webhook.XSignature + delivered.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &delivered.Payload); err != nil { + return nil, errors.New("failed to decode sms:delivered payload: " + err.Error()) + } + return &delivered, nil + case "sms:received": + var received WebhookReceived + received.DeviceID = webhook.DeviceID + received.Event = webhook.Event + received.ID = webhook.ID + received.WebhookID = webhook.WebhookID + received.XSignature = webhook.XSignature + received.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &received.Payload); err != nil { + return nil, errors.New("failed to decode sms:received payload: " + err.Error()) + } + return &received, nil + default: + return nil, errors.New("unknown event type: " + webhook.Event) + } +}