From 67fabb5517e1c24ff5d7811c28384e9224134f5f Mon Sep 17 00:00:00 2001 From: mari__ Date: Wed, 24 Sep 2025 19:50:58 +0200 Subject: [PATCH 1/5] Add Discord webhook adapter --- lib/notification/adapter/discord_webhook.js | 125 ++++++++++++++++++++ lib/notification/adapter/discord_webhook.md | 4 + 2 files changed, 129 insertions(+) create mode 100644 lib/notification/adapter/discord_webhook.js create mode 100644 lib/notification/adapter/discord_webhook.md diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js new file mode 100644 index 00000000..53acfc7f --- /dev/null +++ b/lib/notification/adapter/discord_webhook.js @@ -0,0 +1,125 @@ +import fetch from 'node-fetch'; +import { getJob } from '../../services/storage/jobStorage.js'; +import { markdown2Html } from '../../services/markdown.js'; +import { normalizeImageUrl } from '../../utils.js'; + +/** + * Generates an idempotent decimal color code. The input string-based color code is + * generated using the djb2 hash algorithm. + * + * @param {string} str - Input string as color code base + * @returns {number} Generated decimal color code (0 - 16777215) + */ +const generateColorFromString = (str) => { + let hash = 5381; // initial value + const input = String(str); + + for (let i = 0; i < input.length; i++) { + // hash * 33 + charCode + hash = ((hash << 5) + hash) + input.charCodeAt(i); + // Ensure the hash is 32 bit + hash |= 0; + } + + let positiveHash = hash >>> 0; + const maxColorValue = 16777215; + const colorDecimal = positiveHash % maxColorValue; + + return colorDecimal; +}; + +/** + * Creates an embed per listing + * (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html). + * + * @param {string} jobKey - Key of job (used to set embed color) + * @param {object} listing - Object holding listing details + * @returns {object} Discord webhook embed + */ +const buildEmbed = (jobKey, listing) => { + const fields = [ + { + name: 'Price', + value: listing.price, + inline: true, + }, + { + name: 'Size', + value: listing.size.replace(/2m/g, 'm²'), + inline: true, + }, + { + name: 'Address', + value: listing.address, + inline: true, + }, + ] + + const embed = { + title: listing.title, + color: generateColorFromString(jobKey), + url: listing.link, + fields: fields, + } + + if (listing.image) { + embed.image = { + url: normalizeImageUrl(listing.image), + }; + } + + return embed +}; + +export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { + const adapter = notificationConfig.find((adapter) => adapter.id === config.id); + const webhookUrl = adapter?.fields?.webhookUrl; + if (!webhookUrl || newListings.length == 0) return Promise.resolve([]); + + const job = getJob(jobKey); + const jobName = job?.name || jobKey; + + const embeds = newListings.map((listing) => buildEmbed(jobKey, listing) ); + + const MAX_EMBEDS_PER_MESSAGE = 10; // Discord only allows up to 10 embeds + const webhookPromises = []; + + for (let i = 0; i < embeds.length; i += MAX_EMBEDS_PER_MESSAGE) { + // Send multiple Discord messages with up to 10 embeds per message + const embedChunk = embeds.slice(i, i + MAX_EMBEDS_PER_MESSAGE); + + const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : ''; + const body = JSON.stringify({ + content: content, + embeds: embedChunk, + }) + + const fetchPromise = fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + .catch(error => { + console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error); + return Promise.reject(new Error(`Webhook failed: ${error.message}`)); + }); + + webhookPromises.push(fetchPromise); + } + + return Promise.allSettled(webhookPromises); +}; + +export const config = { + id: 'discord_webhook', + name: 'Discord Webhook', + readme: markdown2Html('lib/notification/adapter/discord_webhook.md'), + description: 'Fredy will send new listings to the Discord channel of your choice.', + fields: { + webhookUrl: { + type: 'text', + label: 'Webhook URL', + description: 'The URL of the Discord webhook to send messages to.', + }, + }, +}; diff --git a/lib/notification/adapter/discord_webhook.md b/lib/notification/adapter/discord_webhook.md new file mode 100644 index 00000000..fbe51b73 --- /dev/null +++ b/lib/notification/adapter/discord_webhook.md @@ -0,0 +1,4 @@ +### Discord Adapter + +To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). +Once you have created a webhook, copy and paste the webhook URL. From f2bc27df84dbf2737c413eead6660f7a0476176b Mon Sep 17 00:00:00 2001 From: mari__ Date: Sat, 27 Sep 2025 12:42:32 +0200 Subject: [PATCH 2/5] apply change from comment to not set null values for listing attributes --- lib/notification/adapter/discord_webhook.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js index 53acfc7f..aac2dbc5 100644 --- a/lib/notification/adapter/discord_webhook.js +++ b/lib/notification/adapter/discord_webhook.js @@ -40,7 +40,7 @@ const buildEmbed = (jobKey, listing) => { const fields = [ { name: 'Price', - value: listing.price, + value: String(listing.price ?? 'n/a'), inline: true, }, { @@ -50,7 +50,7 @@ const buildEmbed = (jobKey, listing) => { }, { name: 'Address', - value: listing.address, + value: String(listing.address ?? 'n/a'), inline: true, }, ] From 878b04c65a49f3c8494efdc248ab6a19f1401f1d Mon Sep 17 00:00:00 2001 From: mari__ Date: Sat, 27 Sep 2025 12:51:31 +0200 Subject: [PATCH 3/5] truncate embed title to not exceed the limit --- lib/notification/adapter/discord_webhook.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js index aac2dbc5..42417eb2 100644 --- a/lib/notification/adapter/discord_webhook.js +++ b/lib/notification/adapter/discord_webhook.js @@ -37,6 +37,12 @@ const generateColorFromString = (str) => { * @returns {object} Discord webhook embed */ const buildEmbed = (jobKey, listing) => { + const maxTitleLength = 252; // Max embed title length is 256 characters + let title = listing.title; + if (title.length > maxTitleLength) { + title = title.substring(0, maxTitleLength) + '...'; + } + const fields = [ { name: 'Price', @@ -56,7 +62,7 @@ const buildEmbed = (jobKey, listing) => { ] const embed = { - title: listing.title, + title: title, color: generateColorFromString(jobKey), url: listing.link, fields: fields, From 83f710c6e0dc434f49494528cb93bbe552ef6005 Mon Sep 17 00:00:00 2001 From: mari__ Date: Sat, 27 Sep 2025 13:15:22 +0200 Subject: [PATCH 4/5] minor formatting --- lib/notification/adapter/discord_webhook.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js index 42417eb2..c5c697ac 100644 --- a/lib/notification/adapter/discord_webhook.js +++ b/lib/notification/adapter/discord_webhook.js @@ -85,14 +85,14 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = const job = getJob(jobKey); const jobName = job?.name || jobKey; - const embeds = newListings.map((listing) => buildEmbed(jobKey, listing) ); + const embeds = newListings.map((listing) => buildEmbed(jobKey, listing)); - const MAX_EMBEDS_PER_MESSAGE = 10; // Discord only allows up to 10 embeds + const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds const webhookPromises = []; - for (let i = 0; i < embeds.length; i += MAX_EMBEDS_PER_MESSAGE) { + for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) { // Send multiple Discord messages with up to 10 embeds per message - const embedChunk = embeds.slice(i, i + MAX_EMBEDS_PER_MESSAGE); + const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage); const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : ''; const body = JSON.stringify({ From 32a14082a66dcda3849aa3ad63775b1ed58e2860 Mon Sep 17 00:00:00 2001 From: mari__ Date: Sat, 27 Sep 2025 14:02:38 +0200 Subject: [PATCH 5/5] apply changes from comments to avoid NPEs --- lib/notification/adapter/discord_webhook.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js index c5c697ac..c62cc7d7 100644 --- a/lib/notification/adapter/discord_webhook.js +++ b/lib/notification/adapter/discord_webhook.js @@ -38,7 +38,7 @@ const generateColorFromString = (str) => { */ const buildEmbed = (jobKey, listing) => { const maxTitleLength = 252; // Max embed title length is 256 characters - let title = listing.title; + let title = String(listing.title ?? 'N/A'); if (title.length > maxTitleLength) { title = title.substring(0, maxTitleLength) + '...'; } @@ -51,7 +51,7 @@ const buildEmbed = (jobKey, listing) => { }, { name: 'Size', - value: listing.size.replace(/2m/g, 'm²'), + value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a', inline: true, }, {