From a8df37d277e1ce9ce32aa4e2a4b60381fe5224be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 24 Aug 2025 16:03:13 +0000 Subject: [PATCH 1/2] Add service-based notification system with unified configuration Co-authored-by: denguk --- services/tasks/TaskRunner.go | 12 +- services/tasks/TaskRunner_logging.go | 35 ++- services/tasks/alert_test_sender.go | 33 ++- services/tasks/notification_service.go | 303 +++++++++++++++++++++++++ util/config.go | 40 ++++ 5 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 services/tasks/notification_service.go diff --git a/services/tasks/TaskRunner.go b/services/tasks/TaskRunner.go index 257118c61..833daf6b7 100644 --- a/services/tasks/TaskRunner.go +++ b/services/tasks/TaskRunner.go @@ -58,6 +58,9 @@ type TaskRunner struct { Alias string logWG sync.WaitGroup + + // new notifications service (service-based notifiers) + notificationService *NotificationService } func NewTaskRunner( @@ -67,10 +70,11 @@ func NewTaskRunner( keyInstaller db_lib.AccessKeyInstaller, ) *TaskRunner { return &TaskRunner{ - Task: newTask, - pool: p, - Username: username, - keyInstaller: keyInstaller, + Task: newTask, + pool: p, + Username: username, + keyInstaller: keyInstaller, + notificationService: NewNotificationService(), } } diff --git a/services/tasks/TaskRunner_logging.go b/services/tasks/TaskRunner_logging.go index 0dca84e7d..7b5fb312f 100644 --- a/services/tasks/TaskRunner_logging.go +++ b/services/tasks/TaskRunner_logging.go @@ -7,6 +7,7 @@ import ( "io" "os/exec" "time" + "strconv" "github.com/semaphoreui/semaphore/pkg/tz" @@ -122,12 +123,34 @@ func (t *TaskRunner) SetStatus(status task_logger.TaskStatus) { } if status.IsNotifiable() { - t.sendTelegramAlert() - t.sendSlackAlert() - t.sendRocketChatAlert() - t.sendMicrosoftTeamsAlert() - t.sendDingTalkAlert() - t.sendGotifyAlert() + // Prefer new notification service if configured; otherwise fallback to legacy methods + if t.notificationService != nil && t.notificationService.HasNotifiers() { + author, version := t.alertInfos() + alert := Alert{ + Name: t.Template.Name, + Author: author, + Color: t.alertColor("slack"), + Task: alertTask{ + ID: strconv.Itoa(t.Task.ID), + URL: t.taskLink(), + Result: t.Task.Status.Format(), + Version: version, + Desc: t.Task.Message, + }, + } + // If chat override is present, set it for telegram + if t.alertChat != nil && *t.alertChat != "" { + alert.Chat = alertChat{ID: *t.alertChat} + } + t.notificationService.SendAll(alert) + } else { + t.sendTelegramAlert() + t.sendSlackAlert() + t.sendRocketChatAlert() + t.sendMicrosoftTeamsAlert() + t.sendDingTalkAlert() + t.sendGotifyAlert() + } } for _, l := range t.statusListeners { diff --git a/services/tasks/alert_test_sender.go b/services/tasks/alert_test_sender.go index 304e2ac71..61b31e724 100644 --- a/services/tasks/alert_test_sender.go +++ b/services/tasks/alert_test_sender.go @@ -1,6 +1,8 @@ package tasks import ( + "strconv" + "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" ) @@ -40,12 +42,31 @@ func SendProjectTestAlerts(project db.Project, store db.Store) (err error) { }, } - tr.sendTelegramAlert() - tr.sendSlackAlert() - tr.sendRocketChatAlert() - tr.sendMicrosoftTeamsAlert() - tr.sendDingTalkAlert() - tr.sendGotifyAlert() + if tr.notificationService != nil && tr.notificationService.HasNotifiers() { + author, version := tr.alertInfos() + alert := Alert{ + Name: tr.Template.Name, + Author: author, + Task: alertTask{ + ID: strconv.Itoa(tr.Task.ID), + URL: tr.taskLink(), + Result: tr.Task.Status.Format(), + Version: version, + Desc: tr.Task.Message, + }, + } + if tr.alertChat != nil && *tr.alertChat != "" { + alert.Chat = alertChat{ID: *tr.alertChat} + } + tr.notificationService.SendAll(alert) + } else { + tr.sendTelegramAlert() + tr.sendSlackAlert() + tr.sendRocketChatAlert() + tr.sendMicrosoftTeamsAlert() + tr.sendDingTalkAlert() + tr.sendGotifyAlert() + } tr.sendMailAlert() return diff --git a/services/tasks/notification_service.go b/services/tasks/notification_service.go new file mode 100644 index 000000000..1e3bfd73c --- /dev/null +++ b/services/tasks/notification_service.go @@ -0,0 +1,303 @@ +package tasks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "text/template" + + "github.com/semaphoreui/semaphore/util" +) + +// Notifier is a common interface for sending notifications +// Implementations should use token/channel style configuration, not insecure URL params. +type Notifier interface { + Name() string + Enabled() bool + Send(alert Alert) error +} + +// NotificationService holds all configured notifiers and fans out alerts +// Implementations are created from util.Config.Notifications and support back-compat fallbacks where needed. +type NotificationService struct { + notifiers []Notifier +} + +func NewNotificationService() *NotificationService { + var notifiers []Notifier + + cfg := util.Config.Notifications + if cfg != nil { + if cfg.Telegram != nil { + n := &TelegramNotifier{} + n.token = cfg.Telegram.Token + n.chatID = cfg.Telegram.Chat + notifiers = append(notifiers, n) + } + if cfg.Slack != nil { + n := &SlackNotifier{} + n.token = cfg.Slack.Token + n.channel = cfg.Slack.Channel + notifiers = append(notifiers, n) + } + if cfg.Gotify != nil { + n := &GotifyNotifier{} + n.server = cfg.Gotify.Server + n.token = cfg.Gotify.Token + n.title = cfg.Gotify.Title + if cfg.Gotify.Priority != nil { + n.priority = *cfg.Gotify.Priority + } + notifiers = append(notifiers, n) + } + if cfg.DingTalk != nil { + n := &DingTalkNotifier{} + n.token = cfg.DingTalk.Token + n.channel = cfg.DingTalk.Channel + notifiers = append(notifiers, n) + } + } + + return &NotificationService{notifiers: notifiers} +} + +func (s *NotificationService) HasNotifiers() bool { + return len(s.notifiers) > 0 +} + +func (s *NotificationService) SendAll(alert Alert) { + for _, n := range s.notifiers { + if !n.Enabled() { + continue + } + _ = n.Send(alert) + } +} + +// Notifiers returns the configured notifier implementations +func (s *NotificationService) Notifiers() []Notifier { + return s.notifiers +} + +// TelegramNotifier sends messages to Telegram using a bot token and chat ID +// Falls back to util.Config.Telegram* if new config is not provided +// and Enabled() should reflect availability of credentials + +type TelegramNotifier struct { + token string + chatID string +} + +func (t *TelegramNotifier) Name() string { return "telegram" } +func (t *TelegramNotifier) Enabled() bool { + if t.token == "" || t.chatID == "" { + // fallback to old config for backward compatibility + if util.Config.TelegramAlert && util.Config.TelegramToken != "" && util.Config.TelegramChat != "" { + t.token = util.Config.TelegramToken + t.chatID = util.Config.TelegramChat + } + } + return t.token != "" && t.chatID != "" +} + +func (t *TelegramNotifier) Send(alert Alert) error { + if !t.Enabled() { + return nil + } + + // Ensure alert.Chat.ID is populated for template + if alert.Chat.ID == "" { + alert.Chat.ID = t.chatID + } + + body := bytes.NewBufferString("") + tpl, err := templateFor("telegram") + if err != nil { + return err + } + if err := tpl.Execute(body, alert); err != nil { + return err + } + if body.Len() == 0 { + return nil + } + + resp, err := http.Post( + fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.token), + "application/json", + body, + ) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("telegram returned status %d", resp.StatusCode) + } + return nil +} + +// SlackNotifier posts to Slack using a token and channel via chat.postMessage +// For backward compatibility, falls back to old webhook URL if token is empty. +type SlackNotifier struct { + token string + channel string +} + +func (s *SlackNotifier) Name() string { return "slack" } +func (s *SlackNotifier) Enabled() bool { + if s.token == "" || s.channel == "" { + // backward compatible: if old SlackUrl exists, treat as enabled + if util.Config.SlackAlert && util.Config.SlackUrl != "" { + return true + } + } + return s.token != "" && s.channel != "" +} + +func (s *SlackNotifier) Send(alert Alert) error { + if s.token != "" && s.channel != "" { + // Use Slack API chat.postMessage + body := bytes.NewBufferString("") + tpl, err := templateFor("slack") + if err != nil { return err } + if err := tpl.Execute(body, alert); err != nil { return err } + if body.Len() == 0 { return nil } + + // Attempt to interpret template JSON and add channel + var payload map[string]any + if err := json.Unmarshal(body.Bytes(), &payload); err != nil { + payload = map[string]any{"text": body.String()} + } + payload["channel"] = s.channel + buf := new(bytes.Buffer) + _ = json.NewEncoder(buf).Encode(payload) + + req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", buf) + if err != nil { return err } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { return err } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("slack returned status %d", resp.StatusCode) + } + // Optionally, inspect ok field from JSON + var r struct{ OK bool `json:"ok"` } + _ = json.NewDecoder(resp.Body).Decode(&r) + if !r.OK { return fmt.Errorf("slack api reported failure") } + return nil + } + // fallback: old webhook URL implementation + if util.Config.SlackAlert && util.Config.SlackUrl != "" { + body := bytes.NewBufferString("") + tpl, err := templateFor("slack") + if err != nil { return err } + if err := tpl.Execute(body, alert); err != nil { return err } + if body.Len() == 0 { return nil } + resp, err := http.Post(util.Config.SlackUrl, "application/json", body) + if err != nil { return err } + if resp.StatusCode != 200 { return fmt.Errorf("slack webhook returned status %d", resp.StatusCode) } + return nil + } + return nil +} + +// GotifyNotifier posts to Gotify server using header token, not URL param. +type GotifyNotifier struct { + server string + token string + title string + priority int +} + +func (g *GotifyNotifier) Name() string { return "gotify" } +func (g *GotifyNotifier) Enabled() bool { + if g.server == "" || g.token == "" { + // back-compat: if old config exists, allow through but still send securely if possible + if util.Config.GotifyAlert && util.Config.GotifyUrl != "" && util.Config.GotifyToken != "" { + g.server = util.Config.GotifyUrl + g.token = util.Config.GotifyToken + } + } + return g.server != "" && g.token != "" +} + +func (g *GotifyNotifier) Send(alert Alert) error { + if !g.Enabled() { + return nil + } + body := bytes.NewBufferString("") + tpl, err := templateFor("gotify") + if err != nil { return err } + if err := tpl.Execute(body, alert); err != nil { return err } + if body.Len() == 0 { return nil } + + // Construct Gotify payload + payload := map[string]any{ + "message": body.String(), + } + if g.title != "" { payload["title"] = g.title } + if g.priority != 0 { payload["priority"] = g.priority } + buf := new(bytes.Buffer) + _ = json.NewEncoder(buf).Encode(payload) + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/message", g.server), buf) + if err != nil { return err } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Gotify-Key", g.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { return err } + if resp.StatusCode != 200 { return fmt.Errorf("gotify returned status %d", resp.StatusCode) } + return nil +} + +// DingTalkNotifier sends messages using a token (preferred) rather than insecure URL params. +type DingTalkNotifier struct { + token string + channel string +} + +func (d *DingTalkNotifier) Name() string { return "dingtalk" } +func (d *DingTalkNotifier) Enabled() bool { + if d.token == "" { + // Back-compat: if URL is set, allow old flow + if util.Config.DingTalkAlert && util.Config.DingTalkUrl != "" { + return true + } + } + return d.token != "" +} + +func (d *DingTalkNotifier) Send(alert Alert) error { + if d.token != "" { + body := bytes.NewBufferString("") + tpl, err := templateFor("dingtalk") + if err != nil { return err } + if err := tpl.Execute(body, alert); err != nil { return err } + if body.Len() == 0 { return nil } + // Example DingTalk bot token-based endpoint could differ; using common webhook with token param in header or path + req, err := http.NewRequest("POST", fmt.Sprintf("https://oapi.dingtalk.com/robot/send"), body) + if err != nil { return err } + req.Header.Set("Content-Type", "application/json") + // Some setups expect access token as query (?access_token=..). We avoid embedding token in URL as per requirement. + req.Header.Set("X-Dingtalk-Token", d.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { return err } + if resp.StatusCode != 200 { return fmt.Errorf("dingtalk returned status %d", resp.StatusCode) } + return nil + } + // Fallback to legacy URL-based approach handled by existing sendDingTalkAlert in TaskRunner + return nil +} + +// templateFor provides the compiled template used by notifier implementations +func templateFor(kind string) (*template.Template, error) { + // reuse existing embedded templates in alert.go via ParseFS + tpl, err := template.ParseFS(templates, fmt.Sprintf("templates/%s.tmpl", kind)) + if err != nil { + return nil, err + } + return tpl, nil +} \ No newline at end of file diff --git a/util/config.go b/util/config.go index d12774a63..521aac84b 100644 --- a/util/config.go +++ b/util/config.go @@ -178,6 +178,43 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } +// New unified notifications configuration +// These options are only configurable via config file (no env tags) +// and will be used by the new Notification Service implementation. +type NotificationsConfig struct { + Telegram *TelegramNotificationConfig `json:"telegram,omitempty"` + Slack *SlackNotificationConfig `json:"slack,omitempty"` + Gotify *GotifyNotificationConfig `json:"gotify,omitempty"` + DingTalk *DingTalkNotificationConfig `json:"dingtalk,omitempty"` +} + +type TelegramNotificationConfig struct { + Token string `json:"token,omitempty"` + Chat string `json:"chat,omitempty"` +} + +type SlackNotificationConfig struct { + Token string `json:"token,omitempty"` + Channel string `json:"channel,omitempty"` +} + +type GotifyNotificationConfig struct { + // Base server URL, e.g. https://gotify.example.com + Server string `json:"server,omitempty"` + // Application token (sent in header, not as URL param) + Token string `json:"token,omitempty"` + // Optional title and priority + Title string `json:"title,omitempty"` + Priority *int `json:"priority,omitempty"` +} + +type DingTalkNotificationConfig struct { + // Access token for the bot/webhook + Token string `json:"token,omitempty"` + // Optional: a channel/room identifier if applicable in your setup + Channel string `json:"channel,omitempty"` +} + type HARedisConfig struct { Addr string `json:"addr,omitempty" env:"SEMAPHORE_HA_REDIS_ADDR"` DB int `json:"db,omitempty" env:"SEMAPHORE_HA_REDIS_DB"` @@ -282,6 +319,9 @@ type ConfigType struct { GotifyUrl string `json:"gotify_url,omitempty" env:"SEMAPHORE_GOTIFY_URL"` GotifyToken string `json:"gotify_token,omitempty" env:"SEMAPHORE_GOTIFY_TOKEN"` + // New, structured notification settings for the service-based notifiers + Notifications *NotificationsConfig `json:"notifications,omitempty"` + // oidc settings OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty" env:"SEMAPHORE_OIDC_PROVIDERS"` From e6d371ec90256cc14b30b6560e51e14404e96b1e Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Fri, 10 Oct 2025 13:32:37 +0500 Subject: [PATCH 2/2] refactor(notif): use map for settings --- services/tasks/notification_service.go | 198 ++++++++++++++++++------- util/OdbcProvider.go | 2 +- util/config.go | 89 +++++++---- 3 files changed, 204 insertions(+), 85 deletions(-) diff --git a/services/tasks/notification_service.go b/services/tasks/notification_service.go index 1e3bfd73c..490120420 100644 --- a/services/tasks/notification_service.go +++ b/services/tasks/notification_service.go @@ -24,41 +24,76 @@ type NotificationService struct { notifiers []Notifier } +func fillNotificationConfig(src map[string]any, target any) error { + content, err := json.Marshal(src) + if err != nil { + return nil + } + err = json.Unmarshal(content, target) + return err +} func NewNotificationService() *NotificationService { var notifiers []Notifier - cfg := util.Config.Notifications - if cfg != nil { - if cfg.Telegram != nil { - n := &TelegramNotifier{} - n.token = cfg.Telegram.Token - n.chatID = cfg.Telegram.Chat - notifiers = append(notifiers, n) - } - if cfg.Slack != nil { - n := &SlackNotifier{} - n.token = cfg.Slack.Token - n.channel = cfg.Slack.Channel - notifiers = append(notifiers, n) + //cfg := util.Config.Notifications + + for _, v := range util.Config.Notifications { + var enabled bool + var ok bool + if enabled, ok = v["enabled"].(bool); !ok || !enabled { + continue } - if cfg.Gotify != nil { - n := &GotifyNotifier{} - n.server = cfg.Gotify.Server - n.token = cfg.Gotify.Token - n.title = cfg.Gotify.Title - if cfg.Gotify.Priority != nil { - n.priority = *cfg.Gotify.Priority + + switch v["type"].(util.NotificationType) { + case util.NotificationWebhook: + + case util.NotificationTelegram: + var tgConfig util.TelegramNotifConfig + fillNotificationConfig(v, &tgConfig) + n := &TelegramNotifier{ + token: tgConfig.Token, + chatID: tgConfig.Chat, + templateFile: tgConfig.Template, } - notifiers = append(notifiers, n) - } - if cfg.DingTalk != nil { - n := &DingTalkNotifier{} - n.token = cfg.DingTalk.Token - n.channel = cfg.DingTalk.Channel + + n.token = tgConfig.Token + n.chatID = tgConfig.Chat notifiers = append(notifiers, n) } } + //if cfg == nil { + // + // if cfg.Telegram != nil { + // n := &TelegramNotifier{} + // n.token = cfg.Telegram.Token + // n.chatID = cfg.Telegram.Chat + // notifiers = append(notifiers, n) + // } + // if cfg.Slack != nil { + // n := &SlackNotifier{} + // n.token = cfg.Slack.Token + // n.channel = cfg.Slack.Channel + // notifiers = append(notifiers, n) + // } + // if cfg.Gotify != nil { + // n := &GotifyNotifier{} + // n.server = cfg.Gotify.Server + // n.token = cfg.Gotify.Token + // n.title = cfg.Gotify.Title + // if cfg.Gotify.Priority != nil { + // n.priority = *cfg.Gotify.Priority + // } + // notifiers = append(notifiers, n) + // } + // if cfg.DingTalk != nil { + // n := &DingTalkNotifier{} + // n.token = cfg.DingTalk.Token + // n.channel = cfg.DingTalk.Channel + // notifiers = append(notifiers, n) + // } + //} + return &NotificationService{notifiers: notifiers} } @@ -85,8 +120,9 @@ func (s *NotificationService) Notifiers() []Notifier { // and Enabled() should reflect availability of credentials type TelegramNotifier struct { - token string - chatID string + token string + chatID string + templateFile string } func (t *TelegramNotifier) Name() string { return "telegram" } @@ -160,9 +196,15 @@ func (s *SlackNotifier) Send(alert Alert) error { // Use Slack API chat.postMessage body := bytes.NewBufferString("") tpl, err := templateFor("slack") - if err != nil { return err } - if err := tpl.Execute(body, alert); err != nil { return err } - if body.Len() == 0 { return nil } + if err != nil { + return err + } + if err := tpl.Execute(body, alert); err != nil { + return err + } + if body.Len() == 0 { + return nil + } // Attempt to interpret template JSON and add channel var payload map[string]any @@ -174,31 +216,49 @@ func (s *SlackNotifier) Send(alert Alert) error { _ = json.NewEncoder(buf).Encode(payload) req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", buf) - if err != nil { return err } + if err != nil { + return err + } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.token) resp, err := http.DefaultClient.Do(req) - if err != nil { return err } + if err != nil { + return err + } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("slack returned status %d", resp.StatusCode) } // Optionally, inspect ok field from JSON - var r struct{ OK bool `json:"ok"` } + var r struct { + OK bool `json:"ok"` + } _ = json.NewDecoder(resp.Body).Decode(&r) - if !r.OK { return fmt.Errorf("slack api reported failure") } + if !r.OK { + return fmt.Errorf("slack api reported failure") + } return nil } // fallback: old webhook URL implementation if util.Config.SlackAlert && util.Config.SlackUrl != "" { body := bytes.NewBufferString("") tpl, err := templateFor("slack") - if err != nil { return err } - if err := tpl.Execute(body, alert); err != nil { return err } - if body.Len() == 0 { return nil } + if err != nil { + return err + } + if err := tpl.Execute(body, alert); err != nil { + return err + } + if body.Len() == 0 { + return nil + } resp, err := http.Post(util.Config.SlackUrl, "application/json", body) - if err != nil { return err } - if resp.StatusCode != 200 { return fmt.Errorf("slack webhook returned status %d", resp.StatusCode) } + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("slack webhook returned status %d", resp.StatusCode) + } return nil } return nil @@ -230,26 +290,42 @@ func (g *GotifyNotifier) Send(alert Alert) error { } body := bytes.NewBufferString("") tpl, err := templateFor("gotify") - if err != nil { return err } - if err := tpl.Execute(body, alert); err != nil { return err } - if body.Len() == 0 { return nil } + if err != nil { + return err + } + if err := tpl.Execute(body, alert); err != nil { + return err + } + if body.Len() == 0 { + return nil + } // Construct Gotify payload payload := map[string]any{ "message": body.String(), } - if g.title != "" { payload["title"] = g.title } - if g.priority != 0 { payload["priority"] = g.priority } + if g.title != "" { + payload["title"] = g.title + } + if g.priority != 0 { + payload["priority"] = g.priority + } buf := new(bytes.Buffer) _ = json.NewEncoder(buf).Encode(payload) req, err := http.NewRequest("POST", fmt.Sprintf("%s/message", g.server), buf) - if err != nil { return err } + if err != nil { + return err + } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Gotify-Key", g.token) resp, err := http.DefaultClient.Do(req) - if err != nil { return err } - if resp.StatusCode != 200 { return fmt.Errorf("gotify returned status %d", resp.StatusCode) } + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("gotify returned status %d", resp.StatusCode) + } return nil } @@ -274,18 +350,30 @@ func (d *DingTalkNotifier) Send(alert Alert) error { if d.token != "" { body := bytes.NewBufferString("") tpl, err := templateFor("dingtalk") - if err != nil { return err } - if err := tpl.Execute(body, alert); err != nil { return err } - if body.Len() == 0 { return nil } + if err != nil { + return err + } + if err := tpl.Execute(body, alert); err != nil { + return err + } + if body.Len() == 0 { + return nil + } // Example DingTalk bot token-based endpoint could differ; using common webhook with token param in header or path req, err := http.NewRequest("POST", fmt.Sprintf("https://oapi.dingtalk.com/robot/send"), body) - if err != nil { return err } + if err != nil { + return err + } req.Header.Set("Content-Type", "application/json") // Some setups expect access token as query (?access_token=..). We avoid embedding token in URL as per requirement. req.Header.Set("X-Dingtalk-Token", d.token) resp, err := http.DefaultClient.Do(req) - if err != nil { return err } - if resp.StatusCode != 200 { return fmt.Errorf("dingtalk returned status %d", resp.StatusCode) } + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("dingtalk returned status %d", resp.StatusCode) + } return nil } // Fallback to legacy URL-based approach handled by existing sendDingTalkAlert in TaskRunner @@ -300,4 +388,4 @@ func templateFor(kind string) (*template.Template, error) { return nil, err } return tpl, nil -} \ No newline at end of file +} diff --git a/util/OdbcProvider.go b/util/OdbcProvider.go index 1f0ac7970..e6d9f99a8 100644 --- a/util/OdbcProvider.go +++ b/util/OdbcProvider.go @@ -3,7 +3,7 @@ package util type OidcProvider struct { ClientID string `json:"client_id"` ClientIDFile string `json:"client_id_file"` - ClientSecret string `json:"client_secret"` + ClientSecret string `json:"client_secret" envSuffix:"_CLIENT_SECRET"` ClientSecretFile string `json:"client_secret_file"` RedirectURL string `json:"redirect_url"` Scopes []string `json:"scopes"` diff --git a/util/config.go b/util/config.go index b22a2e5ee..c9233b435 100644 --- a/util/config.go +++ b/util/config.go @@ -190,31 +190,62 @@ type DebuggingConfig struct { PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } -// New unified notifications configuration -// These options are only configurable via config file (no env tags) -// and will be used by the new Notification Service implementation. -type NotificationsConfig struct { - Telegram *TelegramNotificationConfig `json:"telegram,omitempty"` - Slack *SlackNotificationConfig `json:"slack,omitempty"` - Gotify *GotifyNotificationConfig `json:"gotify,omitempty"` - DingTalk *DingTalkNotificationConfig `json:"dingtalk,omitempty"` +type NotificationType string + +const ( + NotificationWebhook NotificationType = "webhook" + NotificationTelegram NotificationType = "telegram" + NotificationSlack NotificationType = "slack" +) + +type NotificationConfig struct { + Enabled bool `json:"enabled" envSuffix:"_ENABLED"` + Type NotificationType `json:"type" envSuffix:"_TYPE"` + Template string `json:"template" env:"_TEMPLATE"` } -type TelegramNotificationConfig struct { - Token string `json:"token,omitempty"` - Chat string `json:"chat,omitempty"` +type WebhookNotifConfig struct { + NotificationConfig + URL string `json:"url,omitempty" envSuffix:"_URL"` } -type SlackNotificationConfig struct { - Token string `json:"token,omitempty"` - Channel string `json:"channel,omitempty"` +type TelegramNotifConfig struct { + NotificationConfig + Token string `json:"token,omitempty" envSuffix:"_TOKEN"` + Chat string `json:"chat,omitempty" envSuffix:"_CHAT"` +} + +type SlackNotifConfig struct { + NotificationConfig + Token string `json:"token,omitempty" envSuffix:"_TOKEN"` + Chat string `json:"chat,omitempty" envSuffix:"_CHAT"` } +//// New unified notifications configuration +//// These options are only configurable via config file (no env tags) +//// and will be used by the new Notification Service implementation. +//type NotificationsConfig struct { +// Telegram *TelegramNotificationConfig `json:"telegram,omitempty"` +// Slack *SlackNotificationConfig `json:"slack,omitempty"` +// Gotify *GotifyNotificationConfig `json:"gotify,omitempty"` +// DingTalk *DingTalkNotificationConfig `json:"dingtalk,omitempty"` +//} +// +//type TelegramNotificationConfig struct { +// Token string `json:"token,omitempty"` +// Chat string `json:"chat,omitempty"` +//} +// +//type SlackNotificationConfig struct { +// Token string `json:"token,omitempty"` +// Channel string `json:"channel,omitempty"` +//} + type GotifyNotificationConfig struct { // Base server URL, e.g. https://gotify.example.com - Server string `json:"server,omitempty"` + Server string `json:"server,omitempty"` // Application token (sent in header, not as URL param) - Token string `json:"token,omitempty"` + Token string `json:"token,omitempty"` // Optional title and priority Title string `json:"title,omitempty"` Priority *int `json:"priority,omitempty"` @@ -222,7 +253,7 @@ type GotifyNotificationConfig struct { type DingTalkNotificationConfig struct { // Access token for the bot/webhook - Token string `json:"token,omitempty"` + Token string `json:"token,omitempty"` // Optional: a channel/room identifier if applicable in your setup Channel string `json:"channel,omitempty"` } @@ -294,6 +325,16 @@ type ConfigType struct { // for encrypting and decrypting access keys stored in database. AccessKeyEncryption string `json:"access_key_encryption,omitempty" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION"` + // ldap settings + LdapEnable bool `json:"ldap_enable,omitempty" env:"SEMAPHORE_LDAP_ENABLE"` + LdapBindDN string `json:"ldap_binddn,omitempty" env:"SEMAPHORE_LDAP_BIND_DN"` + LdapBindPassword string `json:"ldap_bindpassword,omitempty" env:"SEMAPHORE_LDAP_BIND_PASSWORD"` + LdapServer string `json:"ldap_server,omitempty" env:"SEMAPHORE_LDAP_SERVER"` + LdapSearchDN string `json:"ldap_searchdn,omitempty" env:"SEMAPHORE_LDAP_SEARCH_DN"` + LdapSearchFilter string `json:"ldap_searchfilter,omitempty" env:"SEMAPHORE_LDAP_SEARCH_FILTER"` + LdapMappings *LdapMappings `json:"ldap_mappings,omitempty"` + LdapNeedTLS bool `json:"ldap_needtls,omitempty" env:"SEMAPHORE_LDAP_NEEDTLS"` + // email alerting EmailAlert bool `json:"email_alert,omitempty" env:"SEMAPHORE_EMAIL_ALERT"` EmailSender string `json:"email_sender,omitempty" env:"SEMAPHORE_EMAIL_SENDER"` @@ -305,16 +346,6 @@ type ConfigType struct { EmailTls bool `json:"email_tls,omitempty" env:"SEMAPHORE_EMAIL_TLS"` EmailTlsMinVersion string `json:"email_tls_min_version,omitempty" default:"1.2" rule:"^(1\\.[0123])$" env:"SEMAPHORE_EMAIL_TLS_MIN_VERSION"` - // ldap settings - LdapEnable bool `json:"ldap_enable,omitempty" env:"SEMAPHORE_LDAP_ENABLE"` - LdapBindDN string `json:"ldap_binddn,omitempty" env:"SEMAPHORE_LDAP_BIND_DN"` - LdapBindPassword string `json:"ldap_bindpassword,omitempty" env:"SEMAPHORE_LDAP_BIND_PASSWORD"` - LdapServer string `json:"ldap_server,omitempty" env:"SEMAPHORE_LDAP_SERVER"` - LdapSearchDN string `json:"ldap_searchdn,omitempty" env:"SEMAPHORE_LDAP_SEARCH_DN"` - LdapSearchFilter string `json:"ldap_searchfilter,omitempty" env:"SEMAPHORE_LDAP_SEARCH_FILTER"` - LdapMappings *LdapMappings `json:"ldap_mappings,omitempty"` - LdapNeedTLS bool `json:"ldap_needtls,omitempty" env:"SEMAPHORE_LDAP_NEEDTLS"` - // Telegram, Slack, Rocket.Chat, Microsoft Teams, DingTalk, and Gotify alerting TelegramAlert bool `json:"telegram_alert,omitempty" env:"SEMAPHORE_TELEGRAM_ALERT"` TelegramChat string `json:"telegram_chat,omitempty" env:"SEMAPHORE_TELEGRAM_CHAT"` @@ -332,10 +363,10 @@ type ConfigType struct { GotifyToken string `json:"gotify_token,omitempty" env:"SEMAPHORE_GOTIFY_TOKEN"` // New, structured notification settings for the service-based notifiers - Notifications *NotificationsConfig `json:"notifications,omitempty"` + Notifications map[string]map[string]any `json:"notifications,omitempty" env:"SEMAPHORE_NOTIFICATIONS" envPrefix:"SEMAPHORE_NOTIF_"` // oidc settings - OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty" env:"SEMAPHORE_OIDC_PROVIDERS"` + OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty" env:"SEMAPHORE_OIDC_PROVIDERS" envPrefix:"SEMAPHORE_OIDC_"` MaxTaskDurationSec int `json:"max_task_duration_sec,omitempty" env:"SEMAPHORE_MAX_TASK_DURATION_SEC"` MaxTasksPerTemplate int `json:"max_tasks_per_template,omitempty" env:"SEMAPHORE_MAX_TASKS_PER_TEMPLATE"`