fedimbed: bluesky support

This commit is contained in:
Cynthia Foxwell 2024-11-08 23:56:29 -07:00
parent e3991b5be7
commit ef9d4b4d0e

View file

@ -41,8 +41,11 @@ const PLATFORM_COLORS = {
birdsitelive: 0x1da1f2, birdsitelive: 0x1da1f2,
iceshrimp: 0x8e82f9, // YCbCr interpolated as the accent color is a gradient iceshrimp: 0x8e82f9, // YCbCr interpolated as the accent color is a gradient
cohost: 0x83254f, cohost: 0x83254f,
bluesky: 0x0085ff,
}; };
const BSKY_DOMAINS = ["bsky.app", "bskye.app", "boobsky.app", "vxbsky.app", "cbsky.app", "fxbsky.app"];
const domainCache = new Map(); const domainCache = new Map();
domainCache.set("cohost.org", "cohost"); // no nodeinfo domainCache.set("cohost.org", "cohost"); // no nodeinfo
@ -106,7 +109,7 @@ async function signedFetch(url, options) {
key: privKey, key: privKey,
headers: headerNames, headers: headerNames,
authorizationHeaderName: "signature", authorizationHeaderName: "signature",
} },
); );
options.headers = Object.assign(headers, options.headers ?? {}); options.headers = Object.assign(headers, options.headers ?? {});
@ -114,6 +117,199 @@ async function signedFetch(url, options) {
return await fetch(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) { async function processUrl(msg, url, spoiler = false) {
let invalidUrl = false; let invalidUrl = false;
let urlObj; let urlObj;
@ -125,6 +321,8 @@ async function processUrl(msg, url, spoiler = false) {
if (invalidUrl) return {}; if (invalidUrl) return {};
if (BSKY_DOMAINS.includes(urlObj.hostname)) return await bluesky(msg, url, spoiler);
// some lemmy instances have old reddit frontend subdomains // some lemmy instances have old reddit frontend subdomains
// but these frontends are just frontends and dont actually expose the API // but these frontends are just frontends and dont actually expose the API
if (urlObj.hostname.startsWith("old.")) { if (urlObj.hostname.startsWith("old.")) {
@ -242,7 +440,7 @@ async function processUrl(msg, url, spoiler = false) {
if (redirUrl) { if (redirUrl) {
logger.verbose( logger.verbose(
"fedimbed", "fedimbed",
`Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(options)}, ${JSON.stringify(headers)}` `Redirecting "${url}" to "${redirUrl}": ${JSON.stringify(options)}, ${JSON.stringify(headers)}`,
); );
let rawPostData2; let rawPostData2;
try { try {
@ -252,7 +450,7 @@ async function processUrl(msg, url, spoiler = false) {
headers: Object.assign(headers, { headers: Object.assign(headers, {
"User-Agent": FRIENDLY_USERAGENT, "User-Agent": FRIENDLY_USERAGENT,
}), }),
}) }),
).then((res) => res.text()); ).then((res) => res.text());
} catch (err) { } catch (err) {
logger.error("fedimbed", `Failed to signed fetch "${url}" via MastoAPI, retrying unsigned: ${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, { headers: Object.assign(headers, {
"User-Agent": FRIENDLY_USERAGENT, "User-Agent": FRIENDLY_USERAGENT,
}), }),
}) }),
).then((res) => res.text()); ).then((res) => res.text());
} catch (err) { } catch (err) {
logger.error("fedimbed", `Failed to fetch "${url}" via MastoAPI: ${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) { } 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 { } else {
cw = postData2.spoiler_warning ?? postData2.spoiler_text ?? postData2.cw; 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 type = attachment.type?.toLowerCase();
const fileType = const fileType =
attachment.pleroma?.mime_type ?? type.indexOf("/") > -1 (attachment.pleroma?.mime_type ?? type.indexOf("/") > -1)
? type ? type
: type + : type +
"/" + "/" +
(url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image" ((url.match(/\.([a-z0-9]{3,4})$/)?.[0] ?? type == "image")
? "png" ? "png"
: type == "video" : type == "video"
? "mp4" ? "mp4"
: "mpeg"); : "mpeg");
if (type.startsWith("image")) { if (type.startsWith("image")) {
images.push({ images.push({
url: attachment.url, url: attachment.url,
@ -465,7 +663,7 @@ async function processUrl(msg, url, spoiler = false) {
? type ? type
: 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")) { if (type.startsWith("image")) {
images.push({ images.push({
url: attachment.url, url: attachment.url,
@ -678,7 +876,7 @@ async function processUrl(msg, url, spoiler = false) {
const bar = Math.round(percent * 30); const bar = Math.round(percent * 30);
return `**${o.name}** (${o.count}, ${Math.round(percent * 100)}%)\n\`[${"=".repeat(bar)}${" ".repeat( 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 <t:${Math.floor(poll.end.getTime() / 1000)}:R>`, .join("\n\n") + `\n\n${poll.total} votes \u2022 Ends <t:${Math.floor(poll.end.getTime() / 1000)}:R>`,
@ -873,8 +1071,8 @@ async function processUrl(msg, url, spoiler = false) {
cw != "" && (images.length > 0 || videos.length > 0 || audios.length > 0) cw != "" && (images.length > 0 || videos.length > 0 || audios.length > 0)
? `:warning: ${cw} || ${url} ||` ? `:warning: ${cw} || ${url} ||`
: spoiler : spoiler
? `|| ${url} ||` ? `|| ${url} ||`
: "", : "",
embeds, embeds,
attachments: files, attachments: files,
allowedMentions: { allowedMentions: {
@ -937,7 +1135,7 @@ events.add("messageCreate", "fedimbed", async function (msg) {
}); });
const fedimbedCommand = new InteractionCommand("fedimbed"); 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 = { fedimbedCommand.options.url = {
name: "url", name: "url",
type: ApplicationCommandOptionTypes.STRING, type: ApplicationCommandOptionTypes.STRING,