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(/
/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; } } } } });