Compare commits

...

1 Commits

Author SHA1 Message Date
orangecoding
c78472bd19 adding 'open in fredy' 2026-04-21 19:42:39 +02:00
26 changed files with 345 additions and 58 deletions

View File

@@ -16,7 +16,7 @@ import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js'; import logger from './services/logger.js';
import { geocodeAddress } from './services/geocoding/geoCodingService.js'; import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.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 { updateListingDistance } from './services/storage/listingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { formatListing } from './utils/formatListing.js'; import { formatListing } from './utils/formatListing.js';
@@ -300,17 +300,19 @@ class FredyPipelineExecutioner {
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete. * @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
* @throws {NoNewListingsWarning} When there are no listings to notify about. * @throws {NoNewListingsWarning} When there are no listings to notify about.
*/ */
_notify(newListings) { async _notify(newListings) {
if (newListings.length === 0) { if (newListings.length === 0) {
throw new NoNewListingsWarning(); 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 formattedListings = newListings.map(formatListing);
const settings = await getSettings();
const baseUrl = settings?.baseUrl ?? '';
const sendNotifications = notify.send( const sendNotifications = notify.send(
this._providerId, this._providerId,
formattedListings, formattedListings,
this._jobNotificationConfig, this._jobNotificationConfig,
this._jobKey, this._jobKey,
baseUrl,
); );
return Promise.all(sendNotifications).then(() => newListings); return Promise.all(sendNotifications).then(() => newListings);
} }

View File

@@ -18,6 +18,9 @@ generalSettingsRouter.get('/', async (req, res) => {
}); });
generalSettingsRouter.post('/', async (req, res) => { generalSettingsRouter.post('/', async (req, res) => {
const { sqlitepath, ...appSettings } = req.body || {}; const { sqlitepath, ...appSettings } = req.body || {};
if (typeof appSettings.baseUrl === 'string') {
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
}
const localSettings = await getSettings(); const localSettings = await getSettings();
if (localSettings.demoMode) { if (localSettings.demoMode) {

View File

@@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch'; 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 { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; 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, { return fetch(server, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -5,9 +5,18 @@
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
export const send = ({ serviceName, newListings, jobKey }) => { export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
/* eslint-disable no-console */ /* 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 */ /* eslint-enable no-console */
}; };
export const config = { export const config = {

View File

@@ -39,9 +39,10 @@ const generateColorFromString = (str) => {
* *
* @param {string} jobKey - Key of job (used to set embed color) * @param {string} jobKey - Key of job (used to set embed color)
* @param {object} listing - Object holding listing details * @param {object} listing - Object holding listing details
* @param baseUrl
* @returns {object} Discord webhook embed * @returns {object} Discord webhook embed
*/ */
const buildEmbed = (jobKey, listing) => { const buildEmbed = (jobKey, listing, baseUrl) => {
const maxTitleLength = 252; // Max embed title length is 256 characters const maxTitleLength = 252; // Max embed title length is 256 characters
let title = String(listing.title ?? 'N/A'); let title = String(listing.title ?? 'N/A');
if (title.length > maxTitleLength) { 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; 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 adapter = notificationConfig.find((adapter) => adapter.id === config.id);
const webhookUrl = adapter?.fields?.webhookUrl; const webhookUrl = adapter?.fields?.webhookUrl;
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]); if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
@@ -90,7 +99,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job?.name || 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 maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
const webhookPromises = []; const webhookPromises = [];

View File

@@ -5,7 +5,7 @@
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
const mapListing = (listing) => ({ const mapListing = (listing, baseUrl) => ({
address: listing.address, address: listing.address,
description: listing.description, description: listing.description,
id: listing.id, id: listing.id,
@@ -14,12 +14,13 @@ const mapListing = (listing) => ({
size: listing.size, size: listing.size,
title: listing.title, title: listing.title,
url: listing.link, 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 { 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 = { const body = {
jobId: jobKey, jobId: jobKey,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@@ -35,7 +35,7 @@ const toBase64 = async (url) => {
} }
}; };
const mapListingsWithCid = async (serviceName, jobKey, listings) => { const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
const out = []; const out = [];
const attachments = []; const attachments = [];
@@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
jobKey, jobKey,
hasImage: false, hasImage: false,
imageCid: '', imageCid: '',
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
}; };
if (imgUrl) { if (imgUrl) {
@@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
return { listings: out, attachments }; 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( const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id, (adapter) => adapter.id === config.id,
).fields; ).fields;
@@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
.map((r) => ({ Email: r.trim() })) .map((r) => ({ Email: r.trim() }))
.filter((r) => r.Email.length > 0); .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({ const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,

View File

@@ -6,15 +6,20 @@
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch'; 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 { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`; let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`; message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
message += newListings.map( message += newListings.map((o) => {
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n', 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, { return fetch(webhook, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { normalizeImageUrl } from '../../utils.js'; 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 { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const message = ` const message = `
Address: ${newListing.address} Address: ${newListing.address}
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')} Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
Price: ${newListing.price} Price: ${newListing.price}
Link: ${newListing.link}`; Link: ${newListing.link}${fredyLine}`;
const sanitizeHeaderValue = (value) => const sanitizeHeaderValue = (value) =>
String(value ?? '') String(value ?? '')

View File

@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch'; 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 { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey); const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
const results = await Promise.all( const results = await Promise.all(
newListings.map(async (newListing) => { newListings.map(async (newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; 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(); const form = new FormData();
form.append('token', token); form.append('token', token);

View File

@@ -14,7 +14,7 @@ const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template); const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) => const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => { listings.map((l) => {
const image = normalizeImageUrl(l.image); const image = normalizeImageUrl(l.image);
return { return {
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
price: l.price || '', price: l.price || '',
image, image,
hasImage: Boolean(image), hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, 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 { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const to = receiver const to = receiver
@@ -41,7 +42,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
const resend = new Resend(apiKey); const resend = new Resend(apiKey);
const listings = mapListings(serviceName, jobKey, newListings); const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
const html = emailTemplate({ const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,

View File

@@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail';
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js'; import { normalizeImageUrl } from '../../utils.js';
const mapListings = (serviceName, jobKey, listings) => const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => { listings.map((l) => {
const image = normalizeImageUrl(l.image); const image = normalizeImageUrl(l.image);
return { return {
@@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) =>
hasImage: Boolean(image), hasImage: Boolean(image),
// optional plain text snippet // optional plain text snippet
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '), snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, 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; const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
sgMail.setApiKey(apiKey); sgMail.setApiKey(apiKey);
@@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
.map((r) => r.trim()) .map((r) => r.trim())
.filter(Boolean); .filter(Boolean);
const listings = mapListings(serviceName, jobKey, newListings); const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
const msg = { const msg = {
templateId, templateId,

View File

@@ -7,7 +7,7 @@ import Slack from 'slack';
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js'; import { normalizeImageUrl } from '../../utils.js';
const buildBlocks = (serviceName, jobKey, p) => { const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
const blocks = [ const blocks = [
{ {
type: 'header', 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({ blocks.push({
type: 'context', type: 'context',
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
@@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => {
return blocks; 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; const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
return Promise.allSettled( return Promise.allSettled(
@@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
token, token,
channel, channel,
text: `${serviceName} ${jobKey}: ${p.title}`, text: `${serviceName} ${jobKey}: ${p.title}`,
blocks: buildBlocks(serviceName, jobKey, p), blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
unfurl_links: false, unfurl_links: false,
unfurl_media: false, unfurl_media: false,
}), }),

View File

@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
import { markdown2Html } from '../../services/markdown.js'; import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js'; import { normalizeImageUrl } from '../../utils.js';
const buildBlocks = (serviceName, jobKey, p) => { const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
const blocks = [ const blocks = [
{ {
type: 'header', 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({ blocks.push({
type: 'context', type: 'context',
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
@@ -51,7 +58,7 @@ const postJson = (url, body) =>
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 adapter = notificationConfig.find((a) => a.id === config.id);
const webhookUrl = adapter?.fields?.webhookUrl; const webhookUrl = adapter?.fields?.webhookUrl;
if (!webhookUrl) return Promise.resolve([]); if (!webhookUrl) return Promise.resolve([]);
@@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const promises = newListings.map((p) => { const promises = newListings.map((p) => {
const body = JSON.stringify({ const body = JSON.stringify({
text: `${serviceName} ${jobKey}: ${p.title}`, text: `${serviceName} ${jobKey}: ${p.title}`,
blocks: buildBlocks(serviceName, jobKey, p), blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
unfurl_links: false, unfurl_links: false,
unfurl_media: false, unfurl_media: false,
}); });

View File

@@ -14,7 +14,7 @@ const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template); const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) => const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => { listings.map((l) => {
const image = normalizeImageUrl(l.image); const image = normalizeImageUrl(l.image);
return { return {
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
price: l.price || '', price: l.price || '',
image, image,
hasImage: Boolean(image), hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, 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( const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id, (adapter) => adapter.id === config.id,
).fields; ).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({ const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,

View File

@@ -80,12 +80,14 @@ function escapeHtml(s = '') {
* @param {string} [o.link] * @param {string} [o.link]
* @returns {string} * @returns {string}
*/ */
function buildCaption(jobName, serviceName, o) { function buildCaption(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml( return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
o.link || '', o.link || '',
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024); )}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
} }
/** /**
@@ -95,13 +97,15 @@ function buildCaption(jobName, serviceName, o) {
* @param {Object} o - Listing object * @param {Object} o - Listing object
* @returns {string} * @returns {string}
*/ */
function buildText(jobName, serviceName, o) { function buildText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return ( return (
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` + `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` + `<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
`${escapeHtml(meta)}` `${escapeHtml(meta)}${fredyLink}`
); );
} }
@@ -110,12 +114,14 @@ function buildText(jobName, serviceName, o) {
* @param {string} jobName * @param {string} jobName
* @param {string} serviceName * @param {string} serviceName
* @param {Object} o - Listing object * @param {Object} o - Listing object
* @param baseUrl
* @returns {string} * @returns {string}
*/ */
function buildCaptionPlain(jobName, serviceName, o) { function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); 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 * @param {Object} o - Listing object
* @returns {string} * @returns {string}
*/ */
function buildTextPlain(jobName, serviceName, o) { function buildTextPlain(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90); const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); 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. * @param {string} params.jobKey - Storage job key to resolve the human readable job name.
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete. * @returns {Promise<Array<Response>>} 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); const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
if (!adapterCfg || !adapterCfg.fields) { if (!adapterCfg || !adapterCfg.fields) {
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`); 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 img = normalizeImageUrl(o.image);
const textPayload = { const textPayload = {
chat_id: chatId, 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' }), ...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true, disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}), ...(message_thread_id ? { message_thread_id } : {}),
@@ -204,7 +211,9 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
return await throttledCall('sendPhoto', { return await throttledCall('sendPhoto', {
chat_id: chatId, chat_id: chatId,
photo: img, 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' }), ...(plainText ? {} : { parse_mode: 'HTML' }),
...(message_thread_id ? { message_thread_id } : {}), ...(message_thread_id ? { message_thread_id } : {}),
}).catch(async (e) => { }).catch(async (e) => {

View File

@@ -106,6 +106,9 @@
<![endif]--> <![endif]-->
<!--[if !mso]><!-- --> <!--[if !mso]><!-- -->
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a> <a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
{{#if this.fredyUrl}}
<a href="{{this.fredyUrl}}" class="btn" style="background:#1a6fff;color:#ffffff;margin-left:8px;" target="_blank">Open in Fredy</a>
{{/if}}
<!--<![endif]--> <!--<![endif]-->
</td> </td>
</tr> </tr>

View File

@@ -20,10 +20,10 @@ if (adapter.length === 0) {
const findAdapter = (notificationAdapter) => { const findAdapter = (notificationAdapter) => {
return adapter.find((a) => a.config.id === notificationAdapter.id); 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 //this is not being used in tests, therefore adapter are always set
return notificationConfig return notificationConfig
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null) .filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
.map((notificationAdapter) => findAdapter(notificationAdapter)) .map((notificationAdapter) => findAdapter(notificationAdapter))
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey })); .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
}; };

View File

@@ -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)) });
}

View File

@@ -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}`;
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "20.3.3", "version": "20.4.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

View File

@@ -21,6 +21,10 @@ export function getUserSettings(userId) {
return null; return null;
} }
export async function getSettings() {
return { baseUrl: '' };
}
export const updateListingDistance = (id, distance) => { export const updateListingDistance = (id, distance) => {
// noop // noop
}; };

View File

@@ -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');
});
});
});

View File

@@ -1,11 +1,10 @@
{ {
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511", "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876542",
"content": "content":
[ [
{ {
"title": "Fredy goes AI", "title": "Open in...Fredy ;)",
"text": "With Fredy v20.0.0, we are introducing Fredys 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.<br/><br/>The MCP server exposes Fredys 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.<br/><br/>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.", "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 :).<br/>To make this work, Fredy needs to know where it lives on the network. We try to guess the public base URL, but lets be honest, you probably know better. Take a quick look at the baseUrl in the system settings and fix it if it looks off."
"media": "news.mp4"
} }
] ]
} }

Binary file not shown.

View File

@@ -61,6 +61,7 @@ const GeneralSettings = function GeneralSettings() {
const [demoMode, setDemoMode] = React.useState(null); const [demoMode, setDemoMode] = React.useState(null);
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null); const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
const [sqlitePath, setSqlitePath] = React.useState(null); const [sqlitePath, setSqlitePath] = React.useState(null);
const [baseUrl, setBaseUrl] = React.useState('');
const fileInputRef = React.useRef(null); const fileInputRef = React.useRef(null);
const [restoreModalVisible, setRestoreModalVisible] = React.useState(false); const [restoreModalVisible, setRestoreModalVisible] = React.useState(false);
const [precheckInfo, setPrecheckInfo] = React.useState(null); const [precheckInfo, setPrecheckInfo] = React.useState(null);
@@ -94,6 +95,7 @@ const GeneralSettings = function GeneralSettings() {
setAnalyticsEnabled(settings?.analyticsEnabled || false); setAnalyticsEnabled(settings?.analyticsEnabled || false);
setDemoMode(settings?.demoMode || false); setDemoMode(settings?.demoMode || false);
setSqlitePath(settings?.sqlitepath); setSqlitePath(settings?.sqlitepath);
setBaseUrl(settings?.baseUrl ?? '');
} }
init(); init();
@@ -137,6 +139,7 @@ const GeneralSettings = function GeneralSettings() {
demoMode, demoMode,
analyticsEnabled, analyticsEnabled,
sqlitepath: sqlitePath, sqlitepath: sqlitePath,
baseUrl,
}); });
} catch (exception) { } catch (exception) {
console.error(exception); console.error(exception);
@@ -266,6 +269,13 @@ const GeneralSettings = function GeneralSettings() {
/> />
</SegmentPart> </SegmentPart>
<SegmentPart
name="Base URL"
helpText="Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications."
>
<Input type="text" placeholder="Base-Url" value={baseUrl} onChange={(value) => setBaseUrl(value)} />
</SegmentPart>
<SegmentPart <SegmentPart
name="SQLite Database Path" name="SQLite Database Path"
helpText="The directory where Fredy stores its SQLite database files." helpText="The directory where Fredy stores its SQLite database files."