From f012c556e9f02360fee1d27ba1e9ad96d9c7e7bc Mon Sep 17 00:00:00 2001 From: zAhYAng Date: Tue, 11 Aug 2026 21:33:38 +0800 Subject: [PATCH] feat: add WeCom payload renderer --- internal/server/wecom_notification.go | 54 +++++++++++++++++++++ internal/server/wecom_notification_test.go | 56 ++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/server/wecom_notification.go create mode 100644 internal/server/wecom_notification_test.go diff --git a/internal/server/wecom_notification.go b/internal/server/wecom_notification.go new file mode 100644 index 0000000..2b74885 --- /dev/null +++ b/internal/server/wecom_notification.go @@ -0,0 +1,54 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +var wecomTemplateVariableNames = []string{ + "event", + "title", + "message", + "timestamp", + "content", + "number", + "device_id", + "device_name", + "device_label", + "time", +} + +type wecomTemplateValues map[string]string + +func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) { + for _, name := range wecomTemplateVariableNames { + encoded, err := json.Marshal(values[name]) + if err != nil { + return nil, fmt.Errorf("encode WeCom template value %q: %w", name, err) + } + template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded)) + } + if strings.Contains(template, "{{") { + return nil, errors.New("wecom.payload_template contains an unsupported variable") + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 { + return nil, errors.New("wecom.payload_template must render to a non-empty JSON object") + } + return []byte(template), nil +} + +func validateWecomResponse(status int, body []byte) error { + var result struct { + ErrCode *int `json:"errcode"` + } + if status < http.StatusOK || status >= http.StatusMultipleChoices || + json.Unmarshal(body, &result) != nil || result.ErrCode == nil || *result.ErrCode != 0 { + return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected) + } + return nil +} diff --git a/internal/server/wecom_notification_test.go b/internal/server/wecom_notification_test.go new file mode 100644 index 0000000..98cf7f6 --- /dev/null +++ b/internal/server/wecom_notification_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "errors" + "net/http" + "testing" +) + +func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) { + payload, err := renderWecomPayload( + `{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`, + wecomTemplateValues{ + "message": "quote: \"\nline", + "number": "+447386", + }, + ) + if err != nil { + t.Fatal(err) + } + if got, want := string(payload), `{"msgtype":"text","text":{"content":"quote: \"\nline","number":"+447386"}}`; got != want { + t.Fatalf("payload = %s, want %s", got, want) + } +} + +func TestRenderWecomPayloadRejectsInvalidTemplate(t *testing.T) { + for _, template := range []string{ + `{"text":{{unknown}}}`, + `[]`, + `{"msgtype":"text"`, + } { + t.Run(template, func(t *testing.T) { + if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil { + t.Fatalf("template %q was accepted", template) + } + }) + } +} + +func TestValidateWecomResponse(t *testing.T) { + if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":0,"errmsg":"ok"}`)); err != nil { + t.Fatalf("successful response = %v", err) + } + for _, response := range []struct { + status int + body string + }{ + {http.StatusBadGateway, `{"errcode":0}`}, + {http.StatusOK, `{"errcode":40058,"errmsg":"invalid"}`}, + {http.StatusOK, `{}`}, + {http.StatusOK, `not-json`}, + } { + if err := validateWecomResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) { + t.Fatalf("validateWecomResponse(%d, %s) = %v", response.status, response.body, err) + } + } +}