Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dev alerts & /inbox #239

Merged
merged 2 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 8 additions & 19 deletions .vscode/command.code-snippets
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,21 @@
"prefix": ["command", "discord command"],
"body": [
"import BaseCommand from '#src/core/BaseCommand.js';",
"import { ChatInputCommandInteraction } from 'discord.js';",
"import Context from '#src/core/CommandContext/Context.js';",

"export default class $1 extends BaseCommand {",
"\treadonly data = {",
"\t\tname: '$2',",
"\t\tdescription: '$3',",
"\t};",
"\tasync execute(ctx: Context) {",
"\t\t$4",
"\tconstructor() {",
"\t\tsuper({",
"\t\t\tname: '$2',",
"\t\t\tdescription: '$3',",
"\t\t});",
"\t}",
"}",
],
"description": "Create a slash command with a name and description.",
},
"Define an InterChat SubCommand": {
"scope": "javascript,typescript",
"prefix": ["subcommand", "discord subcommand"],
"body": [
"import $1 from './index.js';",
"import { ChatInputCommandInteraction } from 'discord.js';\n",
"export default class $3 extends $1 {",

"\tasync execute(ctx: Context) {",
"\t\t$4",
"\t}",
"}",
],
"description": "Create a slash subcommand with a name and description.",
"description": "Create a slash command with a name and description.",
},
}
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,22 @@ model UserData {
ownedHubs Hub[]
infractions Infraction[] @relation("infractions")
issuedInfractions Infraction[] @relation("issuedInfractions")
inboxLastReadDate DateTime? @default(now())

@@index([xp])
@@index([level])
@@index([messageCount])
}

model Announcement {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content String
thumbnailUrl String?
imageUrl String?
createdAt DateTime @default(now())
}

model ServerData {
id String @id @map("_id") @db.String
premiumStatus Boolean @default(false)
Expand Down
90 changes: 90 additions & 0 deletions src/commands/Main/inbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import BaseCommand from '#src/core/BaseCommand.js';
import Context from '#src/core/CommandContext/Context.js';
import { RegisterInteractionHandler } from '#src/decorators/RegisterInteractionHandler.js';
import { Pagination } from '#src/modules/Pagination.js';
import UserDbService from '#src/services/UserDbService.js';
import { CustomID } from '#src/utils/CustomID.js';
import db from '#src/utils/Db.js';
import { InfoEmbed } from '#src/utils/EmbedUtils.js';
import { getEmoji } from '#src/utils/EmojiUtils.js';
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, RepliableInteraction } from 'discord.js';

export default class InboxCommand extends BaseCommand {
private readonly userDbService = new UserDbService();

constructor() {
super({
name: 'inbox',
description: 'Check your inbox for latest important updates & announcements',
types: { slash: true, prefix: true },
});
}

async execute(ctx: Context) {
await showInbox(ctx, this.userDbService);
}

@RegisterInteractionHandler('inbox', 'viewOlder')
async handleViewOlder(interaction: RepliableInteraction) {
await showInbox(interaction, this.userDbService, { showOlder: true, ephemeral: true });
}
}

export async function showInbox(
interaction: Context | RepliableInteraction,
userDbService: UserDbService,
opts?: { showOlder?: boolean; ephemeral?: boolean },
) {
const userData = await userDbService.getUser(interaction.user.id);
const inboxLastRead = userData?.inboxLastReadDate || new Date();

const announcements = !opts?.showOlder
? await db.announcement.findMany({
where: { createdAt: { gt: inboxLastRead } },
take: 10,
orderBy: { createdAt: 'desc' },
})
: await db.announcement.findMany({
where: { createdAt: { lt: inboxLastRead } },
take: 50, // limit to 50 older announcementsorderBy: { createdAt: 'desc' },
});

const components = !opts?.showOlder
? [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(new CustomID().setIdentifier('inbox', 'viewOlder').toString())
.setLabel('View Older')
.setStyle(ButtonStyle.Secondary),
),
]
: [];

if (announcements.length === 0) {
const embed = new InfoEmbed()
.setTitle(':tada: All caught up!')
.setDescription(
`I'll let you know when there's more. But for now, there's only Chipi here: ${getEmoji('chipi_smile', interaction.client)}`,
);
await interaction.reply({ embeds: [embed], components });
return;
}

new Pagination(interaction.client, { hiddenButtons: ['search', 'select'] })
.addPages(
announcements.map((announcement) => ({
components,
embeds: [
new InfoEmbed()
.setTitle(announcement.title)
.setDescription(announcement.content)
.setThumbnail(announcement.thumbnailUrl)
.setImage(announcement.imageUrl)
.setTimestamp(announcement.createdAt),
],
})),
)
.run(interaction, { ephemeral: opts?.ephemeral });

await userDbService.updateUser(interaction.user.id, { inboxLastReadDate: new Date() });
}
18 changes: 18 additions & 0 deletions src/commands/Staff/dev/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import DevAnnounceCommand from './send-alert.js';
import BaseCommand from '#src/core/BaseCommand.js';

export default class DevCommand extends BaseCommand {
constructor() {
super({
name: 'dev',
description: 'ooh spooky',
types: {
slash: true,
prefix: true,
},
subcommands: {
'send-alert': new DevAnnounceCommand(),
},
});
}
}
95 changes: 95 additions & 0 deletions src/commands/Staff/dev/send-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import BaseCommand from '#src/core/BaseCommand.js';
import Context from '#src/core/CommandContext/Context.js';
import { RegisterInteractionHandler } from '#src/decorators/RegisterInteractionHandler.js';
import Constants from '#src/utils/Constants.js';
import { CustomID } from '#src/utils/CustomID.js';
import db from '#src/utils/Db.js';
import { getEmoji } from '#src/utils/EmojiUtils.js';
import {
ActionRowBuilder,
ModalBuilder,
ModalSubmitInteraction,
TextInputBuilder,
TextInputStyle,
} from 'discord.js';

export default class DevAnnounceCommand extends BaseCommand {
constructor() {
super({
name: 'send-alert',
description: 'Alert something to all users. This will go to their inbox.',
types: { slash: true, prefix: true },
});
}
async execute(ctx: Context) {
const modal = new ModalBuilder()
.setCustomId(new CustomID('devAnnounceModal').toString())
.setTitle('Announcement Creation')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setMaxLength(100)
.setRequired(true)
.setStyle(TextInputStyle.Short),
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('content')
.setLabel('Content of the announcement')
.setMaxLength(4000)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph),
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('thumbnailUrl')
.setLabel('Thumbnail URL')
.setRequired(false)
.setStyle(TextInputStyle.Short),
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('bannerUrl')
.setLabel('Banner URL')
.setRequired(false)
.setStyle(TextInputStyle.Short),
),
);

await ctx.showModal(modal);
}

@RegisterInteractionHandler('devAnnounceModal')
async handleModal(interaction: ModalSubmitInteraction) {
const title = interaction.fields.getTextInputValue('title');
const content = interaction.fields.getTextInputValue('content');
const thumbnailUrlInput = interaction.fields.getTextInputValue('thumbnailUrl');
const imageUrlInput = interaction.fields.getTextInputValue('bannerUrl');

const thumbnailUrl = thumbnailUrlInput.length > 0 ? thumbnailUrlInput : null;
const imageUrl = imageUrlInput.length > 0 ? imageUrlInput : null;

const testThumbnail =
thumbnailUrlInput.length > 0 ? Constants.Regex.ImageURL.test(thumbnailUrlInput) : true;
const testImage =
imageUrlInput.length > 0 ? Constants.Regex.ImageURL.test(imageUrlInput) : true;

if (!testThumbnail || !testImage) {
await interaction.reply({
content: `${getEmoji('x_icon', interaction.client)} Thumbnail or Icon URL is invalid.`,
flags: ['Ephemeral'],
});
return;
}

await db.announcement.create({
data: { title, content, thumbnailUrl, imageUrl },
});

await interaction.reply(
`${getEmoji('tick_icon', interaction.client)} Announcement has been recorded. View using \`/inbox\``,
);
}
}
12 changes: 5 additions & 7 deletions src/core/CommandContext/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,15 @@ export default abstract class Context<T extends ContextT = ContextT> {
if (this.interaction instanceof MessageContextMenuCommandInteraction) {
return this.interaction.targetId;
}
if (!name) return null;

const value = this.options.getString(name);
if (!value) return null;

let messageId: string | null | undefined = extractMessageId(value);
if (this.interaction instanceof Message && this.interaction.reference) {
messageId = this.interaction.reference.messageId;
return this.interaction.reference.messageId ?? null;
}
if (!name) return null;

return messageId ?? null;
const value = this.options.getString(name);
if (!value) return null;
return extractMessageId(value) ?? null;
}

public async getTargetUser(name?: string) {
Expand Down
44 changes: 34 additions & 10 deletions src/events/interactionCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@
* along with InterChat. If not, see <https://www.gnu.org/licenses/>.
*/

import type BaseCommand from '#src/core/BaseCommand.js';
import BaseEventListener from '#src/core/BaseEventListener.js';
import { showRulesScreening } from '#src/interactions/RulesScreening.js';
import { executeCommand, resolveCommand } from '#src/utils/CommandUtils.js';
import Constants from '#utils/Constants.js';
import { CustomID, type ParsedCustomId } from '#utils/CustomID.js';
import { InfoEmbed } from '#utils/EmbedUtils.js';
import { t } from '#utils/Locale.js';
import {
checkIfStaff,
createUnreadDevAlertEmbed,
fetchUserData,
fetchUserLocale,
handleError,
hasUnreadDevAlert,
} from '#utils/Utils.js';
import type { UserData } from '@prisma/client';
import type {
AutocompleteInteraction,
Expand All @@ -25,15 +41,6 @@ import type {
MessageComponentInteraction,
ModalSubmitInteraction,
} from 'discord.js';
import type BaseCommand from '#src/core/BaseCommand.js';
import BaseEventListener from '#src/core/BaseEventListener.js';
import { showRulesScreening } from '#src/interactions/RulesScreening.js';
import Constants from '#utils/Constants.js';
import { CustomID, type ParsedCustomId } from '#utils/CustomID.js';
import { InfoEmbed } from '#utils/EmbedUtils.js';
import { t } from '#utils/Locale.js';
import { checkIfStaff, fetchUserData, fetchUserLocale, handleError } from '#utils/Utils.js';
import { executeCommand, resolveCommand } from '#src/utils/CommandUtils.js';

export default class InteractionCreate extends BaseEventListener<'interactionCreate'> {
readonly name = 'interactionCreate';
Expand All @@ -42,14 +49,31 @@ export default class InteractionCreate extends BaseEventListener<'interactionCre
try {
const preCheckResult = await this.performPreChecks(interaction);
if (!preCheckResult.shouldContinue) return;
await this.handleInteraction(interaction, preCheckResult.dbUser).catch((e) => {
handleError(e, { repliable: interaction });
});

await this.handleInteraction(interaction, preCheckResult.dbUser);
await this.showDevAlertIfAny(interaction, preCheckResult.dbUser);
}
catch (e) {
handleError(e, { repliable: interaction });
}
}

private async showDevAlertIfAny(interaction: Interaction, dbUser: UserData | null) {
if (!interaction.isRepliable() || !interaction.replied || !dbUser) return;

const shouldShow = await hasUnreadDevAlert(dbUser);
if (!shouldShow) return;

await interaction
.followUp({
embeds: [createUnreadDevAlertEmbed(this.getEmoji('info_icon'))],
flags: ['Ephemeral'],
})
.catch(() => null);
}

private async performPreChecks(interaction: Interaction) {
if (this.isInMaintenance(interaction)) {
return { shouldContinue: false, dbUser: null };
Expand Down
Loading
Loading