diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js new file mode 100644 index 00000000..c62cc7d7 --- /dev/null +++ b/lib/notification/adapter/discord_webhook.js @@ -0,0 +1,131 @@ +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 maxTitleLength = 252; // Max embed title length is 256 characters + let title = String(listing.title ?? 'N/A'); + if (title.length > maxTitleLength) { + title = title.substring(0, maxTitleLength) + '...'; + } + + const fields = [ + { + name: 'Price', + value: String(listing.price ?? 'n/a'), + inline: true, + }, + { + name: 'Size', + value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a', + inline: true, + }, + { + name: 'Address', + value: String(listing.address ?? 'n/a'), + inline: true, + }, + ] + + const embed = { + title: 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 maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds + const webhookPromises = []; + + 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 + maxEmbedsPerMessage); + + 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.