HiddenPhox/src/modules/fedimbed.js

584 lines
17 KiB
JavaScript
Raw Normal View History

2023-01-22 04:45:57 +00:00
const {MessageFlags} = require("@projectdysnomia/dysnomia").Constants;
2022-12-06 02:50:22 +00:00
const events = require("../lib/events.js");
const logger = require("../lib/logger.js");
const {hasFlag} = require("../lib/guildSettings.js");
2022-12-06 17:55:57 +00:00
const {parseHtmlEntities, getUploadLimit} = require("../lib/utils.js");
2022-12-06 02:50:22 +00:00
const FRIENDLY_USERAGENT =
2022-12-31 20:32:48 +00:00
"HiddenPhox/fedimbed (https://gitdab.com/Cynosphere/HiddenPhox)";
2022-12-06 02:50:22 +00:00
2023-01-17 19:25:05 +00:00
const URLS_REGEX =
/(?:\s|^)(\|\|\s*)?(https?:\/\/[^\s<]+[^<.,:;"'\]\s])(\s+\|\|)?/g;
2023-01-09 02:09:34 +00:00
const SPOILER_REGEX = /^\|\|([\s\S]+?)\|\|/;
2022-12-06 02:50:22 +00:00
const PATH_REGEX = {
2022-12-06 04:25:26 +00:00
mastodon: /^\/@(.+?)\/(\d+)\/?/,
2022-12-06 02:54:25 +00:00
mastodon2: /^\/(.+?)\/statuses\/\d+\/?/,
2022-12-06 02:50:22 +00:00
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) {
2022-12-06 03:09:13 +00:00
const urlObj = new URL(url);
2022-12-06 02:50:22 +00:00
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;
}
2023-01-09 02:09:34 +00:00
async function processUrl(msg, url, spoiler = false) {
2022-12-06 03:09:13 +00:00
let urlObj = new URL(url);
let platform = await resolvePlatform(url);
let color = PLATFORM_COLORS[platform];
let platformName = platform
2022-12-06 02:50:22 +00:00
.replace("gotosocial", "GoToSocial")
.replace(/^(.)/, (_, c) => c.toUpperCase());
const images = [];
const videos = [];
const audios = [];
let content, cw, author, timestamp;
2022-12-06 02:50:22 +00:00
// Fetch post
2022-12-06 02:59:43 +00:00
const rawPostData = await fetch(url, {
2022-12-06 02:50:22 +00:00
headers: {
"User-Agent": FRIENDLY_USERAGENT,
Accept: "application/activity+json",
},
})
2022-12-06 02:59:43 +00:00
.then((res) => res.text())
2022-12-06 02:50:22 +00:00
.catch((err) => {
logger.error("fedimbed", `Failed to fetch "${url}" as AS2: ${err}`);
});
2022-12-06 02:59:43 +00:00
let postData;
2022-12-06 03:01:25 +00:00
if (rawPostData.startsWith("{")) {
2022-12-06 02:59:43 +00:00
postData = JSON.parse(rawPostData);
} else {
2022-12-06 03:11:43 +00:00
logger.warn("fedimbed", `Got non-JSON for "${url}": ${rawPostData}`);
2022-12-06 02:59:43 +00:00
}
if (postData?.error) {
2022-12-06 02:50:22 +00:00
logger.error("fedimbed", `Received error for "${url}": ${postData.error}`);
}
if (!postData) {
// We failed to get post.
// Assume it was due to AFM or forced HTTP signatures and use MastoAPI
2022-12-06 04:16:37 +00:00
// Follow redirect from /object since we need the ID from /notice
2022-12-06 02:50:22 +00:00
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"));
2022-12-06 03:05:13 +00:00
if (url.startsWith("/")) {
url = urlObj.origin + url;
}
urlObj = new URL(url);
2022-12-06 02:50:22 +00:00
}
2022-12-06 04:10:15 +00:00
let redirUrl;
2022-12-31 19:47:29 +00:00
const options = {};
2022-12-31 19:59:21 +00:00
const headers = {};
2022-12-06 02:50:22 +00:00
if (PATH_REGEX.pleroma2.test(urlObj.pathname)) {
2022-12-06 04:10:15 +00:00
redirUrl = url.replace("notice", "api/v1/statuses");
2022-12-06 04:13:47 +00:00
} else if (PATH_REGEX.mastodon.test(urlObj.pathname)) {
2022-12-06 04:25:26 +00:00
const postId = urlObj.pathname.match(PATH_REGEX.mastodon)?.[2];
redirUrl = urlObj.origin + "/api/v1/statuses/" + postId;
2022-12-06 04:16:37 +00:00
} else if (PATH_REGEX.mastodon2.test(urlObj.pathname)) {
redirUrl = url.replace(/^\/(.+?)\/statuses/, "/api/v1/statuses");
2022-12-31 19:47:29 +00:00
} else if (PATH_REGEX.misskey.test(urlObj.pathname)) {
2022-12-31 20:11:09 +00:00
let noteId = url.split("/notes/")[1];
2022-12-31 20:05:57 +00:00
if (noteId.indexOf("/") > -1) {
noteId = noteId.split("/")[0];
} else if (noteId.indexOf("?") > -1) {
noteId = noteId.split("?")[0];
} else if (noteId.indexOf("#") > -1) {
noteId = noteId.split("#")[0];
}
2022-12-31 20:09:32 +00:00
logger.verbose("fedimbed", "Misskey post ID: " + noteId);
2022-12-31 19:47:29 +00:00
redirUrl = urlObj.origin + "/api/notes/show/";
options.method = "POST";
options.body = JSON.stringify({noteId});
2022-12-31 19:59:21 +00:00
headers["Content-Type"] = "application/json";
2022-12-06 04:10:15 +00:00
} else {
logger.error(
"fedimbed",
`Missing MastoAPI replacement for "${platform}"`
);
}
if (redirUrl) {
2022-12-31 20:15:02 +00:00
logger.verbose(
"fedimbed",
`Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(
options
)}, ${JSON.stringify(headers)}`
);
2022-12-31 19:47:29 +00:00
const rawPostData2 = await fetch(
redirUrl,
2022-12-31 19:59:21 +00:00
Object.assign(options, {
headers: Object.assign(headers, {
"User-Agent": FRIENDLY_USERAGENT,
}),
})
2022-12-31 19:47:29 +00:00
)
2022-12-06 04:18:28 +00:00
.then((res) => res.text())
2022-12-06 02:50:22 +00:00
.catch((err) => {
logger.error(
"fedimbed",
`Failed to fetch "${url}" as MastoAPI: ${err}`
);
});
2022-12-06 04:18:28 +00:00
let postData2;
if (rawPostData2.startsWith("{")) {
postData2 = JSON.parse(rawPostData2);
} else {
2022-12-06 04:46:03 +00:00
logger.warn(
"fedimbed",
`Got non-JSON for "${url}" as MastoAPI: ${rawPostData2}`
);
2022-12-06 04:18:28 +00:00
}
2022-12-06 02:50:22 +00:00
if (!postData2) {
logger.warn(
"fedimbed",
`Bailing trying to re-embed "${url}": Failed to get post from both AS2 and MastoAPI.`
);
} else if (postData2.error) {
2022-12-06 04:10:15 +00:00
logger.error(
"fedimbed",
2022-12-31 19:59:21 +00:00
`Bailing trying to re-embed "${url}", MastoAPI gave us error: ${JSON.stringify(
postData2.error
)}`
2022-12-06 04:10:15 +00:00
);
2022-12-06 02:50:22 +00:00
} else {
2022-12-31 19:47:29 +00:00
cw =
postData2.spoiler_warning ?? postData2.spoiler_text ?? postData2.cw;
2022-12-06 02:50:22 +00:00
content =
postData2.akkoma?.source?.content ??
postData2.pleroma?.content?.["text/plain"] ??
2022-12-31 19:47:29 +00:00
postData2.text ??
2022-12-06 02:50:22 +00:00
postData2.content;
author = {
2022-12-31 20:15:02 +00:00
name:
postData2.account?.display_name ??
postData2.account?.username ??
postData2.user?.name ??
postData2.user?.username,
handle:
postData2.account?.fqn ??
2022-12-31 19:47:29 +00:00
`${postData2.account?.username ?? postData2.user?.username}@${
urlObj.hostname
}`,
url:
postData2.account?.url ??
`${urlObj.origin}/@${
postData2.account?.username ?? postData2.user?.username
}`,
avatar: postData2.account?.avatar ?? postData2.user?.avatarUrl,
2022-12-06 02:50:22 +00:00
};
timestamp = postData2.created_at ?? postData2.createdAt;
2022-12-31 19:47:29 +00:00
const attachments = postData2.media_attachments ?? postData2.files;
if (attachments) {
for (const attachment of attachments) {
const fileType =
attachment.pleroma?.mime_type ?? attachment.type.indexOf("/") > -1
? attachment.type
: attachment.type +
2023-01-06 18:23:22 +00:00
"/" +
2022-12-31 19:47:29 +00:00
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ??
attachment.type == "image"
? "png"
: attachment.type == "video"
? "mp4"
: "mpeg");
if (attachment.type.startsWith("image")) {
images.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
} else if (attachment.type.startsWith("video")) {
videos.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
} else if (attachment.type.startsWith("audio")) {
audios.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
}
2022-12-07 00:01:44 +00:00
}
2022-12-06 02:50:22 +00:00
}
}
}
} 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());
2022-12-07 04:18:54 +00:00
url = postData.id;
}
}
content =
postData._misskey_content ?? postData.source?.content ?? postData.content;
2022-12-06 02:50:22 +00:00
cw = postData.summary;
2022-12-06 03:39:20 +00:00
timestamp = postData.published;
2022-12-06 02:50:22 +00:00
for (const attachment of postData.attachment) {
if (attachment.mediaType.startsWith("video/")) {
videos.push({
url: attachment.url,
desc: attachment.name,
2022-12-06 17:55:57 +00:00
type: attachment.mediaType,
});
} else if (attachment.mediaType.startsWith("image/")) {
images.push({
url: attachment.url,
desc: attachment.name,
2022-12-06 17:55:57 +00:00
type: attachment.mediaType,
});
} else if (attachment.mediaType.startsWith("audio/")) {
audios.push({
url: attachment.url,
desc: attachment.name,
2022-12-06 17:55:57 +00:00
type: attachment.mediaType,
});
}
2022-12-06 02:50:22 +00:00
}
// Author data is not sent with the post with AS2
2022-12-06 03:51:48 +00:00
const authorData = await fetch(postData.actor ?? postData.attributedTo, {
2022-12-06 02:50:22 +00:00
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);
2022-12-06 02:50:22 +00:00
author = {
name: authorData.name,
handle: `${authorData.preferredUsername}@${authorUrlObj.hostname}`,
2022-12-06 02:50:22 +00:00
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(
2022-12-06 03:03:02 +00:00
"fedimbed",
`Bailing trying to re-embed "${url}": Failed to get author.`
2022-12-06 02:50:22 +00:00
);
return;
}
// Start constructing embed
content = content ?? "";
cw = cw ?? "";
// TODO: convert certain HTML tags back to markdown
2022-12-20 01:20:30 +00:00
content = content.replace(/<\/?\s*br\s*\/?>/g, "\n");
content = content.replace(/<\/p><p>/g, "\n\n");
2022-12-06 03:01:25 +00:00
content = content.replace(/(<([^>]+)>)/gi, "");
2022-12-06 02:50:22 +00:00
content = parseHtmlEntities(content);
2022-12-06 03:01:25 +00:00
cw = cw.replace(/(<([^>]+)>)/gi, "");
2022-12-06 02:50:22 +00:00
cw = parseHtmlEntities(cw);
let desc = "";
let MAX_LENGTH = 3999;
2022-12-07 00:01:44 +00:00
if (
cw != "" &&
images.length == 0 &&
videos.length == 0 &&
audios.length == 0
) {
2022-12-06 02:50:22 +00:00
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,
2022-12-06 03:39:20 +00:00
timestamp,
2022-12-06 02:50:22 +00:00
description: desc,
title: `${author.name} (${author.handle})`,
2022-12-06 03:39:20 +00:00
footer: {
text: platformName,
2022-12-06 02:50:22 +00:00
},
thumbnail: {
url: author.avatar,
2022-12-06 02:50:22 +00:00
},
fields: [],
};
if (images.length > 0) {
if (images.length > 1) {
2022-12-06 02:50:22 +00:00
baseEmbed.fields.push({
name: "Images",
value: images
2022-12-06 02:50:22 +00:00
.map((attachment, index) => `[Image ${index + 1}](${attachment.url})`)
.join(" | "),
inline: true,
2022-12-06 02:50:22 +00:00
});
} 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,
2022-12-06 02:50:22 +00:00
});
}
}
const embeds = [];
if (images.length > 0) {
for (const attachment of images) {
2022-12-06 04:46:03 +00:00
const embed = Object.assign({}, baseEmbed);
embed.image = {
url: attachment.url,
};
embeds.push(embed);
}
} else {
embeds.push(baseEmbed);
}
2022-12-06 02:50:22 +00:00
2022-12-06 17:55:57 +00:00
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) => Number(res.headers.get("Content-Length")));
2022-12-06 17:55:57 +00:00
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:
2023-01-17 19:27:55 +00:00
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type.replace("/", ".")
: attachment.type +
2023-01-06 18:23:22 +00:00
"." +
2023-01-17 19:27:55 +00:00
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "mp4")),
2022-12-06 17:58:57 +00:00
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) => Number(res.headers.get("Content-Length")));
2022-12-06 17:58:57 +00:00
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:
2023-01-17 19:27:55 +00:00
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type
.replace("/", ".")
.replace("mpeg", "mp3")
.replace("vnd.wave", "wav")
.replace("x-wav", "wav")
: attachment.type +
2023-01-06 18:23:22 +00:00
"." +
2023-01-17 19:27:55 +00:00
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "mp3")),
2022-12-06 17:55:57 +00:00
contents: file,
});
}
}
}
let sendWait = false;
if (files.length > 0) {
sendWait = true;
2023-01-22 05:02:53 +00:00
await msg.addReaction("\uD83D\uDCE4");
}
await msg.channel
.createMessage({
content:
2023-01-17 19:25:05 +00:00
cw != "" &&
(images.length > 0 || videos.length > 0 || audios.length > 0)
? `:warning: ${cw} || ${url} ||`
2023-01-17 19:25:05 +00:00
: spoiler
? `|| ${url} ||`
: "",
embeds,
files,
allowedMentions: {
repliedUser: false,
},
messageReference: {
messageID: msg.id,
},
})
.then(() => {
if (sendWait) {
msg.removeReaction("\uD83D\uDCE4");
}
if ((msg.flags & MessageFlags.SUPPRESS_EMBEDS) === 0) {
2023-01-22 04:45:57 +00:00
msg.edit({flags: MessageFlags.SUPPRESS_EMBEDS}).catch(() => {});
}
});
2022-12-06 02:50:22 +00:00
}
events.add("messageCreate", "fedimbed", async function (msg) {
2022-12-06 04:48:39 +00:00
if (msg.author.id == hf.bot.user.id) return;
2022-12-06 02:50:22 +00:00
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);
2022-12-29 18:18:22 +00:00
for (let url of urls) {
2023-01-09 02:13:56 +00:00
const hasSpoiler = SPOILER_REGEX.test(url);
2023-01-17 19:25:05 +00:00
url = url
.replace(/\|/g, "")
.trim()
.replace("@\u200b", "@")
.replace("@%E2%80%8B", "@");
2022-12-06 02:50:22 +00:00
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.`
);
2023-01-09 02:09:34 +00:00
await processUrl(msg, url, hasSpoiler).catch((err) => {
2022-12-06 02:50:22 +00:00
logger.error("fedimbed", `Error processing "${url}": ${err}`);
});
break;
}
}
}
}
});