diff --git a/src/app.ts b/src/app.ts index 32b1f37..bf0d554 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import Command from "#utils/Command.js"; import { ButtonStyles, Client, ComponentTypes, InteractionTypes } from "oceanic.js"; import "./express-server.js"; import database from "#utils/mongodb-database.js"; -import interactWithPost from "#utils/interact-with-post.js"; +import interactWithPost from "#utils/interact-with-bluesky.js"; import { existsSync, mkdirSync, rmSync } from "fs"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; @@ -149,14 +149,14 @@ client.on("messageCreate", async (message) => { for (const sessionData of sessionDataList) { // Check if the user added a Bluesky post. - const matchingRegex = /https?:\/\/bsky.app\/profile\/(?\S+)\/post\/(?\S+)/gm; + const matchingRegex = /https?:\/\/bsky.app\/profile\/(?\S+)\/post\/(?\S+)/gm; const matches = [...message.content.matchAll(matchingRegex)]; for (const match of matches) { if (match.groups) { - const {rkey, postCreatorHandle} = match.groups; - await interactWithPost({rkey, postCreatorHandle, actorDID: sessionData.sub, guildID}, "repost"); + const {rkey, targetHandle} = match.groups; + await interactWithPost({rkey, targetHandle, actorDID: sessionData.sub, guildID}, "repost"); await message.createReaction("♻️"); } @@ -187,14 +187,14 @@ client.on("messageReactionRemove", async (uncachedMessage, reactor, reaction) => for (const sessionData of sessionDataList) { // Check if the user added a Bluesky post. - const matchingRegex = /https?:\/\/bsky.app\/profile\/(?\S+)\/post\/(?\S+)/gm; + const matchingRegex = /https?:\/\/bsky.app\/profile\/(?\S+)\/post\/(?\S+)/gm; const matches = [...message.content.matchAll(matchingRegex)]; for (const match of matches) { if (match.groups) { - const {rkey, postCreatorHandle} = match.groups; - await interactWithPost({rkey, postCreatorHandle, actorDID: sessionData.sub, guildID}, "deleteRepost"); + const {rkey, targetHandle} = match.groups; + await interactWithPost({rkey, targetHandle, actorDID: sessionData.sub, guildID}, "deleteRepost"); } diff --git a/src/commands/follow.ts b/src/commands/follow.ts new file mode 100644 index 0000000..7dc5a43 --- /dev/null +++ b/src/commands/follow.ts @@ -0,0 +1,23 @@ +import Command from "#utils/Command.js" +import { ApplicationCommandOptionTypes } from "oceanic.js"; +import interactWithBlueskyNow from "#utils/interact-with-bluesky-now.js"; + +const unlikeNowCommand = new Command({ + name: "follow", + description: "Follow an account on Bluesky.", + options: [ + { + type: ApplicationCommandOptionTypes.STRING, + name: "handle", + description: "What's the name of the account?", + required: true + } + ], + async action(interaction) { + + return await interactWithBlueskyNow(interaction, "follow", "follow"); + + } +}); + +export default unlikeNowCommand; \ No newline at end of file diff --git a/src/commands/like/now.ts b/src/commands/like/now.ts index a7d8d3b..697c9e5 100644 --- a/src/commands/like/now.ts +++ b/src/commands/like/now.ts @@ -1,6 +1,6 @@ import Command from "#utils/Command.js" import { ApplicationCommandOptionTypes } from "oceanic.js"; -import interactWithPostNow from "#utils/interact-with-post-now.js"; +import interactWithBlueskyNow from "#utils/interact-with-bluesky-now.js"; const likeNowSubCommand = new Command({ name: "now", @@ -15,7 +15,7 @@ const likeNowSubCommand = new Command({ ], async action(interaction) { - return await interactWithPostNow(interaction, "like/now", "like"); + return await interactWithBlueskyNow(interaction, "like/now", "like"); } }); diff --git a/src/commands/repost/now.ts b/src/commands/repost/now.ts index 42b47fb..6d59171 100644 --- a/src/commands/repost/now.ts +++ b/src/commands/repost/now.ts @@ -1,6 +1,6 @@ import Command from "#utils/Command.js" import { ApplicationCommandOptionTypes } from "oceanic.js"; -import interactWithPostNow from "#utils/interact-with-post-now.js"; +import interactWithBlueskyNow from "#utils/interact-with-bluesky-now.js"; const repostNowSubCommand = new Command({ name: "now", @@ -15,7 +15,7 @@ const repostNowSubCommand = new Command({ ], async action(interaction) { - return await interactWithPostNow(interaction, "repost/now", "repost"); + return await interactWithBlueskyNow(interaction, "repost/now", "repost"); } }); diff --git a/src/commands/unlike.ts b/src/commands/unlike.ts index 20f1a87..34f4361 100644 --- a/src/commands/unlike.ts +++ b/src/commands/unlike.ts @@ -1,6 +1,6 @@ import Command from "#utils/Command.js" import { ApplicationCommandOptionTypes } from "oceanic.js"; -import interactWithPostNow from "#utils/interact-with-post-now.js"; +import interactWithBlueskyNow from "#utils/interact-with-bluesky-now.js"; const unlikeNowCommand = new Command({ name: "unlike", @@ -15,7 +15,7 @@ const unlikeNowCommand = new Command({ ], async action(interaction) { - return await interactWithPostNow(interaction, "unlike", "deleteLike"); + return await interactWithBlueskyNow(interaction, "unlike", "deleteLike"); } }); diff --git a/src/commands/unrepost.ts b/src/commands/unrepost.ts index 7c269e6..0f8875a 100644 --- a/src/commands/unrepost.ts +++ b/src/commands/unrepost.ts @@ -1,6 +1,6 @@ import Command from "#utils/Command.js" import { ApplicationCommandOptionTypes } from "oceanic.js"; -import interactWithPostNow from "#utils/interact-with-post-now.js"; +import interactWithBlueskyNow from "#utils/interact-with-bluesky-now.js"; const unrepostNowCommand = new Command({ name: "unrepost", @@ -15,7 +15,7 @@ const unrepostNowCommand = new Command({ ], async action(interaction) { - return await interactWithPostNow(interaction, "unrepost", "deleteRepost"); + return await interactWithBlueskyNow(interaction, "unrepost", "deleteRepost"); } }); diff --git a/src/utils/interact-with-post-now.ts b/src/utils/interact-with-bluesky-now.ts similarity index 91% rename from src/utils/interact-with-post-now.ts rename to src/utils/interact-with-bluesky-now.ts index a0c2bb7..ae40e53 100644 --- a/src/utils/interact-with-post-now.ts +++ b/src/utils/interact-with-bluesky-now.ts @@ -1,5 +1,5 @@ import { ButtonStyles, CommandInteraction, ComponentInteraction, ComponentTypes, ModalSubmitInteraction, StringSelectMenu } from "oceanic.js"; -import interactWithPost from "./interact-with-post.js"; +import interactWithBluesky from "./interact-with-bluesky.js"; import database from "./mongodb-database.js"; import createAccountSelector from "./create-account-selector.js"; import getGuildIDFromInteraction from "./get-guild-id-from-interaction.js"; @@ -11,21 +11,22 @@ import { authenticator } from "otplib"; import IncorrectDecryptionKeyError from "./errors/IncorrectDecryptionKeyError.js"; import MFAIncorrectCodeError from "./errors/MFAIncorrectCodeError.js"; -async function interactWithPostNow(interaction: CommandInteraction | ComponentInteraction | ModalSubmitInteraction, customIDPrefix: string, action: "deleteRepost" | "like" | "deleteLike" | "repost") { +async function interactWithBlueskyNow(interaction: CommandInteraction | ComponentInteraction | ModalSubmitInteraction, customIDPrefix: string, action: "follow" | "deleteRepost" | "like" | "deleteLike" | "repost") { const guildID = getGuildIDFromInteraction(interaction); async function confirmAction(options: {interaction: ModalSubmitInteraction | ComponentInteraction, guildID: string, actorDID: string, decryptionKey?: string}) { // Repost the post. - await interactWithPost(options, action); + await interactWithBluesky(options, action); // Let the user know that we liked the post. const responses = { repost: "♻️", like: "💖", deleteLike: "💔", - deleteRepost: "🗑️" + deleteRepost: "🗑️", + follow: "➕" }; await interaction.editOriginal({ content: responses[action], @@ -40,7 +41,8 @@ async function interactWithPostNow(interaction: CommandInteraction | ComponentIn // Ask the user which user they want to post as. await interaction.defer(64); const postLink = interaction.data.options.getString("link"); - if (!postLink) throw new Error(); + const handle = interaction.data.options.getString("handle"); + if (!postLink && !handle) throw new Error(); const defaultSession = await database.collection("sessions").findOne({guildID, isDefault: true}); const accountSelector = await createAccountSelector(guildID, customIDPrefix, (did) => did === defaultSession?.sub); @@ -50,7 +52,7 @@ async function interactWithPostNow(interaction: CommandInteraction | ComponentIn embeds: [ { footer: { - text: postLink.split("?")[0] + text: handle! ?? postLink!.split("?")[0] } } ], @@ -186,4 +188,4 @@ async function interactWithPostNow(interaction: CommandInteraction | ComponentIn } -export default interactWithPostNow; \ No newline at end of file +export default interactWithBlueskyNow; \ No newline at end of file diff --git a/src/utils/interact-with-bluesky.ts b/src/utils/interact-with-bluesky.ts new file mode 100644 index 0000000..dbd374c --- /dev/null +++ b/src/utils/interact-with-bluesky.ts @@ -0,0 +1,78 @@ +import { ComponentInteraction, ModalSubmitInteraction } from "oceanic.js"; +import blueskyClient from "./bluesky-client.js"; +import { Agent } from "@atproto/api"; +import { isThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs.js"; + +async function interactWithBluesky(source: {interaction?: ModalSubmitInteraction | ComponentInteraction, rkey?: string, targetHandle?: string, actorDID?: string, guildID: string, decryptionKey?: string}, action: "follow" | "deletePost" | "deleteLike" | "like" | "deleteRepost" | "repost") { + + let {interaction, rkey, targetHandle, actorDID} = source; + + if (interaction && !targetHandle) { + + // Get the rkey of the post. + const originalMessage = await interaction.getOriginal(); + const originalEmbed = originalMessage.embeds[0]; + const value = originalEmbed?.footer?.text; + + if (action === "follow") { + + targetHandle = value; + + } else { + + const postSplit = value?.split("/"); + rkey = postSplit?.pop(); + if (!postSplit || !rkey) throw new Error(); + + targetHandle = postSplit[4]; + + } + + } + + if (!targetHandle || !actorDID || (action !== "follow" && !rkey)) throw new Error(); + + // Get the CID of the post if necessary. + const session = await blueskyClient.restore(actorDID, "auto", {guildID: source.guildID, decryptionKey: source.decryptionKey}); + const agent = new Agent(session); + const targetDID = targetHandle.includes("did:") ? targetHandle : await blueskyClient.handleResolver.resolve(targetHandle); + let cid; + let uri; + if (!targetDID) throw new Error(); + if (action !== "follow") { + + if (!rkey) throw new Error(); + + const recordResponse = await agent.com.atproto.repo.getRecord({ + collection: "app.bsky.feed.post", + repo: targetDID, + rkey + }); + + cid = recordResponse.data.cid; + + if (!cid) throw new Error(); + + // Get the URI we need. + uri = `at://${targetDID}/app.bsky.feed.post/${rkey}`; + if (action === "deleteLike" || action === "deleteRepost") { + + const response = await agent.getPostThread({uri}); + if (isThreadViewPost(response.data.thread)) { + + const possibleURI = response.data.thread.post.viewer?.[action === "deleteLike" ? "like" : "repost"]; + if (!possibleURI) return; + uri = possibleURI; + + } + + } + + } + + // Interact with Bluesky. + await agent[action](uri ?? targetDID, cid as string); + +} + +export default interactWithBluesky; \ No newline at end of file diff --git a/src/utils/interact-with-post.ts b/src/utils/interact-with-post.ts deleted file mode 100644 index 1885ca0..0000000 --- a/src/utils/interact-with-post.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ComponentInteraction, ModalSubmitInteraction } from "oceanic.js"; -import blueskyClient from "./bluesky-client.js"; -import { Agent } from "@atproto/api"; -import { isThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs.js"; - -async function interactWithPost(source: {interaction?: ModalSubmitInteraction | ComponentInteraction, rkey?: string, postCreatorHandle?: string, actorDID?: string, guildID: string, decryptionKey?: string}, action: "deletePost" | "deleteLike" | "like" | "deleteRepost" | "repost") { - - let {interaction, rkey, postCreatorHandle, actorDID} = source; - - if (interaction) { - - // Get the rkey of the post. - const originalMessage = await interaction.getOriginal(); - const originalEmbed = originalMessage.embeds[0]; - const postLink = originalEmbed?.footer?.text; - const postSplit = postLink?.split("/"); - rkey = postSplit?.pop(); - if (!postSplit || !rkey) throw new Error(); - - postCreatorHandle = postSplit[4]; - - } - - if (!actorDID || !postCreatorHandle || !rkey) throw new Error(); - - // Get the CID of the post. - const session = await blueskyClient.restore(actorDID, "auto", {guildID: source.guildID, decryptionKey: source.decryptionKey}); - const agent = new Agent(session); - const postCreatorDID = postCreatorHandle.includes("did:") ? postCreatorHandle : await blueskyClient.handleResolver.resolve(postCreatorHandle); - if (!postCreatorDID) throw new Error(); - - const { data: {cid} } = await agent.com.atproto.repo.getRecord({ - collection: "app.bsky.feed.post", - repo: postCreatorDID, - rkey - }); - - if (!cid) throw new Error(); - - // Get the URI we need. - let uri = `at://${postCreatorDID}/app.bsky.feed.post/${rkey}`; - if (action === "deleteLike" || action === "deleteRepost") { - - const response = await agent.getPostThread({uri}); - if (isThreadViewPost(response.data.thread)) { - - const possibleURI = response.data.thread.post.viewer?.[action === "deleteLike" ? "like" : "repost"]; - if (!possibleURI) return; - uri = possibleURI; - - } - - } - - // Interact with the post. - await agent[action](uri, cid as string); - -} - -export default interactWithPost; \ No newline at end of file