mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-10 00:49:30 +00:00
Merge 7af6d5914d
into 03f8b68f5b
This commit is contained in:
commit
3efa7e960b
16 changed files with 890 additions and 30 deletions
|
@ -3,6 +3,9 @@
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This Discord bot is not production ready and everything is subject to change
|
> This Discord bot is not production ready and everything is subject to change
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY).
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
Install Dependencies: ``yarn install``
|
Install Dependencies: ``yarn install``
|
||||||
|
|
|
@ -6,11 +6,15 @@
|
||||||
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
||||||
"channels": {
|
"channels": {
|
||||||
"welcome": "WELCOME_CHANNEL_ID",
|
"welcome": "WELCOME_CHANNEL_ID",
|
||||||
"logs": "LOG_CHAANNEL_ID"
|
"logs": "LOG_CHANNEL_ID",
|
||||||
|
"counting": "COUNTING_CHANNEL_ID",
|
||||||
|
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||||
|
"factApproval": "FACT_APPROVAL_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"joinRoles": [
|
"joinRoles": [
|
||||||
"JOIN_ROLE_IDS"
|
"JOIN_ROLE_IDS"
|
||||||
]
|
],
|
||||||
|
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,5 +38,5 @@
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.6.0"
|
"packageManager": "yarn@4.7.0"
|
||||||
}
|
}
|
||||||
|
|
117
src/commands/fun/counting.ts
Normal file
117
src/commands/fun/counting.ts
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '../../types/CommandTypes.js';
|
||||||
|
import { getCountingData, setCount } from '../../util/countingManager.js';
|
||||||
|
import { loadConfig } from '../../util/configLoader.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('counting')
|
||||||
|
.setDescription('Commands related to the counting channel')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('Check the current counting status'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('setcount')
|
||||||
|
.setDescription(
|
||||||
|
'Set the current count to a specific number (Admin only)',
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('count')
|
||||||
|
.setDescription('The number to set as the current count')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const countingData = await getCountingData();
|
||||||
|
const countingChannelId = loadConfig().channels.counting;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Counting Channel Status')
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Current Count',
|
||||||
|
value: countingData.currentCount.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Next Number',
|
||||||
|
value: (countingData.currentCount + 1).toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Highest Count',
|
||||||
|
value: countingData.highestCount.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Total Correct Counts',
|
||||||
|
value: countingData.totalCorrect.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Counting Channel',
|
||||||
|
value: `<#${countingChannelId}>`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'Remember: No user can count twice in a row!' })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (countingData.lastUserId) {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Last Counter',
|
||||||
|
value: `<@${countingData.lastUserId}>`,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed] });
|
||||||
|
} else if (subcommand === 'setcount') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You need administrator permissions to use this command.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = interaction.options.getInteger('count');
|
||||||
|
if (count === null) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Invalid count specified.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCount(count);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
247
src/commands/fun/fact.ts
Normal file
247
src/commands/fun/fact.ts
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addFact,
|
||||||
|
getPendingFacts,
|
||||||
|
approveFact,
|
||||||
|
deleteFact,
|
||||||
|
getLastInsertedFactId,
|
||||||
|
} from '../../db/db.js';
|
||||||
|
import { postFactOfTheDay } from '../../util/factManager.js';
|
||||||
|
import { loadConfig } from '../../util/configLoader.js';
|
||||||
|
import { SubcommandCommand } from '../../types/CommandTypes.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('fact')
|
||||||
|
.setDescription('Manage facts of the day')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('submit')
|
||||||
|
.setDescription('Submit a new fact for approval')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('content')
|
||||||
|
.setDescription('The fact content')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('source')
|
||||||
|
.setDescription('Source of the fact (optional)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('approve')
|
||||||
|
.setDescription('Approve a pending fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to approve')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('delete')
|
||||||
|
.setDescription('Delete a fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to delete')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('pending')
|
||||||
|
.setDescription('List all pending facts (Mod only)'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('post')
|
||||||
|
.setDescription('Post a fact of the day manually (Admin only)'),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
const config = loadConfig();
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'submit') {
|
||||||
|
const content = interaction.options.getString('content', true);
|
||||||
|
const source = interaction.options.getString('source') || undefined;
|
||||||
|
|
||||||
|
const isAdmin = interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
);
|
||||||
|
|
||||||
|
await addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy: interaction.user.id,
|
||||||
|
approved: isAdmin ? true : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const approvalChannel = interaction.guild?.channels.cache.get(
|
||||||
|
config.channels.factApproval,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvalChannel?.isTextBased()) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('New Fact Submission')
|
||||||
|
.setDescription(content)
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Submitted By',
|
||||||
|
value: `<@${interaction.user.id}>`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{ name: 'Source', value: source || 'Not provided', inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const approveButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`approve_fact_${await getLastInsertedFactId()}`)
|
||||||
|
.setLabel('Approve')
|
||||||
|
.setStyle(ButtonStyle.Success);
|
||||||
|
|
||||||
|
const rejectButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`reject_fact_${await getLastInsertedFactId()}`)
|
||||||
|
.setLabel('Reject')
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
approveButton,
|
||||||
|
rejectButton,
|
||||||
|
);
|
||||||
|
|
||||||
|
await approvalChannel.send({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Approval channel not found or is not a text channel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: isAdmin
|
||||||
|
? 'Your fact has been automatically approved and added to the database!'
|
||||||
|
: 'Your fact has been submitted for approval!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'approve') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to approve facts.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await approveFact(id);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Fact #${id} has been approved!`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'delete') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to delete facts.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await deleteFact(id);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Fact #${id} has been deleted!`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'pending') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to view pending facts.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingFacts = await getPendingFacts();
|
||||||
|
|
||||||
|
if (pendingFacts.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'There are no pending facts.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Pending Facts')
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setDescription(
|
||||||
|
pendingFacts
|
||||||
|
.map((fact) => {
|
||||||
|
return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`;
|
||||||
|
})
|
||||||
|
.join('\n\n'),
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [embed],
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'post') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to manually post facts.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postFactOfTheDay(interaction.client);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Fact of the day has been posted!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
131
src/db/db.ts
131
src/db/db.ts
|
@ -1,6 +1,6 @@
|
||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '../util/configLoader.js';
|
||||||
|
@ -230,3 +230,132 @@ export async function getMemberModerationHistory(discordId: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved = false,
|
||||||
|
}: schema.factTableTypes) {
|
||||||
|
try {
|
||||||
|
const result = await db.insert(schema.factTable).values({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved,
|
||||||
|
});
|
||||||
|
|
||||||
|
await del('unusedFacts');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding fact:', error);
|
||||||
|
throw new DatabaseError('Failed to add fact:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastInsertedFactId(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.select({ id: sql<number>`MAX(${schema.factTable.id})` })
|
||||||
|
.from(schema.factTable);
|
||||||
|
|
||||||
|
return result[0]?.id ?? 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting last inserted fact ID:', error);
|
||||||
|
throw new DatabaseError(
|
||||||
|
'Failed to get last inserted fact ID:',
|
||||||
|
error as Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRandomUnusedFact() {
|
||||||
|
try {
|
||||||
|
if (await exists('unusedFacts')) {
|
||||||
|
const facts =
|
||||||
|
await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts');
|
||||||
|
if (facts && facts.length > 0) {
|
||||||
|
return facts[Math.floor(Math.random() * facts.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const facts = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.factTable.approved, true),
|
||||||
|
isNull(schema.factTable.usedOn),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (facts.length === 0) {
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: null })
|
||||||
|
.where(eq(schema.factTable.approved, true));
|
||||||
|
|
||||||
|
return await getRandomUnusedFact();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setJson<(typeof schema.factTable.$inferSelect)[]>(
|
||||||
|
'unusedFacts',
|
||||||
|
facts,
|
||||||
|
);
|
||||||
|
return facts[Math.floor(Math.random() * facts.length)];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting random fact:', error);
|
||||||
|
throw new DatabaseError('Failed to get random fact:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markFactAsUsed(id: number) {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: new Date() })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await del('unusedFacts');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking fact as used:', error);
|
||||||
|
throw new DatabaseError('Failed to mark fact as used:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingFacts() {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(eq(schema.factTable.approved, false));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting pending facts:', error);
|
||||||
|
throw new DatabaseError('Failed to get pending facts:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveFact(id: number) {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ approved: true })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await del('unusedFacts');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error approving fact:', error);
|
||||||
|
throw new DatabaseError('Failed to approve fact:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFact(id: number) {
|
||||||
|
try {
|
||||||
|
await db.delete(schema.factTable).where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await del('unusedFacts');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting fact:', error);
|
||||||
|
throw new DatabaseError('Failed to delete fact:', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ class RedisError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redis.on('error', (error) => {
|
redis.on('error', (error: Error) => {
|
||||||
console.error('Redis connection error:', error);
|
console.error('Redis connection error:', error);
|
||||||
throw new RedisError('Failed to connect to Redis instance: ', error);
|
throw new RedisError('Failed to connect to Redis instance: ', error);
|
||||||
});
|
});
|
||||||
|
|
|
@ -61,3 +61,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({
|
||||||
references: [memberTable.discordId],
|
references: [memberTable.discordId],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type factTableTypes = {
|
||||||
|
id?: number;
|
||||||
|
content: string;
|
||||||
|
source?: string;
|
||||||
|
addedBy: string;
|
||||||
|
addedAt?: Date;
|
||||||
|
approved?: boolean;
|
||||||
|
usedOn?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const factTable = pgTable('facts', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
content: varchar('content').notNull(),
|
||||||
|
source: varchar('source'),
|
||||||
|
addedBy: varchar('added_by').notNull(),
|
||||||
|
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||||
|
approved: boolean('approved').default(false).notNull(),
|
||||||
|
usedOn: timestamp('used_on'),
|
||||||
|
});
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { Events, Interaction } from 'discord.js';
|
||||||
|
|
||||||
import { ExtendedClient } from '../structures/ExtendedClient.js';
|
import { ExtendedClient } from '../structures/ExtendedClient.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '../types/EventTypes.js';
|
||||||
|
import { approveFact, deleteFact } from '../db/db.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
execute: async (interaction: Interaction) => {
|
execute: async (interaction: Interaction) => {
|
||||||
if (!interaction.isCommand()) return;
|
if (interaction.isCommand()) {
|
||||||
|
|
||||||
const client = interaction.client as ExtendedClient;
|
const client = interaction.client as ExtendedClient;
|
||||||
const command = client.commands.get(interaction.commandName);
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
@ -36,5 +36,45 @@ export default {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (interaction.isButton()) {
|
||||||
|
const { customId } = interaction;
|
||||||
|
|
||||||
|
if (customId.startsWith('approve_fact_')) {
|
||||||
|
if (!interaction.memberPermissions?.has('ModerateMembers')) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to approve facts.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factId = parseInt(customId.replace('approve_fact_', ''), 10);
|
||||||
|
await approveFact(factId);
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`,
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
} else if (customId.startsWith('reject_fact_')) {
|
||||||
|
if (!interaction.memberPermissions?.has('ModerateMembers')) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to reject facts.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factId = parseInt(customId.replace('reject_fact_', ''), 10);
|
||||||
|
await deleteFact(factId);
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`,
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Unhandled interaction type:', interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
} as Event<typeof Events.InteractionCreate>;
|
} as Event<typeof Events.InteractionCreate>;
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
|
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
|
||||||
|
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '../types/EventTypes.js';
|
||||||
|
import { loadConfig } from '../util/configLoader.js';
|
||||||
|
import {
|
||||||
|
addCountingReactions,
|
||||||
|
processCountingMessage,
|
||||||
|
resetCounting,
|
||||||
|
} from '../util/countingManager.js';
|
||||||
import logAction from '../util/logging/logAction.js';
|
import logAction from '../util/logging/logAction.js';
|
||||||
|
|
||||||
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
||||||
|
@ -62,4 +68,56 @@ export const messageUpdate: Event<typeof Events.MessageUpdate> = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [messageDelete, messageUpdate];
|
export const messageCreate: Event<typeof Events.MessageCreate> = {
|
||||||
|
name: Events.MessageCreate,
|
||||||
|
execute: async (message: Message) => {
|
||||||
|
try {
|
||||||
|
if (message.author.bot) return;
|
||||||
|
|
||||||
|
const countingChannelId = loadConfig().channels.counting;
|
||||||
|
const countingChannel =
|
||||||
|
message.guild?.channels.cache.get(countingChannelId);
|
||||||
|
|
||||||
|
if (!countingChannel || message.channel.id !== countingChannelId) return;
|
||||||
|
if (!countingChannel.isTextBased()) {
|
||||||
|
console.error('Counting channel not found or is not a text channel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processCountingMessage(message);
|
||||||
|
|
||||||
|
if (result.isValid) {
|
||||||
|
await addCountingReactions(message, result.milestoneType || 'normal');
|
||||||
|
} else {
|
||||||
|
let errorMessage: string;
|
||||||
|
|
||||||
|
switch (result.reason) {
|
||||||
|
case 'not_a_number':
|
||||||
|
errorMessage = `${message.author}, that's not a valid number! The count has been reset. The next number should be **1**.`;
|
||||||
|
break;
|
||||||
|
case 'too_high':
|
||||||
|
errorMessage = `${message.author}, too high! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
|
||||||
|
break;
|
||||||
|
case 'too_low':
|
||||||
|
errorMessage = `${message.author}, too low! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
|
||||||
|
break;
|
||||||
|
case 'same_user':
|
||||||
|
errorMessage = `${message.author}, you can't count twice in a row! The count has been reset. The next number should be **1**.`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = `${message.author}, something went wrong with the count. The count has been reset. The next number should be **1**.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetCounting();
|
||||||
|
|
||||||
|
await countingChannel.send(errorMessage);
|
||||||
|
|
||||||
|
await message.react('❌');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message create:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [messageCreate, messageDelete, messageUpdate];
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Client, Events } from 'discord.js';
|
||||||
import { setMembers } from '../db/db.js';
|
import { setMembers } from '../db/db.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '../util/configLoader.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '../types/EventTypes.js';
|
||||||
|
import { scheduleFactOfTheDay } from '../util/factManager.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
|
@ -21,6 +22,8 @@ export default {
|
||||||
const members = await guild.members.fetch();
|
const members = await guild.members.fetch();
|
||||||
const nonBotMembers = members.filter((m) => !m.user.bot);
|
const nonBotMembers = members.filter((m) => !m.user.bot);
|
||||||
await setMembers(nonBotMembers);
|
await setMembers(nonBotMembers);
|
||||||
|
|
||||||
|
await scheduleFactOfTheDay(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize members in database:', error);
|
console.error('Failed to initialize members in database:', error);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {
|
||||||
CommandInteraction,
|
CommandInteraction,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
SlashCommandOptionsOnlyBuilder,
|
SlashCommandOptionsOnlyBuilder,
|
||||||
|
SlashCommandSubcommandsOnlyBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
export interface Command {
|
export interface Command {
|
||||||
|
@ -13,3 +14,8 @@ export interface OptionsCommand {
|
||||||
data: SlashCommandOptionsOnlyBuilder;
|
data: SlashCommandOptionsOnlyBuilder;
|
||||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
execute: (interaction: CommandInteraction) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubcommandCommand {
|
||||||
|
data: SlashCommandSubcommandsOnlyBuilder;
|
||||||
|
execute: (interaction: CommandInteraction) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
|
@ -7,8 +7,12 @@ export interface Config {
|
||||||
channels: {
|
channels: {
|
||||||
welcome: string;
|
welcome: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
|
counting: string;
|
||||||
|
factOfTheDay: string;
|
||||||
|
factApproval: string;
|
||||||
};
|
};
|
||||||
roles: {
|
roles: {
|
||||||
joinRoles: string[];
|
joinRoles: string[];
|
||||||
|
factPingRole: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
157
src/util/countingManager.ts
Normal file
157
src/util/countingManager.ts
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
import { Message } from 'discord.js';
|
||||||
|
|
||||||
|
import { getJson, setJson } from '../db/redis.js';
|
||||||
|
|
||||||
|
interface CountingData {
|
||||||
|
currentCount: number;
|
||||||
|
lastUserId: string | null;
|
||||||
|
highestCount: number;
|
||||||
|
totalCorrect: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONE_REACTIONS = {
|
||||||
|
normal: '✅',
|
||||||
|
multiples25: '✨',
|
||||||
|
multiples50: '⭐',
|
||||||
|
multiples100: '🎉',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function initializeCountingData(): Promise<CountingData> {
|
||||||
|
const exists = await getJson<CountingData>('counting');
|
||||||
|
if (exists) return exists;
|
||||||
|
|
||||||
|
const initialData: CountingData = {
|
||||||
|
currentCount: 0,
|
||||||
|
lastUserId: null,
|
||||||
|
highestCount: 0,
|
||||||
|
totalCorrect: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setJson<CountingData>('counting', initialData);
|
||||||
|
return initialData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountingData(): Promise<CountingData> {
|
||||||
|
const data = await getJson<CountingData>('counting');
|
||||||
|
if (!data) {
|
||||||
|
return initializeCountingData();
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCountingData(
|
||||||
|
data: Partial<CountingData>,
|
||||||
|
): Promise<void> {
|
||||||
|
const currentData = await getCountingData();
|
||||||
|
const updatedData = { ...currentData, ...data };
|
||||||
|
await setJson<CountingData>('counting', updatedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetCounting(): Promise<void> {
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: 0,
|
||||||
|
lastUserId: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processCountingMessage(message: Message): Promise<{
|
||||||
|
isValid: boolean;
|
||||||
|
expectedCount?: number;
|
||||||
|
isMilestone?: boolean;
|
||||||
|
milestoneType?: keyof typeof MILESTONE_REACTIONS;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const countingData = await getCountingData();
|
||||||
|
|
||||||
|
const content = message.content.trim();
|
||||||
|
const count = Number(content);
|
||||||
|
|
||||||
|
if (isNaN(count) || !Number.isInteger(count)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
expectedCount: countingData.currentCount + 1,
|
||||||
|
reason: 'not_a_number',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCount = countingData.currentCount + 1;
|
||||||
|
if (count !== expectedCount) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
expectedCount,
|
||||||
|
reason: count > expectedCount ? 'too_high' : 'too_low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countingData.lastUserId === message.author.id) {
|
||||||
|
return { isValid: false, expectedCount, reason: 'same_user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = countingData.currentCount + 1;
|
||||||
|
const newHighestCount = Math.max(newCount, countingData.highestCount);
|
||||||
|
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: newCount,
|
||||||
|
lastUserId: message.author.id,
|
||||||
|
highestCount: newHighestCount,
|
||||||
|
totalCorrect: countingData.totalCorrect + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isMilestone = false;
|
||||||
|
let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal';
|
||||||
|
|
||||||
|
if (newCount % 100 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples100';
|
||||||
|
} else if (newCount % 50 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples50';
|
||||||
|
} else if (newCount % 25 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples25';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
expectedCount: newCount + 1,
|
||||||
|
isMilestone,
|
||||||
|
milestoneType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing counting message:', error);
|
||||||
|
return { isValid: false, reason: 'error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCountingReactions(
|
||||||
|
message: Message,
|
||||||
|
milestoneType: keyof typeof MILESTONE_REACTIONS,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await message.react(MILESTONE_REACTIONS[milestoneType]);
|
||||||
|
|
||||||
|
if (milestoneType === 'multiples100') {
|
||||||
|
await message.react('💯');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding counting reactions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountingStatus(): Promise<string> {
|
||||||
|
const data = await getCountingData();
|
||||||
|
return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCount(count: number): Promise<void> {
|
||||||
|
if (!Number.isInteger(count) || count < 0) {
|
||||||
|
throw new Error('Count must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: count,
|
||||||
|
lastUserId: null,
|
||||||
|
});
|
||||||
|
}
|
69
src/util/factManager.ts
Normal file
69
src/util/factManager.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { EmbedBuilder, Client } from 'discord.js';
|
||||||
|
|
||||||
|
import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
|
||||||
|
export async function scheduleFactOfTheDay(client: Client) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const timeUntilMidnight = tomorrow.getTime() - now.getTime();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
postFactOfTheDay(client);
|
||||||
|
scheduleFactOfTheDay(client);
|
||||||
|
}, timeUntilMidnight);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling fact of the day:', error);
|
||||||
|
setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postFactOfTheDay(client: Client) {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const guild = client.guilds.cache.get(config.guildId);
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
console.error('Guild not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factChannel = guild.channels.cache.get(config.channels.factOfTheDay);
|
||||||
|
if (!factChannel?.isTextBased()) {
|
||||||
|
console.error('Fact channel not found or is not a text channel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fact = await getRandomUnusedFact();
|
||||||
|
if (!fact) {
|
||||||
|
console.error('No facts available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🌟 Fact of the Day 🌟')
|
||||||
|
.setDescription(fact.content)
|
||||||
|
.setColor(0xffaa00)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (fact.source) {
|
||||||
|
embed.setFooter({ text: `Source: ${fact.source}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
await factChannel.send({
|
||||||
|
content: `<@&${config.roles.factPingRole}>`,
|
||||||
|
embeds: [embed],
|
||||||
|
});
|
||||||
|
await markFactAsUsed(fact.id!);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error posting fact of the day:', error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
GuildChannel,
|
GuildChannel,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LogActionPayload,
|
LogActionPayload,
|
||||||
ModerationLogAction,
|
ModerationLogAction,
|
||||||
|
@ -22,10 +23,12 @@ import {
|
||||||
getPermissionDifference,
|
getPermissionDifference,
|
||||||
getPermissionNames,
|
getPermissionNames,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import { loadConfig } from '../configLoader.js';
|
||||||
|
|
||||||
export default async function logAction(payload: LogActionPayload) {
|
export default async function logAction(payload: LogActionPayload) {
|
||||||
const logChannel = payload.guild.channels.cache.get('1007787977432383611');
|
const config = loadConfig();
|
||||||
if (!logChannel || !(logChannel instanceof TextChannel)) {
|
const logChannel = payload.guild.channels.cache.get(config.channels.logs);
|
||||||
|
if (!logChannel?.isTextBased()) {
|
||||||
console.error('Log channel not found or is not a Text Channel.');
|
console.error('Log channel not found or is not a Text Channel.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue