Added Basic Fact of the Day Feature

This commit is contained in:
Ahmad 2025-03-08 00:29:19 -05:00
parent 2139f2efa0
commit 40942e2539
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
9 changed files with 544 additions and 27 deletions

View file

@ -7,11 +7,14 @@
"channels": {
"welcome": "WELCOME_CHANNEL_ID",
"logs": "LOG_CHANNEL_ID",
"counting": "COUNTING_CHANNEL_ID"
"counting": "COUNTING_CHANNEL_ID",
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
"factApproval": "FACT_APPROVAL_CHANNEL_ID"
},
"roles": {
"joinRoles": [
"JOIN_ROLE_IDS"
]
],
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
}
}

247
src/commands/fun/fact.ts Normal file
View 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;

View file

@ -1,6 +1,6 @@
import pkg from 'pg';
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 { 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);
}
}

View file

@ -61,3 +61,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({
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'),
});

View file

@ -2,39 +2,79 @@ import { Events, Interaction } from 'discord.js';
import { ExtendedClient } from '../structures/ExtendedClient.js';
import { Event } from '../types/EventTypes.js';
import { approveFact, deleteFact } from '../db/db.js';
export default {
name: Events.InteractionCreate,
execute: async (interaction: Interaction) => {
if (!interaction.isCommand()) return;
if (interaction.isCommand()) {
const client = interaction.client as ExtendedClient;
const command = client.commands.get(interaction.commandName);
const client = interaction.client as ExtendedClient;
const command = client.commands.get(interaction.commandName);
if (!command) {
console.error(
`No command matching ${interaction.commandName} was found.`,
);
return;
}
if (!command) {
console.error(
`No command matching ${interaction.commandName} was found.`,
);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error executing ${interaction.commandName}`);
console.error(error);
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error executing ${interaction.commandName}`);
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
});
} else {
await interaction.reply({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
});
}
}
} else if (interaction.isButton()) {
const { customId } = interaction;
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
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 {
await interaction.reply({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
} 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>;

View file

@ -3,6 +3,7 @@ import { Client, Events } from 'discord.js';
import { setMembers } from '../db/db.js';
import { loadConfig } from '../util/configLoader.js';
import { Event } from '../types/EventTypes.js';
import { scheduleFactOfTheDay } from '../util/factManager.js';
export default {
name: Events.ClientReady,
@ -21,6 +22,8 @@ export default {
const members = await guild.members.fetch();
const nonBotMembers = members.filter((m) => !m.user.bot);
await setMembers(nonBotMembers);
await scheduleFactOfTheDay(client);
} catch (error) {
console.error('Failed to initialize members in database:', error);
}

View file

@ -8,8 +8,11 @@ export interface Config {
welcome: string;
logs: string;
counting: string;
factOfTheDay: string;
factApproval: string;
};
roles: {
joinRoles: string[];
factPingRole: string;
};
}

69
src/util/factManager.ts Normal file
View 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);
}
}

View file

@ -5,6 +5,7 @@ import {
ActionRowBuilder,
GuildChannel,
} from 'discord.js';
import {
LogActionPayload,
ModerationLogAction,
@ -22,10 +23,12 @@ import {
getPermissionDifference,
getPermissionNames,
} from './utils.js';
import { loadConfig } from '../configLoader.js';
export default async function logAction(payload: LogActionPayload) {
const logChannel = payload.guild.channels.cache.get('1007787977432383611');
if (!logChannel || !(logChannel instanceof TextChannel)) {
const config = loadConfig();
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.');
return;
}