diff --git a/src/modules/fedimbed.js b/src/modules/fedimbed.js index e0b0b3b..d674c41 100644 --- a/src/modules/fedimbed.js +++ b/src/modules/fedimbed.js @@ -41,8 +41,11 @@ const PLATFORM_COLORS = { birdsitelive: 0x1da1f2, iceshrimp: 0x8e82f9, // YCbCr interpolated as the accent color is a gradient cohost: 0x83254f, + bluesky: 0x0085ff, }; +const BSKY_DOMAINS = ["bsky.app", "bskye.app", "boobsky.app", "vxbsky.app", "cbsky.app", "fxbsky.app"]; + const domainCache = new Map(); domainCache.set("cohost.org", "cohost"); // no nodeinfo @@ -106,7 +109,7 @@ async function signedFetch(url, options) { key: privKey, headers: headerNames, authorizationHeaderName: "signature", - } + }, ); options.headers = Object.assign(headers, options.headers ?? {}); @@ -114,6 +117,199 @@ async function signedFetch(url, options) { return await fetch(url, options); } +const BSKY_POST_REGEX = /^\/profile\/([a-z0-9][a-z0-9.\-]+[a-z0-9]*)\/post\/([a-z0-9]+)\/?$/i; + +async function blueskyQuoteEmbed(quote, videos) { + const embeds = []; + + const mainEmbed = { + color: PLATFORM_COLORS.bluesky, + url: `https://bsky.app/profile/${quote.author.handle}/post/${quote.uri.substring(quote.uri.lastIndexOf("/"))}`, + author: {name: "\u2198 Quoted Post"}, + title: `${quote.author.display_name} (@${quote.author.handle})`, + thumbnail: { + url: quote.author.avatar, + }, + description: quote.value.text, + footer: { + text: "Bluesky", + }, + timestamp: quote.value.createdAt, + }; + + if (quote.embeds?.[0]) { + const embed = embeds[0]; + switch (embed.$type) { + case "app.bsky.embed.images#view": { + embeds.push(...embed.images.map((image) => ({...mainEmbed, image: {url: image.fullsize}}))); + } + case "app.bsky.embed.video#view": { + const videoUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(quote.author.did)}&cid=${embed.cid}`; + const contentType = await fetch(videoUrl, { + method: "HEAD", + }).then((res) => res.headers.get("Content-Type")); + + videos.push({url: videoUrl, desc: embed.alt, type: contentType}); + + embeds.push({...mainEmbed, fields: [{name: "\u200b", value: `[Video Link](${videoUrl})`}]}); + } + default: { + embeds.push(mainEmbed); + break; + } + } + } else { + embeds.push(mainEmbed); + } + + return embeds; +} + +async function bluesky(msg, url, spoiler = false) { + const urlObj = new URL(url); + urlObj.hostname = "bsky.app"; + url = urlObj.toString(); + + const postMatch = urlObj.pathname.match(BSKY_POST_REGEX); + if (!postMatch) return {}; + + const [_, user, postId] = postMatch; + const postUri = `at://${user}/app.bsky.feed.post/${postId}`; + + const res = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${postUri}&depth=0&parentHeight`, + { + headers: { + "User-Agent": FRIENDLY_USERAGENT, + Accept: "application/json", + }, + }, + ); + + if (!res.ok) throw new Error(`Got non-OK status: ${res.status}`); + + const data = await res.json(); + + if (!data?.thread || !("$type" in data.thread) || data.thread.$type !== "app.bsky.feed.defs#threadViewPost") + throw new Error(`Did not get a valid Bluesky thread`); + + const {post} = data.thread; + + const videos = []; + const embeds = []; + let sendWait = false; + + const mainEmbed = { + color: PLATFORM_COLORS.bluesky, + url, + title: `${post.author.display_name} (@${post.author.handle})`, + description: post.record.text, + thumbnail: { + url: post.author.avatar, + }, + footer: { + text: "Bluesky", + }, + timestamp: post.record.createdAt, + }; + + if (post.embed) { + switch (post.embed.$type) { + case "app.bsky.embed.images#view": { + embeds.push(...post.embed.images.map((image) => ({...mainEmbed, image: {url: image.fullsize}}))); + } + case "app.bsky.embed.video#view": { + const videoUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(post.author.did)}&cid=${post.embed.cid}`; + const contentType = await fetch(videoUrl, { + method: "HEAD", + }).then((res) => res.headers.get("Content-Type")); + + videos.push({url: videoUrl, desc: post.embed.alt, type: contentType}); + + embeds.push({...mainEmbed, fields: [{name: "\u200b", value: `[Video Link](${videoUrl})`}]}); + } + case "app.bsky.embed.record#view": { + const quote = post.embed.record; + embeds.push(mainEmbed, ...(await blueskyQuoteEmbed(quote, videos))); + } + case "app.bsky.embed.recordWithMedia#view": { + if (post.embed.media.$type === "app.bsky.embed.images#view") { + embeds.push(...post.embed.media.images.map((image) => ({...mainEmbed, image: {url: image.fullsize}}))); + } else if (post.embed.media.$type === "app.bsky.embed.video#view") { + const videoUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(post.author.did)}&cid=${post.embed.media.cid}`; + const contentType = await fetch(videoUrl, { + method: "HEAD", + }).then((res) => res.headers.get("Content-Type")); + + videos.push({url: videoUrl, desc: post.embed.alt, type: contentType}); + + embeds.push({...mainEmbed, fields: [{name: "\u200b", value: `[Video Link](${videoUrl})`}]}); + } + + embeds.push(...(await blueskyQuoteEmbed(post.embed.record.record, videos))); + } + default: { + embeds.push(mainEmbed); + break; + } + } + } else { + embeds.push(mainEmbed); + } + + if (videos.length > 0) { + sendWait = true; + if (msg instanceof Message) await msg.addReaction("\uD83D\uDCE4"); + } + + const guild = msg.channel?.guild ?? (msg.guildID ? hf.bot.guilds.get(msg.guildID) : false); + 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"))); + + 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: + (spoiler ? "SPOILER_" : "") + + (attachment.type.indexOf("/") > -1 + ? attachment.type.replace("/", ".").replace("quicktime", "mov") + : attachment.type + "." + (url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? "mp4")), + file, + }); + } + } + } + + return { + response: { + content: spoiler ? `|| ${url} ||` : "", + embeds, + attachments: files, + allowedMentions: { + repliedUser: false, + }, + messageReference: { + messageID: msg.id, + }, + }, + sendWait, + }; +} + async function processUrl(msg, url, spoiler = false) { let invalidUrl = false; let urlObj; @@ -125,6 +321,8 @@ async function processUrl(msg, url, spoiler = false) { if (invalidUrl) return {}; + if (BSKY_DOMAINS.includes(urlObj.hostname)) return await bluesky(msg, url, spoiler); + // 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.")) { @@ -242,7 +440,7 @@ async function processUrl(msg, url, spoiler = false) { if (redirUrl) { logger.verbose( "fedimbed", - `Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(options)}, ${JSON.stringify(headers)}` + `Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(options)}, ${JSON.stringify(headers)}`, ); let rawPostData2; try { @@ -252,7 +450,7 @@ async function processUrl(msg, url, spoiler = false) { 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}`); @@ -265,7 +463,7 @@ async function processUrl(msg, url, spoiler = false) { headers: Object.assign(headers, { "User-Agent": FRIENDLY_USERAGENT, }), - }) + }), ).then((res) => res.text()); } catch (err) { logger.error("fedimbed", `Failed to fetch "${url}" via MastoAPI: ${err}`); @@ -284,7 +482,7 @@ async function processUrl(msg, url, spoiler = false) { } else if (postData2.error) { logger.error( "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 { cw = postData2.spoiler_warning ?? postData2.spoiler_text ?? postData2.cw; @@ -339,15 +537,15 @@ async function processUrl(msg, url, spoiler = false) { const type = attachment.type?.toLowerCase(); const fileType = - attachment.pleroma?.mime_type ?? type.indexOf("/") > -1 + (attachment.pleroma?.mime_type ?? type.indexOf("/") > -1) ? type : type + "/" + - (url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image" + ((url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image") ? "png" : type == "video" - ? "mp4" - : "mpeg"); + ? "mp4" + : "mpeg"); if (type.startsWith("image")) { images.push({ url: attachment.url, @@ -465,7 +663,7 @@ async function processUrl(msg, url, spoiler = false) { ? type : type + "/" + - (url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image" ? "png" : type == "video" ? "mp4" : "mpeg"); + ((url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image") ? "png" : type == "video" ? "mp4" : "mpeg"); if (type.startsWith("image")) { images.push({ url: attachment.url, @@ -678,7 +876,7 @@ async function processUrl(msg, url, spoiler = false) { const bar = Math.round(percent * 30); return `**${o.name}** (${o.count}, ${Math.round(percent * 100)}%)\n\`[${"=".repeat(bar)}${" ".repeat( - 30 - bar + 30 - bar, )}]\``; }) .join("\n\n") + `\n\n${poll.total} votes \u2022 Ends `, @@ -873,8 +1071,8 @@ async function processUrl(msg, url, spoiler = false) { cw != "" && (images.length > 0 || videos.length > 0 || audios.length > 0) ? `:warning: ${cw} || ${url} ||` : spoiler - ? `|| ${url} ||` - : "", + ? `|| ${url} ||` + : "", embeds, attachments: files, allowedMentions: { @@ -937,7 +1135,7 @@ events.add("messageCreate", "fedimbed", async function (msg) { }); const fedimbedCommand = new InteractionCommand("fedimbed"); -fedimbedCommand.helpText = "Better embeds for fediverse (Mastodon, Pleroma, etc) posts"; +fedimbedCommand.helpText = "Better embeds for fediverse (Mastodon, Pleroma, etc) and Bluesky posts"; fedimbedCommand.options.url = { name: "url", type: ApplicationCommandOptionTypes.STRING,