Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、事件类型)
Expand Down
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) 对发出消息的格式做相应的修改

## 🚀 快速开始

Expand Down Expand Up @@ -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.<name>.jsonc`

例如:

- `templates.jsonc` - 默认模板(必需)
- `templates.cn.jsonc` - 中文模板
- `templates.en.jsonc` - 英文模板
- `templates.simple.jsonc` - 简化模板

如果某个 bot 没有指定 `template` 字段,或指定的模板文件不存在,将自动使用 `templates.jsonc` 作为默认模板。

### events.yaml

定义事件模板和具体事件配置:
Expand Down
4 changes: 4 additions & 0 deletions configs/feishu-bots.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
72 changes: 65 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
77 changes: 70 additions & 7 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
}
61 changes: 47 additions & 14 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading