Skip to content

Commit e3e2407

Browse files
committed
feat: Add activity command
1 parent 80cd4a7 commit e3e2407

File tree

7 files changed

+470
-8
lines changed

7 files changed

+470
-8
lines changed

src/commands/activity/activity.ts

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import { ComponentType, InteractionContextType, SlashCommandBuilder, StringSelectMenuBuilder, TextChannel } from "discord.js";
2+
import type { Command } from "../../models/command";
3+
import { LocaleHelper } from "../../utils/locale-helper";
4+
import { getActivity } from "../../database/bee-database";
5+
import { type ChartConfiguration } from "chart.js";
6+
import { ChartJSNodeCanvas, type ChartCallback } from "chartjs-node-canvas";
7+
import logger from "../../logger";
8+
import { addDays, format } from "date-fns";
9+
import { enUS } from "date-fns/locale";
10+
import pl from "nodejs-polars";
11+
import sharp from "sharp";
12+
13+
const translations = LocaleHelper.getCommandTranslations("activity");
14+
15+
const chartCallback: ChartCallback = (ChartJS) => {
16+
ChartJS.defaults.responsive = false;
17+
ChartJS.defaults.maintainAspectRatio = false;
18+
};
19+
const chartJSNodeCanvas = new ChartJSNodeCanvas({
20+
width: 700,
21+
height: 500,
22+
backgroundColour: "#111111",
23+
chartCallback,
24+
plugins: {
25+
globalVariableLegacy: ["chartjs-adapter-date-fns"],
26+
},
27+
});
28+
29+
const ActivityCommand: Command = {
30+
data: new SlashCommandBuilder()
31+
.setName("activity")
32+
.setNameLocalizations(translations.localizedNames)
33+
.setDescription(translations.localizedDescriptions["en-US"]!)
34+
.setDescriptionLocalizations(translations.localizedDescriptions)
35+
.setContexts([InteractionContextType.Guild])
36+
.addChannelOption((option) =>
37+
option
38+
.setName("channel")
39+
.setNameLocalizations(translations.options!.channel!.localizedNames)
40+
.setDescription(translations.options!.channel!.localizedDescriptions["en-US"]!)
41+
.setDescriptionLocalizations(translations.options!.channel!.localizedDescriptions)
42+
.setRequired(false)
43+
),
44+
async execute(interaction) {
45+
await interaction.deferReply();
46+
47+
const guildId = interaction.guildId!;
48+
const channel = interaction.options.getChannel("channel");
49+
if (!!channel && !(channel instanceof TextChannel)) {
50+
return;
51+
}
52+
const userLocale: string = interaction.locale;
53+
const serverName = interaction.guild?.name ?? "Unknown Server";
54+
const botName = `${interaction.client.user?.username ?? ""}#${interaction.client.user?.discriminator ?? ""}`;
55+
56+
const fileBuffer = await plotTrend(guildId, userLocale, serverName, botName, channel);
57+
58+
const trendPeriodSelect = new StringSelectMenuBuilder().setCustomId("activity-period").setOptions([
59+
{
60+
label: translations.responses?.sinceTheBeginning?.[userLocale] ?? "Since the beginning",
61+
value: "0",
62+
description:
63+
translations.responses?.sinceTheBeginningDescription?.[userLocale] ??
64+
"Show the trend since the server's inception.",
65+
default: true,
66+
},
67+
{
68+
label: translations.responses?.forOneYear?.[userLocale] ?? "Trend for 1 year",
69+
value: "1",
70+
description: translations.responses?.forOneYearDescription?.[userLocale] ?? "Show the trend for 1 year.",
71+
},
72+
{
73+
label: translations.responses?.forTwoYears?.[userLocale] ?? "Trend for 2 years",
74+
value: "2",
75+
description: translations.responses?.forTwoYearsDescription?.[userLocale] ?? "Show the trend for 2 years.",
76+
},
77+
{
78+
label: translations.responses?.forThreeYears?.[userLocale] ?? "Trend for 3 years",
79+
value: "3",
80+
description: translations.responses?.forThreeYearsDescription?.[userLocale] ?? "Show the trend for 3 years.",
81+
},
82+
]);
83+
84+
const rollingMeanSelect = new StringSelectMenuBuilder().setCustomId("activity-rolling").setOptions([
85+
{
86+
label: translations.responses?.rolling14Days?.[userLocale] ?? "Average over 14 days",
87+
value: "14",
88+
description: translations.responses?.rolling14DaysDescription?.[userLocale] ?? "Rolling average over 14 days",
89+
default: true,
90+
},
91+
{
92+
label: translations.responses?.rolling7Days?.[userLocale] ?? "Average over 7 days",
93+
value: "7",
94+
description: translations.responses?.rolling7DaysDescription?.[userLocale] ?? "Rolling average over 7 days",
95+
},
96+
{
97+
label: translations.responses?.noRolling?.[userLocale] ?? "No rolling average",
98+
value: "1",
99+
description: translations.responses?.noRollingDescription?.[userLocale] ?? "Remove the rolling average",
100+
},
101+
]);
102+
103+
const components = [
104+
{
105+
type: ComponentType.ActionRow,
106+
components: [trendPeriodSelect],
107+
},
108+
{
109+
type: ComponentType.ActionRow,
110+
components: [rollingMeanSelect],
111+
},
112+
];
113+
114+
const message = await interaction.editReply({
115+
files: [
116+
{
117+
attachment: fileBuffer,
118+
name: "abeille.webp",
119+
contentType: "image/webp",
120+
},
121+
],
122+
components: components,
123+
});
124+
125+
const collector = message.createMessageComponentCollector({
126+
componentType: ComponentType.StringSelect,
127+
time: 10 * 60 * 1000,
128+
});
129+
130+
let period = 0;
131+
let rolling = 14;
132+
133+
collector.on("collect", async (i) => {
134+
if (i.customId === "activity-period") {
135+
period = parseInt(i.values[0] ?? "0");
136+
} else if (i.customId === "activity-rolling") {
137+
rolling = parseInt(i.values[0] ?? "14");
138+
}
139+
logger.debug("Period: %d, Rolling: %d", period, rolling);
140+
141+
const selectedPeriodOption = trendPeriodSelect.options.find((option) => option.data.value === i.values[0]);
142+
const selectedRollingOption = rollingMeanSelect.options.find((option) => option.data.value === i.values[0]);
143+
if (selectedPeriodOption) {
144+
trendPeriodSelect.options.forEach((option) => {
145+
option.setDefault(false);
146+
});
147+
selectedPeriodOption.setDefault(true);
148+
}
149+
if (selectedRollingOption) {
150+
rollingMeanSelect.options.forEach((option) => {
151+
option.setDefault(false);
152+
});
153+
selectedRollingOption.setDefault(true);
154+
}
155+
156+
const deferUpdatePromise = i.update({ content: "", components: components });
157+
158+
const newFileBuffer = await plotTrend(guildId, userLocale, serverName, botName, channel, rolling, period);
159+
await deferUpdatePromise;
160+
await i.editReply({
161+
files: [
162+
{
163+
attachment: newFileBuffer,
164+
name: "abeille.webp",
165+
contentType: "image/webp",
166+
},
167+
],
168+
});
169+
});
170+
171+
collector.on("end", async () => {
172+
logger.debug("Collector ended");
173+
await interaction.editReply({
174+
components: [],
175+
});
176+
});
177+
},
178+
};
179+
180+
async function plotTrend(
181+
guildId: string,
182+
userLocale: string,
183+
serverName: string,
184+
botName: string,
185+
channel: TextChannel | null,
186+
rolling = 14,
187+
dateMode = 0
188+
): Promise<Buffer<ArrayBufferLike>> {
189+
const activityResults = getActivity(guildId, channel?.id);
190+
logger.debug("Activity results: %o", activityResults);
191+
192+
const endDate = activityResults.guildLastMessageDate ?? new Date();
193+
const startDate =
194+
dateMode === 0
195+
? activityResults.guildFirstMessageDate ?? new Date()
196+
: endDate.getTime() - dateMode * 365 * 24 * 60 * 60 * 1000;
197+
const trends: { x: string; y: number }[] = [];
198+
199+
let date = new Date(startDate);
200+
const trendMap = new Map(activityResults.activity.map((t) => [t.date, t.messages ?? 0]));
201+
while (date <= endDate) {
202+
const dateAsStr = format(date, "yyyy-MM-dd");
203+
const value = trendMap.get(dateAsStr) || 0;
204+
trends.push({ x: dateAsStr, y: value });
205+
date = addDays(date, 1);
206+
}
207+
208+
let dataFrame = pl.DataFrame(trends);
209+
dataFrame = dataFrame
210+
.withColumn(pl.col("x").cast(pl.Date))
211+
.withColumn(pl.col("y").rollingMean({ windowSize: rolling }).fillNull(0));
212+
logger.debug("DataFrame: %o", dataFrame);
213+
214+
// Dynamically load the locale based on the user's locale
215+
// Default to en-US if the locale is not found
216+
let locale = (await import("date-fns/locale/" + userLocale))[userLocale.replace("-", "")];
217+
if (!locale) {
218+
locale = enUS;
219+
}
220+
221+
const dataFrameObject = dataFrame.toRecords();
222+
logger.debug("DataFrame object: %o", dataFrameObject);
223+
224+
const chartConfiguration: ChartConfiguration<"line", { x: Date; y: number }[]> = {
225+
type: "line",
226+
data: {
227+
datasets: [
228+
{
229+
data: dataFrameObject as unknown as { x: Date; y: number }[],
230+
borderWidth: 3,
231+
borderColor: "rgb(255, 255, 0)",
232+
backgroundColor: "rgb(136, 136, 8)",
233+
fill: true,
234+
normalized: true,
235+
},
236+
],
237+
},
238+
options: {
239+
animation: false,
240+
responsive: false,
241+
devicePixelRatio: 2,
242+
datasets: {
243+
line: {
244+
borderWidth: 1,
245+
pointStyle: false,
246+
cubicInterpolationMode: rolling === 0 ? "monotone" : "default",
247+
borderJoinStyle: "round",
248+
},
249+
},
250+
layout: {
251+
padding: {
252+
top: 10,
253+
bottom: 15,
254+
right: 25,
255+
left: 25,
256+
},
257+
},
258+
locale: userLocale,
259+
scales: {
260+
y: {
261+
type: "linear",
262+
alignToPixels: true,
263+
border: {
264+
display: false,
265+
},
266+
beginAtZero: true,
267+
min: 0,
268+
ticks: {
269+
callback: function (value) {
270+
const intValue = parseFloat(value.toString());
271+
return (100 * intValue).toFixed(2) + "%";
272+
},
273+
color: "white",
274+
autoSkip: true,
275+
},
276+
grid: {
277+
color: "rgb(88, 88, 88)",
278+
},
279+
},
280+
x: {
281+
type: "time",
282+
alignToPixels: true,
283+
border: {
284+
display: false,
285+
},
286+
ticks: {
287+
color: "white",
288+
autoSkip: true,
289+
maxRotation: 0,
290+
major: { enabled: true },
291+
},
292+
adapters: {
293+
date: {
294+
locale: locale,
295+
},
296+
},
297+
grid: {
298+
color: "rgb(88, 88, 88)",
299+
},
300+
title: {
301+
display: true,
302+
text: (translations.responses?.graphFooter?.[userLocale] ?? "Generated on {date} by {botName}")
303+
.replace("{date}", new Date().toLocaleString(userLocale, { dateStyle: "short" }))
304+
.replace("{botName}", botName),
305+
color: "white",
306+
align: "end",
307+
padding: { top: 10 },
308+
font: {
309+
size: 10,
310+
},
311+
},
312+
},
313+
},
314+
plugins: {
315+
colors: {
316+
enabled: true,
317+
},
318+
title: {
319+
text: (translations.responses?.graphTitle?.[userLocale] ?? "Message count per day"),
320+
font: {
321+
size: 18,
322+
weight: "bold",
323+
},
324+
display: true,
325+
align: "start",
326+
color: "white",
327+
},
328+
subtitle: {
329+
text: channel === null ?
330+
(translations.responses?.graphSubTitle?.[userLocale] ?? "On {serverName}")
331+
.replace("{serverName}", serverName)
332+
: (translations.responses?.graphSubTitleChannel?.[userLocale] ?? "On {serverName}, in channel: '{channelName}'")
333+
.replace("{serverName}", serverName)
334+
.replace("{channelName}", channel.name),
335+
display: true,
336+
align: "start",
337+
padding: { bottom: 25 },
338+
color: "white",
339+
font: {
340+
style: "italic",
341+
},
342+
},
343+
legend: {
344+
display: false,
345+
},
346+
},
347+
},
348+
};
349+
350+
const buffer = await chartJSNodeCanvas.renderToBuffer(chartConfiguration as unknown as ChartConfiguration);
351+
return sharp(buffer).toFormat("webp", { quality: 80 }).toBuffer();
352+
}
353+
354+
export default ActivityCommand;

src/commands/activity/compare.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ async function plotCompare(
256256
},
257257
title: {
258258
display: true,
259-
text: (translations.responses?.graphFooter?.[userLocale] ?? "Generate at {date} by {botName}")
259+
text: (translations.responses?.graphFooter?.[userLocale] ?? "Generated on {date} by {botName}")
260260
.replace("{date}", new Date().toLocaleString(userLocale, { dateStyle: "short" }))
261261
.replace("{botName}", botName),
262262
color: "white",

src/commands/activity/trend.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,18 @@ const TrendCommand: Command = {
8484
{
8585
label: translations.responses?.rolling14Days?.[userLocale] ?? "Average over 14 days",
8686
value: "14",
87-
description: translations.responses?.rolling14DaysDescription?.[userLocale] ?? "Sliding average over 14 days",
87+
description: translations.responses?.rolling14DaysDescription?.[userLocale] ?? "Rolling average over 14 days",
8888
default: true,
8989
},
9090
{
9191
label: translations.responses?.rolling7Days?.[userLocale] ?? "Average over 7 days",
9292
value: "7",
93-
description: translations.responses?.rolling7DaysDescription?.[userLocale] ?? "Sliding average over 7 days",
93+
description: translations.responses?.rolling7DaysDescription?.[userLocale] ?? "Rolling average over 7 days",
9494
},
9595
{
9696
label: translations.responses?.noRolling?.[userLocale] ?? "No rolling average",
9797
value: "1",
98-
description: translations.responses?.noRollingDescription?.[userLocale] ?? "Remove the sliding average",
98+
description: translations.responses?.noRollingDescription?.[userLocale] ?? "Remove the rolling average",
9999
},
100100
]);
101101

@@ -298,7 +298,7 @@ async function plotTrend(
298298
},
299299
title: {
300300
display: true,
301-
text: (translations.responses?.graphFooter?.[userLocale] ?? "Generate at {date} by {botName}")
301+
text: (translations.responses?.graphFooter?.[userLocale] ?? "Generated on {date} by {botName}")
302302
.replace("{date}", new Date().toLocaleString(userLocale, { dateStyle: "short" }))
303303
.replace("{botName}", botName),
304304
color: "white",

0 commit comments

Comments
 (0)