fedimbed: bluesky support
This commit is contained in:
		
							parent
							
								
									e3991b5be7
								
							
						
					
					
						commit
						ef9d4b4d0e
					
				
					 1 changed files with 212 additions and 14 deletions
				
			
		| 
						 | 
				
			
			@ -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,11 +537,11 @@ 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"
 | 
			
		||||
| 
						 | 
				
			
			@ -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 <t:${Math.floor(poll.end.getTime() / 1000)}:R>`,
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue