const events = require("../lib/events.js"); const logger = require("../lib/logger.js"); const {hasFlag} = require("../lib/guildSettings.js"); const {parseHtmlEntities} = require("../lib/utils.js"); const FRIENDLY_USERAGENT = "HiddenPhox/fedimbed (https://gitlab.com/Cynosphere/HiddenPhox)"; const URLS_REGEX = /(?:\s|^)(https?:\/\/[^\s<]+[^<.,:;"'\]\s])/g; const PATH_REGEX = { mastodon: /^\/@?(.+?)\/(statuses\/)?\d+\/?/, pleroma: /^\/objects\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/?/, pleroma2: /^\/notice\/[A-Za-z0-9]+\/?/, misskey: /^\/notes\/[a-z0-9]+\/?/, gotosocial: /^\/@(.+?)\/statuses\/[0-9A-Z]+\/?/, }; const PLATFORM_COLORS = { mastodon: 0x2791da, pleroma: 0xfba457, akkoma: 0x593196, misskey: 0x99c203, calckey: 0x31748f, gotosocial: 0xff853e, }; const domainCache = new Map(); async function resolvePlatform(url) { const urlObj = new URL(url); if (domainCache.has(urlObj.hostname)) return domainCache.get(urlObj.hostname); const probe = await fetch(urlObj.origin + "/.well-known/nodeinfo", { headers: {"User-Agent": FRIENDLY_USERAGENT}, }).then((res) => res.json()); if (!probe?.links) { logger.error("fedimbed", `No nodeinfo for "${urlObj.hostname}"???`); domainCache.set(urlObj.hostname, null); return null; } const nodeinfo = await fetch(probe.links[probe.links.length - 1].href, { headers: {"User-Agent": FRIENDLY_USERAGENT}, }).then((res) => res.json()); if (!nodeinfo?.software?.name) { logger.error( "fedimbed", `Got nodeinfo for "${urlObj.hostname}", but missing software name.` ); domainCache.set(urlObj.hostname, null); return null; } domainCache.set(urlObj.hostname, nodeinfo.software.name); return nodeinfo.software.name; } async function processUrl(msg, url) { const urlObj = new URL(url); const platform = await resolvePlatform(url); const color = PLATFORM_COLORS[platform]; const platformName = platform .replace("gotosocial", "GoToSocial") .replace(/^(.)/, (_, c) => c.toUpperCase()); const attachments = []; let content, cw, author; // Fetch post const postData = await fetch(url, { headers: { "User-Agent": FRIENDLY_USERAGENT, Accept: "application/activity+json", "Content-Type": "application/activity+json", }, }) .then((res) => res.json()) .catch((err) => { logger.error("fedimbed", `Failed to fetch "${url}" as AS2: ${err}`); }); if (postData.error) { logger.error("fedimbed", `Received error for "${url}": ${postData.error}`); return; } if (!postData) { // We failed to get post. // If we're fetching from Pleroma, assume it was due to AMF and use MastoAPI if (PATH_REGEX.pleroma.test(urlObj.pathname)) { url = await fetch(url, { method: "HEAD", headers: { "User-Agent": FRIENDLY_USERAGENT, }, redirect: "manual", }).then((res) => res.headers.get("location")); } if (PATH_REGEX.pleroma2.test(urlObj.pathname)) { const postData2 = await fetch(url.replace("notice", "api/v1/statuses"), { headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.json()) .catch((err) => { logger.error( "fedimbed", `Failed to fetch "${url}" as MastoAPI: ${err}` ); }); if (!postData2) { logger.warn( "fedimbed", `Bailing trying to re-embed "${url}": Failed to get post from both AS2 and MastoAPI.` ); } else { cw = postData2.spoiler_warning; content = postData2.akkoma?.source?.content ?? postData2.pleroma?.content?.["text/plain"] ?? postData2.content; author = { name: postData2.account.display_name, handle: postData2.account.fqn, url: postData2.account.url, avatar: postData2.account.avatar, }; for (const attachment of postData.media_attachments) { attachments.push({ url: attachment.url, desc: attachment.description, }); } } } } else { content = postData.content; cw = postData.summary; for (const attachment of postData.attachment) { attachments.push({ url: attachment.url, desc: attachment.name, }); } // Author data is not sent with the post with AS2 const authorData = await fetch(postData.actor, { headers: { "User-Agent": FRIENDLY_USERAGENT, Accept: "application/activity+json", "Content-Type": "application/activity+json", }, }) .then((res) => res.json()) .catch((err) => { logger.error("fedimbed", `Failed to get author for "${url}": ${err}`); }); if (authorData) { author = { name: authorData.name, handle: `${authorData.preferredUsername}@${urlObj.hostname}`, url: authorData.url, avatar: authorData.icon.url, }; } } // We could just continue without author but it'd look ugly and be confusing. if (!author) { logger.warn( "fedimbed"`Bailing trying to re-embed "${url}": Failed to get author.` ); return; } // Start constructing embed content = content ?? ""; cw = cw ?? ""; // TODO: convert certain HTML tags back to markdown content = content.replace(/(<([^>]+)>)/gi,""); content = parseHtmlEntities(content); cw = cw.replace(/(<([^>]+)>)/gi,""); cw = parseHtmlEntities(cw); let desc = ""; let MAX_LENGTH = 3999; if (cw != "") { desc += "\u26a0 " + cw + "\n\n||" + content + "||"; MAX_LENGTH -= 8 - cw.length; } else { desc = content; } if (desc.length > MAX_LENGTH) { if (desc.endsWith("||")) { desc = desc.substring(0, MAX_LENGTH - 2); desc += "\u2026||"; } else { desc = desc.substring(0, MAX_LENGTH) + "\u2026"; } } const baseEmbed = { color, url, description: desc, title: `${author.name} (${author.handle})`, author: { name: platformName, }, thumbnail: { url: author.url, }, fields: [], }; if (attachments.length > 0) { if (attachments.length > 1) { baseEmbed.fields.push({ name: "Images", value: attachments .map((attachment, index) => `[Image ${index + 1}](${attachment.url})`) .join(" | "), }); } else { baseEmbed.fields.push({ name: "Image", value: `[Click for image](${attachments[0].url})`, }); } } const embeds = []; for (const attachment of attachments) { const embed = Object.assign({}, baseEmbed); embed.image = { url: attachment.url, }; embeds.push(embed); } msg.channel.createMessage({ embeds, allowedMentions: { repliedUser: false, }, messageReference: { messageID: msg.id, }, }); } events.add("messageCreate", "fedimbed", async function (msg) { if (!msg.guildID) return; if (!(await hasFlag(msg.guildID, "fedimbed"))) return; if (!msg.content || msg.content == "") return; if (URLS_REGEX.test(msg.content)) { const urls = msg.content.match(URLS_REGEX); for (const url of urls) { for (const service of Object.keys(PATH_REGEX)) { const regex = PATH_REGEX[service]; const urlObj = new URL(url); if (regex.test(urlObj.pathname)) { logger.verbose( "fedimbed", `Hit "${service}" for "${url}", processing now.` ); await processUrl(msg, url).catch((err) => { logger.error("fedimbed", `Error processing "${url}": ${err}`); }); break; } } } } });