Skip to content

Commit

Permalink
🔈 feat: Improve VoiceTime module
Browse files Browse the repository at this point in the history
  • Loading branch information
theSaintKappa committed Jun 3, 2024
1 parent 7c7979a commit eba36bd
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 161 deletions.
20 changes: 8 additions & 12 deletions src/commands/slash/voiceTime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { EmbedBuilder, SlashCommandBuilder } from "discord.js";
import { getStateEmbed } from "../../features/voiceTracker";
import { SlashCommandBuilder } from "discord.js";
import { getLeaderboardEmbed, getStateEmbed } from "../../features/voiceTime";
import VoiceTime from "../../models/bot/voiceTime";
import { getInfoReply } from "../../utils/replyEmbeds";
import { CommandScope, type SlashCommandObject } from "../types";

export default {
Expand All @@ -18,18 +17,15 @@ export default {
const subcommand = options.getSubcommand() as "leaderboard" | "state";

switch (subcommand) {
case "leaderboard":
await interaction.reply(await viewLeaderboard());
case "leaderboard": {
const voiceTime = await VoiceTime.find({}).sort({ time: -1 });
await interaction.reply({ embeds: [getLeaderboardEmbed(voiceTime)] });
break;
case "state":
}
case "state": {
await interaction.reply({ embeds: [getStateEmbed()] });
break;
}
}
},
} as SlashCommandObject;

async function viewLeaderboard() {
const voiceTime = await VoiceTime.find({}).sort({ time: -1 });

return getInfoReply("Voice Time Leaderboard", voiceTime.map((vt, i) => `**${i + 1}.** <@${vt.userId}> **→** ${(vt.time / 1000 / 60 / 60).toFixed(2)} hours`).join("\n"));
}
170 changes: 170 additions & 0 deletions src/features/voiceTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { type Client, EmbedBuilder, Events, type Message, type Snowflake, type VoiceBasedChannel } from "discord.js";
import config from "../config.json";
import type { IVoiceTime } from "../db";
import VoiceTime from "../models/bot/voiceTime";
import secrets from "../utils/secrets";

interface VoiceState {
userId: Snowflake;
channelId: Snowflake;
joinTimestamp: Date;
isIncognito: boolean;
isAfk: boolean;
}

const voiceStates = new Map<Snowflake, VoiceState>();

let lastLeaderboardUpdate = 0;

const displayEmbeds = {
leaderboard: new EmbedBuilder().setDescription("***Loading leaderboard...***"),
facts: new EmbedBuilder().setDescription("***Loading facts...***"),
state: new EmbedBuilder().setDescription("***Loading state...***"),
};

export function getLeaderboardEmbed(voiceTime: IVoiceTime[]) {
const medals = ["🥇", "🥈", "🥉"];
const embed = new EmbedBuilder()
.setColor("#ffc70e")
.setTitle("> 🏆 Voice time leaderboard:")
.addFields(
{ name: "\u200B", value: `${voiceTime.map((_, i) => medals[i] ?? `**\`${i + 1}\`**`).join("\n")}\n\u200B`, inline: true },
{ name: "\u200B", value: voiceTime.map(({ userId }) => `\`\u200B\`<@${userId}>`).join("\n"), inline: true },
{ name: "\u200B", value: voiceTime.map(({ time }) => `**\`${(time / 1000 / 60 / 60).toFixed(2)} hours\`**`).join("\n"), inline: true },
)
.setTimestamp();

return embed;
}

function getFactsEmbed(voiceTime: IVoiceTime[]) {
const time = voiceTime[0].time + (Date.now() - (voiceStates.get(voiceTime[0].userId)?.joinTimestamp.getTime() ?? Date.now()));

const embed = new EmbedBuilder()
.setColor("#1869ff")
.setTitle("> 📊 Stats:")
.setDescription(`***### As <@${voiceTime[0].userId}> was connected to a voice channel:***
🌍 The earth has traveled **\`${Number.parseFloat(((time / 1000) * 29.78).toFixed(2)).toLocaleString()} km\`** around the sun
👁️ The average human has blinked **\`${Math.ceil((time / 1000) * 0.28).toLocaleString()} times\`**
🍼 Around **\`${Math.ceil((time / 1000) * 4.32).toLocaleString()}\`** babies were born in the world
🥴 Moses has drunken approximately **\`${((time / 1000 / 60 / 60 / 24) * 2).toFixed(2)} liters\`** of vodka\n\u200B`)
.setTimestamp();

return embed;
}

export function getStateEmbed() {
const embed = new EmbedBuilder().setColor("#ff245e").setTitle("> ⌚ Voice time state:").setTimestamp();

const grouppedChannelStates = new Map<Snowflake, Array<{ userId: Snowflake } & Omit<VoiceState, "channelId">>>();
for (const { userId, channelId, joinTimestamp, isIncognito, isAfk } of voiceStates.values()) {
if (isIncognito) continue;
grouppedChannelStates.has(channelId) ? grouppedChannelStates.get(channelId)?.push({ userId, joinTimestamp, isIncognito, isAfk }) : grouppedChannelStates.set(channelId, [{ userId, joinTimestamp, isIncognito, isAfk }]);
}

if ([...voiceStates.values()].filter(({ isIncognito }) => !isIncognito).length)
embed.setDescription("\u200B").addFields(
[...grouppedChannelStates].flatMap(([channelId, voiceStates]) => {
return {
name: `> <#${channelId}> **(${voiceStates.length})**`,
value: voiceStates
.flatMap(({ userId, joinTimestamp, isAfk }) => `<@${userId}> **→** <t:${Math.floor(joinTimestamp.getTime() / 1000)}:R> ${isAfk ? "💤" : ""}`)
.concat("\u200B")
.join("\n"),
inline: false,
};
}),
);
else embed.setDescription("***No server members are currently in a voice channel.***");

displayEmbeds.state = embed;
return embed;
}

async function updateStateDisplay(message: Message<true>) {
displayEmbeds.state = getStateEmbed();
message.edit({ embeds: Object.values(displayEmbeds) });
}

async function updateLeaderboardDisplay(message: Message<true>) {
const voiceTime = await VoiceTime.find().sort({ time: -1 });

displayEmbeds.leaderboard = getLeaderboardEmbed(voiceTime);
displayEmbeds.facts = getFactsEmbed(voiceTime);
message.edit({ embeds: Object.values(displayEmbeds) });
lastLeaderboardUpdate = Date.now();
}

const isIncognitio = (channel: VoiceBasedChannel) => channel.name.includes("🥸");
const isAfk = (channel: VoiceBasedChannel) => channel.name.includes("💤");

function joinEvent(userId: Snowflake, newChannel: VoiceBasedChannel) {
voiceStates.set(userId, { userId, channelId: newChannel.id, joinTimestamp: new Date(), isIncognito: isIncognitio(newChannel), isAfk: isAfk(newChannel) });
}

function leaveEvent(userId: Snowflake, oldChannel: VoiceBasedChannel) {
const { ...voiceState } = voiceStates.get(userId);
if (!voiceState) return;

voiceStates.delete(userId);
if (!isAfk(oldChannel)) VoiceTime.updateOne({ userId }, { $inc: { time: Date.now() - voiceState.joinTimestamp.getTime() } }, { upsert: true });
}

function switchEvent(userId: Snowflake, newChannel: VoiceBasedChannel, oldChannel: VoiceBasedChannel) {
const { ...voiceState } = voiceStates.get(userId);
if (!voiceState) return;

voiceStates.set(userId, { userId, channelId: newChannel.id, joinTimestamp: new Date(), isIncognito: isIncognitio(newChannel), isAfk: isAfk(newChannel) });
if (!isAfk(oldChannel)) VoiceTime.updateOne({ userId }, { $inc: { time: Date.now() - voiceState.joinTimestamp.getTime() } }, { upsert: true });
}

export async function initializeVoiceTime(client: Client, displayChannel: SendableChannel) {
const guildVoiceStates = client.guilds.cache.get(secrets.testGuildId)?.voiceStates.cache.values() ?? [];
for (const { id, channelId, channel } of guildVoiceStates) {
voiceStates.set(id, { userId: id, channelId: channelId as Snowflake, joinTimestamp: new Date(), isIncognito: isIncognitio(channel as VoiceBasedChannel), isAfk: isAfk(channel as VoiceBasedChannel) });
}

let displayMessage = await displayChannel.messages.fetch({ limit: 1 }).then((messages) => messages.first());
if (!displayMessage?.editable) displayMessage = await displayChannel.send({ embeds: Object.values(displayEmbeds) });

updateStateDisplay(displayMessage);
updateLeaderboardDisplay(displayMessage);

client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
if (oldState.member?.user.bot) return;

if (!oldState.channel && newState.channel) joinEvent(newState.id, newState.channel);
else if (oldState.channel && !newState.channel) leaveEvent(oldState.id, oldState.channel);
else if (oldState.channel && newState.channel && oldState.channelId !== newState.channelId) switchEvent(newState.id, newState.channel, oldState.channel);
else return;

updateStateDisplay(displayMessage);
if (Date.now() - lastLeaderboardUpdate > 60_000) updateLeaderboardDisplay(displayMessage);
});

client.on(Events.MessageCreate, (message) => {
if (message.channelId === config.channels.voiceTime && message.author.id !== client.user?.id && message.deletable) message.delete();
});

let cleanupInProgress = false;
async function cleanup(signal: string | NodeJS.Signals) {
if (cleanupInProgress) return;
cleanupInProgress = true;

console.log(`⛔ [VoiceTime] ${signal} received. Cleaning up...\x1b[0m`);

const now = Date.now();
const updates = [...voiceStates].map(([userId, { joinTimestamp }]) => {
return { updateOne: { filter: { userId }, update: { $inc: { time: now - joinTimestamp.getTime() } }, upsert: true } };
});

await VoiceTime.bulkWrite(updates);
process.exit(0);
}

for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK", "SIGUSR1", "SIGUSR2"]) process.on(signal, cleanup.bind(null, signal));

console.log("✅ [VoiceTime] initialized.");
}

export const getStates = () => voiceStates as ReadonlyMap<Snowflake, VoiceState>;
147 changes: 0 additions & 147 deletions src/features/voiceTracker.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type IPresence, connectMongo } from "./db";
import { updateBotDescriptionQuote } from "./features/botDescription";
import { uploadPics } from "./features/pics";
import { scheduleJobs } from "./features/scheduler";
import { initializeVoiceTime } from "./features/voiceTracker";
import { initializeVoiceTime } from "./features/voiceTime";
import Presence from "./models/bot/presence";
import secrets from "./utils/secrets";

Expand All @@ -27,7 +27,7 @@ client.once(Events.ClientReady, async (client) => {
scheduleJobs(client);

// Initialize voice time tracking
initializeVoiceTime(client);
initializeVoiceTime(client, client.channels.cache.get(config.channels.voiceTime) as SendableChannel);

// Register slash commands
registerCommands(client.user);
Expand Down

0 comments on commit eba36bd

Please sign in to comment.