feat(webhook): optional Discord ping (user-id / role / @here / @everyone) so batch-done actually notifies
This commit is contained in:
parent
d67cfc0b51
commit
e1d04d0838
@ -60,6 +60,7 @@ const DEFAULTS = {
|
|||||||
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
|
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
|
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
|
||||||
webhookUrl: '', // POST target on batch-done (Discord or generic JSON)
|
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
|
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)
|
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
|
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
|
||||||
|
|||||||
@ -27,6 +27,26 @@ function summarizePerHosterFromBatch(summary) {
|
|||||||
return out;
|
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) {
|
function buildWebhookRequest(url, summary, meta) {
|
||||||
const m = meta || {};
|
const m = meta || {};
|
||||||
const total = Number(summary && summary.total) || 0;
|
const total = Number(summary && summary.total) || 0;
|
||||||
@ -45,7 +65,10 @@ function buildWebhookRequest(url, summary, meta) {
|
|||||||
`✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
|
`✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
|
||||||
];
|
];
|
||||||
if (hosterLines) lines.push(hosterLines);
|
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 {
|
} else {
|
||||||
body = JSON.stringify({
|
body = JSON.stringify({
|
||||||
event: 'batch-done',
|
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 };
|
||||||
|
|||||||
7
main.js
7
main.js
@ -289,6 +289,7 @@ function sendBatchWebhook(summary, durationSec) {
|
|||||||
durationSec,
|
durationSec,
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
machineName: require('os').hostname(),
|
machineName: require('os').hostname(),
|
||||||
|
mention: gs.webhookMention || '',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
fetch(req.url, {
|
fetch(req.url, {
|
||||||
@ -1876,8 +1877,9 @@ ipcMain.handle('reveal-log-file', async (_event, target) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('test-webhook', async (_event, url) => {
|
ipcMain.handle('test-webhook', async (_event, payload) => {
|
||||||
const target = String(url || '').trim();
|
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)' };
|
if (!target || !/^https?:\/\//i.test(target)) return { ok: false, error: 'Ungültige URL (muss mit http(s):// beginnen)' };
|
||||||
try {
|
try {
|
||||||
const req = buildWebhookRequest(target, {
|
const req = buildWebhookRequest(target, {
|
||||||
@ -1891,6 +1893,7 @@ ipcMain.handle('test-webhook', async (_event, url) => {
|
|||||||
durationSec: 754,
|
durationSec: 754,
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
machineName: require('os').hostname(),
|
machineName: require('os').hostname(),
|
||||||
|
mention,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
const res = await fetch(req.url, {
|
const res = await fetch(req.url, {
|
||||||
|
|||||||
@ -119,7 +119,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
|
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
|
||||||
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
|
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
|
||||||
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
||||||
testWebhook: (url) => ipcRenderer.invoke('test-webhook', url),
|
testWebhook: (payload) => ipcRenderer.invoke('test-webhook', payload),
|
||||||
onNetworkStatus: (callback) => {
|
onNetworkStatus: (callback) => {
|
||||||
ipcRenderer.on('network-status', (_event, data) => callback(data));
|
ipcRenderer.on('network-status', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2841,6 +2841,11 @@ function renderSettings() {
|
|||||||
<button class="btn btn-xs btn-secondary" id="testWebhookBtn">Test</button>
|
<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>
|
<span class="hint" id="webhookHint">Bei Batch-Ende wird eine Zusammenfassung gepostet (Discord wird automatisch erkannt, sonst generisches JSON).</span>
|
||||||
</div>
|
</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-section-label">Diagnose</div>
|
||||||
<div class="settings-row" id="logPathsBlock">
|
<div class="settings-row" id="logPathsBlock">
|
||||||
<label>Log-Dateien</label>
|
<label>Log-Dateien</label>
|
||||||
@ -2861,13 +2866,14 @@ function renderSettings() {
|
|||||||
if (testWebhookBtn) {
|
if (testWebhookBtn) {
|
||||||
testWebhookBtn.addEventListener('click', async () => {
|
testWebhookBtn.addEventListener('click', async () => {
|
||||||
const url = (document.getElementById('webhookUrlInput')?.value || '').trim();
|
const url = (document.getElementById('webhookUrlInput')?.value || '').trim();
|
||||||
|
const mention = (document.getElementById('webhookMentionInput')?.value || '').trim();
|
||||||
const hint = document.getElementById('webhookHint');
|
const hint = document.getElementById('webhookHint');
|
||||||
if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; }
|
if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; }
|
||||||
testWebhookBtn.disabled = true;
|
testWebhookBtn.disabled = true;
|
||||||
const prev = testWebhookBtn.textContent;
|
const prev = testWebhookBtn.textContent;
|
||||||
testWebhookBtn.textContent = 'Sende…';
|
testWebhookBtn.textContent = 'Sende…';
|
||||||
try {
|
try {
|
||||||
const res = await window.api.testWebhook(url);
|
const res = await window.api.testWebhook({ url, mention });
|
||||||
if (hint) hint.textContent = res && res.ok
|
if (hint) hint.textContent = res && res.ok
|
||||||
? `Test erfolgreich gesendet (HTTP ${res.status}).`
|
? `Test erfolgreich gesendet (HTTP ${res.status}).`
|
||||||
: `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`;
|
: `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)),
|
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)),
|
||||||
logVerbose: !!document.getElementById('logVerboseInput')?.checked,
|
logVerbose: !!document.getElementById('logVerboseInput')?.checked,
|
||||||
webhookUrl: (document.getElementById('webhookUrlInput')?.value || '').trim(),
|
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)),
|
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)),
|
autoRetryDelayMin: Math.max(1, Math.min(120, parseInt(document.getElementById('autoRetryDelayMinInput')?.value || '5', 10) || 5)),
|
||||||
folderMonitor: {
|
folderMonitor: {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert');
|
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 = {
|
const SAMPLE_SUMMARY = {
|
||||||
total: 10,
|
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 });
|
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', () => {
|
test('buildWebhookRequest tolerates empty summary', () => {
|
||||||
const req = buildWebhookRequest('https://example.com/hook', null, {});
|
const req = buildWebhookRequest('https://example.com/hook', null, {});
|
||||||
const body = JSON.parse(req.body);
|
const body = JSON.parse(req.body);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user