Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Confirm reject before actually rejecting #136

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Confirm prompt before rejecting tracks.
- More translations! (Thanks to @karcsesz for the help!)

### Changed
- Updated internal testing utilities.

Expand Down
2 changes: 1 addition & 1 deletion src/actions/network/getYouTubeVideo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe.each([true, false])("YouTube track details (API: %s)", withKey => {
${"is shortened w/ unicode title"} | ${"https://youtu.be/GgwUenaQqlM"} | ${"https://www.youtube.com/watch?v=GgwUenaQqlM"} | ${267}
${"is a playlist entry w/ unicode title"} | ${"https://www.youtube.com/watch?v=GgwUenaQqlM&list=PLOKsOCrQbr0OCj6faA0kck1LwhQW-aj63&index=5"} | ${"https://www.youtube.com/watch?v=GgwUenaQqlM"} | ${267}
${"has extra info w/ unicode title"} | ${"https://www.youtube.com/watch?v=GgwUenaQqlM&ab_channel=TOHOanimation%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB"} | ${"https://www.youtube.com/watch?v=GgwUenaQqlM"} | ${267}
${"is a short livestream VOD"} | ${"https://youtu.be/5XbLY7IIqkY"} | ${"https://www.youtube.com/watch?v=5XbLY7IIqkY"} | ${426}
${"is a short livestream VOD"} | ${"https://youtu.be/kpnW68Q8ltc"} | ${"https://www.youtube.com/watch?v=kpnW68Q8ltc"} | ${413}
`(
"returns info for a YouTube link that $desc, $duration seconds long",
async ({ url, result, duration }: { url: string; result: string; duration: number }) => {
Expand Down
5 changes: 4 additions & 1 deletion src/actions/queue/getQueueChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ async function getQueueChannelFromCommand(context: CommandContext): Promise<Text
return queueChannel;
}

async function getQueueChannelFromGuild(guild: Guild): Promise<TextChannel | null> {
/**
* Finds and returns the channel that serves as the given guild's queue, if one is set.
*/
export async function getQueueChannelFromGuild(guild: Guild): Promise<TextChannel | null> {
const queueChannelId = await getQueueChannelId(guild);
if (queueChannelId === null || !queueChannelId) {
return null;
Expand Down
53 changes: 53 additions & 0 deletions src/actions/rejectQueueEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Logger } from "../logger.js";
import type { Message } from "discord.js";
import { t, type SupportedLocale } from "../i18n.js";
import { createPartialString, composed, push } from "../helpers/composeStrings.js";
import { deleteEntryFromMessage } from "../actions/queue/useQueue.js";
import { getUserWithId } from "../helpers/getUserWithId.js";
import { isQueueOpen } from "../useGuildStorage.js";
import { logUser } from "../helpers/logUser.js";
import { sendPrivately } from "../actions/messages/index.js";
import { timeoutSeconds } from "../helpers/timeoutSeconds.js";

/**
* Rejects a user's song submission, given that song's message in the queue channel.
* Sends a DM to the affected user.
*
* @param message The message which identifies the track to be rejected.
* @param logger The logger to use to log messages about the rejection.
*/
export async function rejectQueueEntryFromMessage(
message: Message<true>,
locale: SupportedLocale,
logger: Logger,
): Promise<void> {
logger.debug("Deleting entry...");
const entry = await deleteEntryFromMessage(message);
if (!entry) {
logger.debug("There was no entry to delete.");
return;
}
logger.debug("Deleted an entry");

const userId = entry.senderId;
const guild = message.guild;
if (!guild) {
logger.debug(`Queue message ${message.id} has no guild.`);
return;
}
const user = await getUserWithId(guild, userId);

logger.verbose(
`Informing User ${logUser(user)} that their song was rejected (using locale ${locale})...`,
);
const rejection = createPartialString();
push(`:persevere:\n${t("dms.rejection-apology", locale)} `, rejection);
push(entry.url, rejection);

await sendPrivately(user, composed(rejection));

if (await isQueueOpen(guild)) {
await timeoutSeconds(2);
await sendPrivately(user, `${t("dms.rejection-submit-another", locale)} :slight_smile:`);
}
}
46 changes: 31 additions & 15 deletions src/events/interactionCreate.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { CommandInteraction, Interaction, TextBasedChannel } from "discord.js";
import type { Command } from "../commands/index.js";
import { ChannelType } from "discord.js";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi, type Mock } from "vitest";

// Mock allCommands to isolate our test code
const mockAllCommands = vi.hoisted(() => new Map<string, Command>());
vi.mock("../commands/index.js", () => ({ allCommands: mockAllCommands }));

// Mock locales cache
vi.mock("../userLocalesCache.js", () => ({ cacheLocaleFromInteraction: vi.fn() }));
import { cacheLocaleFromInteraction } from "../userLocalesCache.js";
const mockCacheLocaleFromInteraction = cacheLocaleFromInteraction as Mock<
typeof cacheLocaleFromInteraction
>;

// Create two mock commands to track handler behavior
const mockGlobalExecute = vi.fn();
const mockGlobalCommand: Command = {
Expand Down Expand Up @@ -81,6 +88,14 @@ const channelId = "the-channel-1234";

const mockGuildMembersFetch = vi.fn();

const mockInCachedGuild = vi.fn<Interaction["inCachedGuild"]>().mockReturnValue(true);
const mockInGuild = vi.fn<Interaction["inGuild"]>().mockReturnValue(true);

const mockIsButton = vi.fn<Interaction["isButton"]>().mockReturnValue(false);
const mockIsChatInputCommand = vi.fn<Interaction["isChatInputCommand"]>().mockReturnValue(true);
const mockIsModalSubmit = vi.fn<Interaction["isModalSubmit"]>().mockReturnValue(false);
const mockIsAutocomplete = vi.fn<Interaction["isAutocomplete"]>().mockReturnValue(false);

// Helper function to create Interactions
// Reduces code duplication
function defaultInteraction(): Interaction {
Expand All @@ -97,8 +112,8 @@ function defaultInteraction(): Interaction {
id: otherUid,
},
channelId,
inCachedGuild: () => true,
inGuild: () => true,
inCachedGuild: mockInCachedGuild,
inGuild: mockInGuild,
member: { id: otherUid },
guild: {
id: "guild-1234",
Expand All @@ -110,9 +125,10 @@ function defaultInteraction(): Interaction {
type: ChannelType.GuildText,
partial: false,
},
isButton: () => false,
isChatInputCommand: () => true,
isAutocomplete: () => false,
isButton: mockIsButton,
isChatInputCommand: mockIsChatInputCommand,
isModalSubmit: mockIsModalSubmit,
isAutocomplete: mockIsAutocomplete,
replied: false,
} as unknown as Interaction;
}
Expand All @@ -121,16 +137,16 @@ describe("on(interactionCreate)", () => {
describe("commands", () => {
test("logs interaction errors", async () => {
const interaction = defaultInteraction();
interaction.isChatInputCommand = (): boolean => {
mockCacheLocaleFromInteraction.mockImplementationOnce(() => {
throw interactionError;
};
});

await expect(interactionCreate.execute(interaction, logger)).rejects.toBe(interactionError);
});

test("does nothing if the interaction isn't a supported interaction type", async () => {
const interaction = defaultInteraction();
interaction.isChatInputCommand = (): boolean => false;
mockIsChatInputCommand.mockReturnValueOnce(false);

await expect(interactionCreate.execute(interaction, logger)).resolves.toBeUndefined();
expect(mockGlobalExecute).not.toHaveBeenCalled();
Expand Down Expand Up @@ -169,8 +185,8 @@ describe("on(interactionCreate)", () => {

test("calls the `execute` method of a global command from DMs", async () => {
let interaction = defaultInteraction();
interaction.inCachedGuild = (): boolean => false;
interaction.inGuild = (): boolean => false;
mockInCachedGuild.mockReturnValueOnce(false);
mockInGuild.mockReturnValueOnce(false);
interaction.member = null;

const channel = {
Expand Down Expand Up @@ -201,8 +217,8 @@ describe("on(interactionCreate)", () => {
test("tells the user off when they try to execute a guilded command from DMs", async () => {
let interaction = defaultInteraction();
(interaction as CommandInteraction).commandName = mockGuildedCommand.name;
interaction.inCachedGuild = (): boolean => false;
interaction.inGuild = (): boolean => false;
mockInCachedGuild.mockReturnValueOnce(false);
mockInGuild.mockReturnValueOnce(false);
interaction.member = null;

const channel = {
Expand Down Expand Up @@ -231,8 +247,8 @@ describe("on(interactionCreate)", () => {

test("fetches the channel when a command comes from a partial DM channel", async () => {
let interaction = defaultInteraction();
interaction.inCachedGuild = (): boolean => false;
interaction.inGuild = (): boolean => false;
mockInCachedGuild.mockReturnValueOnce(false);
mockInGuild.mockReturnValueOnce(false);
interaction.member = null;

const mockChannelFetch = vi.fn();
Expand Down
9 changes: 9 additions & 0 deletions src/events/interactionCreate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { cacheLocaleFromInteraction } from "../userLocalesCache.js";
import { handleButton } from "../handleButton.js";
import { handleInteraction } from "../handleInteraction.js";
import { handleModal } from "../handleModal.js";
import { InteractionType } from "discord.js";
import { onEvent } from "../helpers/onEvent.js";

export const interactionCreate = onEvent("interactionCreate", {
async execute(interaction, logger) {
cacheLocaleFromInteraction(interaction);

if (interaction.isChatInputCommand()) {
await handleInteraction(interaction, logger);
} else if (interaction.isButton()) {
await handleButton(interaction, logger);
} else if (interaction.isModalSubmit()) {
await handleModal(interaction, logger);
} else {
logger.debug(`Unknown interaction type: ${InteractionType[interaction.type]}`);
}
},
});
57 changes: 20 additions & 37 deletions src/handleButton.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import type { ButtonInteraction } from "discord.js";
import type { Logger } from "./logger.js";
import { createPartialString, composed, push } from "./helpers/composeStrings.js";
import { DELETE_BUTTON, DONE_BUTTON, RESTORE_BUTTON } from "./buttons.js";
import { getEnv } from "./helpers/environment.js";
import { getQueueChannel } from "./actions/queue/getQueueChannel.js";
import { getStoredEntry } from "./useQueueStorage.js";
import { getUserWithId } from "./helpers/getUserWithId.js";
import { isQueueOpen } from "./useGuildStorage.js";
import { logUser } from "./helpers/logUser.js";
import { markEntryDoneInQueue, markEntryNotDoneInQueue } from "./actions/queue/useQueue.js";
import { richErrorMessage } from "./helpers/richErrorMessage.js";
import { sendPrivately } from "./actions/messages/index.js";
import { timeoutSeconds } from "./helpers/timeoutSeconds.js";
import {
deleteEntryFromMessage,
markEntryDoneInQueue,
markEntryNotDoneInQueue,
} from "./actions/queue/useQueue.js";
import { DEFAULT_LOCALE, localeIfSupported, t } from "./i18n.js";
import { createConfirmRejectModalForEntry } from "./modals/confirmRejectTrack.js";

/**
* Performs actions from a Discord command interaction.
Expand Down Expand Up @@ -50,12 +43,16 @@ export async function handleButton(interaction: ButtonInteraction, logger: Logge
return;
}

const userLocale =
localeIfSupported(interaction.locale) ??
localeIfSupported(interaction.guildLocale) ??
DEFAULT_LOCALE;
const entry = await getStoredEntry(interaction.message.id);
if (!entry) {
logger.debug("The message does not represent a known song request.");
try {
await interaction.reply({
content: "I don't recognize that entry. Sorry :slight_frown:",
content: `${t("modals.confirm-reject-entry.responses.unknown-entry", userLocale)} :slight_frown:`,
ephemeral: true,
});
} catch (error) {
Expand Down Expand Up @@ -94,32 +91,18 @@ export async function handleButton(interaction: ButtonInteraction, logger: Logge
break;

case DELETE_BUTTON.id: {
logger.debug("Deleting entry...");
const entry = await deleteEntryFromMessage(message);
if (!entry) {
logger.debug("There was no entry to delete.");
break;
}
logger.debug("Deleted an entry");

const userId = entry.senderId;
const guild = interaction.guild;
if (!guild) {
logger.debug(`Queue message ${message.id} has no guild.`);
return;
}
const user = await getUserWithId(guild, userId);

logger.verbose(`Informing User ${logUser(user)} that their song was rejected...`);
const rejection = createPartialString();
push(":persevere:\nI'm very sorry. Your earlier submission was rejected: ", rejection); // TODO: i18n
push(entry.url, rejection);

await sendPrivately(user, composed(rejection));

if (await isQueueOpen(guild)) {
await timeoutSeconds(2);
await sendPrivately(user, "You can submit another song if you'd like to. :slight_smile:"); // TODO: i18n
const modalLocale =
localeIfSupported(interaction.locale) ??
localeIfSupported(interaction.guildLocale) ??
DEFAULT_LOCALE;
const modal = await createConfirmRejectModalForEntry(interaction.client, entry, modalLocale);
try {
logger.debug(
`Showing reject modal to ${logUser(interaction.user)} with locale ${modalLocale}`,
);
await interaction.showModal(modal);
} catch (error) {
logger.error(richErrorMessage(`Failed to show reject modal`, error));
}
break;
}
Expand Down
35 changes: 35 additions & 0 deletions src/handleModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ModalSubmitInteraction } from "discord.js";
import type { Logger } from "./logger.js";
import { getEnv } from "./helpers/environment.js";
import { logUser } from "./helpers/logUser.js";
import { handleConfirmRejectModal } from "./modals/confirmRejectTrack.js";

/**
* Performs actions from a Discord modal interaction.
* The interaction is ignored if the interaction is from a bot.
*
* @param interaction The Discord interaction to handle.
* @param logger The logger to talk to about what's going on.
*/
export async function handleModal(
interaction: ModalSubmitInteraction,
logger: Logger,
): Promise<void> {
// Don't respond to bots unless we're being tested
if (
interaction.user.bot &&
(interaction.user.id !== getEnv("CORDE_BOT_ID") || getEnv("NODE_ENV") !== "test-e2e")
) {
logger.silly("Momma always said not to talk to strangers. They could be *bots* ");
return;
}

// Ignore self interactions
if (interaction.user.id === interaction.client.user?.id) return;

// Handle form submission
logger.debug(`User ${logUser(interaction.user)} submitted modal: '${interaction.customId}'`);

// Our only modal so far is track reject confirmation
await handleConfirmRejectModal(interaction, logger);
}
15 changes: 15 additions & 0 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@
}
}
},
"dms": {
"rejection-apology": "Es tut mir sehr leid. Ihre frühere Einreichung wurde abgelehnt:",
"rejection-submit-another": "Sie können, wenn Sie möchten, ein anderes Lied einreichen."
},
"modals": {
"confirm-reject-entry": {
"title": "{user}s Lied ablehnen?",
"confirm-input": "Wenn Sie sicher sind, geben Sie hier das Wort „{confirm}“ ein:",
"responses": {
"success": "Der Ablehnungsbescheid wurde versendet.",
"unknown-entry": "Entschuldigung, ich erkenne diesen Eintrag nicht.",
"wrong-word": "Entschuldigung, Sie müssen das Wort „{confirm}“ eingeben"
}
}
},
"common": {
"count": {
"infinity": "Unendlichkeit",
Expand Down
15 changes: 15 additions & 0 deletions src/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@
}
}
},
"dms": {
"rejection-apology": "I'm very sorry. Your earlier submission was rejected:",
"rejection-submit-another": "You can submit another song if you'd like to."
},
"modals": {
"confirm-reject-entry": {
"title": "Reject {user}'s song?",
"confirm-input": "Type the word \"{confirm}\" here if you're sure:",
"responses": {
"success": "The rejection notice was sent.",
"unknown-entry": "Sorry, I don't recognize that entry.",
"wrong-word": "Sorry, you must enter the word \"{confirm}\""
}
}
},
"common": {
"count": {
"infinity": "Infinity",
Expand Down
Loading