initial test of fedimbed
This commit is contained in:
		
							parent
							
								
									da72bf17c1
								
							
						
					
					
						commit
						3f39f840ff
					
				
					 2 changed files with 287 additions and 0 deletions
				
			
		| 
						 | 
				
			
			@ -3,6 +3,7 @@ const {getGuildData, setGuildData} = require("./guildData.js");
 | 
			
		|||
const flags = Object.freeze({
 | 
			
		||||
  codePreviews: 1 << 0,
 | 
			
		||||
  tweetUnrolling: 1 << 1,
 | 
			
		||||
  fedimbed: 1 << 2,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function getFlags(guildId) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										286
									
								
								src/modules/fedimbed.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										286
									
								
								src/modules/fedimbed.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,286 @@
 | 
			
		|||
const events = require("../lib/events.js");
 | 
			
		||||
const logger = require("../lib/logger.js");
 | 
			
		||||
const {hasFlag} = require("../lib/guildSettings.js");
 | 
			
		||||
const {parseHtmlEntities} = require("../lib/utils.js");
 | 
			
		||||
 | 
			
		||||
const FRIENDLY_USERAGENT =
 | 
			
		||||
  "HiddenPhox/fedimbed (https://gitlab.com/Cynosphere/HiddenPhox)";
 | 
			
		||||
 | 
			
		||||
const URLS_REGEX = /(?:\s|^)(https?:\/\/[^\s<]+[^<.,:;"'\]\s])/g;
 | 
			
		||||
 | 
			
		||||
const PATH_REGEX = {
 | 
			
		||||
  mastodon: /^\/@?(.+?)\/(statuses\/)?\d+\/?/,
 | 
			
		||||
  pleroma:
 | 
			
		||||
    /^\/objects\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/?/,
 | 
			
		||||
  pleroma2: /^\/notice\/[A-Za-z0-9]+\/?/,
 | 
			
		||||
  misskey: /^\/notes\/[a-z0-9]+\/?/,
 | 
			
		||||
  gotosocial: /^\/@(.+?)\/statuses\/[0-9A-Z]+\/?/,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const PLATFORM_COLORS = {
 | 
			
		||||
  mastodon: 0x2791da,
 | 
			
		||||
  pleroma: 0xfba457,
 | 
			
		||||
  akkoma: 0x593196,
 | 
			
		||||
  misskey: 0x99c203,
 | 
			
		||||
  calckey: 0x31748f,
 | 
			
		||||
  gotosocial: 0xff853e,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const domainCache = new Map();
 | 
			
		||||
async function resolvePlatform(url) {
 | 
			
		||||
  const urlObj = new URL(url);
 | 
			
		||||
  if (domainCache.has(urlObj.hostname)) return domainCache.get(urlObj.hostname);
 | 
			
		||||
 | 
			
		||||
  const probe = await fetch(urlObj.origin + "/.well-known/nodeinfo", {
 | 
			
		||||
    headers: {"User-Agent": FRIENDLY_USERAGENT},
 | 
			
		||||
  }).then((res) => res.json());
 | 
			
		||||
 | 
			
		||||
  if (!probe?.links) {
 | 
			
		||||
    logger.error("fedimbed", `No nodeinfo for "${urlObj.hostname}"???`);
 | 
			
		||||
    domainCache.set(urlObj.hostname, null);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nodeinfo = await fetch(probe.links[probe.links.length - 1].href, {
 | 
			
		||||
    headers: {"User-Agent": FRIENDLY_USERAGENT},
 | 
			
		||||
  }).then((res) => res.json());
 | 
			
		||||
 | 
			
		||||
  if (!nodeinfo?.software?.name) {
 | 
			
		||||
    logger.error(
 | 
			
		||||
      "fedimbed",
 | 
			
		||||
      `Got nodeinfo for "${urlObj.hostname}", but missing software name.`
 | 
			
		||||
    );
 | 
			
		||||
    domainCache.set(urlObj.hostname, null);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  domainCache.set(urlObj.hostname, nodeinfo.software.name);
 | 
			
		||||
  return nodeinfo.software.name;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function processUrl(msg, url) {
 | 
			
		||||
  const urlObj = new URL(url);
 | 
			
		||||
  const platform = await resolvePlatform(url);
 | 
			
		||||
  const color = PLATFORM_COLORS[platform];
 | 
			
		||||
  const platformName = platform
 | 
			
		||||
    .replace("gotosocial", "GoToSocial")
 | 
			
		||||
    .replace(/^(.)/, (_, c) => c.toUpperCase());
 | 
			
		||||
 | 
			
		||||
  const attachments = [];
 | 
			
		||||
  let content, cw, author;
 | 
			
		||||
 | 
			
		||||
  // Fetch post
 | 
			
		||||
  const postData = await fetch(url, {
 | 
			
		||||
    headers: {
 | 
			
		||||
      "User-Agent": FRIENDLY_USERAGENT,
 | 
			
		||||
      Accept: "application/activity+json",
 | 
			
		||||
      "Content-Type": "application/activity+json",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
    .then((res) => res.json())
 | 
			
		||||
    .catch((err) => {
 | 
			
		||||
      logger.error("fedimbed", `Failed to fetch "${url}" as AS2: ${err}`);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  if (postData.error) {
 | 
			
		||||
    logger.error("fedimbed", `Received error for "${url}": ${postData.error}`);
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!postData) {
 | 
			
		||||
    // We failed to get post.
 | 
			
		||||
    // If we're fetching from Pleroma, assume it was due to AMF and use MastoAPI
 | 
			
		||||
    if (PATH_REGEX.pleroma.test(urlObj.pathname)) {
 | 
			
		||||
      url = await fetch(url, {
 | 
			
		||||
        method: "HEAD",
 | 
			
		||||
        headers: {
 | 
			
		||||
          "User-Agent": FRIENDLY_USERAGENT,
 | 
			
		||||
        },
 | 
			
		||||
        redirect: "manual",
 | 
			
		||||
      }).then((res) => res.headers.get("location"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (PATH_REGEX.pleroma2.test(urlObj.pathname)) {
 | 
			
		||||
      const postData2 = await fetch(url.replace("notice", "api/v1/statuses"), {
 | 
			
		||||
        headers: {
 | 
			
		||||
          "User-Agent": FRIENDLY_USERAGENT,
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
        .then((res) => res.json())
 | 
			
		||||
        .catch((err) => {
 | 
			
		||||
          logger.error(
 | 
			
		||||
            "fedimbed",
 | 
			
		||||
            `Failed to fetch "${url}" as MastoAPI: ${err}`
 | 
			
		||||
          );
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      if (!postData2) {
 | 
			
		||||
        logger.warn(
 | 
			
		||||
          "fedimbed",
 | 
			
		||||
          `Bailing trying to re-embed "${url}": Failed to get post from both AS2 and MastoAPI.`
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        cw = postData2.spoiler_warning;
 | 
			
		||||
        content =
 | 
			
		||||
          postData2.akkoma?.source?.content ??
 | 
			
		||||
          postData2.pleroma?.content?.["text/plain"] ??
 | 
			
		||||
          postData2.content;
 | 
			
		||||
        author = {
 | 
			
		||||
          name: postData2.account.display_name,
 | 
			
		||||
          handle: postData2.account.fqn,
 | 
			
		||||
          url: postData2.account.url,
 | 
			
		||||
          avatar: postData2.account.avatar,
 | 
			
		||||
        };
 | 
			
		||||
        for (const attachment of postData.media_attachments) {
 | 
			
		||||
          attachments.push({
 | 
			
		||||
            url: attachment.url,
 | 
			
		||||
            desc: attachment.description,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    content = postData.content;
 | 
			
		||||
    cw = postData.summary;
 | 
			
		||||
    for (const attachment of postData.attachment) {
 | 
			
		||||
      attachments.push({
 | 
			
		||||
        url: attachment.url,
 | 
			
		||||
        desc: attachment.name,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Author data is not sent with the post with AS2
 | 
			
		||||
    const authorData = await fetch(postData.actor, {
 | 
			
		||||
      headers: {
 | 
			
		||||
        "User-Agent": FRIENDLY_USERAGENT,
 | 
			
		||||
        Accept: "application/activity+json",
 | 
			
		||||
        "Content-Type": "application/activity+json",
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
      .then((res) => res.json())
 | 
			
		||||
      .catch((err) => {
 | 
			
		||||
        logger.error("fedimbed", `Failed to get author for "${url}": ${err}`);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    if (authorData) {
 | 
			
		||||
      author = {
 | 
			
		||||
        name: authorData.name,
 | 
			
		||||
        handle: `${authorData.preferredUsername}@${urlObj.hostname}`,
 | 
			
		||||
        url: authorData.url,
 | 
			
		||||
        avatar: authorData.icon.url,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // We could just continue without author but it'd look ugly and be confusing.
 | 
			
		||||
  if (!author) {
 | 
			
		||||
    logger.warn(
 | 
			
		||||
      "fedimbed"`Bailing trying to re-embed "${url}": Failed to get author.`
 | 
			
		||||
    );
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Start constructing embed
 | 
			
		||||
  content = content ?? "";
 | 
			
		||||
  cw = cw ?? "";
 | 
			
		||||
 | 
			
		||||
  // TODO: convert certain HTML tags back to markdown
 | 
			
		||||
  content = content.replace(/(<([^>]+)>)/gi,"");
 | 
			
		||||
  content = parseHtmlEntities(content);
 | 
			
		||||
 | 
			
		||||
  cw = cw.replace(/(<([^>]+)>)/gi,"");
 | 
			
		||||
  cw = parseHtmlEntities(cw);
 | 
			
		||||
 | 
			
		||||
  let desc = "";
 | 
			
		||||
  let MAX_LENGTH = 3999;
 | 
			
		||||
  if (cw != "") {
 | 
			
		||||
    desc += "\u26a0 " + cw + "\n\n||" + content + "||";
 | 
			
		||||
    MAX_LENGTH -= 8 - cw.length;
 | 
			
		||||
  } else {
 | 
			
		||||
    desc = content;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (desc.length > MAX_LENGTH) {
 | 
			
		||||
    if (desc.endsWith("||")) {
 | 
			
		||||
      desc = desc.substring(0, MAX_LENGTH - 2);
 | 
			
		||||
      desc += "\u2026||";
 | 
			
		||||
    } else {
 | 
			
		||||
      desc = desc.substring(0, MAX_LENGTH) + "\u2026";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const baseEmbed = {
 | 
			
		||||
    color,
 | 
			
		||||
    url,
 | 
			
		||||
    description: desc,
 | 
			
		||||
    title: `${author.name} (${author.handle})`,
 | 
			
		||||
    author: {
 | 
			
		||||
      name: platformName,
 | 
			
		||||
    },
 | 
			
		||||
    thumbnail: {
 | 
			
		||||
      url: author.url,
 | 
			
		||||
    },
 | 
			
		||||
    fields: [],
 | 
			
		||||
  };
 | 
			
		||||
  if (attachments.length > 0) {
 | 
			
		||||
    if (attachments.length > 1) {
 | 
			
		||||
      baseEmbed.fields.push({
 | 
			
		||||
        name: "Images",
 | 
			
		||||
        value: attachments
 | 
			
		||||
          .map((attachment, index) => `[Image ${index + 1}](${attachment.url})`)
 | 
			
		||||
          .join(" | "),
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      baseEmbed.fields.push({
 | 
			
		||||
        name: "Image",
 | 
			
		||||
        value: `[Click for image](${attachments[0].url})`,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const embeds = [];
 | 
			
		||||
 | 
			
		||||
  for (const attachment of attachments) {
 | 
			
		||||
    const embed = Object.assign({}, baseEmbed);
 | 
			
		||||
    embed.image = {
 | 
			
		||||
      url: attachment.url,
 | 
			
		||||
    };
 | 
			
		||||
    embeds.push(embed);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  msg.channel.createMessage({
 | 
			
		||||
    embeds,
 | 
			
		||||
    allowedMentions: {
 | 
			
		||||
      repliedUser: false,
 | 
			
		||||
    },
 | 
			
		||||
    messageReference: {
 | 
			
		||||
      messageID: msg.id,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
events.add("messageCreate", "fedimbed", async function (msg) {
 | 
			
		||||
  if (!msg.guildID) return;
 | 
			
		||||
  if (!(await hasFlag(msg.guildID, "fedimbed"))) return;
 | 
			
		||||
  if (!msg.content || msg.content == "") return;
 | 
			
		||||
 | 
			
		||||
  if (URLS_REGEX.test(msg.content)) {
 | 
			
		||||
    const urls = msg.content.match(URLS_REGEX);
 | 
			
		||||
    for (const url of urls) {
 | 
			
		||||
      for (const service of Object.keys(PATH_REGEX)) {
 | 
			
		||||
        const regex = PATH_REGEX[service];
 | 
			
		||||
        const urlObj = new URL(url);
 | 
			
		||||
        if (regex.test(urlObj.pathname)) {
 | 
			
		||||
          logger.verbose(
 | 
			
		||||
            "fedimbed",
 | 
			
		||||
            `Hit "${service}" for "${url}", processing now.`
 | 
			
		||||
          );
 | 
			
		||||
          await processUrl(msg, url).catch((err) => {
 | 
			
		||||
            logger.error("fedimbed", `Error processing "${url}": ${err}`);
 | 
			
		||||
          });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue