generated from nhcarrigan/template
### 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:
@@ -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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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} didn’t 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} didn’t 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} didn’t 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 didn’t 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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export type Theme = "cake";
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" };
|
||||
}
|
||||
};
|
||||
@@ -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() ]);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 ],
|
||||
});
|
||||
};
|
||||
@@ -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)));
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ?? "",
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user