feat(notify): native ntfy push notification integration (#41) #130
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
// Ntfy holds ntfy push notification settings.
|
||||
var Ntfy = struct {
|
||||
Enabled bool
|
||||
ServerURL string
|
||||
DefaultTopic string
|
||||
Token string
|
||||
}{
|
||||
Enabled: false,
|
||||
ServerURL: "https://ntfy.mokoconsulting.tech",
|
||||
DefaultTopic: "mokogitea",
|
||||
}
|
||||
|
||||
func loadNtfyFrom(cfg ConfigProvider) {
|
||||
sec := cfg.Section("ntfy")
|
||||
Ntfy.Enabled = sec.Key("ENABLED").MustBool(false)
|
||||
Ntfy.ServerURL = sec.Key("SERVER_URL").MustString(Ntfy.ServerURL)
|
||||
Ntfy.DefaultTopic = sec.Key("DEFAULT_TOPIC").MustString(Ntfy.DefaultTopic)
|
||||
Ntfy.Token = sec.Key("TOKEN").String()
|
||||
}
|
||||
@@ -168,6 +168,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
loadGlobalLockFrom(cfg)
|
||||
loadOtherFrom(cfg)
|
||||
loadUpdateCheckerFrom(cfg)
|
||||
loadNtfyFrom(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if setting.Ntfy.Enabled {
|
||||
notify_service.RegisterNotifier(NewNotifier())
|
||||
}
|
||||
}
|
||||
|
||||
type ntfyNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
// NewNotifier creates a new ntfy notifier.
|
||||
func NewNotifier() notify_service.Notifier {
|
||||
return &ntfyNotifier{}
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) Run() {}
|
||||
|
||||
func repoTopic(repo *repo_model.Repository) string {
|
||||
if repo == nil {
|
||||
return setting.Ntfy.DefaultTopic
|
||||
}
|
||||
return setting.Ntfy.DefaultTopic
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) NewIssue(_ context.Context, issue *issues_model.Issue, _ []*user_model.User) {
|
||||
_ = issue.LoadRepo(context.Background())
|
||||
SendAsync(repoTopic(issue.Repo),
|
||||
fmt.Sprintf("New Issue: %s", issue.Title),
|
||||
fmt.Sprintf("#%d in %s\n%s", issue.Index, issue.Repo.FullName(), issue.Content),
|
||||
"default",
|
||||
"issue,new")
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) IssueChangeStatus(_ context.Context, doer *user_model.User, _ string, issue *issues_model.Issue, _ *issues_model.Comment, closeOrReopen bool) {
|
||||
_ = issue.LoadRepo(context.Background())
|
||||
action := "reopened"
|
||||
if !closeOrReopen {
|
||||
action = "closed"
|
||||
}
|
||||
SendAsync(repoTopic(issue.Repo),
|
||||
fmt.Sprintf("Issue %s: %s", action, issue.Title),
|
||||
fmt.Sprintf("#%d %s by %s", issue.Index, action, doer.Name),
|
||||
"low",
|
||||
"issue,"+action)
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) NewPullRequest(_ context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
|
||||
_ = pr.LoadIssue(context.Background())
|
||||
_ = pr.Issue.LoadRepo(context.Background())
|
||||
SendAsync(repoTopic(pr.Issue.Repo),
|
||||
fmt.Sprintf("New PR: %s", pr.Issue.Title),
|
||||
fmt.Sprintf("#%d in %s\n%s → %s", pr.Issue.Index, pr.Issue.Repo.FullName(), pr.HeadBranch, pr.BaseBranch),
|
||||
"default",
|
||||
"git-pull-request,new")
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) MergePullRequest(_ context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
_ = pr.LoadIssue(context.Background())
|
||||
_ = pr.Issue.LoadRepo(context.Background())
|
||||
SendAsync(repoTopic(pr.Issue.Repo),
|
||||
fmt.Sprintf("PR Merged: %s", pr.Issue.Title),
|
||||
fmt.Sprintf("#%d merged by %s", pr.Issue.Index, doer.Name),
|
||||
"default",
|
||||
"git-merge,merged")
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) NewRelease(_ context.Context, rel *repo_model.Release) {
|
||||
SendAsync(repoTopic(rel.Repo),
|
||||
fmt.Sprintf("New Release: %s", rel.TagName),
|
||||
fmt.Sprintf("%s in %s\n%s", rel.TagName, rel.Repo.FullName(), rel.Note),
|
||||
"high",
|
||||
"rocket,release")
|
||||
}
|
||||
|
||||
func (*ntfyNotifier) WorkflowRunStatusUpdate(_ context.Context, repo *repo_model.Repository, _ *user_model.User, run *actions_model.ActionRun) {
|
||||
if run.Status.String() != "success" && run.Status.String() != "failure" {
|
||||
return // only notify on completion
|
||||
}
|
||||
priority := "default"
|
||||
tags := "white_check_mark,ci"
|
||||
if run.Status.String() == "failure" {
|
||||
priority = "high"
|
||||
tags = "x,ci-fail"
|
||||
}
|
||||
SendAsync(repoTopic(repo),
|
||||
fmt.Sprintf("CI %s: %s", run.Status.String(), run.Title),
|
||||
fmt.Sprintf("Workflow in %s", repo.FullName()),
|
||||
priority,
|
||||
tags)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// Send publishes a notification to the ntfy server.
|
||||
func Send(topic, title, message, priority, tags string) error {
|
||||
if !setting.Ntfy.Enabled || setting.Ntfy.ServerURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if topic == "" {
|
||||
topic = setting.Ntfy.DefaultTopic
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s", strings.TrimRight(setting.Ntfy.ServerURL, "/"), topic)
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Title", title)
|
||||
if priority != "" {
|
||||
req.Header.Set("Priority", priority)
|
||||
}
|
||||
if tags != "" {
|
||||
req.Header.Set("Tags", tags)
|
||||
}
|
||||
if setting.Ntfy.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("ntfy returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
log.Debug("ntfy notification sent: %s — %s", topic, title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAsync sends a notification in a goroutine (non-blocking).
|
||||
func SendAsync(topic, title, message, priority, tags string) {
|
||||
go func() {
|
||||
if err := Send(topic, title, message, priority, tags); err != nil {
|
||||
log.Error("ntfy async send failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user