feat(webhook): optional Discord ping (user-id / role / @here / @everyone) so batch-done actually notifies

This commit is contained in:
Administrator 2026-06-09 23:40:38 +02:00
parent d67cfc0b51
commit e1d04d0838
6 changed files with 75 additions and 7 deletions

View File

@ -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

View File

@ -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 };

View File

@ -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, {

View File

@ -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));
},

View File

@ -2841,6 +2841,11 @@ function renderSettings() {
<button class="btn btn-xs btn-secondary" id="testWebhookBtn">Test</button>
<span class="hint" id="webhookHint">Bei Batch-Ende wird eine Zusammenfassung gepostet (Discord wird automatisch erkannt, sonst generisches JSON).</span>
</div>
<div class="settings-row">
<label>Discord Ping (optional)</label>
<input type="text" class="key-input settings-autosave" id="webhookMentionInput" value="${escapeAttr(globalSettings.webhookMention || '')}" placeholder="deine User-ID, role:ROLLEN-ID, @here oder @everyone">
<span class="hint">Damit Discord dich wirklich benachrichtigt (Push). User-ID: in Discord Entwicklermodus an Rechtsklick auf deinen Namen 'User-ID kopieren'. Leer = nur posten, kein Ping.</span>
</div>
<div class="settings-section-label">Diagnose</div>
<div class="settings-row" id="logPathsBlock">
<label>Log-Dateien</label>
@ -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: {

View File

@ -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);