Feature: Discord-Ping in Benachrichtigungen (optionale Erwaehnung)

Neues Settings-Feld "Discord-Ping" unter der Webhook-URL: User-ID, @everyone
oder @here. Wird jeder Webhook-Nachricht vorangestellt, damit Discord wirklich
pingt statt nur still in den Kanal zu schreiben. Eine nackte Zahl wird als
<@id> verpackt (nur so pingt eine User-Erwaehnung); @everyone/@here und fertige
<@...>-Mentions gehen unveraendert durch. Default leer = Verhalten wie bisher.
5 neue Tests (Normalisierung + Content-Prefix).
This commit is contained in:
Sucukdeluxe 2026-06-09 21:52:47 +02:00
parent f08db49f0b
commit 8ab87bda51
7 changed files with 55 additions and 5 deletions

View File

@ -108,6 +108,7 @@ export function defaultSettings(): AppSettings {
confirmDeleteSelection: true,
backupIncludeDownloads: false,
notifyUrl: "",
notifyMention: "",
notifyOnPackageCompleted: false,
notifyOnPackageFailed: false,
notifyOnRunFinished: false,

View File

@ -10497,7 +10497,8 @@ export class DownloadManager extends EventEmitter {
this.notifiedPackages.add(pkg.id);
void sendNotification(url, {
title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen",
message: `${pkg.name}\n${detail}`
message: `${pkg.name}\n${detail}`,
mention: this.settings.notifyMention
});
}
@ -12121,7 +12122,8 @@ export class DownloadManager extends EventEmitter {
if (this.settings.notifyOnRunFinished && total > 0) {
void sendNotification(this.settings.notifyUrl, {
title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet",
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`,
mention: this.settings.notifyMention
});
}
this.runItemIds.clear();

View File

@ -3,6 +3,7 @@ import { logger } from "./logger";
export interface NotifyPayload {
title: string;
message: string;
mention?: string;
}
const NOTIFY_TIMEOUT_MS = 5000;
@ -12,8 +13,22 @@ export function isNotifyUrlValid(url: string): boolean {
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
}
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
export function normalizeDiscordMention(raw: string): string {
const text = String(raw || "").trim();
if (!text) {
return "";
}
if (/^\d{5,}$/.test(text)) {
return `<@${text}>`;
}
return text;
}
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
const content = `**${payload.title}**\n${payload.message}`.slice(0, 2000);
const mention = normalizeDiscordMention(payload.mention || "");
const content = `${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`.slice(0, 2000);
return {
url: String(url || "").trim(),
init: {

View File

@ -460,6 +460,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed,
notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished,

View File

@ -853,7 +853,7 @@ const emptySnapshot = (): UiSnapshot => ({
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
notifyUrl: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
@ -4960,6 +4960,9 @@ export function App(): ReactElement {
<label>Webhook-URL (Discord)</label>
<input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} />
<div className="hint">In Discord: Servereinstellungen Integrationen Webhooks Neuer Webhook URL kopieren und hier eintragen. Die gewählten Ereignisse landen als Nachricht im Kanal.</div>
<label>Discord-Ping (optional)</label>
<input value={settingsDraft.notifyMention} placeholder="Deine User-ID, @everyone oder @here" onChange={(e) => setText("notifyMention", e.target.value)} />
<div className="hint">Wird jeder Nachricht vorangestellt, damit Discord dich pingt. Eigene ID: Discord-Einstellungen Erweitert Entwicklermodus an, dann Rechtsklick auf deinen Namen "User-ID kopieren" und die Zahl hier eintragen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageCompleted} onChange={(e) => setBool("notifyOnPackageCompleted", e.target.checked)} /> Benachrichtigen wenn ein Paket fertig ist</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageFailed} onChange={(e) => setBool("notifyOnPackageFailed", e.target.checked)} /> Benachrichtigen wenn ein Paket fehlschlägt</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Benachrichtigen wenn der Durchlauf beendet ist</label>

View File

@ -133,6 +133,7 @@ export interface AppSettings {
confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
notifyUrl: string;
notifyMention: string;
notifyOnPackageCompleted: boolean;
notifyOnPackageFailed: boolean;
notifyOnRunFinished: boolean;

View File

@ -1,5 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { buildNotifyRequest, isNotifyUrlValid, sendNotification } from "../src/main/notify";
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification } from "../src/main/notify";
describe("normalizeDiscordMention", () => {
it("wraps a bare user ID as a pinging mention", () => {
expect(normalizeDiscordMention("123456789012345678")).toBe("<@123456789012345678>");
expect(normalizeDiscordMention(" 987654321 ")).toBe("<@987654321>");
});
it("passes @everyone/@here and formed mentions through", () => {
expect(normalizeDiscordMention("@everyone")).toBe("@everyone");
expect(normalizeDiscordMention("@here")).toBe("@here");
expect(normalizeDiscordMention("<@123456789>")).toBe("<@123456789>");
expect(normalizeDiscordMention("<@&111222333>")).toBe("<@&111222333>");
});
it("returns empty for empty input", () => {
expect(normalizeDiscordMention("")).toBe("");
expect(normalizeDiscordMention(" ")).toBe("");
});
});
describe("isNotifyUrlValid", () => {
it("accepts http/https URLs", () => {
@ -30,6 +47,16 @@ describe("buildNotifyRequest", () => {
const body = JSON.parse(String(req.init.body));
expect(body.content.length).toBe(2000);
});
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "123456789012345678" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("<@123456789012345678> **T**\nM");
});
it("sends no mention prefix when the field is empty", () => {
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "" });
const body = JSON.parse(String(req.init.body));
expect(body.content).toBe("**T**\nM");
});
});
describe("sendNotification", () => {