HiddenPhox/src/modules/fedimbed.js

1157 lines
33 KiB
JavaScript

const Dysnomia = require("@projectdysnomia/dysnomia");
const {MessageFlags, ApplicationCommandOptionTypes, Permissions} =
Dysnomia.Constants;
const fs = require("node:fs");
const path = require("node:path");
const httpSignature = require("@peertube/http-signature");
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 InteractionCommand = require("../lib/interactionCommand.js");
const {getOption} = require("../lib/interactionDispatcher.js");
const FRIENDLY_USERAGENT =
"HiddenPhox/fedimbed (https://gitdab.com/Cynosphere/HiddenPhox)";
const URLS_REGEX =
/(?:\s|^|\]\()(\|\|\s*)?(https?:\/\/[^\s<]+[^<.,:;"'\]|)\s])(\s*\)?\|\||\s*[\S]*?\))?/g;
const SPOILER_REGEX = /(?:\s|^)\|\|([\s\S]+?)\|\|/;
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]+\/?/,
lemmy: /^\/post\/\d+\/?/,
honk: /^\/u\/(.+?)\/h\/(.+?)\/?/,
cohost: /^\/[A-Za-z0-9]+\/post\/\d+-[A-Za-z0-9-]+\/?/,
};
const PLATFORM_COLORS = {
mastodon: 0x2791da,
pleroma: 0xfba457,
akkoma: 0x593196,
misskey: 0x99c203,
calckey: 0x31748f,
firefish: 0xf07a5b, // YCbCr interpolated from the two logo colors
gotosocial: 0xff853e,
lemmy: 0x14854f,
birdsitelive: 0x1da1f2,
iceshrimp: 0x8e82f9, // YCbCr interpolated as the accent color is a gradient
cohost: 0x83254f,
};
const domainCache = new Map();
domainCache.set("cohost.org", "cohost"); // no nodeinfo
async function resolvePlatform(url) {
const urlObj = new URL(url);
if (domainCache.has(urlObj.hostname)) return domainCache.get(urlObj.hostname);
const res = await fetch(urlObj.origin + "/.well-known/nodeinfo", {
headers: {"User-Agent": FRIENDLY_USERAGENT},
}).then((res) => res.text());
if (!res.startsWith("{")) {
logger.error("fedimbed", `No nodeinfo for "${urlObj.hostname}"???`);
domainCache.set(urlObj.hostname, null);
return null;
}
const probe = JSON.parse(res);
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;
}
const keyId = "https://hf.c7.pm/actor#main-key";
const privKey = fs.readFileSync(
path.resolve(__dirname, "../../priv/private.pem")
);
async function signedFetch(url, options) {
const urlObj = new URL(url);
const headers = {
host: urlObj.host,
date: new Date().toUTCString(),
};
const headerNames = ["(request-target)", "host", "date"];
httpSignature.sign(
{
getHeader: (name) => headers[name.toLowerCase()],
setHeader: (name, value) => (headers[name] = value),
method: options.method ?? "GET",
path: urlObj.pathname,
},
{
keyId,
key: privKey,
headers: headerNames,
authorizationHeaderName: "signature",
}
);
options.headers = Object.assign(headers, options.headers ?? {});
return await fetch(url, options);
}
function htmlToMarkdown(str) {
// FIXME: stop being lazy and use an html parser
str = str.replace(/<a .*?href="([^"]+?)".*?>(.+?)<\/a>/gi, (_, url, text) =>
url == text ? url : `[${text}](${url})`
);
str = str.replace(
/<img .*?src="([^"]+?)".*?(alt|title)="([^"]+?)".*?\/>/gi,
"[$3]($1)"
);
str = str.replace(/<\/?\s*br\s*\/?>/gi, "\n");
str = str.replace(
/<blockquote.*?>((.|\n)*?)<\/blockquote>/gi,
(_, quote) => "> " + quote.split("\n").join("\n> ")
);
str = str.replace(/<\/p><p>/gi, "\n\n");
str = str.replace(/<ol>/gi, "\n");
str = str.replace(/<li>/gi, "- ");
str = str.replace(/<\/li>/gi, "\n");
str = str.replace(/<\/?code>/gi, "`");
str = str.replace(/<\/?em>/gi, "*");
str = str.replace(/<\/?u>/gi, "__");
str = str.replace(/<\/?s>/gi, "~~");
str = str.replace(/(<([^>]+)>)/gi, "");
str = parseHtmlEntities(str);
// whyyyyyyyyyyyy
str = str.replace(/\[https?:\/\/.+?\]\((https?:\/\/.+?)\)/gi, "$1");
return str;
}
async function processUrl(msg, url, spoiler = false) {
let invalidUrl = false;
let urlObj;
try {
urlObj = new URL(url);
} catch {
invalidUrl = true;
}
if (invalidUrl) return {};
// some lemmy instances have old reddit frontend subdomains
// but these frontends are just frontends and dont actually expose the API
if (urlObj.hostname.startsWith("old.")) {
urlObj.hostname = urlObj.hostname.replace("old.", "");
url = urlObj.href;
}
let platform = (await resolvePlatform(url)) ?? "<no nodeinfo>";
let color = PLATFORM_COLORS[platform];
let platformName = platform
.replace("gotosocial", "GoToSocial")
.replace("birdsitelive", '"Twitter" (BirdsiteLive)')
.replace(/^(.)/, (_, c) => c.toUpperCase())
.replace("Cohost", "cohost");
const images = [];
const videos = [];
const audios = [];
let content,
cw,
author,
timestamp,
title,
poll,
emotes = [],
sensitive = false;
// Fetch post
let rawPostData;
try {
rawPostData = await signedFetch(url, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
Accept: "application/activity+json",
},
}).then((res) => res.text());
} catch (err) {
logger.error(
"fedimbed",
`Failed to signed fetch "${url}", retrying unsigned: ${err}`
);
}
if (!rawPostData) {
try {
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}": ${err}`);
}
}
let postData;
if (rawPostData?.startsWith("{")) {
try {
postData = JSON.parse(rawPostData);
} catch (err) {
logger.error(
"fedimbed",
`Failed to decode JSON for "${url}": ${err}\n "${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 or forced HTTP signatures and use MastoAPI
// Follow redirect from /object since we need the ID from /notice
if (PATH_REGEX.pleroma.test(urlObj.pathname)) {
url = await signedFetch(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;
const options = {};
const headers = {};
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 if (PATH_REGEX.misskey.test(urlObj.pathname)) {
let noteId = url.split("/notes/")[1];
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];
}
logger.verbose("fedimbed", "Misskey post ID: " + noteId);
redirUrl = urlObj.origin + "/api/notes/show/";
options.method = "POST";
options.body = JSON.stringify({noteId});
headers["Content-Type"] = "application/json";
} else {
logger.error(
"fedimbed",
`Missing MastoAPI replacement for "${platform}"`
);
}
if (redirUrl) {
logger.verbose(
"fedimbed",
`Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(
options
)}, ${JSON.stringify(headers)}`
);
let rawPostData2;
try {
rawPostData2 = await signedFetch(
redirUrl,
Object.assign(options, {
headers: Object.assign(headers, {
"User-Agent": FRIENDLY_USERAGENT,
}),
})
).then((res) => res.text());
} catch (err) {
logger.error(
"fedimbed",
`Failed to signed fetch "${url}" via MastoAPI, retrying unsigned: ${err}`
);
}
if (!rawPostData2) {
try {
rawPostData2 = await signedFetch(
redirUrl,
Object.assign(options, {
headers: Object.assign(headers, {
"User-Agent": FRIENDLY_USERAGENT,
}),
})
).then((res) => res.text());
} catch (err) {
logger.error(
"fedimbed",
`Failed to fetch "${url}" via MastoAPI: ${err}`
);
}
}
let postData2;
if (rawPostData2?.startsWith("{")) {
postData2 = JSON.parse(rawPostData2);
} else {
logger.warn(
"fedimbed",
`Got non-JSON for "${url}" via MastoAPI: ${rawPostData2}`
);
}
if (!postData2) {
logger.warn(
"fedimbed",
`Bailing trying to re-embed "${url}": Failed to get post from normal and MastoAPI.`
);
} else if (postData2.error) {
logger.error(
"fedimbed",
`Bailing trying to re-embed "${url}", MastoAPI gave us error: ${JSON.stringify(
postData2.error
)}`
);
} else {
cw =
postData2.spoiler_warning ?? postData2.spoiler_text ?? postData2.cw;
content =
postData2.akkoma?.source?.content ??
postData2.pleroma?.content?.["text/plain"] ??
postData2.text ??
postData2.content;
author = {
name:
postData2.account?.display_name ??
postData2.account?.username ??
postData2.user?.name ??
postData2.user?.username,
handle:
postData2.account?.fqn ??
`${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,
};
timestamp = postData2.created_at ?? postData2.createdAt;
emotes = postData2.emojis
.filter((x) => !x.name.endsWith("@."))
.map((x) => ({name: `:${x.name}:`, url: x.url}));
sensitive = postData2.sensitive;
const attachments = postData2.media_attachments ?? postData2.files;
if (attachments) {
for (const attachment of attachments) {
const contentType = await fetch(attachment.url, {
method: "HEAD",
}).then((res) => res.headers.get("Content-Type"));
if (contentType) {
if (contentType.startsWith("image/")) {
images.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: contentType,
});
} else if (contentType.startsWith("video/")) {
videos.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: contentType,
});
} else if (contentType.startsWith("audio/")) {
audios.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: contentType,
});
}
} else {
const type = attachment.type?.toLowerCase();
const fileType =
attachment.pleroma?.mime_type ?? type.indexOf("/") > -1
? type
: type +
"/" +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image"
? "png"
: type == "video"
? "mp4"
: "mpeg");
if (type.startsWith("image")) {
images.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
} else if (type.startsWith("video")) {
videos.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
} else if (type.startsWith("audio")) {
audios.push({
url: attachment.url,
desc: attachment.description ?? attachment.comment,
type: fileType,
});
}
}
}
}
if (!spoiler && postData2.sensitive && attachments.length > 0) {
spoiler = true;
}
if (postData2.poll) {
poll = {
end: new Date(postData2.poll.expires_at),
total: postData2.poll.votes_count,
options: postData2.poll.options.map((o) => ({
name: o.title,
count: o.votes_count,
})),
};
}
}
}
} 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;
sensitive = postData.sensitive;
if (postData.tag) {
let tag = postData.tag;
// gts moment
if (!Array.isArray(tag)) tag = [tag];
emotes = tag
.filter((x) => !!x.icon)
.map((x) => ({name: x.name, url: x.icon.url}));
}
// NB: gts doesnt send singular attachments as array
const attachments = Array.isArray(postData.attachment)
? postData.attachment
: [postData.attachment];
for (const attachment of attachments) {
if (attachment.mediaType) {
if (attachment.mediaType.startsWith("video/")) {
videos.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: attachment.mediaType,
});
} else if (attachment.mediaType.startsWith("image/")) {
images.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: attachment.mediaType,
});
} else if (attachment.mediaType.startsWith("audio/")) {
audios.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: attachment.mediaType,
});
}
} else if (attachment.url) {
const contentType = await fetch(attachment.url, {
method: "HEAD",
}).then((res) => res.headers.get("Content-Type"));
if (contentType) {
if (contentType.startsWith("image/")) {
images.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: contentType,
});
} else if (contentType.startsWith("video/")) {
videos.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: contentType,
});
} else if (contentType.startsWith("audio/")) {
audios.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: contentType,
});
}
} else {
const type = attachment.type?.toLowerCase();
const fileType =
type.indexOf("/") > -1
? type
: type +
"/" +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image"
? "png"
: type == "video"
? "mp4"
: "mpeg");
if (type.startsWith("image")) {
images.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: fileType,
});
} else if (type.startsWith("video")) {
videos.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: fileType,
});
} else if (type.startsWith("audio")) {
audios.push({
url: attachment.url,
desc:
attachment.name ?? attachment.description ?? attachment.comment,
type: fileType,
});
}
}
} else {
logger.warn(
"fedimbed",
`Unhandled attachment structure! ${JSON.stringify(attachment)}`
);
}
}
if (!spoiler && postData.sensitive && attachments.length > 0) {
spoiler = true;
}
if (postData.image?.url) {
const imageUrl = new URL(postData.image.url);
const contentType = await fetch(postData.image.url, {
method: "HEAD",
}).then((res) => res.headers.get("Content-Type"));
images.push({
url: postData.image.url,
desc: "",
type:
contentType ??
"image/" +
imageUrl.pathname.substring(imageUrl.pathname.lastIndexOf(".") + 1),
});
}
if (postData.name) title = postData.name;
// Author data is not sent with the post with AS2
const authorData = await signedFetch(
postData.actor ?? postData.attributedTo,
{
headers: {
"User-Agent": FRIENDLY_USERAGENT,
Accept: "application/activity+json",
},
}
)
.then((res) => res.json())
.catch((err) => {
// only posts can be activity+json right now, reduce log spam
if (platform !== "cohost")
logger.error("fedimbed", `Failed to get author for "${url}": ${err}`);
});
if (authorData) {
const authorUrlObj = new URL(authorData.url ?? authorData.id);
author = {
name: authorData.name,
handle: `${authorData.preferredUsername}@${authorUrlObj.hostname}`,
url: authorData.url,
avatar: authorData.icon?.url,
};
} else {
// bootleg author, mainly for cohost
const authorUrl = postData.actor ?? postData.attributedTo;
const authorUrlObj = new URL(authorUrl);
const name = authorUrlObj.pathname.substring(
authorUrlObj.pathname.lastIndexOf("/") + 1
);
author = {
name,
handle: `${name}@${authorUrlObj.hostname}`,
url: authorUrl,
};
}
if (postData.endTime && postData.oneOf && postData.votersCount) {
poll = {
end: new Date(postData.endTime),
total: postData.votersCount,
options: postData.oneOf.map((o) => ({
name: o.name,
count: o.replies.totalItems,
})),
};
}
}
// 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 ?? "";
content = htmlToMarkdown(content);
for (const emote of emotes) {
content = content.replaceAll(emote.name, `[${emote.name}](${emote.url})`);
}
cw = htmlToMarkdown(cw);
let desc = "";
let MAX_LENGTH = 3999;
if (
(cw != "" || sensitive) &&
images.length == 0 &&
videos.length == 0 &&
audios.length == 0
) {
desc += "||" + content + "||";
MAX_LENGTH -= 4;
if (cw != "") {
desc = "\u26a0 " + cw + "\n\n" + desc;
MAX_LENGTH -= 4 - 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 user = author.name
? `${author.name} (${author.handle})`
: author.handle;
const baseEmbed = {
color,
url,
timestamp,
description: desc,
title: title ?? user,
author: title
? {
name: user,
url: author.url,
}
: null,
footer: {
text: platformName,
},
thumbnail: {
url: author.avatar,
},
fields: [],
};
if (images.length > 0) {
if (images.length > 1) {
const links = images
.map((attachment, index) => `[Image ${index + 1}](${attachment.url})`)
.join(" | ");
if (links.length <= 1024)
baseEmbed.fields.push({
name: "Images",
value: links,
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,
});
}
}
if (poll) {
baseEmbed.fields.push({
name: "Poll",
value:
poll.options
.map((o) => {
const percent = o.count / poll.total;
const bar = Math.round(percent * 30);
return `**${o.name}** (${o.count}, ${Math.round(
percent * 100
)}%)\n\`[${"=".repeat(bar)}${" ".repeat(30 - bar)}]\``;
})
.join("\n\n") +
`\n\n${poll.total} votes \u2022 Ends <t:${Math.floor(
poll.end.getTime() / 1000
)}:R>`,
});
}
let sendWait = false;
if (videos.length > 0 || audios.length > 0 || images.length > 4) {
sendWait = true;
if (msg instanceof Dysnomia.Message) await msg.addReaction("\uD83D\uDCE4");
}
const embeds = [];
const files = [];
const guild =
msg.channel?.guild ??
(msg.guildID ? hf.bot.guilds.get(msg.guildID) : false);
if (images.length > 0) {
if (images.length <= 4) {
for (const attachment of images) {
const embed = Object.assign({}, baseEmbed);
embed.image = {
url: attachment.url,
};
embeds.push(embed);
}
} else if (images.length > 4 && images.length <= 10) {
for (const attachment of images) {
const size = await fetch(attachment.url, {
method: "HEAD",
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
}).then((res) => Number(res.headers.get("Content-Length")));
if (size <= getUploadLimit(guild)) {
const file = await fetch(attachment.url, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
})
.then((res) => res.arrayBuffer())
.then((buf) => Buffer.from(buf));
files.push({
filename:
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type.replace("/", ".")
: attachment.type +
"." +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "png")),
file,
description: attachment.desc,
});
}
}
embeds.push(baseEmbed);
} else {
const ten = images.slice(0, 10);
for (const attachment of ten) {
const size = await fetch(attachment.url, {
method: "HEAD",
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
}).then((res) => Number(res.headers.get("Content-Length")));
if (size <= getUploadLimit(guild)) {
const file = await fetch(attachment.url, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
})
.then((res) => res.arrayBuffer())
.then((buf) => Buffer.from(buf));
files.push({
filename:
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type.replace("/", ".")
: attachment.type +
"." +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "png")),
file,
description: attachment.desc,
});
}
}
if (images.length <= 14) {
const fourteen = images.slice(10, 14);
for (const attachment of fourteen) {
const embed = Object.assign({}, baseEmbed);
embed.image = {
url: attachment.url,
};
embeds.push(embed);
}
} else if (images.length <= 18) {
const fourteen = images.slice(10, 14);
for (const attachment of fourteen) {
const embed = Object.assign({}, baseEmbed);
embed.image = {
url: attachment.url,
};
embeds.push(embed);
}
const eighteen = images.slice(14, 18);
const _embed = {
color: baseEmbed.color,
url: baseEmbed.url + "?_",
title: "Additional Images",
};
for (const attachment of eighteen) {
const embed = Object.assign({}, _embed);
embed.image = {
url: attachment.url,
};
embeds.push(embed);
}
}
}
} else {
embeds.push(baseEmbed);
}
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")));
if (size <= getUploadLimit(guild)) {
const file = await fetch(attachment.url, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
})
.then((res) => res.arrayBuffer())
.then((buf) => Buffer.from(buf));
files.push({
filename:
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type.replace("/", ".").replace("quicktime", "mov")
: attachment.type +
"." +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "mp4")),
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")));
if (size <= getUploadLimit(guild)) {
const file = await fetch(attachment.url, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
},
})
.then((res) => res.arrayBuffer())
.then((buf) => Buffer.from(buf));
files.push({
filename:
(cw != "" || spoiler ? "SPOILER_" : "") +
(attachment.type.indexOf("/") > -1
? attachment.type
.replace("/", ".")
.replace("mpeg", "mp3")
.replace("vnd.wave", "wav")
.replace("x-", "")
: attachment.type +
"." +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "mp3")),
file,
});
}
}
}
return {
response: {
content:
cw != "" &&
(images.length > 0 || videos.length > 0 || audios.length > 0)
? `:warning: ${cw} || ${url} ||`
: spoiler
? `|| ${url} ||`
: "",
embeds,
attachments: files,
allowedMentions: {
repliedUser: false,
},
messageReference: {
messageID: msg.id,
},
},
sendWait,
};
}
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 (let url of urls) {
const hasSpoiler = SPOILER_REGEX.test(url);
url = url
.replace(/\|/g, "")
.replace(/^\]\(/, "")
.replace(/\s*[\S]*?\)$/, "")
.trim()
.replace("@\u200b", "@")
.replace("@%E2%80%8B", "@");
let urlObj;
try {
urlObj = new URL(url);
} catch {
// noop
}
for (const service of Object.keys(PATH_REGEX)) {
const regex = PATH_REGEX[service];
if (urlObj && regex.test(urlObj.pathname)) {
logger.verbose(
"fedimbed",
`Hit "${service}" for "${url}", processing now.`
);
try {
const {response, sendWait} = await processUrl(msg, url, hasSpoiler);
await msg.channel.createMessage(response).then(() => {
if (sendWait) {
msg.removeReaction("\uD83D\uDCE4");
}
if ((msg.flags & MessageFlags.SUPPRESS_EMBEDS) === 0) {
msg.edit({flags: MessageFlags.SUPPRESS_EMBEDS}).catch(() => {});
}
});
} catch (err) {
logger.error(
"fedimbed",
`Error processing "${url}":\n` + err.stack
);
}
break;
}
}
}
}
});
const fedimbedCommand = new InteractionCommand("fedimbed");
fedimbedCommand.helpText =
"Better embeds for fediverse (Mastodon, Pleroma, etc) posts";
fedimbedCommand.options.url = {
name: "url",
type: ApplicationCommandOptionTypes.STRING,
description: "URL to attempt to parse for re-embedding",
required: true,
default: "",
};
fedimbedCommand.options.spoiler = {
name: "spoiler",
type: ApplicationCommandOptionTypes.BOOLEAN,
description: "Send embed spoilered",
required: false,
default: false,
};
fedimbedCommand.permissions = Permissions.embedLinks | Permissions.attachFiles;
fedimbedCommand.callback = async function (interaction) {
let url = getOption(interaction, fedimbedCommand, "url");
const spoiler = getOption(interaction, fedimbedCommand, "spoiler");
url = url
.replace(/\|/g, "")
.replace(/^\]\(/, "")
.replace(/\s*[\S]*?\)$/, "")
.trim()
.replace("@\u200b", "@")
.replace("@%E2%80%8B", "@");
let urlObj;
try {
urlObj = new URL(url);
} catch (err) {
return {
content: `Failed to parse URL:\`\`\`\n${err}\`\`\``,
flags: MessageFlags.EPHEMERAL,
};
}
let hasService = false;
for (const service of Object.keys(PATH_REGEX)) {
const regex = PATH_REGEX[service];
if (urlObj && regex.test(urlObj.pathname)) {
hasService = true;
break;
}
}
if (hasService) {
try {
const {response} = await processUrl(interaction, url, spoiler);
if (!response)
return {
content: "Failed to process URL.",
flags: MessageFlags.EPHEMERAL,
};
delete response.messageReference;
return response;
} catch (err) {
logger.error("fedimbed", `Error processing "${url}":\n` + err.stack);
return {
content: "Failed to process URL.",
flags: MessageFlags.EPHEMERAL,
};
}
} else {
return {
content: "Did not get a valid service for this URL.",
flags: MessageFlags.EPHEMERAL,
};
}
};
hf.registerCommand(fedimbedCommand);