fedimbed: much needed cleanup and reply context

This commit is contained in:
Cynthia Foxwell 2025-03-14 17:57:41 -06:00
parent d39bf5f2a4
commit 9fba9d2c9e
Signed by: Cynosphere
SSH key fingerprint: SHA256:H3SM8ufP/uxqLwKSH7xY89TDnbR9uOHzjLoBr0tlajk

View file

@ -122,6 +122,14 @@ async function resolvePlatform(url) {
} }
} }
function normalizePlatform(platform) {
return platform
.replace("gotosocial", "GoToSocial")
.replace("birdsitelive", '"Twitter" (BirdsiteLive)')
.replace(/^(.)/, (_, c) => c.toUpperCase())
.replace("Cohost", "cohost");
}
const keyId = "https://hf.c7.pm/actor#main-key"; const keyId = "https://hf.c7.pm/actor#main-key";
const privKey = fs.readFileSync(require.resolve("#root/priv/private.pem")); const privKey = fs.readFileSync(require.resolve("#root/priv/private.pem"));
async function signedFetch(url, options) { async function signedFetch(url, options) {
@ -406,7 +414,7 @@ async function bluesky(msg, url, spoiler = false) {
if (data.thread.parent) { if (data.thread.parent) {
const reply = data.thread.parent.post; const reply = data.thread.parent.post;
mainEmbed.author = { mainEmbed.author = {
name: `Replying to: ${reply.author.displayName} (${reply.author.handle})`, name: `Replying to: ${reply.author.displayName} (@${reply.author.handle})`,
icon_url: "https://cdn.discordapp.com/emojis/1308640078825787412.png", icon_url: "https://cdn.discordapp.com/emojis/1308640078825787412.png",
url: `https://bsky.app/profile/${reply.author.handle}/post/${reply.uri.substring( url: `https://bsky.app/profile/${reply.author.handle}/post/${reply.uri.substring(
reply.uri.lastIndexOf("/") + 1 reply.uri.lastIndexOf("/") + 1
@ -570,104 +578,53 @@ async function bluesky(msg, url, spoiler = false) {
}; };
} }
async function processUrl(msg, url, spoiler = false, command = false) { async function fetchPost(url, platform, forceMastoAPI = false) {
let canFedi = await hasFlag(msg.guildID, "fedimbed"); let urlObj = new URL(url);
let canBsky = await hasFlag(msg.guildID, "bskyEmbeds"); let postData;
if (command === true) { if (!forceMastoAPI) {
canFedi = true; let rawPostData;
canBsky = true;
}
let invalidUrl = false;
let urlObj;
try {
urlObj = new URL(url);
} catch {
invalidUrl = true;
}
if (invalidUrl) return {};
if (BSKY_DOMAINS.includes(urlObj.hostname.toLowerCase())) {
if (canBsky) {
return await bluesky(msg, url, spoiler);
} else {
return {};
}
}
if (!canFedi) 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 { try {
rawPostData = await fetch(url, { rawPostData = await signedFetch(url, {
headers: { headers: {
"User-Agent": FRIENDLY_USERAGENT, "User-Agent": FRIENDLY_USERAGENT,
Accept: "application/activity+json", Accept: "application/activity+json",
}, },
}).then((res) => res.text()); }).then((res) => res.text());
} catch (err) { } catch (err) {
logger.error("fedimbed", `Failed to fetch "${url}": ${err}`); logger.error("fedimbed", `Failed to signed fetch "${url}", retrying unsigned: ${err}`);
} }
} if (!rawPostData) {
try {
let postData; rawPostData = await fetch(url, {
if (rawPostData?.startsWith("{")) { headers: {
try { "User-Agent": FRIENDLY_USERAGENT,
postData = JSON.parse(rawPostData); Accept: "application/activity+json",
} catch (err) { },
logger.error("fedimbed", `Failed to decode JSON for "${url}": ${err}\n "${rawPostData}"`); }).then((res) => res.text());
} catch (err) {
logger.error("fedimbed", `Failed to fetch "${url}": ${err}`);
}
} }
} else {
logger.warn("fedimbed", `Got non-JSON for "${url}": ${rawPostData}`);
}
if (postData?.error) { if (rawPostData?.startsWith("{")) {
logger.error("fedimbed", `Received error for "${url}": ${postData.error}`); 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) { if (!postData) {
// We failed to get post. // We failed to get post. (or we forced ourselves to be here)
// Assume it was due to AFM or forced HTTP signatures and use MastoAPI // Assume it was due to AFM or some other issue and (try to) use MastoAPI (or equivalent)
// Follow redirect from /object since we need the ID from /notice // Follow redirect from /object since we need the ID from /notice
if (PATH_REGEX.pleroma.test(urlObj.pathname)) { if (PATH_REGEX.pleroma.test(urlObj.pathname)) {
@ -754,119 +711,211 @@ async function processUrl(msg, url, spoiler = false, command = false) {
if (!postData2) { if (!postData2) {
logger.warn("fedimbed", `Bailing trying to re-embed "${url}": Failed to get post from normal and MastoAPI.`); logger.warn("fedimbed", `Bailing trying to re-embed "${url}": Failed to get post from normal and MastoAPI.`);
return null;
} else if (postData2.error) { } else if (postData2.error) {
logger.error( logger.error(
"fedimbed", "fedimbed",
`Bailing trying to re-embed "${url}", MastoAPI gave us error: ${JSON.stringify(postData2.error)}` `Bailing trying to re-embed "${url}", MastoAPI gave us error: ${JSON.stringify(postData2.error)}`
); );
} else { return null;
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; postData2._fedimbed_mastoapi = true;
if (attachments) { return postData2;
for (const attachment of attachments) { } else {
const contentType = await fetch(attachment.url, { return null;
method: "HEAD", }
}).then((res) => res.headers.get("Content-Type")); } else {
return postData;
}
}
if (contentType) { async function processUrl(msg, url, spoiler = false, command = false) {
if (contentType.startsWith("image/")) { let canFedi = await hasFlag(msg.guildID, "fedimbed");
images.push({ let canBsky = await hasFlag(msg.guildID, "bskyEmbeds");
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 = if (command === true) {
attachment.pleroma?.mime_type ?? type.indexOf("/") > -1 canFedi = true;
? type canBsky = true;
: type + }
"/" +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image" let invalidUrl = false;
? "png" let urlObj;
: type == "video" try {
? "mp4" urlObj = new URL(url);
: "mpeg"); } catch {
if (type.startsWith("image")) { invalidUrl = true;
images.push({ }
url: attachment.url,
desc: attachment.description ?? attachment.comment, if (invalidUrl) return {};
type: fileType,
}); if (BSKY_DOMAINS.includes(urlObj.hostname.toLowerCase())) {
} else if (type.startsWith("video")) { if (canBsky) {
videos.push({ return await bluesky(msg, url, spoiler);
url: attachment.url, } else {
desc: attachment.description ?? attachment.comment, return {};
type: fileType, }
}); }
} else if (type.startsWith("audio")) { if (!canFedi) return {};
audios.push({
url: attachment.url, // some lemmy instances have old reddit frontend subdomains
desc: attachment.description ?? attachment.comment, // but these frontends are just frontends and dont actually expose the API
type: fileType, 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 = normalizePlatform(platform);
const images = [];
const videos = [];
const audios = [];
let content,
cw,
author,
timestamp,
title,
poll,
context,
contextUrl,
emotes = [],
sensitive = false;
// Fetch post
const postData = await fetchPost(url, platform);
if (postData._fedimbed_mastoapi) {
if (postData.url) {
const realUrlObj = new URL(postData.url);
if (realUrlObj.origin != urlObj.origin) {
platform = await resolvePlatform(postData.url);
color = PLATFORM_COLORS[platform];
platformName = normalizePlatform(platform);
url = postData.url;
urlObj = realUrlObj;
}
}
cw = postData.spoiler_warning ?? postData.spoiler_text ?? postData.cw;
content =
postData.akkoma?.source?.content ??
postData.pleroma?.content?.["text/plain"] ??
postData.text ??
postData.content;
author = {
name:
postData.account?.display_name ?? postData.account?.username ?? postData.user?.name ?? postData.user?.username,
handle: postData.account?.fqn ?? `${postData.account?.username ?? postData.user?.username}@${urlObj.hostname}`,
url: postData.account?.url ?? `${urlObj.origin}/@${postData.account?.username ?? postData.user?.username}`,
avatar: postData.account?.avatar ?? postData.user?.avatarUrl,
};
timestamp = postData.created_at ?? postData.createdAt;
emotes = postData.emojis.filter((x) => !x.name.endsWith("#.")).map((x) => ({name: `:${x.name}:`, url: x.url}));
sensitive = postData.sensitive;
if (postData.in_reply_to_id) {
// this url is a dummy and will failed if gone to normally
const replyData = await fetchPost(
`https://${urlObj.origin}/@fedimbed_reply_fake_user_sorry/${postData.in_reply_to_id}`,
platform,
true
);
if (replyData) {
contextUrl = replyData.url;
context = `Replying to: ${
replyData.account?.display_name ??
replyData.account?.username ??
replyData.user?.name ??
replyData.user?.username
} (${
replyData.account?.fqn ?? `${replyData.account?.username ?? replyData.user?.username}@${urlObj.hostname}`
})`;
}
}
const attachments = postData.media_attachments ?? postData.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,
})),
};
}
} }
} }
if (!spoiler && postData.sensitive && attachments.length > 0) {
spoiler = true;
}
if (postData.poll) {
poll = {
end: new Date(postData.poll.expires_at),
total: postData.poll.votes_count,
options: postData.poll.options.map((o) => ({
name: o.title,
count: o.votes_count,
})),
};
}
} else { } else {
if (postData.id) { if (postData.id) {
const realUrlObj = new URL(postData.id); const realUrlObj = new URL(postData.id);
if (realUrlObj.origin != urlObj.origin) { if (realUrlObj.origin != urlObj.origin) {
platform = await resolvePlatform(postData.id); platform = await resolvePlatform(postData.id);
color = PLATFORM_COLORS[platform]; color = PLATFORM_COLORS[platform];
platformName = platform.replace("gotosocial", "GoToSocial").replace(/^(.)/, (_, c) => c.toUpperCase()); platformName = normalizePlatform(platform);
url = postData.id; url = postData.id;
urlObj = realUrlObj;
} }
} }
@ -882,6 +931,49 @@ async function processUrl(msg, url, spoiler = false, command = false) {
emotes = tag.filter((x) => !!x.icon).map((x) => ({name: x.name, url: x.icon.url})); emotes = tag.filter((x) => !!x.icon).map((x) => ({name: x.name, url: x.icon.url}));
} }
if (postData.inReplyTo) {
contextUrl = postData.inReplyTo;
context = "Replying to: ";
const replyData = await fetchPost(postData.inReplyTo, platform);
if (replyData) {
if (replyData._fedimbed_mastoapi) {
context += `${
replyData.account?.display_name ??
replyData.account?.username ??
replyData.user?.name ??
replyData.user?.username
} (@${
replyData.account?.fqn ?? `${replyData.account?.username ?? replyData.user?.username}@${urlObj.hostname}`
})`;
} else {
const authorData = await signedFetch(postData.actor ?? postData.attributedTo, {
headers: {
"User-Agent": FRIENDLY_USERAGENT,
Accept: "application/activity+json",
},
})
.then((res) => res.json())
.catch((err) => {
/*if (platform !== "cohost")*/ logger.error("fedimbed", `Failed to get author for "${url}": ${err}`);
});
if (authorData) {
const authorUrlObj = new URL(authorData.url ?? authorData.id);
context += `${authorData.name} (${authorData.preferredUsername}@${authorUrlObj.hostname})`;
} else {
// bootleg author
const authorUrl = replyData.actor ?? replyData.attributedTo;
const authorUrlObj = new URL(authorUrl);
const name = authorUrlObj.pathname.substring(authorUrlObj.pathname.lastIndexOf("/") + 1);
context += `${name}@${authorUrlObj.hostname}`;
}
}
} else {
context += "<failed to get reply author>";
}
}
// NB: gts doesnt send singular attachments as array // NB: gts doesnt send singular attachments as array
const attachments = Array.isArray(postData.attachment) ? postData.attachment : [postData.attachment]; const attachments = Array.isArray(postData.attachment) ? postData.attachment : [postData.attachment];
for (const attachment of attachments) { for (const attachment of attachments) {
@ -991,8 +1083,7 @@ async function processUrl(msg, url, spoiler = false, command = false) {
}) })
.then((res) => res.json()) .then((res) => res.json())
.catch((err) => { .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 (platform !== "cohost") logger.error("fedimbed", `Failed to get author for "${url}": ${err}`);
}); });
if (authorData) { if (authorData) {
@ -1004,7 +1095,7 @@ async function processUrl(msg, url, spoiler = false, command = false) {
avatar: authorData.icon?.url, avatar: authorData.icon?.url,
}; };
} else { } else {
// bootleg author, mainly for cohost // bootleg author
const authorUrl = postData.actor ?? postData.attributedTo; const authorUrl = postData.actor ?? postData.attributedTo;
const authorUrlObj = new URL(authorUrl); const authorUrlObj = new URL(authorUrl);
const name = authorUrlObj.pathname.substring(authorUrlObj.pathname.lastIndexOf("/") + 1); const name = authorUrlObj.pathname.substring(authorUrlObj.pathname.lastIndexOf("/") + 1);
@ -1083,6 +1174,12 @@ async function processUrl(msg, url, spoiler = false, command = false) {
name: user, name: user,
url: author.url, url: author.url,
} }
: context
? {
name: context,
url: contextUrl,
icon_url: "https://cdn.discordapp.com/emojis/1308640078825787412.png",
}
: null, : null,
footer: { footer: {
text: platformName, text: platformName,