From 39f02b5d7a33ae866259ce5d6c6cc5b42f07a5cf Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 22:04:48 +0800 Subject: [PATCH] refactor(message_gateway): upgrade notification template engine with text/template and rich helpers --- .../domain/message_gateway/push/template.go | 263 +++++++++++++++++- .../message_gateway/push/template_test.go | 54 ++++ 2 files changed, 307 insertions(+), 10 deletions(-) diff --git a/backend/plugins/domain/message_gateway/push/template.go b/backend/plugins/domain/message_gateway/push/template.go index 9e472bd3..f0121985 100644 --- a/backend/plugins/domain/message_gateway/push/template.go +++ b/backend/plugins/domain/message_gateway/push/template.go @@ -4,17 +4,263 @@ package push import ( + "bytes" "encoding/json" "fmt" "maps" + "regexp" "slices" "strconv" "strings" + "text/template" + "time" ) +var ( + // placeholderRegex matches {{ ... }} tags + placeholderRegex = regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`) + // identifierRegex matches simple identifiers like name or user.username + identifierRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.]*$`) +) + +// jsonMap is a map that serializes to JSON when printed as a string in templates. +type jsonMap map[string]any + +func (m jsonMap) String() string { + b, err := json.Marshal(map[string]any(m)) + if err != nil { + return fmt.Sprintf("%v", map[string]any(m)) + } + return string(b) +} + +func (m jsonMap) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any(m)) +} + +// jsonSlice is a slice that serializes to JSON when printed as a string in templates. +type jsonSlice []any + +func (s jsonSlice) String() string { + b, err := json.Marshal([]any(s)) + if err != nil { + return fmt.Sprintf("%v", []any(s)) + } + return string(b) +} + +func (s jsonSlice) MarshalJSON() ([]byte, error) { + return json.Marshal([]any(s)) +} + +var defaultFuncMap = template.FuncMap{ + "default": func(fallback any, val any) any { + if val == nil { + return fallback + } + switch v := val.(type) { + case string: + if v == "" { + return fallback + } + case bool: + if !v { + return fallback + } + case int: + if v == 0 { + return fallback + } + case int32: + if v == 0 { + return fallback + } + case int64: + if v == 0 { + return fallback + } + case float64: + if v == 0 { + return fallback + } + } + return val + }, + "toJson": func(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(b) + }, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "trim": strings.TrimSpace, + "dateFormat": func(format string, t any) string { + switch v := t.(type) { + case time.Time: + return v.Format(format) + case *time.Time: + if v != nil { + return v.Format(format) + } + } + return fmt.Sprint(t) + }, +} + +// hasKey checks if a dot-delimited or plain key exists in body +func hasKey(body map[string]any, key string) bool { + if _, ok := body[key]; ok { + return true + } + parts := strings.Split(key, ".") + var cur any = body + for _, part := range parts { + m, ok := cur.(map[string]any) + if !ok { + return false + } + val, exists := m[part] + if !exists { + return false + } + cur = val + } + return true +} + +// normalizeTemplate converts legacy {{key}} / {{user.name}} into Go template {{.user.name}} +// while preserving Go template keywords, dot expressions, pipelines, and missing placeholders. +func normalizeTemplate(tmpl string, body map[string]any) string { + return placeholderRegex.ReplaceAllStringFunc(tmpl, func(match string) string { + sub := strings.TrimSpace(match[2 : len(match)-2]) + if sub == "" { + return match + } + // If it's already a dot expression or special variable ($...) + if strings.HasPrefix(sub, ".") || strings.HasPrefix(sub, "$") { + return match + } + // If it's a known Go template keyword or block + firstWord := strings.Fields(sub)[0] + switch firstWord { + case "if", "else", "end", "range", "with", "template", "define", "block", "nil", "true", "false": + return match + } + // Check if it's a pipeline like `key | default "val"` + if strings.Contains(sub, "|") { + const pipelineSplitCount = 2 + parts := strings.SplitN(sub, "|", pipelineSplitCount) + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + if identifierRegex.MatchString(left) && !strings.HasPrefix(left, ".") && !strings.HasPrefix(left, "$") { + return fmt.Sprintf("{{ .%s | %s }}", left, right) + } + return match + } + // Simple identifier: if present in body, convert to dot expression; otherwise preserve as is for fallback + if identifierRegex.MatchString(sub) { + if hasKey(body, sub) { + return fmt.Sprintf("{{ .%s }}", sub) + } + // Missing key: keep original text so fallback or literal is preserved + return match + } + return match + }) +} + +// prepareContext pre-processes the body map so that: +// 1. Dotted keys like "user.name" are expanded to nested map structure. +// 2. Complex structs, slices, and maps have JSON-friendly string representations when directly interpolated. +func prepareContext(body map[string]any) jsonMap { + if body == nil { + return make(jsonMap) + } + ctx := make(jsonMap, len(body)) + for k, v := range body { + formatted := formatContextValue(v) + ctx[k] = formatted + // If key contains '.', expand into nested hierarchy + if strings.Contains(k, ".") { + parts := strings.Split(k, ".") + cur := ctx + for i := 0; i < len(parts)-1; i++ { + sub, ok := cur[parts[i]].(jsonMap) + if !ok { + sub = make(jsonMap) + cur[parts[i]] = sub + } + cur = sub + } + cur[parts[len(parts)-1]] = formatted + } + } + return ctx +} + +// formatContextValue formats slices and maps to JSON representation for direct string printing, +// while preserving basic scalar types for template functions. +func formatContextValue(v any) any { + if v == nil { + return "" + } + switch val := v.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool, time.Time: + return val + case []byte: + return string(val) + case map[string]any: + jm := make(jsonMap, len(val)) + for k, subVal := range val { + jm[k] = formatContextValue(subVal) + } + return jm + case []any: + js := make(jsonSlice, len(val)) + for i, subVal := range val { + js[i] = formatContextValue(subVal) + } + return js + case []string: + js := make(jsonSlice, len(val)) + for i, subVal := range val { + js[i] = subVal + } + return js + default: + return val + } +} + // ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body. -// It is a single-pass parser designed for high performance and low allocations. -func ParseTemplate(template string, body map[string]any) string { +// It supports Go text/template expressions (e.g. if/else, pipelines, default, toJson) as well as legacy {{key}} placeholders. +func ParseTemplate(templateStr string, body map[string]any) string { + if templateStr == "" { + return "" + } + + normalized := normalizeTemplate(templateStr, body) + ctx := prepareContext(body) + + tmpl, err := template.New("push_tmpl"). + Funcs(defaultFuncMap). + Option("missingkey=zero"). + Parse(normalized) + if err != nil { + return fallbackReplace(templateStr, body) + } + + var buf bytes.Buffer + if err = tmpl.Execute(&buf, ctx); err != nil { + return fallbackReplace(templateStr, body) + } + + return buf.String() +} + +func fallbackReplace(template string, body map[string]any) string { var buf strings.Builder buf.Grow(len(template)) @@ -25,27 +271,25 @@ func ParseTemplate(template string, body map[string]any) string { buf.WriteString(template[i:]) break } - // Write prefix buf.WriteString(template[i : i+pos]) - i += pos + 2 // skip "{{" + i += pos + 2 endPos := strings.Index(template[i:], "}}") if endPos == -1 { - // Unbalanced "{{" buf.WriteString("{{") buf.WriteString(template[i:]) break } - key := template[i : i+endPos] + key := strings.TrimSpace(template[i : i+endPos]) + key = strings.TrimPrefix(key, ".") if val, ok := body[key]; ok { buf.WriteString(formatValue(val)) } else { - // Keep the placeholder if key not found buf.WriteString("{{") - buf.WriteString(key) + buf.WriteString(template[i : i+endPos]) buf.WriteString("}}") } - i += endPos + 2 // skip "}}" + i += endPos + 2 } return buf.String() } @@ -70,7 +314,6 @@ func formatValue(v any) string { case bool: return strconv.FormatBool(val) default: - // If it's a map, slice, or struct, marshal it to JSON. b, err := json.Marshal(v) if err == nil { return string(b) diff --git a/backend/plugins/domain/message_gateway/push/template_test.go b/backend/plugins/domain/message_gateway/push/template_test.go index 5132212a..cf11d140 100644 --- a/backend/plugins/domain/message_gateway/push/template_test.go +++ b/backend/plugins/domain/message_gateway/push/template_test.go @@ -64,6 +64,60 @@ func TestParseTemplate(t *testing.T) { body: map[string]any{"obj": map[string]any{"key": "value"}}, expected: `obj: {"key":"value"}`, }, + { + name: "nested property from flat map", + template: "hello {{user.username}}", + body: map[string]any{"user.username": "Alice"}, + expected: "hello Alice", + }, + { + name: "nested property from nested map", + template: "hello {{user.username}}", + body: map[string]any{"user": map[string]any{"username": "Bob"}}, + expected: "hello Bob", + }, + { + name: "go template dot syntax", + template: "hello {{.user.username}}", + body: map[string]any{"user": map[string]any{"username": "Charlie"}}, + expected: "hello Charlie", + }, + { + name: "default value helper fallback", + template: "hello {{.nickname | default \"Guest\"}}", + body: map[string]any{"nickname": ""}, + expected: "hello Guest", + }, + { + name: "default value helper provided", + template: "hello {{.nickname | default \"Guest\"}}", + body: map[string]any{"nickname": "David"}, + expected: "hello David", + }, + { + name: "conditional if else true", + template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}", + body: map[string]any{"is_admin": true, "name": "Eve"}, + expected: "Admin: Eve", + }, + { + name: "conditional if else false", + template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}", + body: map[string]any{"is_admin": false, "name": "Frank"}, + expected: "User: Frank", + }, + { + name: "upper and lower helper", + template: "{{.title | upper}} - {{.level | lower}}", + body: map[string]any{"title": "Warning", "level": "INFO"}, + expected: "WARNING - info", + }, + { + name: "toJson helper", + template: "payload: {{toJson .data}}", + body: map[string]any{"data": map[string]any{"status": "ok"}}, + expected: `payload: {"status":"ok"}`, + }, } for _, tt := range tests {