diff --git a/src/modules/fedimbed.js b/src/modules/fedimbed.js index 8ef0778..8e2fe47 100644 --- a/src/modules/fedimbed.js +++ b/src/modules/fedimbed.js @@ -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 privKey = fs.readFileSync(require.resolve("#root/priv/private.pem")); async function signedFetch(url, options) { @@ -406,7 +414,7 @@ async function bluesky(msg, url, spoiler = false) { if (data.thread.parent) { const reply = data.thread.parent.post; 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", url: `https://bsky.app/profile/${reply.author.handle}/post/${reply.uri.substring( reply.uri.lastIndexOf("/") + 1 @@ -570,104 +578,53 @@ async function bluesky(msg, url, spoiler = false) { }; } -async function processUrl(msg, url, spoiler = false, command = false) { - let canFedi = await hasFlag(msg.guildID, "fedimbed"); - let canBsky = await hasFlag(msg.guildID, "bskyEmbeds"); +async function fetchPost(url, platform, forceMastoAPI = false) { + let urlObj = new URL(url); + let postData; - if (command === true) { - canFedi = true; - 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)) ?? ""; - 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) { + if (!forceMastoAPI) { + let rawPostData; try { - rawPostData = await fetch(url, { + rawPostData = await signedFetch(url, { headers: { "User-Agent": FRIENDLY_USERAGENT, Accept: "application/activity+json", }, }).then((res) => res.text()); } catch (err) { - logger.error("fedimbed", `Failed to fetch "${url}": ${err}`); + logger.error("fedimbed", `Failed to signed fetch "${url}", retrying unsigned: ${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}"`); + 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}`); + } } - } else { - logger.warn("fedimbed", `Got non-JSON for "${url}": ${rawPostData}`); - } - if (postData?.error) { - logger.error("fedimbed", `Received error for "${url}": ${postData.error}`); + 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 + // We failed to get post. (or we forced ourselves to be here) + // 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 if (PATH_REGEX.pleroma.test(urlObj.pathname)) { @@ -754,119 +711,211 @@ async function processUrl(msg, url, spoiler = false, command = false) { if (!postData2) { logger.warn("fedimbed", `Bailing trying to re-embed "${url}": Failed to get post from normal and MastoAPI.`); + return null; } 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; + return null; + } - 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")); + postData2._fedimbed_mastoapi = true; + return postData2; + } else { + return null; + } + } else { + return postData; + } +} - 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(); +async function processUrl(msg, url, spoiler = false, command = false) { + let canFedi = await hasFlag(msg.guildID, "fedimbed"); + let canBsky = await hasFlag(msg.guildID, "bskyEmbeds"); - 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 (command === true) { + canFedi = true; + 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)) ?? ""; + 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 { 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()); + platformName = normalizePlatform(platform); 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})); } + 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 += ""; + } + } + // NB: gts doesnt send singular attachments as array const attachments = Array.isArray(postData.attachment) ? postData.attachment : [postData.attachment]; for (const attachment of attachments) { @@ -991,8 +1083,7 @@ async function processUrl(msg, url, spoiler = false, command = false) { }) .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 (platform !== "cohost")*/ logger.error("fedimbed", `Failed to get author for "${url}": ${err}`); }); if (authorData) { @@ -1004,7 +1095,7 @@ async function processUrl(msg, url, spoiler = false, command = false) { avatar: authorData.icon?.url, }; } else { - // bootleg author, mainly for cohost + // bootleg author const authorUrl = postData.actor ?? postData.attributedTo; const authorUrlObj = new URL(authorUrl); const name = authorUrlObj.pathname.substring(authorUrlObj.pathname.lastIndexOf("/") + 1); @@ -1083,6 +1174,12 @@ async function processUrl(msg, url, spoiler = false, command = false) { name: user, url: author.url, } + : context + ? { + name: context, + url: contextUrl, + icon_url: "https://cdn.discordapp.com/emojis/1308640078825787412.png", + } : null, footer: { text: platformName,