feat(notify): own webhook as a full notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
The custom webhook could be entered but never switched on since the notification matrix replaced the old alarm channels. It is now a third column next to e-mail and Discord for every event and the daily summary; bundles go out as JSON (event "digest"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c154af251
commit
261579f0f2
10 files changed
+202
-56
No files matched your search
@@ -140,10 +140,8 @@ func (s *Server) startAlarmTicker() {
|
||||
if set.Events.Due.Discord {
|
||||
channels = append(channels, "discord")
|
||||
}
|
||||
for _, ch := range a.AlarmChannels {
|
||||
if ch == "webhook" {
|
||||
channels = append(channels, "webhook")
|
||||
}
|
||||
if set.Events.Due.Webhook {
|
||||
channels = append(channels, "webhook")
|
||||
}
|
||||
if a.AlarmEmail == "" {
|
||||
a.AlarmEmail = a.UserEmail
|
||||
|
||||
+72
-22
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one
|
||||
// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks
|
||||
// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can
|
||||
// the channels per event kind (own mail address, own Discord webhook, own webhook with a JSON body), can
|
||||
// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the
|
||||
// one who made them, except on watched tasks when the user asked for it. The demo sends nothing.
|
||||
|
||||
@@ -47,7 +47,7 @@ func (s *Server) notify(kind, actorID string, recipients []string, payload map[s
|
||||
if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
|
||||
continue
|
||||
}
|
||||
if ch := set.Channels(kind); !ch.Email && !ch.Discord {
|
||||
if !set.Channels(kind).Any() {
|
||||
continue
|
||||
}
|
||||
if err := s.store.QueueNotification(uid, kind, payload); err != nil {
|
||||
@@ -161,7 +161,7 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
continue
|
||||
}
|
||||
set := s.store.NotificationSettingsOf(uid)
|
||||
var emailItems, discordItems []notifyItem
|
||||
var out channelItems
|
||||
ids := make([]int64, 0, len(queued))
|
||||
maxAttempts := 0
|
||||
for _, q := range queued {
|
||||
@@ -173,15 +173,9 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
continue
|
||||
}
|
||||
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||
ch := set.Channels(q.Kind)
|
||||
if ch.Email {
|
||||
emailItems = append(emailItems, item)
|
||||
}
|
||||
if ch.Discord {
|
||||
discordItems = append(discordItems, item)
|
||||
}
|
||||
out.add(set.Channels(q.Kind), item)
|
||||
}
|
||||
if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil {
|
||||
if err := s.deliver(uid, "Neuigkeiten", out); err != nil {
|
||||
if maxAttempts+1 >= notifyMaxAttempts {
|
||||
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
||||
_ = s.store.DeleteQueuedNotifications(ids)
|
||||
@@ -194,8 +188,26 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// deliver sends the items to the user's mail address and/or Discord webhook.
|
||||
func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error {
|
||||
// channelItems collects what goes out on each channel.
|
||||
type channelItems struct {
|
||||
email, discord, webhook []notifyItem
|
||||
}
|
||||
|
||||
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||
if ch.Email {
|
||||
c.email = append(c.email, items...)
|
||||
}
|
||||
if ch.Discord {
|
||||
c.discord = append(c.discord, items...)
|
||||
}
|
||||
if ch.Webhook {
|
||||
c.webhook = append(c.webhook, items...)
|
||||
}
|
||||
}
|
||||
|
||||
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
|
||||
func (s *Server) deliver(userID, heading string, out channelItems) error {
|
||||
emailItems, discordItems := out.email, out.discord
|
||||
u, err := s.store.GetUserByID(userID)
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
return nil // account gone: nothing to deliver
|
||||
@@ -219,6 +231,11 @@ func (s *Server) deliver(userID, heading string, emailItems, discordItems []noti
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if len(out.webhook) > 0 && u.CustomWebhookURL != "" {
|
||||
if err := s.sendNotifyWebhook(u.CustomWebhookURL, heading, out.webhook); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -237,6 +254,44 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
|
||||
return s.sendDiscordDigest(webhookURL, heading, items)
|
||||
}
|
||||
|
||||
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
|
||||
if s.notifyWebhookHook != nil {
|
||||
return s.notifyWebhookHook(webhookURL, heading, items)
|
||||
}
|
||||
return s.sendWebhookDigest(webhookURL, heading, items)
|
||||
}
|
||||
|
||||
// sendWebhookDigest posts the bundle as JSON: {"event":"digest","heading":…,"items":[{kind,title,text}],…}.
|
||||
func (s *Server) sendWebhookDigest(webhookURL, heading string, items []notifyItem) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||
return err
|
||||
}
|
||||
list := make([]map[string]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
list = append(list, map[string]string{"kind": it.kind, "title": it.title, "text": it.text})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "digest",
|
||||
"heading": heading,
|
||||
"items": list,
|
||||
"link": s.notifyLink(),
|
||||
"timestamp": domain.NowISO(),
|
||||
})
|
||||
resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) notifyLink() string {
|
||||
if !s.cfg.BaseURLSet {
|
||||
return ""
|
||||
@@ -322,7 +377,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
for _, uid := range s.store.ActiveUserIDs() {
|
||||
set := s.store.NotificationSettingsOf(uid)
|
||||
ds := set.DailySummary
|
||||
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) {
|
||||
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
|
||||
continue
|
||||
}
|
||||
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
|
||||
@@ -333,14 +388,9 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
|
||||
continue
|
||||
}
|
||||
var mail, discord []notifyItem
|
||||
if ds.Email {
|
||||
mail = items
|
||||
}
|
||||
if ds.Discord {
|
||||
discord = items
|
||||
}
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil {
|
||||
var out channelItems
|
||||
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
|
||||
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ func notifyServer(t *testing.T) (*Server, *[]sentNote) {
|
||||
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
|
||||
return nil
|
||||
}
|
||||
srv.notifyWebhookHook = func(url, heading string, items []notifyItem) error {
|
||||
lines := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
lines = append(lines, it.text)
|
||||
}
|
||||
sent = append(sent, sentNote{channel: "webhook", to: url, text: strings.Join(lines, "\n")})
|
||||
return nil
|
||||
}
|
||||
return srv, &sent
|
||||
}
|
||||
|
||||
@@ -102,6 +110,18 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
|
||||
t.Fatalf("expected one Discord message, got %+v", *sent)
|
||||
}
|
||||
|
||||
// Own webhook as a channel of its own
|
||||
*sent = nil
|
||||
set.Events.Watched = domain.ChannelChoice{Webhook: true}
|
||||
_ = store.SetNotificationSettings(me.ID, set)
|
||||
own := "https://hooks.example.com/mindsift"
|
||||
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, nil, &own, nil, nil)
|
||||
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"normal"}`, "")
|
||||
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||
if len(*sent) != 1 || (*sent)[0].channel != "webhook" || (*sent)[0].to != own {
|
||||
t.Fatalf("expected one webhook message, got %+v", *sent)
|
||||
}
|
||||
|
||||
// Paused: nothing at all
|
||||
*sent = nil
|
||||
set.Paused = true
|
||||
@@ -114,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
|
||||
|
||||
// Settings round trip over the profile API
|
||||
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) {
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
|
||||
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type Server struct {
|
||||
// Replace the delivery of bundled notifications in tests (notify.go)
|
||||
notifyEmailHook func(to, subject, text, html string) error
|
||||
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
|
||||
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
|
||||
Reference in new issue
Block a user