Webhook Discord

This commit is contained in:
2026-07-02 18:38:04 +02:00
parent 34fe0b461e
commit 3505bc149d
10 changed files with 171 additions and 1 deletions

102
server/lib/discord.js Normal file
View File

@@ -0,0 +1,102 @@
const SALES_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_SALES_URL || '').trim();
const DOWNLOADS_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_DOWNLOADS_URL || '').trim();
const PURCHASE_EVENT_TYPES = new Set([
'INITIAL_PURCHASE',
'RENEWAL',
'NON_RENEWING_PURCHASE',
'PRODUCT_CHANGE',
]);
const PLAN_NAMES_BY_PRODUCT = {
monthly_pro: 'Pro (monatlich)',
yearly_pro: 'Pro (jährlich)',
topup_small: 'Top-up Small (30 Credits)',
topup_medium: 'Top-up Medium (100 Credits)',
topup_large: 'Top-up Large (250 Credits)',
};
const EMBED_COLOR_SALE = 0x2ecc71;
const EMBED_COLOR_NEW_USER = 0x3498db;
const isPurchaseEventType = (eventType) =>
PURCHASE_EVENT_TYPES.has(String(eventType || '').toUpperCase());
// Header/payload values end up in Discord embeds; strip markdown-relevant
// characters and cap length so user-controlled input cannot break or spoof
// the embed (Discord rejects fields over 1024 chars).
const sanitizeEmbedText = (value, maxLength = 64) =>
String(value ?? '').replace(/[\r\n`]/g, ' ').trim().slice(0, maxLength);
const formatStore = (store) => {
const normalized = String(store || '').toUpperCase();
if (normalized === 'APP_STORE' || normalized === 'MAC_APP_STORE') return 'iOS';
if (normalized === 'PLAY_STORE') return 'Android';
return normalized || 'Unbekannt';
};
const formatPlatform = (platform) => {
const normalized = String(platform || '').toLowerCase();
if (normalized === 'ios') return 'iOS';
if (normalized === 'android') return 'Android';
return 'Unbekannt';
};
const formatPrice = (price, currency) => {
if (typeof price !== 'number' || !Number.isFinite(price)) return '';
return `${price.toFixed(2)} ${String(currency || 'USD').toUpperCase()}`;
};
const sendDiscordEmbed = (webhookUrl, embed) => {
if (!webhookUrl) return;
fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
signal: AbortSignal.timeout(5000),
})
// Drain the body so undici can release the connection back to the pool.
.then((response) => response.arrayBuffer())
.catch(() => {});
};
const notifyPurchase = ({ productId, price, currency, store, isTrial, isRenewal, isSandbox } = {}) => {
const safeProductId = sanitizeEmbedText(productId);
const planName = Object.hasOwn(PLAN_NAMES_BY_PRODUCT, safeProductId)
? PLAN_NAMES_BY_PRODUCT[safeProductId]
: safeProductId || 'Unbekannt';
const title = isTrial
? '🛍️ Trial gestartet'
: isRenewal
? '🔁 Abo verlängert'
: '🛍️ Neuer Kauf';
sendDiscordEmbed(SALES_WEBHOOK_URL, {
title: isSandbox ? `${title} (Sandbox)` : title,
color: EMBED_COLOR_SALE,
fields: [
{ name: 'Produkt', value: `${planName} (\`${safeProductId || ''}\`)`, inline: true },
{ name: 'Preis', value: isTrial ? 'Trial' : formatPrice(price, currency), inline: true },
{ name: 'Gerät', value: formatStore(store), inline: true },
],
timestamp: new Date().toISOString(),
});
};
const notifyNewUser = ({ platform, appVersion, provider } = {}) => {
sendDiscordEmbed(DOWNLOADS_WEBHOOK_URL, {
title: '📲 Neuer User',
color: EMBED_COLOR_NEW_USER,
fields: [
{ name: 'Gerät', value: formatPlatform(platform), inline: true },
{ name: 'App-Version', value: sanitizeEmbedText(appVersion) || 'Unbekannt', inline: true },
{ name: 'Anmeldung', value: String(provider || 'Unbekannt'), inline: true },
],
timestamp: new Date().toISOString(),
});
};
module.exports = {
isPurchaseEventType,
notifyPurchase,
notifyNewUser,
};