Skip to content

Commit fbd98d7

Browse files
Merge pull request #236 from Pdzly/feature/230-reaction-statistics
Implement reaction statistics
2 parents b301d06 + 92a6a9f commit fbd98d7

11 files changed

Lines changed: 1569 additions & 0 deletions

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ModerationModule } from "./modules/moderation/moderation.module.js";
2222
import { ModmailModule } from "./modules/modmail/modmail.module.js";
2323
import ModuleManager from "./modules/moduleManager.js";
2424
import PastifyModule from "./modules/pastify/pastify.module.js";
25+
import { ReactionStatsModule } from "./modules/reactionStats/reactionStats.module.js";
2526
import { RolesModule } from "./modules/roles/roles.module.js";
2627
import { ShowcaseModule } from "./modules/showcase.module.js";
2728
import { StarboardModule } from "./modules/starboard/starboard.module.js";
@@ -73,6 +74,7 @@ export const moduleManager = new ModuleManager(
7374
UserModule,
7475
AchievementsModule,
7576
ThreatDetectionModule,
77+
ReactionStatsModule,
7678
],
7779
);
7880

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
ApplicationCommandType,
4+
type Guild,
5+
type GuildMember,
6+
} from "discord.js";
7+
import type { Command, ExecutableSubcommand } from "djs-slash-helper";
8+
import { createStandardEmbed } from "../../util/embeds.js";
9+
import { actualMention } from "../../util/users.js";
10+
import { medal } from "../leaderboard/leaderboard.js";
11+
import {
12+
chunkEmbedFieldValues,
13+
formatEmojiForEmbed,
14+
} from "./reactionStats.embed.js";
15+
import {
16+
getGlobalStats,
17+
getTopMessages,
18+
getUserStats,
19+
periodLabel,
20+
type TimePeriod,
21+
} from "./reactionStats.service.js";
22+
23+
function canRenderCustomEmoji(
24+
guild: Guild,
25+
emojiId: bigint | null,
26+
): boolean | undefined {
27+
if (!emojiId) return undefined;
28+
return guild.client.emojis.resolve(emojiId.toString()) !== null;
29+
}
30+
31+
const periodChoices = [
32+
{ name: "All Time", value: "all" },
33+
{ name: "Last Year", value: "year" },
34+
{ name: "Last 30 Days", value: "month" },
35+
{ name: "Last 7 Days", value: "week" },
36+
{ name: "Last 24 Hours", value: "day" },
37+
];
38+
39+
const UserSubcommand: ExecutableSubcommand = {
40+
type: ApplicationCommandOptionType.Subcommand,
41+
name: "user",
42+
description: "View reaction stats for a specific user",
43+
options: [
44+
{
45+
type: ApplicationCommandOptionType.User,
46+
name: "target",
47+
description: "The user to view stats for (defaults to yourself)",
48+
required: false,
49+
},
50+
{
51+
type: ApplicationCommandOptionType.String,
52+
name: "period",
53+
description: "Time period to filter by",
54+
required: false,
55+
choices: periodChoices,
56+
},
57+
],
58+
handle: async (interaction) => {
59+
await interaction.deferReply();
60+
const guild = interaction.guild;
61+
if (!guild) {
62+
await interaction.followUp("This command can only be used in a server.");
63+
return;
64+
}
65+
66+
const targetUser =
67+
interaction.options.getUser("target") ?? interaction.user;
68+
const period = (interaction.options.getString("period") ??
69+
"all") as TimePeriod;
70+
71+
const stats = await getUserStats(BigInt(targetUser.id), period);
72+
73+
if (stats.totalReactions === 0) {
74+
await interaction.followUp({
75+
embeds: [
76+
createStandardEmbed(interaction.member as GuildMember)
77+
.setTitle(`Reaction Stats: ${targetUser.username}`)
78+
.setThumbnail(targetUser.displayAvatarURL())
79+
.setDescription(
80+
`No reactions recorded for ${actualMention(targetUser)} (${periodLabel(period)}).`,
81+
),
82+
],
83+
});
84+
return;
85+
}
86+
87+
const emojiLines = stats.topEmojis.map(
88+
(e, i) =>
89+
`${medal(i)} ${formatEmojiForEmbed(
90+
e.emojiName,
91+
e.isCustomEmoji,
92+
e.emojiId,
93+
{
94+
canRenderCustomEmoji: canRenderCustomEmoji(guild, e.emojiId),
95+
},
96+
)} — **${e.count}** time${e.count !== 1 ? "s" : ""}`,
97+
);
98+
99+
const embed = createStandardEmbed(interaction.member as GuildMember)
100+
.setTitle(`Reaction Stats: ${targetUser.username}`)
101+
.setThumbnail(targetUser.displayAvatarURL())
102+
.setDescription(`Stats for ${actualMention(targetUser)}`)
103+
.addFields(
104+
{
105+
name: "Period",
106+
value: periodLabel(period),
107+
inline: true,
108+
},
109+
{
110+
name: "Total Reactions",
111+
value: stats.totalReactions.toLocaleString(),
112+
inline: true,
113+
},
114+
{
115+
name: "Messages Reacted To",
116+
value: stats.uniqueMessagesReacted.toLocaleString(),
117+
inline: true,
118+
},
119+
);
120+
121+
if (emojiLines.length > 0) {
122+
embed.addFields({
123+
name: `Top ${emojiLines.length} Emojis`,
124+
value: emojiLines.join("\n"),
125+
inline: false,
126+
});
127+
}
128+
129+
await interaction.followUp({ embeds: [embed] });
130+
},
131+
};
132+
133+
const GlobalSubcommand: ExecutableSubcommand = {
134+
type: ApplicationCommandOptionType.Subcommand,
135+
name: "global",
136+
description: "View global reaction statistics",
137+
options: [
138+
{
139+
type: ApplicationCommandOptionType.String,
140+
name: "period",
141+
description: "Time period to filter by",
142+
required: false,
143+
choices: periodChoices,
144+
},
145+
],
146+
handle: async (interaction) => {
147+
await interaction.deferReply();
148+
const guild = interaction.guild;
149+
if (!guild) {
150+
await interaction.followUp("This command can only be used in a server.");
151+
return;
152+
}
153+
154+
const period = (interaction.options.getString("period") ??
155+
"all") as TimePeriod;
156+
const stats = await getGlobalStats(period);
157+
158+
if (stats.totalReactions === 0) {
159+
await interaction.followUp({
160+
embeds: [
161+
createStandardEmbed(interaction.member as GuildMember)
162+
.setTitle("Global Reaction Stats")
163+
.setDescription(`No reactions recorded (${periodLabel(period)}).`),
164+
],
165+
});
166+
return;
167+
}
168+
169+
const reactorLines = await Promise.all(
170+
stats.topReactors.map(async (r, i) => {
171+
const user = await guild.client.users
172+
.fetch(r.userId.toString())
173+
.catch(() => null);
174+
const mention = user ? actualMention(user) : "Unknown User";
175+
return `${medal(i)} ${mention} — **${r.count}** reaction${r.count !== 1 ? "s" : ""}`;
176+
}),
177+
);
178+
179+
const emojiLines = stats.topEmojis.map(
180+
(e, i) =>
181+
`${medal(i)} ${formatEmojiForEmbed(
182+
e.emojiName,
183+
e.isCustomEmoji,
184+
e.emojiId,
185+
{
186+
canRenderCustomEmoji: canRenderCustomEmoji(guild, e.emojiId),
187+
},
188+
)} — **${e.count}** time${e.count !== 1 ? "s" : ""}`,
189+
);
190+
191+
const receiverLines = await Promise.all(
192+
stats.topReceivers.map(async (r, i) => {
193+
const user = await guild.client.users
194+
.fetch(r.messageAuthorId.toString())
195+
.catch(() => null);
196+
const mention = user ? actualMention(user) : "Unknown User";
197+
return `${medal(i)} ${mention} — **${r.count}** reaction${r.count !== 1 ? "s" : ""} received`;
198+
}),
199+
);
200+
201+
const embed = createStandardEmbed(interaction.member as GuildMember)
202+
.setTitle("Global Reaction Stats")
203+
.addFields(
204+
{
205+
name: "Period",
206+
value: periodLabel(period),
207+
inline: true,
208+
},
209+
{
210+
name: "Total Reactions",
211+
value: stats.totalReactions.toLocaleString(),
212+
inline: true,
213+
},
214+
);
215+
216+
if (reactorLines.length > 0) {
217+
embed.addFields({
218+
name: "🏆 Top Reactors",
219+
value: reactorLines.join("\n"),
220+
inline: false,
221+
});
222+
}
223+
224+
if (emojiLines.length > 0) {
225+
embed.addFields({
226+
name: "🔥 Most Used Emojis",
227+
value: emojiLines.join("\n"),
228+
inline: false,
229+
});
230+
}
231+
232+
if (receiverLines.length > 0) {
233+
embed.addFields({
234+
name: "💬 Most Reacted Users (received)",
235+
value: receiverLines.join("\n"),
236+
inline: false,
237+
});
238+
}
239+
240+
await interaction.followUp({ embeds: [embed] });
241+
},
242+
};
243+
244+
const MessagesSubcommand: ExecutableSubcommand = {
245+
type: ApplicationCommandOptionType.Subcommand,
246+
name: "messages",
247+
description: "View the most reacted messages",
248+
options: [
249+
{
250+
type: ApplicationCommandOptionType.String,
251+
name: "period",
252+
description: "Time period to filter by",
253+
required: false,
254+
choices: periodChoices,
255+
},
256+
],
257+
handle: async (interaction) => {
258+
await interaction.deferReply();
259+
const guild = interaction.guild;
260+
if (!guild) {
261+
await interaction.followUp("This command can only be used in a server.");
262+
return;
263+
}
264+
265+
const period = (interaction.options.getString("period") ??
266+
"all") as TimePeriod;
267+
const topMessages = await getTopMessages(period);
268+
269+
if (topMessages.length === 0) {
270+
await interaction.followUp({
271+
embeds: [
272+
createStandardEmbed(interaction.member as GuildMember)
273+
.setTitle("Most Reacted Messages")
274+
.setDescription(`No reactions recorded (${periodLabel(period)}).`),
275+
],
276+
});
277+
return;
278+
}
279+
280+
const messageLines = await Promise.all(
281+
topMessages.map(async (m, i) => {
282+
const author = await guild.client.users
283+
.fetch(m.messageAuthorId.toString())
284+
.catch(() => null);
285+
const authorName = author ? actualMention(author) : "Unknown User";
286+
const messageLink = `https://discord.com/channels/${guild.id}/${m.channelId}/${m.messageId}`;
287+
return (
288+
`${medal(i)} **#${i + 1}** — [Jump to message](${messageLink})\n` +
289+
` By ${authorName} • **${m.reactionCount}** reaction${m.reactionCount !== 1 ? "s" : ""} from **${m.uniqueReactors}** user${m.uniqueReactors !== 1 ? "s" : ""}`
290+
);
291+
}),
292+
);
293+
const messageChunks = chunkEmbedFieldValues(messageLines);
294+
295+
const embed = createStandardEmbed(
296+
interaction.member as GuildMember,
297+
).setTitle("Most Reacted Messages");
298+
embed.addFields({
299+
name: "Period",
300+
value: periodLabel(period),
301+
inline: true,
302+
});
303+
for (const [index, chunk] of messageChunks.entries()) {
304+
embed.addFields({
305+
name:
306+
messageChunks.length > 1
307+
? `Results (${index + 1}/${messageChunks.length})`
308+
: "Results",
309+
value: chunk,
310+
inline: false,
311+
});
312+
}
313+
314+
await interaction.followUp({ embeds: [embed] });
315+
},
316+
};
317+
318+
export const ReactionStatsCommand: Command<ApplicationCommandType.ChatInput> = {
319+
type: ApplicationCommandType.ChatInput,
320+
name: "reactionstats",
321+
description: "View reaction statistics for users, emojis, and messages",
322+
options: [UserSubcommand, GlobalSubcommand, MessagesSubcommand],
323+
handle() {},
324+
};

0 commit comments

Comments
 (0)