feat: initial prototype (#3)
Node.js CI / Lint and Test (push) Successful in 42s

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #3
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #3.
This commit is contained in:
2025-08-14 23:14:08 -07:00
committed by Naomi Carrigan
parent f67291b8da
commit c9ec681471
35 changed files with 6265 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
} from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/about` command interaction.
* @param _pavelle - Pavelle's Discord instance (unused).
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const about: Command = async(_pavelle, interaction) => {
try {
const components = [
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent("# About Pavelle"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Hi there~! I am Pavelle, a bot that allows you to throw items (like cake!) at your fellow server members. My creation was inspired by the Steve Aoki CakeThrow Bot!",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What can I do?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"To get started, a server admin will need to subscribe to our service. Then your members can begin throwing objects at each other, either randomly or to a specific target with `/throw`. Make sure to check your `/leaderboard` to see who has the most points!",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What if I need help?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"My deepest apologies if I have made a mistake! Please reach out to us in our Discord server or on the forum, and we will do our best to assist you.",
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
];
await interaction.editReply({
components: components,
flags: MessageFlags.IsComponentsV2,
});
} catch (error) {
const id = await errorHandler(error, "about command");
await interaction.editReply({
content:
`An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`,
});
}
};
+52
View File
@@ -0,0 +1,52 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { errorHandler } from "../utils/errorHandler.js";
import { isTheme } from "../utils/typeguards.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/config` command.
* @param pavelle - Pavelle's instance.
* @param interaction - The interaction payload from Discord.
*/
export const config: Command = async(pavelle, interaction) => {
try {
const theme = interaction.options.getString("theme");
const spoiler = interaction.options.getBoolean("spoiler");
const query: { theme?: string; spoiler?: boolean } = {};
if (theme !== null && isTheme(theme)) {
query.theme = theme;
}
if (spoiler !== null) {
query.spoiler = spoiler;
}
const result = await pavelle.db.servers.upsert({
create: {
serverId: interaction.guild.id,
spoiler: spoiler ?? false,
theme: theme !== null && isTheme(theme)
? theme
: "cake",
},
update: query,
where: {
serverId: interaction.guild.id,
},
});
await interaction.editReply({
content: `Your settings have been updated! Your new settings are now:\nTheme: ${result.theme}\nSpoiler GIFs: ${result.spoiler.toString()}\n-# Omitted settings were not updated. If ${String(theme)} was not a valid theme, that setting has not been updated.`,
});
} catch (error) {
const id = await errorHandler(error, "config command");
await interaction.editReply({
content:
`An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`,
});
}
};
+81
View File
@@ -0,0 +1,81 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/leaderboard` command.
* @param pavelle - Pavelle's instance.
* @param interaction - The interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- Lazy.
export const leaderboard: Command = async(pavelle, interaction) => {
try {
const members = await pavelle.db.users.findMany({
where: {
serverId: interaction.guild.id,
},
});
const sorted = members.sort((a, b) => {
return b.points - a.points;
});
const topTen = sorted.slice(0, 10).map((member, index) => {
return `- **#${index.toString()}:** <@${member.userId}> - ${member.points.toString()} point(s).`;
}).
join("\n");
const yourScore = sorted.find((member) => {
return member.userId === interaction.member.id;
});
const yourRank = yourScore
? `You are rank #${sorted.indexOf(yourScore).toString()} with ${yourScore.points.toString()} points.`
: "You are not ranked. Try throwing some stuff!";
await interaction.editReply({
components: [
{
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention.
accent_color: null,
components: [
{
content: "# Leaderboard",
type: 10,
},
{
divider: true,
spacing: 1,
type: 14,
},
{
content: "## Top 10 Members",
type: 10,
},
{
content: topTen,
type: 10,
},
{
content: `-# ${yourRank}`,
type: 10,
},
],
spoiler: false,
type: 17,
},
],
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
const id = await errorHandler(error, "leaderboard command");
await interaction.editReply({
content:
`An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`,
});
}
};
+102
View File
@@ -0,0 +1,102 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags } from "discord.js";
import { checkGuildEntitlement } from "../modules/checkEntitlement.js";
import { generateScore } from "../modules/generateScore.js";
import { getCachedCount } from "../modules/getCachedCount.js";
import { getConfig } from "../modules/getConfig.js";
import { getTarget } from "../modules/getTarget.js";
import { getThrowComponents } from "../modules/getThrowComponents.js";
import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/throw` command interaction.
* @param pavelle - Pavelle's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- Big logic, but lotsa components
export const throwCmd: Command = async(pavelle, interaction) => {
try {
const { member, guild } = interaction;
const count = getCachedCount(pavelle, `${guild.id}-${member.id}`);
if (count <= 0) {
await interaction.editReply({
components: [
{
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention.
accent_color: null,
components: [
{
content:
// eslint-disable-next-line stylistic/max-len -- long string.
"Oopsie! You are out of throws! Your count resets at the top of every hour.",
type: 10,
},
],
spoiler: false,
type: 17,
},
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
const isEntitled = await checkGuildEntitlement(pavelle, guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
return;
}
pavelle.throwCache.set(`${guild.id}-${member.id}`, count - 1);
const target = await getTarget(interaction);
const score = generateScore();
const { theme, spoiler } = await getConfig(pavelle, guild.id);
const updated = await pavelle.db.users.upsert({
create: {
points: score,
serverId: guild.id,
userId: member.id,
},
update: {
points: {
increment: score,
},
},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma index convention.
serverId_userId: {
serverId: guild.id,
userId: member.id,
},
},
});
const components = getThrowComponents(
pavelle,
member.id,
target.id,
guild.id,
theme,
score,
spoiler,
updated.points,
);
await interaction.editReply({
allowedMentions: {
parse: [ "users" ],
},
components: components,
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
const id = await errorHandler(error, "throw command");
await interaction.editReply({
content:
`An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`,
});
}
};
+15
View File
@@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const entitledGuilds = [
// Naomi's server.
"1354624415861833870",
// FreeCodeCamp
"692816967895220344",
// Caylus Kingdom
"443134315778539530",
];
export { entitledGuilds };
+25
View File
@@ -0,0 +1,25 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const experts = [
"a giant",
"Thor",
"Bruce Lee",
"Chuck Norris",
"this bot's dev, Naomi",
"Goku",
"Saitama",
"Hulk",
"Mark Henry",
"Hercules",
"Superman",
"Wonder Woman",
"Conan the Barbarian",
"Kratos",
"Katniss",
"Xena",
"She-Hulk",
];
+25
View File
@@ -0,0 +1,25 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { about } from "../commands/about.js";
import { config } from "../commands/config.js";
import { leaderboard } from "../commands/leaderboard.js";
import { throwCmd } from "../commands/throwCmd.js";
import type { Command } from "../interfaces/command.js";
const defaultHandler: Command = async(_lynira, interaction) => {
await interaction.editReply({
content: "This command is not implemented yet.",
});
};
export const handlers: { _default: Command } & Record<string, Command> = {
_default: defaultHandler,
about: about,
config: config,
leaderboard: leaderboard,
throw: throwCmd,
};
+156
View File
@@ -0,0 +1,156 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Lots of large strings here. */
import type { Theme } from "../interfaces/theme.ts";
const successes: Record<Theme, Array<string>> = {
cake: [
"Using simple trigonometry, {user} calculated and threw the cake that successfully hit {target}!",
"{user} has thrown the cake with all their strength and managed to hit {target}!",
"Gotcha! {user} has successfully hit {target} right in the face!",
"{target} made no efforts to {user}'s throw.",
"{user} threw the cake their mother made with love and hit {target}! A great throw but at what cost?",
"{user} has skillfully thrown the cake and hit {target} right in the face!",
"{user} nailed it! {target} didnt see that one coming!",
"That cake throw to hit {target} was so perfect! The cake must feel honored.",
"Score! {target} just got a face full of {user}'s throwing skills.",
"That was a direct hit on {target}! {user} should join the Olympics!",
"{user} is basically a professional athlete now after that one. There was nothing you could do, {target}.",
"{user}'s cake throw at {target} was so smooth, it deserves a slow-motion action replay.",
"{user} just threw that cake at {target} like the cake owed them money!",
"Bullseye! {user} deserves a trophy for that throw at {target}!",
"That was a perfect throw, {user}! Cake just gave {target} a new hairstyle. Trendy!",
"{user} threw cake like a pro, and {target} didnt stand a chance.",
],
};
const bonuses: Record<Theme, Array<string>> = {
cake: [
"{user} threw the cake with might comparable to {expert}'s and hit {target} so hard they fell down!",
"{user} should consider a career in demolition with that cake throw at {target}.",
"{user} just launched cake like a rocket, and it hit {target} square in the face!",
"{user} threw cake like {expert}, and {target} didnt stand a chance!",
"That throw was so epic, {user} just made {target} go viral!",
"{user} hit {target} with cake and created a new dance move: 'The Impact Shuffle!'",
],
};
const failures: Record<Theme, Array<string>> = {
cake: [
"{user} got distracted at the last possible second of the throw and ended up missing {target}!",
"{user} threw the cake as far as they can, but they don't have enough arm strength and the cake missed {target}!",
"Although {user} has tried their best, the cake was thrown without sufficient strength and speed and fell right in front of {target}!",
"{user} threw cake and it landed in a tree! Nature wins this round!",
"Well, that was a swing and a miss! {target} is still standing, {user}!",
"{user} aimed for {target} but hit a passing bird instead. Oops!",
"{target} just dodged {user}'s cake like Neo!",
"{user} threw cake like a champ, but {target} dodged expertly!",
],
};
const counters: Record<Theme, Array<string>> = {
cake: [
"{user} successfully threw the cake towards {target} with the right strength and accuracy, though {target} caught it mid-air and threw it right back at {user}!",
"{user} aimed for {target} but hit their own foot instead. Whoops.",
"Wait, what? {target} just caught cake and threw it back at {user}!",
"{user} threw cake, and {target} just intercepted it like a pro and threw it back!",
"{user} did their best but {target} caught it and turned the tables! Now {user} knows what it's like to be hit by cake!",
"Counterattack! {user} thought they were the thrower, but now THEY'RE the target of the cake!",
"{user} got hit with their own cake like it was a boomerang!",
"Welp, that throw backfired! {target} just served cake right back to {user}!",
"Just as {user} was about to hit {target}, they appeared with their own cake and hit {user} instead!",
],
};
const gifs: Record<Theme, Array<string>> = {
cake: [
"https://i.hep.gg/6UlAYgr34.gif",
"https://i.hep.gg/UVPwXKHLP.gif",
"https://i.hep.gg/SdzCbTRVR.gif",
"https://i.hep.gg/nB6DsoeH5.gif",
"https://i.hep.gg/kPoEOhyaQ.gif",
"https://i.hep.gg/qEfn3EGG8.gif",
"https://i.hep.gg/ZVv86odHak.gif",
"https://i.hep.gg/2NSMExuNI.gif",
"https://tenor.com/view/steve-aoki-cake-face-gif-5705436",
"https://tenor.com/view/gritty-cake-gif-25910058",
"https://tenor.com/view/steve-aoki-cake-face-gif-5705438",
],
};
const successTitles: Record<Theme, Array<string>> = {
cake: [
"💪 Nice throw!",
"🎯 Bullseye!",
"🔥 Direct hit!",
"🏆 Nailed it!",
"🥳 Perfect shot!",
"👏 Well done!",
"⭐ Amazing aim!",
"💥 Solid hit!",
"😎 Too easy!",
"🥇 Champion throw!",
],
};
const failureTitles: Record<Theme, Array<string>> = {
cake: [
"💔 So close!",
"🙈 Missed it!",
"😅 Better luck next time!",
"🤷 Oops!",
"📉 Not quite!",
"🥴 Swing and a miss!",
"🤦 That didnt go as planned!",
"💨 Just missed!",
"😬 Whiffed it!",
"🫠 Embarrassing miss!",
],
};
const bonusTitles: Record<Theme, Array<string>> = {
cake: [
"✨ Bonus round!",
"🎉 Double points!",
"🍀 Lucky shot!",
"💖 Crowd goes wild!",
"🤑 Jackpot throw!",
"🌟 Critical hit!",
"🥂 Celebration time!",
"🍰 Extra cake for you!",
"🔮 Magical throw!",
"🎆 Fireworks-worthy!",
],
};
const counterTitles: Record<Theme, Array<string>> = {
cake: [
"🔄 Counterattack!",
"🛡️ Deflected!",
"⚔️ Reversal!",
"👀 Watch out!",
"😱 Incoming!",
"🥶 Right back at you!",
"🚀 Return to sender!",
"🤯 Unexpected twist!",
"🎭 Tables have turned!",
"💥 Boom! Counter hit!",
],
};
export {
successes,
bonuses,
failures,
counters,
gifs,
successTitles,
failureTitles,
bonusTitles,
counterTitles,
};
+56
View File
@@ -0,0 +1,56 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import {
Client,
GatewayIntentBits,
Events,
} from "discord.js";
import { scheduleJob } from "node-schedule";
import { processCommand } from "./modules/processCommand.js";
import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js";
import type { Pavelle } from "./interfaces/pavelle.js";
const pavelle: Pavelle = {
db: new PrismaClient(),
discord: new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
],
}),
throwCache: new Map<string, number>(),
};
pavelle.discord.once(Events.ClientReady, () => {
void logger.log("debug", `Logged in as ${pavelle.discord.user?.username ?? "unknown"}`);
});
pavelle.discord.on(Events.InteractionCreate, (interaction) => {
if (!interaction.isChatInputCommand()) {
return;
}
if (!interaction.inCachedGuild()) {
return;
}
void processCommand(pavelle, interaction);
});
pavelle.discord.on(Events.Error, (error) => {
void logger.error("Client error", error);
});
pavelle.discord.on(Events.Warn, (message) => {
void logger.log("info", message);
});
await pavelle.discord.login(process.env.BOT_TOKEN);
scheduleJob("bust-cache", "0 * * * *", () => {
pavelle.throwCache = new Map<string, number>();
});
instantiateServer();
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Pavelle } from "./pavelle.js";
import type { ChatInputCommandInteraction } from "discord.js";
export type Command = (
pavelle: Pavelle,
interaction: ChatInputCommandInteraction<"cached">
)=> Promise<void>;
+14
View File
@@ -0,0 +1,14 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type { Client } from "discord.js";
export interface Pavelle {
db: PrismaClient;
discord: Client;
throwCache: Map<string, number>;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Enums have a different standard. */
export enum Score {
COUNTER = -1,
FAIL = 0,
SUCCEED = 1,
BONUS = 5,
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export type Theme = "cake";
+32
View File
@@ -0,0 +1,32 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { entitledGuilds } from "../config/entitlements.js";
import type { Pavelle } from "../interfaces/pavelle.js";
import type { Guild } from "discord.js";
/**
* Checks if a guild has subscribed.
* @param pavelle - Pavelle's Discord instance.
* @param guild - The guild to check.
* @returns A boolean indicating whether the guild has an active subscription.
*/
const checkGuildEntitlement = async(
pavelle: Pavelle,
guild: Guild,
): Promise<boolean> => {
if (entitledGuilds.includes(guild.id)) {
return true;
}
const entitlements = await pavelle.discord.application?.entitlements.fetch({
excludeDeleted: true,
excludeEnded: true,
guild: guild,
});
return Boolean(entitlements && entitlements.size > 0);
};
export { checkGuildEntitlement };
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Score } from "../interfaces/score.js";
/**
* Uses our odds spread to calculate the result of
* a throw, returning the appropriate score from the
* enum.
* @returns The number of points the user earned.
*/
export const generateScore = (): number => {
const random = Math.floor(Math.random() * 100);
if (random < 10) {
return Score.COUNTER;
}
if (random < 50) {
return Score.FAIL;
}
if (random < 95) {
return Score.SUCCEED;
}
return Score.BONUS;
};
+17
View File
@@ -0,0 +1,17 @@
/**
* @copyright Naomi
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Pavelle } from "../interfaces/pavelle.js";
/**
* Returns the cached number of throws a user has remaining.
* @param pavelle - Pavelle's instance.
* @param id - The guild and user ID, in `guild-user` format.
* @returns The number of throws remaining.
*/
export const getCachedCount = (pavelle: Pavelle, id: string): number => {
return pavelle.throwCache.get(id) ?? 3;
};
+43
View File
@@ -0,0 +1,43 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logger } from "../utils/logger.js";
import { isTheme } from "../utils/typeguards.js";
import type { Pavelle } from "../interfaces/pavelle.js";
import type { Theme } from "../interfaces/theme.js";
/**
* Get a config object from the database, with a
* fallback value where needed.
* @param pavelle - Pavelle's instance.
* @param guildId - The ID of the server to find the config for.
* @returns A config object containing theme and spoiler.
*/
export const getConfig = async(
pavelle: Pavelle, guildId: string,
): Promise<{ theme: Theme; spoiler: boolean }> => {
try {
const config = await pavelle.db.servers.findUnique({
where: {
serverId: guildId,
},
});
if (!config) {
return { spoiler: false, theme: "cake" };
}
return {
spoiler: config.spoiler,
theme: isTheme(config.theme)
? config.theme
: "cake",
};
} catch (error) {
if (error instanceof Error) {
await logger.error("get theme module", error);
}
return { spoiler: false, theme: "cake" };
}
};
+29
View File
@@ -0,0 +1,29 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { getRandomValue } from "../utils/getRandomValue.js";
import type { GuildMember, ChatInputCommandInteraction } from "discord.js";
/**
* Determines the target for an item throw. If the user specified a target,
* use that. Otherwise, attempt to fetch all members (to refresh cache), then
* return a random member from the cache.
* @param interaction - The interaction payload from Discord.
* @returns A member from the server.
*/
export const getTarget
= async(
interaction: ChatInputCommandInteraction<"cached">,
): Promise<GuildMember> => {
const selectedTarget = interaction.options.getMember("target");
if (selectedTarget) {
return selectedTarget;
}
await interaction.guild.members.fetch().catch(() => {
return null;
});
return getRandomValue([ ...interaction.guild.members.cache.values() ]);
};
+152
View File
@@ -0,0 +1,152 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/max-params, max-lines-per-function -- Lazy. */
import { experts } from "../config/experts.js";
import {
counters,
successes,
failures,
bonuses,
successTitles,
failureTitles,
counterTitles,
bonusTitles,
gifs,
} from "../config/themes.js";
import { Score } from "../interfaces/score.js";
import { getCachedCount } from "../modules/getCachedCount.js";
import { getRandomValue } from "../utils/getRandomValue.js";
import type { Pavelle } from "../interfaces/pavelle.js";
import type { Theme } from "../interfaces/theme.js";
import type { AnyAPIActionRowComponent } from "discord.js";
const getAccentColour = (score: Score): number => {
if (score === Score.COUNTER) {
return 15_418_782;
}
if (score === Score.FAIL) {
return 15_277_667;
}
if (score === Score.SUCCEED) {
return 5_763_719;
}
return 7_506_394;
};
const interpolate = (
userId: string,
targetId: string,
text: string,
): string => {
const expert = getRandomValue(experts);
return text.
replaceAll("{user}", `<@${userId}>`).
replaceAll("{target}", `<@${targetId}>`).
replaceAll("{expert}", expert);
};
const getTitle = (
theme: Theme,
score: Score,
): string => {
if (score === Score.COUNTER) {
return getRandomValue(counterTitles[theme]);
}
if (score === Score.FAIL) {
return getRandomValue(failureTitles[theme]);
}
if (score === Score.SUCCEED) {
return getRandomValue(successTitles[theme]);
}
return getRandomValue(bonusTitles[theme]);
};
const getMessage = (
userId: string,
targetId: string,
theme: Theme,
score: Score,
): string => {
if (score === Score.COUNTER) {
return interpolate(userId, targetId, getRandomValue(counters[theme]));
}
if (score === Score.FAIL) {
return interpolate(userId, targetId, getRandomValue(failures[theme]));
}
if (score === Score.SUCCEED) {
return interpolate(userId, targetId, getRandomValue(successes[theme]));
}
return interpolate(userId, targetId, getRandomValue(bonuses[theme]));
};
/**
* Uses the theme and score value to determine the components to render.
* @param pavelle - Pavelle's instance.
* @param userId - The ID of the user who threw.
* @param targetId - The ID of the target who got hit.
* @param serverId - The ID of the guild this took place in.
* @param theme - The theme the server is using.
* @param score - The score the user earned.
* @param spoiler - Whether or not the GIF should be spoilered.
* @param total - The user's new point total.
* @returns An array of JSON component data.
*/
export const getThrowComponents = (
pavelle: Pavelle,
userId: string,
targetId: string,
serverId: string,
theme: Theme,
score: Score,
spoiler: boolean,
total: number,
): Array<AnyAPIActionRowComponent> => {
const count = getCachedCount(pavelle, `${serverId}-${userId}`);
const title = getTitle(theme, score);
const message = getMessage(userId, targetId, theme, score);
const gif = getRandomValue(gifs[theme]);
return [
{
// eslint-disable-next-line @typescript-eslint/naming-convention -- This is Discord's API requirement.
accent_color: getAccentColour(score),
components: [
{
content: `# ${title}`,
type: 10,
},
{
content: message,
type: 10,
},
{
divider: true,
spacing: 1,
type: 14,
},
{
items: [
{
description: null,
media: {
url: gif,
},
spoiler: spoiler,
},
],
type: 12,
},
{
content: `-# You now have ${total.toString()} point(s).\n-# You have ${count.toString()} remaining throws for the hour.`,
type: 10,
},
],
spoiler: false,
type: 17,
},
];
};
+20
View File
@@ -0,0 +1,20 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { handlers } from "../config/handlers.js";
import type { Command } from "../interfaces/command.js";
/**
* Process a command interaction.
* @param pavelle - The Pavelle instance.
* @param interaction - The interaction to process.
*/
export const processCommand: Command = async(pavelle, interaction) => {
await interaction.deferReply();
const { commandName } = interaction;
// eslint-disable-next-line no-underscore-dangle -- Accessing private property for command handler.
await (handlers[commandName] ?? handlers._default)(pavelle, interaction);
};
+38
View File
@@ -0,0 +1,38 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags,
TextDisplayBuilder,
type ChatInputCommandInteraction,
} from "discord.js";
/**
* Responds with a default image and a button to subscribe.
* @param interaction - The interaction object from Discord.
*/
export const sendUnentitledResponse = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
const components = [
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Oh dear, your community does not seem to have an active subscription! I am afraid I cannot let you throw things until a server admin resolves that.",
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1405735335949766716"),
),
];
await interaction.editReply({
components: components,
flags: [ MessageFlags.IsComponentsV2 ],
});
};
+79
View File
@@ -0,0 +1,79 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import fastify from "fastify";
import { logger } from "../utils/logger.js";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Pavelle</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Discord bot that allows you to throw things (like cake) at your fellow server members. " />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Pavelle</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/pavelle.png" width="250" alt="Pavelle" />
<section>
<p>Discord bot that allows you to throw things (like cake) at your fellow server members. </p>
<a href="https://discord.com/oauth2/authorize?client_id=1405735335949766716" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
<i class="fab fa-discord"></i> Add to Discord
</a>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/pavelle">
<i class="fa-solid fa-code"></i> Source Code
</a>
</p>
<p>
<a href="https://docs.nhcarrigan.com/">
<i class="fa-solid fa-book"></i> Documentation
</a>
</p>
<p>
<a href="https://chat.nhcarrigan.com">
<i class="fa-solid fa-circle-info"></i> Support
</a>
</p>
</section>
</main>
</body>
</html>`;
/**
* Starts up a web server for health monitoring.
*/
export const instantiateServer = (): void => {
try {
const server = fastify({
logger: false,
});
server.get("/", (_request, response) => {
response.header("Content-Type", "text/html");
response.send(html);
});
server.listen({ port: 6019 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 6019.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
+29
View File
@@ -0,0 +1,29 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import crypto from "node:crypto";
import { logger } from "./logger.js";
/**
* Generates a UUID for an error, sends the error to the logger,
* and returns the UUID to be shared with the user.
* @param error - The error to log.
* @param context - The context in which the error occurred.
* @returns A UUID string assigned to the error.
*/
export const errorHandler = async(
error: unknown,
context: string,
): Promise<string> => {
const id = crypto.randomUUID();
await logger.error(
`${context} - Error ID: ${id}`,
error instanceof Error
? error
: new Error(String(error)),
);
return id;
};
+17
View File
@@ -0,0 +1,17 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Returns a random value from the provided array.
* @template T - The type of the elements in the array.
* @param array - The array to select a random value from.
* @returns A random value from the array.
*/
export const getRandomValue = <T>(array: Array<T>): T => {
const randomIndex = Math.floor(Math.random() * array.length);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We know the array is not empty.
return array[randomIndex] as T;
};
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger(
"Pavelle",
process.env.LOG_TOKEN ?? "",
);
+18
View File
@@ -0,0 +1,18 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable jsdoc/require-jsdoc -- Typeguards are simple. */
import { successes } from "../config/themes.js";
import type { Theme } from "../interfaces/theme.js";
const isTheme = (theme: string): theme is Theme => {
return theme in successes;
};
export {
isTheme,
};