diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index 5495cd2..1ab3453 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -16,7 +16,7 @@ import urlModifier from './services/queryStringMutator.js'; import logger from './services/logger.js'; import { geocodeAddress } from './services/geocoding/geoCodingService.js'; import { distanceMeters } from './services/listings/distanceCalculator.js'; -import { getUserSettings } from './services/storage/settingsStorage.js'; +import { getUserSettings, getSettings } from './services/storage/settingsStorage.js'; import { updateListingDistance } from './services/storage/listingsStorage.js'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import { formatListing } from './utils/formatListing.js'; @@ -300,17 +300,19 @@ class FredyPipelineExecutioner { * @returns {Promise} Resolves to the provided listings after notifications complete. * @throws {NoNewListingsWarning} When there are no listings to notify about. */ - _notify(newListings) { + async _notify(newListings) { if (newListings.length === 0) { throw new NoNewListingsWarning(); } - // TODO: move this to the notification adapter, so it will handle for all providers in same way. const formattedListings = newListings.map(formatListing); + const settings = await getSettings(); + const baseUrl = settings?.baseUrl ?? ''; const sendNotifications = notify.send( this._providerId, formattedListings, this._jobNotificationConfig, this._jobKey, + baseUrl, ); return Promise.all(sendNotifications).then(() => newListings); } diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index 0dfe47c..1e08ee8 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -18,6 +18,9 @@ generalSettingsRouter.get('/', async (req, res) => { }); generalSettingsRouter.post('/', async (req, res) => { const { sqlitepath, ...appSettings } = req.body || {}; + if (typeof appSettings.baseUrl === 'string') { + appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, ''); + } const localSettings = await getSettings(); if (localSettings.demoMode) { diff --git a/lib/notification/adapter/apprise.js b/lib/notification/adapter/apprise.js index e40d295..52b599f 100644 --- a/lib/notification/adapter/apprise.js +++ b/lib/notification/adapter/apprise.js @@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js'; import { getJob } from '../../services/storage/jobStorage.js'; import fetch from 'node-fetch'; -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; const promises = newListings.map((newListing) => { const title = `${jobName} at ${serviceName}: ${newListing.title}`; - const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`; + const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; + const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`; return fetch(server, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/lib/notification/adapter/console.js b/lib/notification/adapter/console.js index b0a4050..57265cb 100755 --- a/lib/notification/adapter/console.js +++ b/lib/notification/adapter/console.js @@ -5,9 +5,18 @@ import { markdown2Html } from '../../services/markdown.js'; -export const send = ({ serviceName, newListings, jobKey }) => { +export const send = ({ serviceName, newListings, jobKey, baseUrl }) => { /* eslint-disable no-console */ - return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))]; + const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null; + return [ + Promise.resolve( + console.info( + `Found entry from service ${serviceName}, Job: ${jobKey}:`, + newListings, + ...(fredyLinks ? [`Open in Fredy: ${fredyLinks}`] : []), + ), + ), + ]; /* eslint-enable no-console */ }; export const config = { diff --git a/lib/notification/adapter/discord_webhook.js b/lib/notification/adapter/discord_webhook.js index 5d34e60..cd6fbd9 100644 --- a/lib/notification/adapter/discord_webhook.js +++ b/lib/notification/adapter/discord_webhook.js @@ -39,9 +39,10 @@ const generateColorFromString = (str) => { * * @param {string} jobKey - Key of job (used to set embed color) * @param {object} listing - Object holding listing details + * @param baseUrl * @returns {object} Discord webhook embed */ -const buildEmbed = (jobKey, listing) => { +const buildEmbed = (jobKey, listing, baseUrl) => { const maxTitleLength = 252; // Max embed title length is 256 characters let title = String(listing.title ?? 'N/A'); if (title.length > maxTitleLength) { @@ -79,10 +80,18 @@ const buildEmbed = (jobKey, listing) => { }; } + if (baseUrl && listing.id) { + fields.push({ + name: 'Open in Fredy', + value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`, + inline: false, + }); + } + return embed; }; -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const adapter = notificationConfig.find((adapter) => adapter.id === config.id); const webhookUrl = adapter?.fields?.webhookUrl; if (!webhookUrl || newListings.length === 0) return Promise.resolve([]); @@ -90,7 +99,7 @@ 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, baseUrl)); const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds const webhookPromises = []; diff --git a/lib/notification/adapter/http.js b/lib/notification/adapter/http.js index 7f1c6f3..dfdcb89 100644 --- a/lib/notification/adapter/http.js +++ b/lib/notification/adapter/http.js @@ -5,7 +5,7 @@ import { markdown2Html } from '../../services/markdown.js'; -const mapListing = (listing) => ({ +const mapListing = (listing, baseUrl) => ({ address: listing.address, description: listing.description, id: listing.id, @@ -14,12 +14,13 @@ const mapListing = (listing) => ({ size: listing.size, title: listing.title, url: listing.link, + fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null, }); -export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields; - const listings = newListings.map(mapListing); + const listings = newListings.map((l) => mapListing(l, baseUrl)); const body = { jobId: jobKey, timestamp: new Date().toISOString(), diff --git a/lib/notification/adapter/mailJet.js b/lib/notification/adapter/mailJet.js index 1bf9b35..6bd3fae 100755 --- a/lib/notification/adapter/mailJet.js +++ b/lib/notification/adapter/mailJet.js @@ -35,7 +35,7 @@ const toBase64 = async (url) => { } }; -const mapListingsWithCid = async (serviceName, jobKey, listings) => { +const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => { const out = []; const attachments = []; @@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => { jobKey, hasImage: false, imageCid: '', + fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, }; if (imgUrl) { @@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => { return { listings: out, attachments }; }; -export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find( (adapter) => adapter.id === config.id, ).fields; @@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe .map((r) => ({ Email: r.trim() })) .filter((r) => r.Email.length > 0); - const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings); + const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings, baseUrl); const html = emailTemplate({ serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, diff --git a/lib/notification/adapter/mattermost.js b/lib/notification/adapter/mattermost.js index 5e60f0b..0e0e7ef 100644 --- a/lib/notification/adapter/mattermost.js +++ b/lib/notification/adapter/mattermost.js @@ -6,15 +6,20 @@ import { markdown2Html } from '../../services/markdown.js'; import { getJob } from '../../services/storage/jobStorage.js'; import fetch from 'node-fetch'; -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`; - message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`; - message += newListings.map( - (o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n', - ); + message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`; + message += newListings.map((o) => { + const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : ''; + return ( + `| [${o.title}](${o.link}) | ` + + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + + ` |${fredyCell}\n` + ); + }); return fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/lib/notification/adapter/ntfy.js b/lib/notification/adapter/ntfy.js index 7d8adbe..fbe060d 100644 --- a/lib/notification/adapter/ntfy.js +++ b/lib/notification/adapter/ntfy.js @@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js'; import fetch from 'node-fetch'; import { normalizeImageUrl } from '../../utils.js'; -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; const promises = newListings.map((newListing) => { + const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; const message = ` Address: ${newListing.address} Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')} Price: ${newListing.price} -Link: ${newListing.link}`; +Link: ${newListing.link}${fredyLine}`; const sanitizeHeaderValue = (value) => String(value ?? '') diff --git a/lib/notification/adapter/pushover.js b/lib/notification/adapter/pushover.js index 36b1400..1a54f0f 100644 --- a/lib/notification/adapter/pushover.js +++ b/lib/notification/adapter/pushover.js @@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js'; import { getJob } from '../../services/storage/jobStorage.js'; import fetch from 'node-fetch'; -export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; @@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe const results = await Promise.all( newListings.map(async (newListing) => { const title = `${jobName} at ${serviceName}: ${newListing.title}`; - const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`; + const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : ''; + const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`; const form = new FormData(); form.append('token', token); diff --git a/lib/notification/adapter/resend.js b/lib/notification/adapter/resend.js index 983496f..1638c64 100755 --- a/lib/notification/adapter/resend.js +++ b/lib/notification/adapter/resend.js @@ -14,7 +14,7 @@ const __dirname = getDirName(); const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); const emailTemplate = Handlebars.compile(template); -const mapListings = (serviceName, jobKey, listings) => +const mapListings = (serviceName, jobKey, listings, baseUrl) => listings.map((l) => { const image = normalizeImageUrl(l.image); return { @@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) => price: l.price || '', image, hasImage: Boolean(image), + fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, serviceName, jobKey, }; }); -export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const to = receiver @@ -41,7 +42,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe const resend = new Resend(apiKey); - const listings = mapListings(serviceName, jobKey, newListings); + const listings = mapListings(serviceName, jobKey, newListings, baseUrl); const html = emailTemplate({ serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, diff --git a/lib/notification/adapter/sendGrid.js b/lib/notification/adapter/sendGrid.js index b6ef005..26fa6a8 100755 --- a/lib/notification/adapter/sendGrid.js +++ b/lib/notification/adapter/sendGrid.js @@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail'; import { markdown2Html } from '../../services/markdown.js'; import { normalizeImageUrl } from '../../utils.js'; -const mapListings = (serviceName, jobKey, listings) => +const mapListings = (serviceName, jobKey, listings, baseUrl) => listings.map((l) => { const image = normalizeImageUrl(l.image); return { @@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) => hasImage: Boolean(image), // optional plain text snippet snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '), + fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, serviceName, jobKey, }; }); -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields; sgMail.setApiKey(apiKey); @@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = .map((r) => r.trim()) .filter(Boolean); - const listings = mapListings(serviceName, jobKey, newListings); + const listings = mapListings(serviceName, jobKey, newListings, baseUrl); const msg = { templateId, diff --git a/lib/notification/adapter/slack.js b/lib/notification/adapter/slack.js index c38d0d2..af4cb6f 100755 --- a/lib/notification/adapter/slack.js +++ b/lib/notification/adapter/slack.js @@ -7,7 +7,7 @@ import Slack from 'slack'; import { markdown2Html } from '../../services/markdown.js'; import { normalizeImageUrl } from '../../utils.js'; -const buildBlocks = (serviceName, jobKey, p) => { +const buildBlocks = (serviceName, jobKey, p, baseUrl) => { const blocks = [ { type: 'header', @@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => { }); } + if (baseUrl && p.id) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` }, + }); + } + blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], @@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => { return blocks; }; -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields; return Promise.allSettled( @@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = token, channel, text: `${serviceName} ${jobKey}: ${p.title}`, - blocks: buildBlocks(serviceName, jobKey, p), + blocks: buildBlocks(serviceName, jobKey, p, baseUrl), unfurl_links: false, unfurl_media: false, }), diff --git a/lib/notification/adapter/slack_with_webhooks.js b/lib/notification/adapter/slack_with_webhooks.js index a8c1dc2..66868d2 100755 --- a/lib/notification/adapter/slack_with_webhooks.js +++ b/lib/notification/adapter/slack_with_webhooks.js @@ -7,7 +7,7 @@ import fetch from 'node-fetch'; import { markdown2Html } from '../../services/markdown.js'; import { normalizeImageUrl } from '../../utils.js'; -const buildBlocks = (serviceName, jobKey, p) => { +const buildBlocks = (serviceName, jobKey, p, baseUrl) => { const blocks = [ { type: 'header', @@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => { }); } + if (baseUrl && p.id) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` }, + }); + } + blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], @@ -51,7 +58,7 @@ const postJson = (url, body) => body, }); -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const adapter = notificationConfig.find((a) => a.id === config.id); const webhookUrl = adapter?.fields?.webhookUrl; if (!webhookUrl) return Promise.resolve([]); @@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = const promises = newListings.map((p) => { const body = JSON.stringify({ text: `${serviceName} ${jobKey}: ${p.title}`, - blocks: buildBlocks(serviceName, jobKey, p), + blocks: buildBlocks(serviceName, jobKey, p, baseUrl), unfurl_links: false, unfurl_media: false, }); diff --git a/lib/notification/adapter/smtp.js b/lib/notification/adapter/smtp.js index 494a6fb..ac7d5f8 100644 --- a/lib/notification/adapter/smtp.js +++ b/lib/notification/adapter/smtp.js @@ -14,7 +14,7 @@ const __dirname = getDirName(); const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); const emailTemplate = Handlebars.compile(template); -const mapListings = (serviceName, jobKey, listings) => +const mapListings = (serviceName, jobKey, listings, baseUrl) => listings.map((l) => { const image = normalizeImageUrl(l.image); return { @@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) => price: l.price || '', image, hasImage: Boolean(image), + fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, serviceName, jobKey, }; }); -export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { +export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { const { host, port, secure, username, password, receiver, from } = notificationConfig.find( (adapter) => adapter.id === config.id, ).fields; @@ -51,7 +52,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe }, }); - const listings = mapListings(serviceName, jobKey, newListings); + const listings = mapListings(serviceName, jobKey, newListings, baseUrl); const html = emailTemplate({ serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, diff --git a/lib/notification/adapter/telegram.js b/lib/notification/adapter/telegram.js index ee1aad5..2b582a2 100644 --- a/lib/notification/adapter/telegram.js +++ b/lib/notification/adapter/telegram.js @@ -80,12 +80,14 @@ function escapeHtml(s = '') { * @param {string} [o.link] * @returns {string} */ -function buildCaption(jobName, serviceName, o) { +function buildCaption(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); + const fredyLink = + baseUrl && o.id ? `\nOpen in Fredy` : ''; return `${escapeHtml(jobName)} (${escapeHtml(serviceName)})\n${escapeHtml(title)}\n${escapeHtml(meta)}`.slice(0, 1024); + )}'>${escapeHtml(title)}\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024); } /** @@ -95,13 +97,15 @@ function buildCaption(jobName, serviceName, o) { * @param {Object} o - Listing object * @returns {string} */ -function buildText(jobName, serviceName, o) { +function buildText(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); + const fredyLink = + baseUrl && o.id ? `\nOpen in Fredy` : ''; return ( `${escapeHtml(jobName)} (${escapeHtml(serviceName)})\n` + `${escapeHtml(title)}\n` + - `${escapeHtml(meta)}` + `${escapeHtml(meta)}${fredyLink}` ); } @@ -110,12 +114,14 @@ function buildText(jobName, serviceName, o) { * @param {string} jobName * @param {string} serviceName * @param {Object} o - Listing object + * @param baseUrl * @returns {string} */ -function buildCaptionPlain(jobName, serviceName, o) { +function buildCaptionPlain(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); - return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}`.slice(0, 4096); + const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : ''; + return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096); } /** @@ -125,10 +131,11 @@ function buildCaptionPlain(jobName, serviceName, o) { * @param {Object} o - Listing object * @returns {string} */ -function buildTextPlain(jobName, serviceName, o) { +function buildTextPlain(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); - return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}`; + const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : ''; + return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`; } /** @@ -143,7 +150,7 @@ function buildTextPlain(jobName, serviceName, o) { * @param {string} params.jobKey - Storage job key to resolve the human readable job name. * @returns {Promise>} Promise resolving when all send operations complete. */ -export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => { +export const send = ({ serviceName, newListings = [], notificationConfig, jobKey, baseUrl }) => { const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id); if (!adapterCfg || !adapterCfg.fields) { throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`); @@ -189,7 +196,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey const img = normalizeImageUrl(o.image); const textPayload = { chat_id: chatId, - text: plainText ? buildTextPlain(jobName, serviceName, o) : buildText(jobName, serviceName, o), + text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl), ...(plainText ? {} : { parse_mode: 'HTML' }), disable_web_page_preview: true, ...(message_thread_id ? { message_thread_id } : {}), @@ -204,7 +211,9 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey return await throttledCall('sendPhoto', { chat_id: chatId, photo: img, - caption: plainText ? buildCaptionPlain(jobName, serviceName, o) : buildCaption(jobName, serviceName, o), + caption: plainText + ? buildCaptionPlain(jobName, serviceName, o, baseUrl) + : buildCaption(jobName, serviceName, o, baseUrl), ...(plainText ? {} : { parse_mode: 'HTML' }), ...(message_thread_id ? { message_thread_id } : {}), }).catch(async (e) => { diff --git a/lib/notification/emailTemplate/template.hbs b/lib/notification/emailTemplate/template.hbs index 53b95a6..fa06629 100644 --- a/lib/notification/emailTemplate/template.hbs +++ b/lib/notification/emailTemplate/template.hbs @@ -106,6 +106,9 @@ View Listing + {{#if this.fredyUrl}} + Open in Fredy + {{/if}} diff --git a/lib/notification/notify.js b/lib/notification/notify.js index 647584b..f9368f7 100755 --- a/lib/notification/notify.js +++ b/lib/notification/notify.js @@ -20,10 +20,10 @@ if (adapter.length === 0) { const findAdapter = (notificationAdapter) => { return adapter.find((a) => a.config.id === notificationAdapter.id); }; -export const send = (serviceName, newListings, notificationConfig, jobKey) => { +export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => { //this is not being used in tests, therefore adapter are always set return notificationConfig .filter((notificationAdapter) => findAdapter(notificationAdapter) != null) .map((notificationAdapter) => findAdapter(notificationAdapter)) - .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey })); + .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })); }; diff --git a/lib/services/storage/migrations/sql/17.add-base-url-setting.js b/lib/services/storage/migrations/sql/17.add-base-url-setting.js new file mode 100644 index 0000000..d9cfb42 --- /dev/null +++ b/lib/services/storage/migrations/sql/17.add-base-url-setting.js @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { nanoid } from 'nanoid'; +import { guessBaseUrl } from '../../../../utils/detectBaseUrl.js'; + +export function up(db) { + const exists = db.prepare(`SELECT 1 FROM settings WHERE name = 'baseUrl' AND user_id IS NULL LIMIT 1`).get(); + if (exists) return; + + const portRow = db.prepare(`SELECT value FROM settings WHERE name = 'port' AND user_id IS NULL LIMIT 1`).get(); + let port = 9998; + try { + port = JSON.parse(portRow?.value ?? '9998'); + } catch { + /* keep default */ + } + + db.prepare( + `INSERT INTO settings (id, create_date, name, value, user_id) + VALUES (@id, @create_date, 'baseUrl', @value, NULL)`, + ).run({ id: nanoid(), create_date: Date.now(), value: JSON.stringify(guessBaseUrl(port)) }); +} diff --git a/lib/utils/detectBaseUrl.js b/lib/utils/detectBaseUrl.js new file mode 100644 index 0000000..ba0fd55 --- /dev/null +++ b/lib/utils/detectBaseUrl.js @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import os from 'os'; +import fs from 'fs'; + +const DOCKER_BRIDGE_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.']; + +export function isRunningInDocker() { + if (process.env.FREDY_DOCKER === '1') return true; + try { + fs.accessSync('/.dockerenv'); + return true; + } catch { + /* not docker */ + } + try { + const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8'); + return /docker|containerd|kubepods/.test(cgroup); + } catch { + return false; + } +} + +function isDockerBridgeIp(addr) { + return DOCKER_BRIDGE_PREFIXES.some((prefix) => addr.startsWith(prefix)); +} + +export function detectLocalIp() { + if (isRunningInDocker()) { + return process.env.FREDY_HOST_IP ?? '172.17.0.1'; + } + const ifaces = os.networkInterfaces(); + for (const preferred of ['en0', 'eth0', 'wlan0', 'ens3', 'ens18']) { + for (const entry of ifaces[preferred] ?? []) { + if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) { + return entry.address; + } + } + } + for (const iface of Object.values(ifaces)) { + for (const entry of iface ?? []) { + if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) { + return entry.address; + } + } + } + return 'localhost'; +} + +export function guessBaseUrl(port) { + return `http://${detectLocalIp()}:${port}`; +} diff --git a/package.json b/package.json index 1777cc8..b8a9286 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "20.3.3", + "version": "20.4.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/test/mocks/mockStore.js b/test/mocks/mockStore.js index 213fee3..adbad3d 100644 --- a/test/mocks/mockStore.js +++ b/test/mocks/mockStore.js @@ -21,6 +21,10 @@ export function getUserSettings(userId) { return null; } +export async function getSettings() { + return { baseUrl: '' }; +} + export const updateListingDistance = (id, distance) => { // noop }; diff --git a/test/utils/detectBaseUrl.test.js b/test/utils/detectBaseUrl.test.js new file mode 100644 index 0000000..6fb115c --- /dev/null +++ b/test/utils/detectBaseUrl.test.js @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('fs'); +vi.mock('os'); + +import * as fsMock from 'fs'; +import * as osMock from 'os'; +import { isRunningInDocker, detectLocalIp, guessBaseUrl } from '../../lib/utils/detectBaseUrl.js'; + +describe('detectBaseUrl', () => { + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + vi.clearAllMocks(); + delete process.env.FREDY_DOCKER; + delete process.env.FREDY_HOST_IP; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('isRunningInDocker', () => { + it('returns true when FREDY_DOCKER=1', () => { + process.env.FREDY_DOCKER = '1'; + expect(isRunningInDocker()).toBe(true); + }); + + it('returns false when no Docker signals present', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error('not found'); + }); + vi.mocked(fsMock.readFileSync).mockImplementation(() => { + throw new Error('not found'); + }); + expect(isRunningInDocker()).toBe(false); + }); + + it('returns true when /.dockerenv is accessible', () => { + vi.mocked(fsMock.accessSync).mockReturnValue(undefined); + expect(isRunningInDocker()).toBe(true); + }); + + it('returns true when /proc/self/cgroup contains docker', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error('not found'); + }); + vi.mocked(fsMock.readFileSync).mockReturnValue('12:cpu:/docker/abc123'); + expect(isRunningInDocker()).toBe(true); + }); + + it('returns true when /proc/self/cgroup contains containerd', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error('not found'); + }); + vi.mocked(fsMock.readFileSync).mockReturnValue('0::/../containerd/abc'); + expect(isRunningInDocker()).toBe(true); + }); + }); + + describe('detectLocalIp', () => { + it('returns 172.17.0.1 when running in Docker (default)', () => { + process.env.FREDY_DOCKER = '1'; + expect(detectLocalIp()).toBe('172.17.0.1'); + }); + + it('returns FREDY_HOST_IP when set in Docker', () => { + process.env.FREDY_DOCKER = '1'; + process.env.FREDY_HOST_IP = '192.168.1.50'; + expect(detectLocalIp()).toBe('192.168.1.50'); + }); + + it('skips docker bridge IPs and returns real LAN IP', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(fsMock.readFileSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(osMock.networkInterfaces).mockReturnValue({ + docker0: [{ family: 'IPv4', address: '172.17.0.1', internal: false }], + en0: [{ family: 'IPv4', address: '192.168.1.100', internal: false }], + }); + expect(detectLocalIp()).toBe('192.168.1.100'); + }); + + it('prefers en0 over arbitrary interfaces', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(fsMock.readFileSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(osMock.networkInterfaces).mockReturnValue({ + utun3: [{ family: 'IPv4', address: '10.8.0.1', internal: false }], + en0: [{ family: 'IPv4', address: '192.168.178.50', internal: false }], + }); + expect(detectLocalIp()).toBe('192.168.178.50'); + }); + + it('falls back to localhost when no suitable interface found', () => { + vi.mocked(fsMock.accessSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(fsMock.readFileSync).mockImplementation(() => { + throw new Error(); + }); + vi.mocked(osMock.networkInterfaces).mockReturnValue({ + lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }], + }); + expect(detectLocalIp()).toBe('localhost'); + }); + }); + + describe('guessBaseUrl', () => { + it('returns correctly formatted URL', () => { + process.env.FREDY_DOCKER = '1'; + expect(guessBaseUrl(9998)).toBe('http://172.17.0.1:9998'); + }); + + it('includes custom port', () => { + process.env.FREDY_DOCKER = '1'; + expect(guessBaseUrl(8080)).toBe('http://172.17.0.1:8080'); + }); + }); +}); diff --git a/ui/src/assets/news/news.json b/ui/src/assets/news/news.json index 3c942a9..a2fc6c8 100644 --- a/ui/src/assets/news/news.json +++ b/ui/src/assets/news/news.json @@ -1,11 +1,10 @@ { - "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511", + "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876542", "content": [ { - "title": "Fredy goes AI", - "text": "With Fredy v20.0.0, we are introducing Fredy’s own MCP server. This brings a powerful new capability: you can connect your local LLM directly to Fredy and explore the data it collects in a much more flexible way.

The MCP server exposes Fredy’s tools and findings through a structured interface, allowing your LLM to query listings, inspect collected details, and analyze results programmatically. Instead of manually searching through the data, you can simply ask your model questions and let it dig into what Fredy has discovered for you.

In practice, this means your local LLM can interact with Fredy almost like an assistant: investigating properties, summarizing listings, filtering results, or helping you identify interesting opportunities based on the data Fredy gathered.", - "media": "news.mp4" + "title": "Open in...Fredy ;)", + "text": "With the latest version of Fredy, every notification now comes with a link that opens the listing directly inside Fredy. This is also a key step toward an upcoming...milestone :).
To make this work, Fredy needs to know where it lives on the network. We try to guess the public base URL, but let’s be honest, you probably know better. Take a quick look at the baseUrl in the system settings and fix it if it looks off." } ] } diff --git a/ui/src/assets/news/news.mp4 b/ui/src/assets/news/news.mp4 deleted file mode 100644 index c8c834d..0000000 Binary files a/ui/src/assets/news/news.mp4 and /dev/null differ diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index 5e23e66..3103f9e 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -61,6 +61,7 @@ const GeneralSettings = function GeneralSettings() { const [demoMode, setDemoMode] = React.useState(null); const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null); const [sqlitePath, setSqlitePath] = React.useState(null); + const [baseUrl, setBaseUrl] = React.useState(''); const fileInputRef = React.useRef(null); const [restoreModalVisible, setRestoreModalVisible] = React.useState(false); const [precheckInfo, setPrecheckInfo] = React.useState(null); @@ -94,6 +95,7 @@ const GeneralSettings = function GeneralSettings() { setAnalyticsEnabled(settings?.analyticsEnabled || false); setDemoMode(settings?.demoMode || false); setSqlitePath(settings?.sqlitepath); + setBaseUrl(settings?.baseUrl ?? ''); } init(); @@ -137,6 +139,7 @@ const GeneralSettings = function GeneralSettings() { demoMode, analyticsEnabled, sqlitepath: sqlitePath, + baseUrl, }); } catch (exception) { console.error(exception); @@ -266,6 +269,13 @@ const GeneralSettings = function GeneralSettings() { /> + + setBaseUrl(value)} /> + +