diff --git a/src/lib/guildSettings.js b/src/lib/guildSettings.js index 6df760d..f1b0071 100644 --- a/src/lib/guildSettings.js +++ b/src/lib/guildSettings.js @@ -3,6 +3,7 @@ const {getGuildData, setGuildData} = require("./guildData.js"); const flags = Object.freeze({ codePreviews: 1 << 0, tweetUnrolling: 1 << 1, + fedimbed: 1 << 2, }); async function getFlags(guildId) { diff --git a/src/modules/fedimbed.js b/src/modules/fedimbed.js new file mode 100644 index 0000000..2e8f27d --- /dev/null +++ b/src/modules/fedimbed.js @@ -0,0 +1,286 @@ +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; + } + } + } + } +});