import 'dotenv/config' import { JSDOM } from 'jsdom' import sanitizeHtml from 'sanitize-html' // ======================================================================== // helper functions // ======================================================================== // regex to match service name within an html anchor tag // used when linkFormat is 'html' in config.js export function htmlServiceLinkRegex(serviceName) { return new RegExp( `(]+href=["'][^"']*["'][^>]*>)${serviceName}(<\\/a>)`, 'i', ) } // regex to match service name within a markdown link // used when linkFormat is 'markdown' in config.js export function markdownServiceLinkRegex(serviceName) { return new RegExp(`(\\[)${serviceName}(\\]\\([^)]*\\))`, 'i') } // escapes special markdown characters so they display literally export function markdownCharEscape(text) { return text .replaceAll('`', '\\`') .replaceAll('*', '\\*') .replaceAll('_', '\\_') .replaceAll('{', '\\{') .replaceAll('}', '\\}') .replaceAll('[', '\\[') .replaceAll(']', '\\]') .replaceAll('(', '\\(') .replaceAll(')', '\\)') .replaceAll('#', '\\#') .replaceAll('+', '\\+') .replaceAll('-', '\\-') .replaceAll('.', '\\.') .replaceAll('!', '\\!') } // ======================================================================== // generic feed parsers // ======================================================================== // rss feed parser - grabs the latest item from any standard rss feed export async function getRSSItemTitle(feedUrl) { const res = await fetch(feedUrl) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') const title = item.querySelector('title').textContent const link = item.querySelector('link').textContent.trim() const cleanTitle = sanitizeHtml(title, { allowedTags: [], allowedAttributes: {}, })?.trim() return { text: cleanTitle, url: link } } // atom feed parser - grabs the latest entry from any standard atom feed export async function getAtomFeed(url) { const res = await fetch(url) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const entry = dom.window.document.querySelector('entry') if (!entry) { throw new Error('No recent items found') } const title = entry.querySelector('title').textContent const link = entry.querySelector('link').getAttribute('href') const cleanTitle = sanitizeHtml(title, { allowedTags: [], allowedAttributes: {}, })?.trim() return { text: cleanTitle, url: link } } // json feed parser - grabs the latest item title // optionally includes images separately export async function getJsonFeedItemTitle(feedUrl, showImage = true) { const res = await fetch(feedUrl) const data = await res.json() const post = data.items[0] const { title, image, url } = post return { text: title, url, image: showImage ? image : null } } // ======================================================================== // music // ======================================================================== // listenbrainz json api - grabs your most recent listen // returns "track name
by artist name" export async function getListenBrainzScrobble(config) { const { feedUrl, userId } = config const res = await fetch(feedUrl) const data = await res.json() if (!data.payload.listens || data.payload.listens.length === 0) { throw new Error('No recent listens found') } const listen = data.payload.listens[0] const { track_name, artist_name } = listen.track_metadata const text = `${track_name}
by ${artist_name}` const url = `https://listenbrainz.org/user/${userId}/` return { text, url } } // maloja music scrobbling api - grabs your most recent listen // returns "track name
by artist name" // not configured in services.js by default but kept from upstream export async function getMalojaScrobble(malojaUrl) { const res = await fetch(`${malojaUrl}/apis/mlj_1/scrobbles?perpage=1`) const data = await res.json() const scrobble = data.list[0] const { title, album: { artists }, } = scrobble.track return `${title}
by ${artists.join(', ')}` } // ======================================================================== // social media // ======================================================================== // mastodon rss feed parser - filters out replies (posts starting with @) // also replaces urls with 🔗 emoji to keep things tidy // extracts images from posts if available export async function getMastodonPost(config) { const { feedUrl, showImage = false } = typeof config === 'string' ? { feedUrl: config } : config const res = await fetch(feedUrl) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) // get all items from the rss feed const items = dom.window.document.querySelectorAll('item') // find the first item that isn't a reply (doesn't start with @) for (const item of items) { const description = item.querySelector('description')?.textContent || '' // skip if it is a reply if (description.trim().startsWith('@')) { continue } const link = item.querySelector('link')?.textContent.trim() // extract image from description html if available const imgMatch = description.match(/]+src="([^"]+)"/) const image = imgMatch ? imgMatch[1] : null // use description as the text, stripping HTML let cleanText = sanitizeHtml(description, { allowedTags: [], allowedAttributes: {}, })?.trim() // replace URLs with a link emoji cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() return { text: cleanText, url: link, image: showImage ? image : null } } // fallback to first item if no non-reply found const firstItem = items[0] const description = firstItem.querySelector('description')?.textContent || '' const link = firstItem.querySelector('link')?.textContent.trim() // extract image from description html if available const imgMatch = description.match(/]+src="([^"]+)"/) const image = imgMatch ? imgMatch[1] : null let cleanText = sanitizeHtml(description, { allowedTags: [], allowedAttributes: {}, })?.trim() // replace URLs with link emoji cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() return { text: cleanText, url: link, image: showImage ? image : null } } // ======================================================================== // movies & tv // ======================================================================== // letterboxd rss parser - can optionally extract poster images from cdata // images are embedded in the description field as html export async function getLetterboxdActivity(config) { const { feedUrl, showImage = false } = config const res = await fetch(feedUrl) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') const title = item.querySelector('title').textContent const link = item.querySelector('link').textContent.trim() let image = null if (showImage) { // Parse CDATA content as HTML to extract image const description = item.querySelector('description').textContent const imgMatch = description.match(/]+src="([^"]+)"/) image = imgMatch ? imgMatch[1] : null } return { text: title, url: link, image } } // trakt episode history - requires TRAKT_SLURM variable (see readme for info) // can optionally include trakt widget thumbnail image export async function getTraktEpisode(config) { const { userId, traktId, showImage = false } = config const res = await fetch( `https://trakt.tv/users/${userId}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) const entry = dom.window.document.querySelector('entry') const title = entry.querySelector('title').textContent const link = entry.querySelector('link').getAttribute('href') const image = showImage ? `https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=episode&image_only=1` : null return { text: title, url: link, image } } // trakt movie history - requires TRAKT_SLURM variable (see readme for info) // can optionally include trakt widget thumbnail image export async function getTraktMovie(config) { const { userId, traktId, showImage = false } = config const res = await fetch( `https://trakt.tv/users/${userId}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) const entry = dom.window.document.querySelector('entry') const title = entry.querySelector('title').textContent const link = entry.querySelector('link').getAttribute('href') const image = showImage ? `https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=movie&image_only=1` : null return { text: title, url: link, image } } // ======================================================================== // books // ======================================================================== // hardcover graphql api - grabs your currently reading book // requires HARDCOVER_API_KEY see readme for more info export async function getHardcoverActivity(config) { const { userId, feedUrl } = config const apiKey = process.env.HARDCOVER_API_KEY if (!apiKey) { throw new Error('HARDCOVER_API_KEY environment variable not set') } const query = ` query { user(username: "${userId}") { currently_reading_books(limit: 1) { title id } } } ` const res = await fetch(feedUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', authorization: apiKey, }, body: JSON.stringify({ query }), }) const data = await res.json() if ( !data.data?.user?.currently_reading_books || data.data.user.currently_reading_books.length === 0 ) { throw new Error('Nothing!') } const book = data.data.user.currently_reading_books[0] return { text: book.title, url: `https://hardcover.app/books/${book.id}`, } } // ======================================================================== // gaming // ======================================================================== // steam web api - grabs your most recently played game // requires STEAM_WEBAPI_KEY see readme for more info // needs your steam id 64 (not username) - convert at steamid.io export async function getSteamRecentlyPlayed(config) { const { steamId } = config const apiKey = process.env.STEAM_WEBAPI_KEY if (!apiKey) { throw new Error('STEAM_WEBAPI_KEY environment variable not set') } const res = await fetch( `https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=${process.env.STEAM_WEBAPI_KEY}&steamid=${steamId}&format=json`, ) const data = await res.json() if (!data.response.games || data.response.games.length === 0) { throw new Error('No recent games found') } const game = data.response.games[0] const { name, appid } = game return { text: name, url: `https://store.steampowered.com/app/${appid}`, } } // ======================================================================== // code & development // ======================================================================== // source.tube (forgejo) rss parser - strips username from the beginning // turns "dylan pushed an update to..." into "pushed an update to..." export async function getSourceTubeActivity(config) { const { feedUrl, userId } = config const res = await fetch(feedUrl) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') if (!item) { throw new Error('No recent activity found') } const title = item.querySelector('title').textContent const link = item.querySelector('link').textContent.trim() let cleanTitle = sanitizeHtml(title, { allowedTags: [], allowedAttributes: {}, })?.trim() // remove username prefix (e.g., "dylan pushed..." -> "pushed...") if (userId) { const usernamePattern = new RegExp(`^${userId}\\s+`, 'i') cleanTitle = cleanTitle.replace(usernamePattern, '') } return { text: cleanTitle, url: link } }