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,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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
prod
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
]));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import NaomisConfig from "@nhcarrigan/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...NaomisConfig
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4956
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./prod"
|
||||||
|
},
|
||||||
|
"exclude": ["commandJson.js"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user