feat: initial prototype
Node.js CI / Lint and Test (pull_request) Successful in 45s

This commit is contained in:
2025-08-14 23:12:02 -07:00
parent f67291b8da
commit 15ecbf5bbe
35 changed files with 6265 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Node.js CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint and Test
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10
- name: Install Dependencies
run: pnpm install
- name: Verify Build
run: pnpm run build
- name: Lint Source Files
run: pnpm run lint
- name: Run Tests
run: pnpm run test
+3
View File
@@ -0,0 +1,3 @@
node_modules
prod
+32
View File
@@ -0,0 +1,32 @@
import { ApplicationIntegrationType, InteractionContextType, SlashCommandBuilder } from "discord.js";
const about = new SlashCommandBuilder()
.setName("about")
.setDescription("Get information about this application.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]);
const throwCmd = new SlashCommandBuilder()
.setName("throw")
.setDescription("Throw an item at a random or specific server member!")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.addUserOption(option => option.setName("target").setDescription("The user you want to throw at. Random if omitted."));
const leaderboard = new SlashCommandBuilder()
.setName("leaderboard")
.setDescription("See your server's leaderboard!")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]);
const config = new SlashCommandBuilder()
.setName("config")
.setDescription("Configure your server's settings.")
.addStringOption(option => option.setName("theme").setDescription("The theme you want to use. Determines what gets thrown.")).addBooleanOption(option => option.setName("spoiler").setDescription("Whether or not to hide GIFs behind a spoiler for accessibility."))
console.log(JSON.stringify([
about.toJSON(),
throwCmd.toJSON(),
leaderboard.toJSON(),
config.toJSON()
]));
+5
View File
@@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
];
+33
View File
@@ -0,0 +1,33 @@
{
"name": "pavelle",
"version": "0.0.0",
"description": "Discord bot that allows you to throw things (like cake) at your fellow members.",
"main": "index.js",
"type": "module",
"scripts": {
"build": "prisma generate && tsc",
"lint": "eslint src --max-warnings 0",
"start": "op run --env-file=prod.env -- node prod/index.js",
"test": "echo \"Error: no test specified\" && exit 0"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.14.0",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "24.2.1",
"@types/node-schedule": "2.1.8",
"eslint": "9.33.0",
"prisma": "6.14.0",
"typescript": "5.9.2"
},
"dependencies": {
"@nhcarrigan/logger": "1.0.0",
"@prisma/client": "6.14.0",
"discord.js": "14.21.0",
"fastify": "5.5.0",
"node-schedule": "2.1.1"
}
}
+4956
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGO_URI")
}
model Users {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String
points Int @default(0)
serverId String
@@unique([serverId, userId], map: "serverId_userId")
}
model Servers {
id String @id @default(auto()) @map("_id") @db.ObjectId
serverId String @unique
theme String
spoiler Boolean @default(false)
}
+3
View File
@@ -0,0 +1,3 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
BOT_TOKEN="op://Environment Variables - Naomi/Pavelle/bot token"
MONGO_URI="op://Environment Variables - Naomi/Pavelle/mongo"
+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,
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
},
"exclude": ["commandJson.js"]
}