feat(notify): own webhook as a full notification channel
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:
sandrodachandClaude Opus 5.5 committed 2026-10-30 19:02:46 +01:00
1 parent 6c154af251
commit 261579f0f2
10 files changed
+201 -55

No files matched your search

+4
View File
@@ -6,12 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
### Changed ### Changed
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer. - Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile. - Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz. - Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
### Fixed ### Fixed
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite. - Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen. - Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe). - Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
+1 -1
View File
@@ -106,7 +106,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
## Benachrichtigungen ## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht. `internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## Link-Vorschau ## Link-Vorschau
@@ -51,6 +51,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels); const hasLegacy = Array.isArray(user.alarmChannels);
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true; const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true; const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
const s = user.notificationSettings; const s = user.notificationSettings;
return { return {
paused: Boolean(s?.paused), paused: Boolean(s?.paused),
@@ -59,18 +60,22 @@ export function buildNotificationSettings(user: User): NotificationSettings {
due: { due: {
email: s?.events?.due?.email ?? dueEmailDefault, email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault, discord: s?.events?.due?.discord ?? dueDiscordDefault,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
}, },
shared: { shared: {
email: s?.events?.shared?.email ?? true, email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false, discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
}, },
taskDone: { taskDone: {
email: s?.events?.taskDone?.email ?? false, email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false, discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
}, },
watched: { watched: {
email: s?.events?.watched?.email ?? true, email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true, discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
}, },
}, },
dailySummary: { dailySummary: {
@@ -78,6 +83,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
time: s?.dailySummary?.time || '07:00', time: s?.dailySummary?.time || '07:00',
email: s?.dailySummary?.email ?? true, email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false, discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
}, },
}; };
} }
@@ -250,9 +256,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// --- Multi-Channel Alarms State --- // --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
);
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() => const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
buildNotificationSettings(currentUser) buildNotificationSettings(currentUser)
); );
@@ -270,7 +273,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setDisplayName(currentUser.displayName || ''); setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || ''); setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || ''); setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setNotificationSettings(buildNotificationSettings(currentUser)); setNotificationSettings(buildNotificationSettings(currentUser));
setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
@@ -278,13 +280,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]); }, [currentUser]);
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
);
};
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => { const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
setNotificationSettings((prev) => ({ setNotificationSettings((prev) => ({
...prev, ...prev,
events: { events: {
@@ -386,7 +383,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const nextAlarmChannels = [ const nextAlarmChannels = [
...(notificationSettings.events.due.email ? ['email'] : []), ...(notificationSettings.events.due.email ? ['email'] : []),
...(notificationSettings.events.due.discord ? ['discord'] : []), ...(notificationSettings.events.due.discord ? ['discord'] : []),
...(alarmChannels.includes('webhook') ? ['webhook'] : []), ...(notificationSettings.events.due.webhook ? ['webhook'] : []),
]; ];
const updated = await api.updateSettings({ const updated = await api.updateSettings({
alarmChannels: nextAlarmChannels, alarmChannels: nextAlarmChannels,
@@ -1119,13 +1116,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
</div> </div>
{/* Routing Matrix: Events x (E-Mail / Discord) */} {/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
{(() => { {(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
return ( return (
<> <>
<div className="p-4 bg-canvas border border-border space-y-3"> <div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border"> <div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span> <span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1"> <span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Mail className="w-3.5 h-3.5 text-accent" /> <Mail className="w-3.5 h-3.5 text-accent" />
@@ -1135,6 +1133,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<MessageSquare className="w-3.5 h-3.5 text-accent" /> <MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span> <span>Discord</span>
</span> </span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
@@ -1143,7 +1145,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
return ( return (
<div <div
key={row.key} key={row.key}
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5" className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
> >
<span className="font-medium text-main">{row.label}</span> <span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center"> <div className="w-16 flex justify-center">
@@ -1165,6 +1167,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
} }
/> />
</div> </div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
title={
hasCustomWebhook
? `${row.label} – Webhook`
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
</div> </div>
); );
})} })}
@@ -1178,6 +1192,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist. Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p> </p>
)} )}
{!hasCustomWebhook && (
<p className="text-[11px] text-faint">
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
</p>
)}
</div> </div>
{/* Daily Summary */} {/* Daily Summary */}
@@ -1250,6 +1269,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
} }
/> />
</div> </div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Webhook</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.webhook}
disabled={!hasCustomWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, webhook: v },
}))
}
title={
hasCustomWebhook
? 'Tageszusammenfassung – Webhook'
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1359,17 +1396,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{/* Channel 3: Custom HTTP Webhook */} {/* Channel 3: Custom HTTP Webhook */}
<div <div
className={`p-4 border transition-colors space-y-3 bg-canvas ${ className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border' customWebhookUrl.trim() ? 'border-accent' : 'border-border'
}`} }`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" /> <Globe className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block"> <span className="font-bold text-main block">
@@ -1377,10 +1408,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</span> </span>
<span className="text-[11px] text-muted"> <span className="text-[11px] text-muted">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
Automatisierungen. Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -88,19 +88,53 @@ describe('UserSettingsView notification matrix', () => {
paused: true, paused: true,
includeOwnWatched: true, includeOwnWatched: true,
events: { events: {
due: { email: true, discord: true }, due: { email: true, discord: true, webhook: false },
shared: { email: true, discord: false }, shared: { email: true, discord: false, webhook: false },
taskDone: { email: true, discord: false }, taskDone: { email: true, discord: false, webhook: false },
watched: { email: true, discord: false }, watched: { email: true, discord: false, webhook: false },
}, },
dailySummary: { dailySummary: {
enabled: true, enabled: true,
time: '08:30', time: '08:30',
email: true, email: true,
discord: true, discord: true,
webhook: false,
}, },
}, },
}) })
); );
}); });
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={() => {}}
onBack={() => {}}
/>
);
fireEvent.click(screen.getByText('Alarme'));
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
expect(off.length).toBe(5);
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
target: { value: 'https://hooks.example.com/mindsift' },
});
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
});
}); });
+2
View File
@@ -49,6 +49,7 @@ export type SharingMode = 'private' | 'organizations' | 'open';
export interface NotificationChannelRouting { export interface NotificationChannelRouting {
email: boolean; email: boolean;
discord: boolean; discord: boolean;
webhook: boolean;
} }
export interface NotificationEvents { export interface NotificationEvents {
@@ -63,6 +64,7 @@ export interface DailySummarySettings {
time: string; time: string;
email: boolean; email: boolean;
discord: boolean; discord: boolean;
webhook: boolean;
} }
export interface NotificationSettings { export interface NotificationSettings {
+1 -3
View File
@@ -140,11 +140,9 @@ func (s *Server) startAlarmTicker() {
if set.Events.Due.Discord { if set.Events.Due.Discord {
channels = append(channels, "discord") channels = append(channels, "discord")
} }
for _, ch := range a.AlarmChannels { if set.Events.Due.Webhook {
if ch == "webhook" {
channels = append(channels, "webhook") channels = append(channels, "webhook")
} }
}
if a.AlarmEmail == "" { if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail a.AlarmEmail = a.UserEmail
} }
+72 -22
View File
@@ -18,7 +18,7 @@ import (
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one // 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 // 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 // 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. // 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) { if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
continue continue
} }
if ch := set.Channels(kind); !ch.Email && !ch.Discord { if !set.Channels(kind).Any() {
continue continue
} }
if err := s.store.QueueNotification(uid, kind, payload); err != nil { if err := s.store.QueueNotification(uid, kind, payload); err != nil {
@@ -161,7 +161,7 @@ func (s *Server) flushNotifications(now time.Time) {
continue continue
} }
set := s.store.NotificationSettingsOf(uid) set := s.store.NotificationSettingsOf(uid)
var emailItems, discordItems []notifyItem var out channelItems
ids := make([]int64, 0, len(queued)) ids := make([]int64, 0, len(queued))
maxAttempts := 0 maxAttempts := 0
for _, q := range queued { for _, q := range queued {
@@ -173,15 +173,9 @@ func (s *Server) flushNotifications(now time.Time) {
continue continue
} }
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]} item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
ch := set.Channels(q.Kind) out.add(set.Channels(q.Kind), item)
if ch.Email {
emailItems = append(emailItems, item)
} }
if ch.Discord { if err := s.deliver(uid, "Neuigkeiten", out); err != nil {
discordItems = append(discordItems, item)
}
}
if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil {
if maxAttempts+1 >= notifyMaxAttempts { if maxAttempts+1 >= notifyMaxAttempts {
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err) slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
_ = s.store.DeleteQueuedNotifications(ids) _ = 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. // channelItems collects what goes out on each channel.
func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error { 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) u, err := s.store.GetUserByID(userID)
if err != nil || u == nil || !u.IsActive { if err != nil || u == nil || !u.IsActive {
return nil // account gone: nothing to deliver return nil // account gone: nothing to deliver
@@ -219,6 +231,11 @@ func (s *Server) deliver(userID, heading string, emailItems, discordItems []noti
firstErr = err 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 return firstErr
} }
@@ -237,6 +254,44 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
return s.sendDiscordDigest(webhookURL, heading, items) 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 { func (s *Server) notifyLink() string {
if !s.cfg.BaseURLSet { if !s.cfg.BaseURLSet {
return "" return ""
@@ -322,7 +377,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
for _, uid := range s.store.ActiveUserIDs() { for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid) set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) { if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
continue continue
} }
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location()) 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) { if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
continue continue
} }
var mail, discord []notifyItem var out channelItems
if ds.Email { out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
mail = items if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
}
if ds.Discord {
discord = items
}
if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil {
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err) slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
} }
} }
+21 -1
View File
@@ -28,6 +28,14 @@ func notifyServer(t *testing.T) (*Server, *[]sentNote) {
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")}) sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
return nil 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 return srv, &sent
} }
@@ -102,6 +110,18 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
t.Fatalf("expected one Discord message, got %+v", *sent) 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 // Paused: nothing at all
*sent = nil *sent = nil
set.Paused = true set.Paused = true
@@ -114,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
// Settings round trip over the profile API // 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"}}}`, "") 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()) t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
} }
} }
+1
View File
@@ -38,6 +38,7 @@ type Server struct {
// Replace the delivery of bundled notifications in tests (notify.go) // Replace the delivery of bundled notifications in tests (notify.go)
notifyEmailHook func(to, subject, text, html string) error notifyEmailHook func(to, subject, text, html string) error
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) 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 { func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
+6
View File
@@ -157,13 +157,18 @@ type NotificationEvents struct {
type ChannelChoice struct { type ChannelChoice struct {
Email bool `json:"email"` Email bool `json:"email"`
Discord bool `json:"discord"` Discord bool `json:"discord"`
Webhook bool `json:"webhook"` // own webhook (customWebhookUrl), JSON payload
} }
// Any reports whether at least one channel is chosen.
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook }
type DailySummary struct { type DailySummary struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Time string `json:"time"` // HH:MM, server time zone Time string `json:"time"` // HH:MM, server time zone
Email bool `json:"email"` Email bool `json:"email"`
Discord bool `json:"discord"` Discord bool `json:"discord"`
Webhook bool `json:"webhook"`
} }
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels. // DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
@@ -172,6 +177,7 @@ func DefaultNotificationSettings(alarmChannels []string) NotificationSettings {
for _, c := range alarmChannels { for _, c := range alarmChannels {
due.Email = due.Email || c == "email" due.Email = due.Email || c == "email"
due.Discord = due.Discord || c == "discord" due.Discord = due.Discord || c == "discord"
due.Webhook = due.Webhook || c == "webhook"
} }
return NotificationSettings{ return NotificationSettings{
Events: NotificationEvents{ Events: NotificationEvents{