From e1d04d083837421346cb01f6e5cdf2d0402a0a27 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 9 Jun 2026 23:40:38 +0200 Subject: [PATCH] feat(webhook): optional Discord ping (user-id / role / @here / @everyone) so batch-done actually notifies --- lib/config-store.js | 1 + lib/webhook-notify.js | 27 +++++++++++++++++++++++++-- main.js | 7 +++++-- preload.js | 2 +- renderer/app.js | 9 ++++++++- tests/webhook-notify.test.js | 36 +++++++++++++++++++++++++++++++++++- 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/config-store.js b/lib/config-store.js index 4f2e048..d6f372a 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -60,6 +60,7 @@ const DEFAULTS = { sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load logVerbose: false, // when true, [DEBUG] level entries are written to debug.log webhookUrl: '', // POST target on batch-done (Discord or generic JSON) + webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay) // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge diff --git a/lib/webhook-notify.js b/lib/webhook-notify.js index 5a098a8..ea26507 100644 --- a/lib/webhook-notify.js +++ b/lib/webhook-notify.js @@ -27,6 +27,26 @@ function summarizePerHosterFromBatch(summary) { return out; } +function resolveDiscordMention(raw) { + const s = String(raw || '').trim(); + if (!s) return null; + const keyword = s.replace(/^@/, '').toLowerCase(); + if (keyword === 'here' || keyword === 'everyone') { + return { token: `@${keyword}`, allowed: { parse: ['everyone'] } }; + } + const roleMatch = s.match(/^(?:<@&(\d+)>|role:(\d+))$/i); + if (roleMatch) { + const id = roleMatch[1] || roleMatch[2]; + return { token: `<@&${id}>`, allowed: { roles: [id] } }; + } + const userMatch = s.match(/^(?:<@!?(\d+)>|user:(\d+)|(\d{5,30}))$/i); + if (userMatch) { + const id = userMatch[1] || userMatch[2] || userMatch[3]; + return { token: `<@${id}>`, allowed: { users: [id] } }; + } + return null; +} + function buildWebhookRequest(url, summary, meta) { const m = meta || {}; const total = Number(summary && summary.total) || 0; @@ -45,7 +65,10 @@ function buildWebhookRequest(url, summary, meta) { `✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` ]; if (hosterLines) lines.push(hosterLines); - body = JSON.stringify({ content: lines.join('\n') }); + const mention = resolveDiscordMention(m.mention); + const payload = { content: (mention ? mention.token + ' ' : '') + lines.join('\n') }; + payload.allowed_mentions = mention ? mention.allowed : { parse: [] }; + body = JSON.stringify(payload); } else { body = JSON.stringify({ event: 'batch-done', @@ -69,4 +92,4 @@ function buildWebhookRequest(url, summary, meta) { }; } -module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest }; +module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention }; diff --git a/main.js b/main.js index 00ec21a..475e973 100644 --- a/main.js +++ b/main.js @@ -289,6 +289,7 @@ function sendBatchWebhook(summary, durationSec) { durationSec, appVersion: app.getVersion(), machineName: require('os').hostname(), + mention: gs.webhookMention || '', timestamp: new Date().toISOString() }); fetch(req.url, { @@ -1876,8 +1877,9 @@ ipcMain.handle('reveal-log-file', async (_event, target) => { } }); -ipcMain.handle('test-webhook', async (_event, url) => { - const target = String(url || '').trim(); +ipcMain.handle('test-webhook', async (_event, payload) => { + const target = (typeof payload === 'string' ? payload : (payload && payload.url) || '').trim(); + const mention = (payload && typeof payload === 'object' && payload.mention) || ''; if (!target || !/^https?:\/\//i.test(target)) return { ok: false, error: 'Ungültige URL (muss mit http(s):// beginnen)' }; try { const req = buildWebhookRequest(target, { @@ -1891,6 +1893,7 @@ ipcMain.handle('test-webhook', async (_event, url) => { durationSec: 754, appVersion: app.getVersion(), machineName: require('os').hostname(), + mention, timestamp: new Date().toISOString() }); const res = await fetch(req.url, { diff --git a/preload.js b/preload.js index 5b215a4..142bb25 100644 --- a/preload.js +++ b/preload.js @@ -119,7 +119,7 @@ contextBridge.exposeInMainWorld('api', { resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload), resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'), getLogPaths: () => ipcRenderer.invoke('get-log-paths'), - testWebhook: (url) => ipcRenderer.invoke('test-webhook', url), + testWebhook: (payload) => ipcRenderer.invoke('test-webhook', payload), onNetworkStatus: (callback) => { ipcRenderer.on('network-status', (_event, data) => callback(data)); }, diff --git a/renderer/app.js b/renderer/app.js index 81ae2dc..7f9a901 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2841,6 +2841,11 @@ function renderSettings() { Bei Batch-Ende wird eine Zusammenfassung gepostet (Discord wird automatisch erkannt, sonst generisches JSON). +
+ + + Damit Discord dich wirklich benachrichtigt (Push). User-ID: in Discord Entwicklermodus an → Rechtsklick auf deinen Namen → 'User-ID kopieren'. Leer = nur posten, kein Ping. +
Diagnose
@@ -2861,13 +2866,14 @@ function renderSettings() { if (testWebhookBtn) { testWebhookBtn.addEventListener('click', async () => { const url = (document.getElementById('webhookUrlInput')?.value || '').trim(); + const mention = (document.getElementById('webhookMentionInput')?.value || '').trim(); const hint = document.getElementById('webhookHint'); if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; } testWebhookBtn.disabled = true; const prev = testWebhookBtn.textContent; testWebhookBtn.textContent = 'Sende…'; try { - const res = await window.api.testWebhook(url); + const res = await window.api.testWebhook({ url, mention }); if (hint) hint.textContent = res && res.ok ? `Test erfolgreich gesendet (HTTP ${res.status}).` : `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`; @@ -3260,6 +3266,7 @@ async function saveSettings(options = {}) { globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), logVerbose: !!document.getElementById('logVerboseInput')?.checked, webhookUrl: (document.getElementById('webhookUrlInput')?.value || '').trim(), + webhookMention: (document.getElementById('webhookMentionInput')?.value || '').trim(), autoRetryRounds: Math.max(0, Math.min(5, parseInt(document.getElementById('autoRetryRoundsInput')?.value || '0', 10) || 0)), autoRetryDelayMin: Math.max(1, Math.min(120, parseInt(document.getElementById('autoRetryDelayMinInput')?.value || '5', 10) || 5)), folderMonitor: { diff --git a/tests/webhook-notify.test.js b/tests/webhook-notify.test.js index f884e3f..25be146 100644 --- a/tests/webhook-notify.test.js +++ b/tests/webhook-notify.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert'); -const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest } = require('../lib/webhook-notify'); +const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention } = require('../lib/webhook-notify'); const SAMPLE_SUMMARY = { total: 10, @@ -74,6 +74,40 @@ test('buildWebhookRequest produces raw JSON payload for generic URLs', () => { assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 }); }); +test('resolveDiscordMention: @here / @everyone use parse=everyone', () => { + assert.deepStrictEqual(resolveDiscordMention('@here'), { token: '@here', allowed: { parse: ['everyone'] } }); + assert.deepStrictEqual(resolveDiscordMention('everyone'), { token: '@everyone', allowed: { parse: ['everyone'] } }); +}); + +test('resolveDiscordMention: bare numeric id → user mention', () => { + assert.deepStrictEqual(resolveDiscordMention('123456789012345'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } }); + assert.deepStrictEqual(resolveDiscordMention('<@!123456789012345>'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } }); +}); + +test('resolveDiscordMention: role:id and <@&id> → role mention', () => { + assert.deepStrictEqual(resolveDiscordMention('role:99887766'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } }); + assert.deepStrictEqual(resolveDiscordMention('<@&99887766>'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } }); +}); + +test('resolveDiscordMention: empty / junk → null', () => { + assert.strictEqual(resolveDiscordMention(''), null); + assert.strictEqual(resolveDiscordMention(' '), null); + assert.strictEqual(resolveDiscordMention('not-an-id'), null); +}); + +test('buildWebhookRequest: discord with mention prepends token + sets allowed_mentions', () => { + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60, mention: '123456789012345' }); + const body = JSON.parse(req.body); + assert.ok(body.content.startsWith('<@123456789012345> ')); + assert.deepStrictEqual(body.allowed_mentions, { users: ['123456789012345'] }); +}); + +test('buildWebhookRequest: discord without mention blocks all pings (allowed_mentions parse empty)', () => { + const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60 }); + const body = JSON.parse(req.body); + assert.deepStrictEqual(body.allowed_mentions, { parse: [] }); +}); + test('buildWebhookRequest tolerates empty summary', () => { const req = buildWebhookRequest('https://example.com/hook', null, {}); const body = JSON.parse(req.body);