Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"dev": "pnpm --filter backend dev",
"build": "pnpm --recursive build",
"test": "pnpm --recursive test",
"test:unit": "pnpm --recursive test:unit",
"test:integration": "vitest run tests/integration",
"lint": "pnpm --recursive lint",
"lint:fix": "pnpm --recursive lint:fix",
"format": "pnpm --recursive format",
Expand All @@ -26,8 +28,11 @@
"husky": "^9.1.7",
"lint-staged": "^17.3.0",
"prettier": "^3.9.6",
"typescript-eslint": "^8.65.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0"
},
"dependencies": {
"date-fns": "^4.4.0"
},
"pnpm": {
"overrides": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ export type SoundPlayCount = {
readonly playCount: number;
};

export type SoundPlayedDates = {
readonly id: number;
readonly name: string;
readonly latestDate: Date;
readonly oldestDate: Date;
};

export type PlayedSoundsRepository = {
addPlayedSound(data: CreatePlayedSoundData): Promise<PlayedSound>;

getSoundPlayCount(soundId: number, userId?: string, year?: number): Promise<number>;
getSoundPlayCounts(limit?: number, userId?: string, year?: number): Promise<SoundPlayCount[]>;
getSoundPlayedDates(userId?: string, year?: number): Promise<SoundPlayedDates[]>;
};

const transformPlayedSound = (dbSoundPlay: Selectable<PlayedSounds>): PlayedSound => {
Expand Down Expand Up @@ -68,9 +76,25 @@ export const createPlayedSoundsRepository = (db: Kysely<DB>): PlayedSoundsReposi
return rows.map(r => ({ id: r.sound_id, name: r.name, playCount: Number(r.play_count) }));
};

const getSoundPlayedDates = async (userId?: string, year?: number): Promise<SoundPlayedDates[]> => {
const rows = await db
.selectFrom("played_sounds as ps")
.innerJoin("sounds as s", "s.id", "ps.sound_id")
.select(["s.id", "s.name", db.fn.max("played_at").as("latest_date"), db.fn.min("played_at").as("oldest_date")])
.$if(userId !== undefined, qb => qb.where("ps.user_id", "=", userId!))
.$if(year !== undefined, qb => qb.where(sql`extract(year from ps.played_at)`, "=", year!))
.groupBy(["s.id", "s.name"])
.orderBy("latest_date", "desc")
.orderBy("name", "asc")
.execute();

return rows.map(r => ({ id: r.id, name: r.name, oldestDate: r.oldest_date, latestDate: r.latest_date }));
};

return {
addPlayedSound,
getSoundPlayCount,
getSoundPlayCounts,
getSoundPlayedDates,
};
};
2 changes: 1 addition & 1 deletion packages/backend/src/application/commands/AudioCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const createAudioCommands = ({ soundService, discordChatService, commandC
}

const response = `Available sounds:\n${sounds.map(sound => `- ${sound}`).join("\n")}`;
await discordChatService.replyToInteraction(interaction, response, true);
await discordChatService.replyToInteraction(interaction, response, { ephemeral: true });
},
getAutocompleteChoices: commandChoicesService.getAutocompleteChoices,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const createBannedFeaturesCommands = ({ bannedFeaturesRepository, discord
]);

const response = `\`\`\`\nBanned Features and Users\n${"-".repeat(30)}\n${lines.join("\n")}\n\`\`\``;
await discordChatService.replyToInteraction(interaction, response, true);
await discordChatService.replyToInteraction(interaction, response, { ephemeral: true });
},
};

Expand Down
33 changes: 30 additions & 3 deletions packages/backend/src/application/commands/PlayedSoundsCommands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { PlayedSoundsRepository } from "@adapters/repositories/PlayedSoundsRepository.js";
import type { SoundRepository } from "@adapters/repositories/SoundRepository.js";
import type { CommandChoicesService } from "@core/services/CommandChoicesService.js";
import { type DiscordChatService, MESSAGE_LENGTH_LIMIT } from "@core/services/DiscordChatService.js";
import { type DiscordChatService } from "@core/services/DiscordChatService.js";
import { format } from "date-fns";
import { type ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";

import type { Command } from "./Commands.js";
Expand Down Expand Up @@ -72,13 +73,39 @@ export const createPlayedSoundsCommands = ({ soundRepository, playedSoundsReposi
);

const response = `${year ? `${year} ` : ""}Played Sound Counts\n-------------------\nRank Count Name\n${result.join(`\n`)}`;
const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```";
await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`);
await discordChatService.replyToInteraction(interaction, response, { backticks: true });
},
};

const fmt = (date: Date) => format(date, "MMM d, yyyy h:mm:ss a").padEnd(32);

const soundPlayedDates: Command = {
data: new SlashCommandBuilder()
.setName("sound-played-dates")
.setDescription("Returns the first and latest dates each sound was played")
.addUserOption(option => option.setName("user").setDescription("Optional user to filter by").setRequired(false))
.addNumberOption(option => option.setName("year").setDescription("Optional year to filter by").setRequired(false)),
execute: async (interaction: ChatInputCommandInteraction) => {
const options = interaction.options;
const user = options.getUser("user") ?? undefined;
const year = options.getNumber("year") ?? undefined;

const dates = await playedSoundsRepository.getSoundPlayedDates(user?.id, year);
if (dates.length === 0) {
await interaction.reply(`No played sounds ${year ? `for ${year}` : ""}`);
return;
}

const result = dates.map(d => `${d.name.padEnd(14)}${fmt(d.latestDate)}${fmt(d.oldestDate)}`);

const response = `Name Newest Played Date Oldest Played Date\n${"-".repeat(64)}\n${result.join(`\n`)}`;
await discordChatService.replyToInteraction(interaction, response, { backticks: true });
},
};

return {
"sound-play-count": soundPlayCount,
"sound-play-counts": soundPlayCounts,
"sound-played-dates": soundPlayedDates,
};
};
8 changes: 3 additions & 5 deletions packages/backend/src/application/commands/ReactionCommands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { KarmaEmoteNames } from "@adapters/repositories/ReactionEmoteRepository.js";
import type { ReactionRepository } from "@adapters/repositories/ReactionRepository.js";
import { type DiscordChatService, MESSAGE_LENGTH_LIMIT } from "@core/services/DiscordChatService.js";
import { type DiscordChatService } from "@core/services/DiscordChatService.js";
import { formatEmoji } from "@core/utils/emojiUtils.js";
import { getJumpUrl } from "@core/utils/messageUtils.js";
import { type ChatInputCommandInteraction, GuildMember, MessageFlags, Role, SlashCommandBuilder, userMention } from "discord.js";
Expand Down Expand Up @@ -138,8 +138,7 @@ export const createReactionCommands = ({ reactionRepository, discordChatService
);

const response = `${year ? `${year} ` : ""}Emote Leaderboard (Top ${limit})\n-------------------------------\nRank Count Emote\n${result.join(`\n`)}`;
const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```";
await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`);
await discordChatService.replyToInteraction(interaction, response, { backticks: true });
},
};

Expand Down Expand Up @@ -176,8 +175,7 @@ export const createReactionCommands = ({ reactionRepository, discordChatService
);

const response = `${year ? `${year} ` : ""}Karma Leaderboard\n------------------------\nRank Karma User\n${result.join(`\n`)}`;
const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```";
await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`);
await discordChatService.replyToInteraction(interaction, response, { backticks: true });
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const createSoundTagCommands = ({ soundTagService, discordChatService, co
}

const response = `Available tags:\n${tags.map(tag => `- ${tag.name}`).join("\n")}`;
await discordChatService.replyToInteraction(interaction, response, true);
await discordChatService.replyToInteraction(interaction, response, { ephemeral: true });
},
};

Expand Down
38 changes: 27 additions & 11 deletions packages/backend/src/core/services/DiscordChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ export const MESSAGE_LENGTH_LIMIT = 2000;

export type SendMode = "split" | "file";

//Extra optional params passed to replyToInteraction and followUpToInteraction.
export type ResponseOptions = {
//If the response is only visible to the invoker of the interaction.
ephemeral?: boolean;

//If backticks for Markdown code blocks should automatically be added around the content (unless we're in file mode).
backticks?: boolean;
};

const DefaultResponseOptions: ResponseOptions = { ephemeral: false, backticks: false };

export type DiscordChatService = {
readonly hasBeenPinged: (latestMessage: Message) => boolean;
readonly replaceUserRoleAndChannelMentions: (message: Message) => Promise<string>;
readonly sendTypingIndicator: (abortSignal: AbortSignal, channel: TextChannel) => Promise<void>;
readonly formatMessageContent: (content: string, sendMode?: SendMode) => MessageCreateOptions[];
readonly sendMessage: (content: string, channel: TextChannel, sendMode?: SendMode) => Promise<void>;
readonly replyToInteraction: (interaction: ChatInputCommandInteraction, content: string, ephemeral?: boolean) => Promise<void>;
readonly followUpToInteraction: (interaction: ChatInputCommandInteraction, content: string, ephemeral?: boolean) => Promise<void>;
readonly replyToInteraction: (interaction: ChatInputCommandInteraction, content: string, options?: ResponseOptions) => Promise<void>;
readonly followUpToInteraction: (interaction: ChatInputCommandInteraction, content: string, options?: ResponseOptions) => Promise<void>;
};

export type DiscordChatServiceDeps = {
Expand Down Expand Up @@ -78,21 +89,26 @@ export const createDiscordChatService = ({ config }: DiscordChatServiceDeps): Di
}
}

//Calls reply on the interaction, sending the result back as a file if content is too long.
async function replyToInteraction(interaction: ChatInputCommandInteraction, content: string, ephemeral = false) {
//Formats the response back for Discord interactions.
function formatResponseContent(content: string, options = DefaultResponseOptions) {
const { ephemeral, backticks } = options;
const mode = content.length > MESSAGE_LENGTH_LIMIT ? "file" : "split";
const result = formatMessageContent(content, mode)[0]!;
const formatted: InteractionReplyOptions = { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined };
const enclosingChars = mode === "split" && backticks ? "```" : "";

//There will never be more than 1 since interactions don't support more than 1 message.
const result = formatMessageContent(`${enclosingChars}${content}${enclosingChars}`, mode)[0]!;
return { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined } satisfies InteractionReplyOptions;
}

//Calls reply on the interaction, sending the result back as a file if content is too long.
async function replyToInteraction(interaction: ChatInputCommandInteraction, content: string, options = DefaultResponseOptions) {
const formatted = formatResponseContent(content, options);
await interaction.reply(formatted);
}

//Calls followUp on the interaction, sending the result back as a file if content is too long.
async function followUpToInteraction(interaction: ChatInputCommandInteraction, content: string, ephemeral = false) {
const mode = content.length > MESSAGE_LENGTH_LIMIT ? "file" : "split";
const result = formatMessageContent(content, mode)[0]!;
const formatted: InteractionReplyOptions = { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined };

async function followUpToInteraction(interaction: ChatInputCommandInteraction, content: string, options = DefaultResponseOptions) {
const formatted = formatResponseContent(content, options);
await interaction.followUp(formatted);
}

Expand Down
Loading
Loading