From ccfd7f7ee9e01b7732caf490dcfa1c6eb25a5dd6 Mon Sep 17 00:00:00 2001 From: Robert He Date: Thu, 9 Oct 2025 17:38:42 +0800 Subject: [PATCH] feat: allow template quick switch --- QUICKSTART.md | 25 ++++++++++- README.md | 30 +++++++++++-- configs/feishu-bots.yaml | 4 ++ internal/config/config.go | 72 ++++++++++++++++++++++++++--- internal/config/config_test.go | 77 +++++++++++++++++++++++++++++--- internal/handler/handler.go | 61 +++++++++++++++++++------ internal/handler/handler_test.go | 22 ++++----- 7 files changed, 248 insertions(+), 43 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index 2bcf2a0..d9ff493 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -51,11 +51,32 @@ - 编辑 `./configs/` 目录下的配置文件,参考 [README.md](README.md) or [configs](configs/) 目录下的示例配置文件的注释说明。你最可能需要修改的有下面几个内容: - [./configs/server.yaml](configs/server.yaml):修改服务器监听地址和端口 - - [./configs/feishu-bots.yaml](configs/feishu-bots.yaml):配置飞书机器人的 Webhook URL 和别名 + - [./configs/feishu-bots.yaml](configs/feishu-bots.yaml):配置飞书机器人的 Webhook URL 和别名(可选配置模板) - [./configs/repos.yaml](configs/repos.yaml):配置需要监听的 GitHub 仓库和事件,以及对应的通知对象 + - [./configs/templates.jsonc](configs/templates.jsonc):默认消息模板(可选:创建/使用 `templates.<自定义名称,如「cn」>.jsonc` 自定义模板) - 修改后保存,程序会在下一次收到 GitHub Webhook 请求时自动热重载最新配置。 -5. 简要调试 +5. 多模板配置(可选) + + 如果需要为不同的飞书 bot 配置不同的消息模板(如中英文双语),可以: + + ```bash + # 复制默认模板创建中文模板 + cp ./configs/templates.jsonc ./configs/templates.cn.jsonc + ``` + + 然后在 `./configs/feishu-bots.yaml` 中指定模板: + + ```yaml + feishu_bots: + - alias: 'team-cn' + url: 'https://open.feishu.cn/open-apis/bot/v2/hook/cn-webhook' + template: 'cn' # 使用中文模板 + ``` + + 详细说明请参考 [多模板配置指南](docs/MULTI_TEMPLATE.md)。 + +6. 简要调试 - 若没有收到通知,请检查: - GitHub Webhook 配置(Payload URL、Secret、事件类型) diff --git a/README.md b/README.md index 5537b07..5269b67 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,7 @@ - 详见 [configs/events.yaml](configs/events.yaml) - 对应的处理方法以及文档详见 [internal/handler/](internal/handler/) - 默认提供的消息模板详见 [configs/templates.jsonc](configs/templates.jsonc) -- 也可以自定义模板,使用我们 `handler` 提供的的 `占位符变量` ([详见文档](internal/handler/README.md)) 对发出消息的格式做相应的修改 - - 注意:模板引擎的语法、过滤器和条件块示例详见 `internal/template/README.md`,里面有占位符示例和进阶用法。 +- 也可以自定义模板,使用我们 `handler` 提供的的 `占位符变量` ([详见文档](internal/handler/README.md)) 以及 `template` 提供的 `模板引擎的语法` `过滤器` `条件块` 等功能 ([详见文档](internal/template/README.md)) 对发出消息的格式做相应的修改 ## 🚀 快速开始 @@ -99,8 +97,34 @@ feishu_bots: - alias: 'org-notify' url: 'https://open.feishu.cn/open-apis/bot/v2/hook/zzzzzzz' + + - alias: 'org-cn-notify' + url: 'https://open.feishu.cn/open-apis/bot/v2/hook/aaaaaaa' + template: 'cn' # 可选:指定使用的消息模板,默认为 'default' ``` +**多模板支持**: + +从 v1.1.0 开始,支持为不同的飞书 bot 配置不同的消息模板。这在以下场景特别有用: + +- 中英文双语团队,需要发送不同语言的通知 +- 不同团队需要不同格式的消息 +- 测试环境和生产环境使用不同的消息格式 + +配置方法: + +1. 在 `feishu-bots.yaml` 中为 bot 指定 `template` 字段(可选) +2. 在 `configs/` 目录下创建对应的模板文件,命名格式为 `templates..jsonc` + +例如: + +- `templates.jsonc` - 默认模板(必需) +- `templates.cn.jsonc` - 中文模板 +- `templates.en.jsonc` - 英文模板 +- `templates.simple.jsonc` - 简化模板 + +如果某个 bot 没有指定 `template` 字段,或指定的模板文件不存在,将自动使用 `templates.jsonc` 作为默认模板。 + ### events.yaml 定义事件模板和具体事件配置: diff --git a/configs/feishu-bots.yaml b/configs/feishu-bots.yaml index 2e37516..af67cc1 100644 --- a/configs/feishu-bots.yaml +++ b/configs/feishu-bots.yaml @@ -14,3 +14,7 @@ feishu_bots: - alias: "org-notify" url: "https://open.feishu.cn/open-apis/bot/v2/hook/zzzzzzz" + + - alias: "org-cn-notify" + url: "https://open.feishu.cn/open-apis/bot/v2/hook/zzzzzzz" + template: "cn" diff --git a/internal/config/config.go b/internal/config/config.go index ac74563..5727245 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,7 @@ type Config struct { Repos ReposConfig Events EventsConfig FeishuBots FeishuBotsConfig - Templates TemplatesConfig + Templates map[string]TemplatesConfig // Key: template name (e.g., "default", "cn") } // ServerConfig represents server.yaml @@ -56,8 +56,9 @@ type FeishuBotsConfig struct { } type FeishuBot struct { - Alias string `yaml:"alias"` - URL string `yaml:"url"` + Alias string `yaml:"alias"` + URL string `yaml:"url"` + Template string `yaml:"template"` // Optional: template name (e.g., "cn"), defaults to "default" } // TemplatesConfig represents templates.jsonc (JSONC) @@ -76,7 +77,9 @@ type PayloadTemplate struct { // Load loads all configuration files from the given directory func Load(configDir string) (*Config, error) { - cfg := &Config{} + cfg := &Config{ + Templates: make(map[string]TemplatesConfig), + } // Load server.yaml if err := loadConfigFile(filepath.Join(configDir, "server.yaml"), &cfg.Server); err != nil { @@ -98,15 +101,70 @@ func Load(configDir string) (*Config, error) { return nil, fmt.Errorf("failed to load feishu-bots.yaml: %w", err) } - // Load templates.jsonc (JSONC is required) - templatesJSONC := filepath.Join(configDir, "templates.jsonc") - if err := loadConfigFile(templatesJSONC, &cfg.Templates); err != nil { + // Load templates.jsonc as default template (required) + defaultTemplatesPath := filepath.Join(configDir, "templates.jsonc") + var defaultTemplates TemplatesConfig + if err := loadConfigFile(defaultTemplatesPath, &defaultTemplates); err != nil { return nil, fmt.Errorf("failed to load templates.jsonc: %w", err) } + cfg.Templates["default"] = defaultTemplates + + // Load additional template files (templates.*.jsonc) + // Scan for templates.cn.jsonc, templates.en.jsonc, etc. + entries, err := os.ReadDir(configDir) + if err != nil { + return nil, fmt.Errorf("failed to read config directory: %w", err) + } + + templatePattern := regexp.MustCompile(`^templates\.([a-zA-Z0-9_-]+)\.jsonc$`) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := templatePattern.FindStringSubmatch(entry.Name()) + if len(matches) > 1 { + templateName := matches[1] + var tmpl TemplatesConfig + templatePath := filepath.Join(configDir, entry.Name()) + if err := loadConfigFile(templatePath, &tmpl); err != nil { + return nil, fmt.Errorf("failed to load %s: %w", entry.Name(), err) + } + cfg.Templates[templateName] = tmpl + } + } return cfg, nil } +// GetBotTemplate returns the template name for a given bot alias +// Returns "default" if the bot doesn't specify a template or if the bot is not found +func (c *Config) GetBotTemplate(botAlias string) string { + for _, bot := range c.FeishuBots.FeishuBots { + if bot.Alias == botAlias { + if bot.Template != "" { + return bot.Template + } + return "default" + } + } + return "default" +} + +// GetTemplateConfig returns the template configuration for a given template name +// Returns the default template if the specified template is not found +func (c *Config) GetTemplateConfig(templateName string) TemplatesConfig { + if tmpl, exists := c.Templates[templateName]; exists { + return tmpl + } + // Fallback to default + if tmpl, exists := c.Templates["default"]; exists { + return tmpl + } + // Return empty config if even default is missing (shouldn't happen) + return TemplatesConfig{} +} + // loadConfigFile loads either YAML or JSONC (JSON with comments) based on file extension func loadConfigFile(path string, out any) error { data, err := os.ReadFile(path) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0289c5e..e88569d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -46,6 +46,9 @@ events: feishu_bots: - alias: "test-bot" url: "https://example.com/webhook" + - alias: "test-bot-cn" + url: "https://example.com/webhook-cn" + template: "cn" ` templatesYAML := ` @@ -62,15 +65,32 @@ feishu_bots: } } } +` + + templatesCnYAML := ` +{ + // templates.cn.jsonc + "templates": { + "push": { + "payloads": [ + { + "tags": ["default"], + "payload": { "msg_type": "text", "content": { "text": "测试" } } + } + ] + } + } +} ` // Write test config files files := map[string]string{ - "server.yaml": serverYAML, - "repos.yaml": reposYAML, - "events.yaml": eventsYAML, - "feishu-bots.yaml": botsYAML, - "templates.jsonc": templatesYAML, + "server.yaml": serverYAML, + "repos.yaml": reposYAML, + "events.yaml": eventsYAML, + "feishu-bots.yaml": botsYAML, + "templates.jsonc": templatesYAML, + "templates.cn.jsonc": templatesCnYAML, } for name, content := range files { @@ -99,7 +119,50 @@ feishu_bots: t.Errorf("Expected 1 repo, got %d", len(cfg.Repos.Repos)) } - if len(cfg.FeishuBots.FeishuBots) != 1 { - t.Errorf("Expected 1 bot, got %d", len(cfg.FeishuBots.FeishuBots)) + if len(cfg.FeishuBots.FeishuBots) != 2 { + t.Errorf("Expected 2 bots, got %d", len(cfg.FeishuBots.FeishuBots)) + } + + // Test template loading + if len(cfg.Templates) != 2 { + t.Errorf("Expected 2 templates (default + cn), got %d", len(cfg.Templates)) + } + + if _, ok := cfg.Templates["default"]; !ok { + t.Error("Expected default template to be loaded") + } + + if _, ok := cfg.Templates["cn"]; !ok { + t.Error("Expected cn template to be loaded") + } + + // Test GetBotTemplate + if tmpl := cfg.GetBotTemplate("test-bot"); tmpl != "default" { + t.Errorf("Expected default template for test-bot, got %s", tmpl) + } + + if tmpl := cfg.GetBotTemplate("test-bot-cn"); tmpl != "cn" { + t.Errorf("Expected cn template for test-bot-cn, got %s", tmpl) + } + + if tmpl := cfg.GetBotTemplate("non-existent"); tmpl != "default" { + t.Errorf("Expected default template for non-existent bot, got %s", tmpl) + } + + // Test GetTemplateConfig + defaultTmpl := cfg.GetTemplateConfig("default") + if _, ok := defaultTmpl.Templates["push"]; !ok { + t.Error("Expected push template in default config") + } + + cnTmpl := cfg.GetTemplateConfig("cn") + if _, ok := cnTmpl.Templates["push"]; !ok { + t.Error("Expected push template in cn config") + } + + // Test fallback for non-existent template + fallbackTmpl := cfg.GetTemplateConfig("non-existent") + if _, ok := fallbackTmpl.Templates["push"]; !ok { + t.Error("Expected fallback to default template") } } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index bfe2868..334911f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -196,29 +196,62 @@ func (h *Handler) processWebhook(eventType string, payload map[string]any) error // Determine tags for template selection tags := template.DetermineTags(eventType, payload) - // Select template - tmpl, err := template.SelectTemplate(eventType, tags, h.config.Templates) - if err != nil { - return fmt.Errorf("failed to select template: %w", err) - } - - // Prepare data for template filling + // Prepare data for template filling (common for all templates) data := h.prepareTemplateData(eventType, payload) - // Fill template - filledPayload, err := template.FillTemplate(tmpl, data) - if err != nil { - return fmt.Errorf("failed to fill template: %w", err) + // Group targets by template + targetsByTemplate := h.groupTargetsByTemplate(repoPattern.NotifyTo) + + // Process each template group + var errs []string + for templateName, targets := range targetsByTemplate { + logger.Info("Processing %d target(s) with template: %s", len(targets), templateName) + + // Get the appropriate template configuration + templatesConfig := h.config.GetTemplateConfig(templateName) + + // Select template + tmpl, err := template.SelectTemplate(eventType, tags, templatesConfig) + if err != nil { + logger.Error("Failed to select template for %s: %v", templateName, err) + errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) + continue + } + + // Fill template + filledPayload, err := template.FillTemplate(tmpl, data) + if err != nil { + logger.Error("Failed to fill template for %s: %v", templateName, err) + errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) + continue + } + + // Send notifications to this group + if err := h.notifier.Send(targets, filledPayload); err != nil { + logger.Error("Failed to send notifications for template %s: %v", templateName, err) + errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) + } } - // Send notifications - if err := h.notifier.Send(repoPattern.NotifyTo, filledPayload); err != nil { - return fmt.Errorf("failed to send notifications: %w", err) + if len(errs) > 0 { + return fmt.Errorf("failed to process some templates: %s", strings.Join(errs, "; ")) } return nil } +// groupTargetsByTemplate groups notification targets by their template preference +func (h *Handler) groupTargetsByTemplate(targets []string) map[string][]string { + result := make(map[string][]string) + + for _, target := range targets { + templateName := h.config.GetBotTemplate(target) + result[templateName] = append(result[templateName], target) + } + + return result +} + func (h *Handler) extractRepoFullName(payload map[string]any) string { if repo, ok := payload["repository"].(map[string]any); ok { if fullName, ok := repo["full_name"].(string); ok { diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 0053c74..794e62a 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -60,16 +60,18 @@ func TestServeHTTP_FormEncodedPayload(t *testing.T) { "push": map[string]any{"ref": "*"}, }, }, - Templates: config.TemplatesConfig{ - Templates: map[string]config.EventTemplate{ - "push": { - Payloads: []config.PayloadTemplate{ - { - Tags: []string{"push", "default"}, - Payload: map[string]any{ - "msg_type": "text", - "content": map[string]any{ - "text": "Test push: {{repository.full_name}}", + Templates: map[string]config.TemplatesConfig{ + "default": { + Templates: map[string]config.EventTemplate{ + "push": { + Payloads: []config.PayloadTemplate{ + { + Tags: []string{"push", "default"}, + Payload: map[string]any{ + "msg_type": "text", + "content": map[string]any{ + "text": "Test push: {{repository.full_name}}", + }, }, }, },