const {MessageFlags, Routes} = require("oceanic.js"); const events = require("../lib/events.js"); const logger = require("../lib/logger.js"); const {hasFlag} = require("../lib/guildSettings.js"); const {parseHtmlEntities, getUploadLimit} = 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: /^\/@(.+?)\/(\d+)\/?/, mastodon2: /^\/(.+?)\/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) { let urlObj = new URL(url); let platform = await resolvePlatform(url); let color = PLATFORM_COLORS[platform]; let platformName = platform .replace("gotosocial", "GoToSocial") .replace(/^(.)/, (_, c) => c.toUpperCase()); const images = []; const videos = []; const audios = []; let content, cw, author, timestamp; // Fetch post const rawPostData = await fetch(url, { headers: { "User-Agent": FRIENDLY_USERAGENT, Accept: "application/activity+json", }, }) .then((res) => res.text()) .catch((err) => { logger.error("fedimbed", `Failed to fetch "${url}" as AS2: ${err}`); }); let postData; if (rawPostData.startsWith("{")) { postData = JSON.parse(rawPostData); } else { logger.warn("fedimbed", `Got non-JSON for "${url}": ${rawPostData}`); } if (postData?.error) { logger.error("fedimbed", `Received error for "${url}": ${postData.error}`); } if (!postData) { // We failed to get post. // Assume it was due to AFM and use MastoAPI // Follow redirect from /object since we need the ID from /notice 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 (url.startsWith("/")) { url = urlObj.origin + url; } urlObj = new URL(url); } let redirUrl; if (PATH_REGEX.pleroma2.test(urlObj.pathname)) { redirUrl = url.replace("notice", "api/v1/statuses"); } else if (PATH_REGEX.mastodon.test(urlObj.pathname)) { const postId = urlObj.pathname.match(PATH_REGEX.mastodon)?.[2]; redirUrl = urlObj.origin + "/api/v1/statuses/" + postId; } else if (PATH_REGEX.mastodon2.test(urlObj.pathname)) { redirUrl = url.replace(/^\/(.+?)\/statuses/, "/api/v1/statuses"); } else { logger.error( "fedimbed", `Missing MastoAPI replacement for "${platform}"` ); } if (redirUrl) { logger.verbose("fedimbed", `Redirecting "${url}" to "${redirUrl}"`); const rawPostData2 = await fetch(redirUrl, { headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.text()) .catch((err) => { logger.error( "fedimbed", `Failed to fetch "${url}" as MastoAPI: ${err}` ); }); let postData2; if (rawPostData2.startsWith("{")) { postData2 = JSON.parse(rawPostData2); } else { logger.warn( "fedimbed", `Got non-JSON for "${url}" as MastoAPI: ${rawPostData2}` ); } if (!postData2) { logger.warn( "fedimbed", `Bailing trying to re-embed "${url}": Failed to get post from both AS2 and MastoAPI.` ); } else if (postData2.error) { logger.error( "fedimbed", `Bailing trying to re-embed "${url}", MastoAPI gave us error: ${postData2.error}` ); } else { cw = postData2.spoiler_warning ?? postData2.spoiler_text; content = postData2.akkoma?.source?.content ?? postData2.pleroma?.content?.["text/plain"] ?? postData2.content; author = { name: postData2.account.display_name, handle: postData2.account.fqn ?? `${postData2.account.username}@${urlObj.hostname}`, url: postData2.account.url, avatar: postData2.account.avatar, }; timestamp = postData2.created_at; for (const attachment of postData2.media_attachments) { const fileType = attachment.pleroma?.mime_type ?? attachment.type + (url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? attachment.type == "image" ? "png" : attachment.type == "video" ? "mp4" : "mpeg"); if (attachment.type == "image") { images.push({ url: attachment.url, desc: attachment.description, type: fileType, }); } else if (attachment.type == "video") { videos.push({ url: attachment.url, desc: attachment.description, type: fileType, }); } else if (attachment.type == "audio") { audios.push({ url: attachment.url, desc: attachment.description, type: fileType, }); } } } } } else { if (postData.id) { const realUrlObj = new URL(postData.id); if (realUrlObj.origin != urlObj.origin) { platform = await resolvePlatform(postData.id); color = PLATFORM_COLORS[platform]; platformName = platform .replace("gotosocial", "GoToSocial") .replace(/^(.)/, (_, c) => c.toUpperCase()); url = postData.id; } } content = postData._misskey_content ?? postData.source?.content ?? postData.content; cw = postData.summary; timestamp = postData.published; for (const attachment of postData.attachment) { if (attachment.mediaType.startsWith("video/")) { videos.push({ url: attachment.url, desc: attachment.name, type: attachment.mediaType, }); } else if (attachment.mediaType.startsWith("image/")) { images.push({ url: attachment.url, desc: attachment.name, type: attachment.mediaType, }); } else if (attachment.mediaType.startsWith("audio/")) { audios.push({ url: attachment.url, desc: attachment.name, type: attachment.mediaType, }); } } // Author data is not sent with the post with AS2 const authorData = await fetch(postData.actor ?? postData.attributedTo, { headers: { "User-Agent": FRIENDLY_USERAGENT, Accept: "application/activity+json", }, }) .then((res) => res.json()) .catch((err) => { logger.error("fedimbed", `Failed to get author for "${url}": ${err}`); }); if (authorData) { const authorUrlObj = new URL(authorData.url); author = { name: authorData.name, handle: `${authorData.preferredUsername}@${authorUrlObj.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(/<\/?\s*br\s*\/?>/g, "\n"); content = content.replace(/<\/p>
/g, "\n\n"); content = content.replace(/(<([^>]+)>)/gi, ""); content = parseHtmlEntities(content); cw = cw.replace(/(<([^>]+)>)/gi, ""); cw = parseHtmlEntities(cw); let desc = ""; let MAX_LENGTH = 3999; if ( cw != "" && images.length == 0 && videos.length == 0 && audios.length == 0 ) { 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, timestamp, description: desc, title: `${author.name} (${author.handle})`, footer: { text: platformName, }, thumbnail: { url: author.avatar, }, fields: [], }; if (images.length > 0) { if (images.length > 1) { baseEmbed.fields.push({ name: "Images", value: images .map((attachment, index) => `[Image ${index + 1}](${attachment.url})`) .join(" | "), inline: true, }); } else { baseEmbed.fields.push({ name: "Image", value: `[Click for image](${images[0].url})`, inline: true, }); } } if (videos.length > 0) { if (videos.length > 1) { baseEmbed.fields.push({ name: "Videos", value: videos .map((attachment, index) => `[Video ${index + 1}](${attachment.url})`) .join(" | "), inline: true, }); } else { baseEmbed.fields.push({ name: "Video", value: `[Click for video](${videos[0].url})`, inline: true, }); } } if (audios.length > 0) { if (audios.length > 1) { baseEmbed.fields.push({ name: "Audios", value: audios .map((attachment, index) => `[Audio ${index + 1}](${attachment.url})`) .join(" | "), inline: true, }); } else { baseEmbed.fields.push({ name: "Audio", value: `[Click for audio](${audios[0].url})`, inline: true, }); } } const embeds = []; if (images.length > 0) { for (const attachment of images) { const embed = Object.assign({}, baseEmbed); embed.image = { url: attachment.url, }; embeds.push(embed); } } else { embeds.push(baseEmbed); } const files = []; if (videos.length > 0) { for (const attachment of videos) { const size = await fetch(attachment.url, { method: "HEAD", headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.blob()) .then((blob) => blob.size); if (size <= getUploadLimit(msg.channel.guild)) { const file = await fetch(attachment.url, { headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.arrayBuffer()) .then((buf) => Buffer.from(buf)); files.push({ name: attachment.type.replace("/", "."), contents: file, }); } } } if (audios.length > 0) { for (const attachment of audios) { const size = await fetch(attachment.url, { method: "HEAD", headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.blob()) .then((blob) => blob.size); if (size <= getUploadLimit(msg.channel.guild)) { const file = await fetch(attachment.url, { headers: { "User-Agent": FRIENDLY_USERAGENT, }, }) .then((res) => res.arrayBuffer()) .then((buf) => Buffer.from(buf)); files.push({ name: attachment.type.replace("/", ".").replace("mpeg", "mp3"), contents: file, }); } } } // NB: OceanicJS/Oceanic#32 //await msg.edit({flags: MessageFlags.SUPPRESS_EMBEDS}).catch(() => {}); await hf.bot.rest .authRequest({ method: "PATCH", path: Routes.CHANNEL_MESSAGE(msg.channel.id, msg.id), json: { flags: MessageFlags.SUPPRESS_EMBEDS, }, }) .catch(() => {}); await msg.channel.createMessage({ content: cw && (images.length > 0 || videos.length > 0 || audios.length > 0) ? `:warning: ${cw} || ${url} ||` : "", embeds, files, allowedMentions: { repliedUser: false, }, messageReference: { messageID: msg.id, }, }); } events.add("messageCreate", "fedimbed", async function (msg) { if (msg.author.id == hf.bot.user.id) return; 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; } } } } });