Skip to content
This repository was archived by the owner on Oct 9, 2025. It is now read-only.

Commit d82672d

Browse files
authored
feat: dev alerts & /inbox (#239)
* feat: dev alerts * fix(redis): set expiration time for lastAskedDate
1 parent d70191a commit d82672d

File tree

13 files changed

+385
-98
lines changed

13 files changed

+385
-98
lines changed

.vscode/command.code-snippets

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,21 @@
44
"prefix": ["command", "discord command"],
55
"body": [
66
"import BaseCommand from '#src/core/BaseCommand.js';",
7-
"import { ChatInputCommandInteraction } from 'discord.js';",
7+
"import Context from '#src/core/CommandContext/Context.js';",
88

99
"export default class $1 extends BaseCommand {",
10-
"\treadonly data = {",
11-
"\t\tname: '$2',",
12-
"\t\tdescription: '$3',",
13-
"\t};",
14-
"\tasync execute(ctx: Context) {",
15-
"\t\t$4",
10+
"\tconstructor() {",
11+
"\t\tsuper({",
12+
"\t\t\tname: '$2',",
13+
"\t\t\tdescription: '$3',",
14+
"\t\t});",
1615
"\t}",
17-
"}",
18-
],
19-
"description": "Create a slash command with a name and description.",
20-
},
21-
"Define an InterChat SubCommand": {
22-
"scope": "javascript,typescript",
23-
"prefix": ["subcommand", "discord subcommand"],
24-
"body": [
25-
"import $1 from './index.js';",
26-
"import { ChatInputCommandInteraction } from 'discord.js';\n",
27-
"export default class $3 extends $1 {",
16+
2817
"\tasync execute(ctx: Context) {",
2918
"\t\t$4",
3019
"\t}",
3120
"}",
3221
],
33-
"description": "Create a slash subcommand with a name and description.",
22+
"description": "Create a slash command with a name and description.",
3423
},
3524
}

prisma/schema.prisma

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,22 @@ model UserData {
178178
ownedHubs Hub[]
179179
infractions Infraction[] @relation("infractions")
180180
issuedInfractions Infraction[] @relation("issuedInfractions")
181+
inboxLastReadDate DateTime? @default(now())
181182
182183
@@index([xp])
183184
@@index([level])
184185
@@index([messageCount])
185186
}
186187

188+
model Announcement {
189+
id String @id @default(auto()) @map("_id") @db.ObjectId
190+
title String
191+
content String
192+
thumbnailUrl String?
193+
imageUrl String?
194+
createdAt DateTime @default(now())
195+
}
196+
187197
model ServerData {
188198
id String @id @map("_id") @db.String
189199
premiumStatus Boolean @default(false)

src/commands/Main/inbox.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import BaseCommand from '#src/core/BaseCommand.js';
2+
import Context from '#src/core/CommandContext/Context.js';
3+
import { RegisterInteractionHandler } from '#src/decorators/RegisterInteractionHandler.js';
4+
import { Pagination } from '#src/modules/Pagination.js';
5+
import UserDbService from '#src/services/UserDbService.js';
6+
import { CustomID } from '#src/utils/CustomID.js';
7+
import db from '#src/utils/Db.js';
8+
import { InfoEmbed } from '#src/utils/EmbedUtils.js';
9+
import { getEmoji } from '#src/utils/EmojiUtils.js';
10+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, RepliableInteraction } from 'discord.js';
11+
12+
export default class InboxCommand extends BaseCommand {
13+
private readonly userDbService = new UserDbService();
14+
15+
constructor() {
16+
super({
17+
name: 'inbox',
18+
description: 'Check your inbox for latest important updates & announcements',
19+
types: { slash: true, prefix: true },
20+
});
21+
}
22+
23+
async execute(ctx: Context) {
24+
await showInbox(ctx, this.userDbService);
25+
}
26+
27+
@RegisterInteractionHandler('inbox', 'viewOlder')
28+
async handleViewOlder(interaction: RepliableInteraction) {
29+
await showInbox(interaction, this.userDbService, { showOlder: true, ephemeral: true });
30+
}
31+
}
32+
33+
export async function showInbox(
34+
interaction: Context | RepliableInteraction,
35+
userDbService: UserDbService,
36+
opts?: { showOlder?: boolean; ephemeral?: boolean },
37+
) {
38+
const userData = await userDbService.getUser(interaction.user.id);
39+
const inboxLastRead = userData?.inboxLastReadDate || new Date();
40+
41+
const announcements = !opts?.showOlder
42+
? await db.announcement.findMany({
43+
where: { createdAt: { gt: inboxLastRead } },
44+
take: 10,
45+
orderBy: { createdAt: 'desc' },
46+
})
47+
: await db.announcement.findMany({
48+
where: { createdAt: { lt: inboxLastRead } },
49+
take: 50, // limit to 50 older announcementsorderBy: { createdAt: 'desc' },
50+
});
51+
52+
const components = !opts?.showOlder
53+
? [
54+
new ActionRowBuilder<ButtonBuilder>().addComponents(
55+
new ButtonBuilder()
56+
.setCustomId(new CustomID().setIdentifier('inbox', 'viewOlder').toString())
57+
.setLabel('View Older')
58+
.setStyle(ButtonStyle.Secondary),
59+
),
60+
]
61+
: [];
62+
63+
if (announcements.length === 0) {
64+
const embed = new InfoEmbed()
65+
.setTitle(':tada: All caught up!')
66+
.setDescription(
67+
`I'll let you know when there's more. But for now, there's only Chipi here: ${getEmoji('chipi_smile', interaction.client)}`,
68+
);
69+
await interaction.reply({ embeds: [embed], components });
70+
return;
71+
}
72+
73+
new Pagination(interaction.client, { hiddenButtons: ['search', 'select'] })
74+
.addPages(
75+
announcements.map((announcement) => ({
76+
components,
77+
embeds: [
78+
new InfoEmbed()
79+
.setTitle(announcement.title)
80+
.setDescription(announcement.content)
81+
.setThumbnail(announcement.thumbnailUrl)
82+
.setImage(announcement.imageUrl)
83+
.setTimestamp(announcement.createdAt),
84+
],
85+
})),
86+
)
87+
.run(interaction, { ephemeral: opts?.ephemeral });
88+
89+
await userDbService.updateUser(interaction.user.id, { inboxLastReadDate: new Date() });
90+
}

src/commands/Staff/dev/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import DevAnnounceCommand from './send-alert.js';
2+
import BaseCommand from '#src/core/BaseCommand.js';
3+
4+
export default class DevCommand extends BaseCommand {
5+
constructor() {
6+
super({
7+
name: 'dev',
8+
description: 'ooh spooky',
9+
types: {
10+
slash: true,
11+
prefix: true,
12+
},
13+
subcommands: {
14+
'send-alert': new DevAnnounceCommand(),
15+
},
16+
});
17+
}
18+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import BaseCommand from '#src/core/BaseCommand.js';
2+
import Context from '#src/core/CommandContext/Context.js';
3+
import { RegisterInteractionHandler } from '#src/decorators/RegisterInteractionHandler.js';
4+
import Constants from '#src/utils/Constants.js';
5+
import { CustomID } from '#src/utils/CustomID.js';
6+
import db from '#src/utils/Db.js';
7+
import { getEmoji } from '#src/utils/EmojiUtils.js';
8+
import {
9+
ActionRowBuilder,
10+
ModalBuilder,
11+
ModalSubmitInteraction,
12+
TextInputBuilder,
13+
TextInputStyle,
14+
} from 'discord.js';
15+
16+
export default class DevAnnounceCommand extends BaseCommand {
17+
constructor() {
18+
super({
19+
name: 'send-alert',
20+
description: 'Alert something to all users. This will go to their inbox.',
21+
types: { slash: true, prefix: true },
22+
});
23+
}
24+
async execute(ctx: Context) {
25+
const modal = new ModalBuilder()
26+
.setCustomId(new CustomID('devAnnounceModal').toString())
27+
.setTitle('Announcement Creation')
28+
.addComponents(
29+
new ActionRowBuilder<TextInputBuilder>().addComponents(
30+
new TextInputBuilder()
31+
.setCustomId('title')
32+
.setLabel('Title')
33+
.setMaxLength(100)
34+
.setRequired(true)
35+
.setStyle(TextInputStyle.Short),
36+
),
37+
new ActionRowBuilder<TextInputBuilder>().addComponents(
38+
new TextInputBuilder()
39+
.setCustomId('content')
40+
.setLabel('Content of the announcement')
41+
.setMaxLength(4000)
42+
.setRequired(true)
43+
.setStyle(TextInputStyle.Paragraph),
44+
),
45+
new ActionRowBuilder<TextInputBuilder>().addComponents(
46+
new TextInputBuilder()
47+
.setCustomId('thumbnailUrl')
48+
.setLabel('Thumbnail URL')
49+
.setRequired(false)
50+
.setStyle(TextInputStyle.Short),
51+
),
52+
new ActionRowBuilder<TextInputBuilder>().addComponents(
53+
new TextInputBuilder()
54+
.setCustomId('bannerUrl')
55+
.setLabel('Banner URL')
56+
.setRequired(false)
57+
.setStyle(TextInputStyle.Short),
58+
),
59+
);
60+
61+
await ctx.showModal(modal);
62+
}
63+
64+
@RegisterInteractionHandler('devAnnounceModal')
65+
async handleModal(interaction: ModalSubmitInteraction) {
66+
const title = interaction.fields.getTextInputValue('title');
67+
const content = interaction.fields.getTextInputValue('content');
68+
const thumbnailUrlInput = interaction.fields.getTextInputValue('thumbnailUrl');
69+
const imageUrlInput = interaction.fields.getTextInputValue('bannerUrl');
70+
71+
const thumbnailUrl = thumbnailUrlInput.length > 0 ? thumbnailUrlInput : null;
72+
const imageUrl = imageUrlInput.length > 0 ? imageUrlInput : null;
73+
74+
const testThumbnail =
75+
thumbnailUrlInput.length > 0 ? Constants.Regex.ImageURL.test(thumbnailUrlInput) : true;
76+
const testImage =
77+
imageUrlInput.length > 0 ? Constants.Regex.ImageURL.test(imageUrlInput) : true;
78+
79+
if (!testThumbnail || !testImage) {
80+
await interaction.reply({
81+
content: `${getEmoji('x_icon', interaction.client)} Thumbnail or Icon URL is invalid.`,
82+
flags: ['Ephemeral'],
83+
});
84+
return;
85+
}
86+
87+
await db.announcement.create({
88+
data: { title, content, thumbnailUrl, imageUrl },
89+
});
90+
91+
await interaction.reply(
92+
`${getEmoji('tick_icon', interaction.client)} Announcement has been recorded. View using \`/inbox\``,
93+
);
94+
}
95+
}

src/core/CommandContext/Context.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,15 @@ export default abstract class Context<T extends ContextT = ContextT> {
139139
if (this.interaction instanceof MessageContextMenuCommandInteraction) {
140140
return this.interaction.targetId;
141141
}
142-
if (!name) return null;
143-
144-
const value = this.options.getString(name);
145-
if (!value) return null;
146142

147-
let messageId: string | null | undefined = extractMessageId(value);
148143
if (this.interaction instanceof Message && this.interaction.reference) {
149-
messageId = this.interaction.reference.messageId;
144+
return this.interaction.reference.messageId ?? null;
150145
}
146+
if (!name) return null;
151147

152-
return messageId ?? null;
148+
const value = this.options.getString(name);
149+
if (!value) return null;
150+
return extractMessageId(value) ?? null;
153151
}
154152

155153
public async getTargetUser(name?: string) {

src/events/interactionCreate.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
* along with InterChat. If not, see <https://www.gnu.org/licenses/>.
1616
*/
1717

18+
import type BaseCommand from '#src/core/BaseCommand.js';
19+
import BaseEventListener from '#src/core/BaseEventListener.js';
20+
import { showRulesScreening } from '#src/interactions/RulesScreening.js';
21+
import { executeCommand, resolveCommand } from '#src/utils/CommandUtils.js';
22+
import Constants from '#utils/Constants.js';
23+
import { CustomID, type ParsedCustomId } from '#utils/CustomID.js';
24+
import { InfoEmbed } from '#utils/EmbedUtils.js';
25+
import { t } from '#utils/Locale.js';
26+
import {
27+
checkIfStaff,
28+
createUnreadDevAlertEmbed,
29+
fetchUserData,
30+
fetchUserLocale,
31+
handleError,
32+
hasUnreadDevAlert,
33+
} from '#utils/Utils.js';
1834
import type { UserData } from '@prisma/client';
1935
import type {
2036
AutocompleteInteraction,
@@ -25,15 +41,6 @@ import type {
2541
MessageComponentInteraction,
2642
ModalSubmitInteraction,
2743
} from 'discord.js';
28-
import type BaseCommand from '#src/core/BaseCommand.js';
29-
import BaseEventListener from '#src/core/BaseEventListener.js';
30-
import { showRulesScreening } from '#src/interactions/RulesScreening.js';
31-
import Constants from '#utils/Constants.js';
32-
import { CustomID, type ParsedCustomId } from '#utils/CustomID.js';
33-
import { InfoEmbed } from '#utils/EmbedUtils.js';
34-
import { t } from '#utils/Locale.js';
35-
import { checkIfStaff, fetchUserData, fetchUserLocale, handleError } from '#utils/Utils.js';
36-
import { executeCommand, resolveCommand } from '#src/utils/CommandUtils.js';
3744

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

46-
await this.handleInteraction(interaction, preCheckResult.dbUser);
56+
await this.showDevAlertIfAny(interaction, preCheckResult.dbUser);
4757
}
4858
catch (e) {
4959
handleError(e, { repliable: interaction });
5060
}
5161
}
5262

63+
private async showDevAlertIfAny(interaction: Interaction, dbUser: UserData | null) {
64+
if (!interaction.isRepliable() || !interaction.replied || !dbUser) return;
65+
66+
const shouldShow = await hasUnreadDevAlert(dbUser);
67+
if (!shouldShow) return;
68+
69+
await interaction
70+
.followUp({
71+
embeds: [createUnreadDevAlertEmbed(this.getEmoji('info_icon'))],
72+
flags: ['Ephemeral'],
73+
})
74+
.catch(() => null);
75+
}
76+
5377
private async performPreChecks(interaction: Interaction) {
5478
if (this.isInMaintenance(interaction)) {
5579
return { shouldContinue: false, dbUser: null };

0 commit comments

Comments
 (0)