Skip to content

feat: cookie hunter #140

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

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface CacheEntries {
lobbyIds: string[];
onDemandChannels: string[];
quoiFeurChannels: string[];
cookieHunterChannels: string[];
currentHuntMessageId: string;
cookieHunterDailyCount: Record<string, number>;
cookieHunterDailyLogChannels: string[];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about adding a bit of jsdoc on top of some of these entries?

it may not be obvious what we are storing here without looking at the code

is cookieHunterDailyCount a discord user id <> score record?
what is cookieHunterDailyLogChannels ?

cookieHunterScoreboard: Record<string, number>;
milkJokerUserId: string;
recurringMessages: { id: string; channelId: string; frequency: Frequency; message: string }[];
}

Expand Down Expand Up @@ -65,3 +71,4 @@ class CacheImpl implements Cache<CacheEntries> {
}

export const cache = new CacheImpl();
export type { CacheEntries };
74 changes: 74 additions & 0 deletions src/helpers/channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
ChannelType,
type ChatInputCommandInteraction,
type DMChannel,
type NonThreadGuildBasedChannel,
} from 'discord.js';

import { cache, type CacheEntries } from '../core/cache';

type ChannelArrayCacheKey = Pick<
CacheEntries,
'quoiFeurChannels' | 'cookieHunterChannels' | 'cookieHunterDailyLogChannels'
>;

type FeatureName = 'Cookie Hunter' | 'Cookie Hunter Daily logs' | 'Quoi-Feur';

export const addChannelInCache = async (
interaction: ChatInputCommandInteraction,
featureName: FeatureName,
cacheKey: keyof ChannelArrayCacheKey,
) => {
const { channel } = interaction;
if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return;

const channels = await cache.get(cacheKey, []);
if (channels.includes(channel.id)) {
await interaction.reply({
content: `${featureName} is already enabled in this channel`,
ephemeral: true,
});
return;
}

await cache.set(cacheKey, [...channels, channel.id]);
await interaction.reply({ content: `${featureName} enabled in this channel`, ephemeral: true });
};

export const removeChannelFromChache = async (
interaction: ChatInputCommandInteraction,
featureName: FeatureName,
cacheKey: keyof ChannelArrayCacheKey,
) => {
const { channel } = interaction;
if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return;

const channels = await cache.get(cacheKey, []);
if (!channels.includes(channel.id)) {
await interaction.reply({
content: `${featureName} is not enabled in this channel`,
ephemeral: true,
});
return;
}

await cache.set(
cacheKey,
channels.filter((channelId) => channelId !== channel.id),
);
await interaction.reply({ content: `${featureName} disabled in this channel`, ephemeral: true });
};

export const cleanCacheOnChannelDelete = async (
channel: DMChannel | NonThreadGuildBasedChannel,
cacheKey: keyof ChannelArrayCacheKey,
) => {
const { id } = channel;
const channels = await cache.get(cacheKey, []);
if (!channels.includes(id)) return;

await cache.set(
cacheKey,
channels.filter((channelId) => channelId !== id),
);
};
1 change: 1 addition & 0 deletions src/helpers/timeConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ONE_MINUTE = 1 * 60 * 1000;
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createAllModules } from './core/createEnvForModule';
import { env } from './core/env';
import { getIntentsFromModules } from './core/getIntentsFromModules';
import { loadModules } from './core/loadModules';
import { cookieHunter } from './modules/cookieHunter/cookieHunter.module';
import { coolLinksManagement } from './modules/coolLinksManagement/coolLinksManagement.module';
import { fart } from './modules/fart/fart.module';
import { fixEmbedTwitterVideo } from './modules/fixEmbedTwitterVideo/fixEmbedTwitterVideo.module';
Expand All @@ -18,6 +19,7 @@ const modules = [
quoiFeur,
recurringMessage,
fixEmbedTwitterVideo,
cookieHunter,
];

const createdModules = await createAllModules(modules);
Expand Down
193 changes: 193 additions & 0 deletions src/modules/cookieHunter/cookieHunter.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { CronJob } from 'cron';
import type {
ChatInputCommandInteraction,
Client,
Message,
MessageReaction,
PartialMessageReaction,
PartialUser,
User,
} from 'discord.js';

import { cache } from '../../core/cache';
import { ONE_MINUTE } from '../../helpers/timeConstants';

const IT_IS_SNACK_TIME = '0 30 16 * * *'; // 4:30pm every day

let jobCurrentlyRunning: CronJob | null = null;

const sendMessageInRandomChannel = async (client: Client<true>) => {
const channel = await cache.get('cookieHunterChannels', []);
if (!channel.length) return;

const randomChannel = channel[Math.floor(Math.random() * channel.length)];
if (!randomChannel) return;

const channelToSend = await client.channels.fetch(randomChannel);

if (!channelToSend || !channelToSend.isTextBased()) return;
const cookieMessage = await channelToSend.send('**👵 Qui veut des cookies ?**');
await Promise.all([
cache.set('currentHuntMessageId', cookieMessage.id),
cache.set('cookieHunterDailyCount', {}),
cookieMessage.react('🥛'),
cookieMessage.react('🍪'), // 1 point for grandma here, she beats everyone who doesn't find her
]);
setTimeout(() => void dailyHuntEnd(client, cookieMessage), ONE_MINUTE);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe overkill to manage this now, and i m not sure often the bot crash/restarts. but what happens if the bot crashes? the daily hunt will never stop

a simple fix would be to manage it in countCookies method. when some dude react, you check if the message was posted more than a minute ago and if so, you don't count the point and manually call dailyHuntEnd

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I'm not a big fan of this setTimeout too... I will need to store the start time to do that but look like a good solution to avoid an infinite hunt

};

const handleMilkReaction = async (
reaction: MessageReaction | PartialMessageReaction,
user: User | PartialUser,
isMilkJokerAlreadyFound: boolean,
) => {
if (isMilkJokerAlreadyFound) {
await reaction.message.reply({
content: `Il est lent ce lait... 🥛`,
options: { ephemeral: true },
});
} else {
await cache.set('milkJokerUserId', user.id);
await reaction.message.reply({
content: `Premier arrivé, premier servit. Cul sec 🥛 !`,
options: { ephemeral: true },
});
}
};

const applyMilkJoker = async () => {
const milkJokerUserId = await cache.get('milkJokerUserId');
if (!milkJokerUserId) return;

const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {});
const userDailyCount = cookieHunterDailyCount[milkJokerUserId] || 0;
const newDailyCount = { ...cookieHunterDailyCount, [milkJokerUserId]: userDailyCount * 2 };
await cache.set('cookieHunterDailyCount', newDailyCount);
};

const logDailyCount = async (client: Client<true>) => {
const dailyLogChannels = await cache.get('cookieHunterDailyLogChannels', []);
if (!dailyLogChannels.length) return;

const currentHuntMessageId = await cache.get('currentHuntMessageId');
if (!currentHuntMessageId)
throw new Error('Lost the hunt message id before logging the daily count');

const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {});
const hunterCount = Object.keys(cookieHunterDailyCount).length - 1; // grandma is not a hunter

const milkJokerUserId = await cache.get('milkJokerUserId');

const resume = `**🍪 Résumé de la chasse aux cookies du jour**\n`;
const where = `Mamie a servi des cookies dans <#${currentHuntMessageId}>\n`;
const baseMessage = `${resume}${where}`;

const message =
hunterCount > 0
? getHuntersFoundGrandmaMessage(baseMessage, cookieHunterDailyCount, milkJokerUserId)
: `${baseMessage}**🍪 Personne n'a trouvé Mamie !**\nFaut dire qu'elle se cache bien (et que vous êtes nazes) !`;

for (const channelId of dailyLogChannels) {
const channel = await client.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) continue;
await channel.send(message);
}
};

const getHuntersFoundGrandmaMessage = (
baseMessage: string,
cookieHunterDailyCount: Record<string, number>,
milkJokerUserId: string | undefined,
) => {
const cookieEatenCount = Object.values(cookieHunterDailyCount).reduce(
(acc, count) => acc + count,
0,
);
const dailyRank = Object.entries(cookieHunterDailyCount).sort((a, b) => b[1] - a[1]);

return [
baseMessage,
`Nombre de cookies total mangés : ${cookieEatenCount}`,
`**Classement des chasseurs de cookies du jour**`,
dailyRank.map(([userId, count]) => `<@${userId}>: ${count}`).join('\n'),
milkJokerUserId
? `<@${milkJokerUserId}> a accompagné ses cookies d'un grand verre de lait 🥛`
: null,
`Sacré bande de gourmands !`,
]
.filter(Boolean)
.join('\n');
};

const updateGlobalScoreboard = async () => {
const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {});
const coockieHunterScoreboard = await cache.get('cookieHunterScoreboard', {});
for (const [userId, count] of Object.entries(cookieHunterDailyCount)) {
coockieHunterScoreboard[userId] = (coockieHunterScoreboard[userId] || 0) + count;
}
await cache.set('cookieHunterScoreboard', coockieHunterScoreboard);
};

const dailyHuntEnd = async (client: Client<true>, cookieMessage: Message) => {
await cookieMessage.delete();
await applyMilkJoker();
await logDailyCount(client);
await updateGlobalScoreboard();
await Promise.all([
cache.delete('milkJokerUserId'),
cache.delete('currentHuntMessageId'),
cache.delete('cookieHunterDailyCount'),
]);
};

export const startHunting = (client: Client<true>) => {
console.log('Cookie hunter started');
if (jobCurrentlyRunning !== null) {
// needed in case that the bot fire multiple ready event
jobCurrentlyRunning.stop();
}
jobCurrentlyRunning = new CronJob(
IT_IS_SNACK_TIME,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not random ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for the V1

() => sendMessageInRandomChannel(client),
null,
true,
'Europe/Paris',
);
};

export const countCookies = async (
reaction: MessageReaction | PartialMessageReaction,
user: User | PartialUser,
) => {
const currentHuntMessageId = await cache.get('currentHuntMessageId');
if (
!currentHuntMessageId ||
reaction.message.id !== currentHuntMessageId ||
reaction.emoji.name === null ||
!['🍪', '🥛'].includes(reaction.emoji.name)
)
return;

const isMilkJokerAlreadyFound = Boolean(await cache.get('milkJokerUserId'));

if (reaction.emoji.name === '🥛' && !user.bot) {
await handleMilkReaction(reaction, user, isMilkJokerAlreadyFound);
}

const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {});
const userDailyCount = cookieHunterDailyCount[user.id] || 0;
const newDailyCount = { ...cookieHunterDailyCount, [user.id]: userDailyCount + 1 };
await cache.set('cookieHunterDailyCount', newDailyCount);
};

export const displayScoreboard = async (interaction: ChatInputCommandInteraction) => {
const cookieHunterScoreboard = await cache.get('cookieHunterScoreboard', {});
const ranking = Object.entries(cookieHunterScoreboard)
.sort((a, b) => b[1] - a[1])
.map(([userId, count], index) => `${index + 1}. <@${userId}>: ${count}`)
.join('\n');

const message = `**🍪 Classement général des chasseurs de cookies**\n${ranking}`;

await interaction.reply(message);
};
71 changes: 71 additions & 0 deletions src/modules/cookieHunter/cookieHunter.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';

import { createModule } from '../../core/createModule';
import {
addChannelInCache,
cleanCacheOnChannelDelete,
removeChannelFromChache,
} from '../../helpers/channels';
import { countCookies, displayScoreboard, startHunting } from './cookieHunter.helpers';

export const cookieHunter = createModule({
name: 'cookieHunter',
slashCommands: () => [
{
schema: new SlashCommandBuilder()
.setName('cookie-hunter')
.setDescription('Cookie hunting game for the server')
.addSubcommand((subcommand) =>
subcommand.setName('start').setDescription('Start the cookie hunt'),
)
.addSubcommand((subcommand) =>
subcommand.setName('enable').setDescription('Enable the cookie hunt in the channel'),
)
.addSubcommand((subcommand) =>
subcommand.setName('disable').setDescription('Disable the cookie hunt in the channel'),
)
.addSubcommand((subcommand) =>
subcommand.setName('add-daily-log').setDescription('Add daily log to the channel'),
)
.addSubcommand((subcommand) =>
subcommand.setName('remove-daily-log').setDescription('Add daily log to the channel'),
)
.addSubcommand((subcommand) =>
subcommand.setName('scoreboard').setDescription('Show the scoreboard'),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.toJSON(),
handler: {
// eslint-disable-next-line @typescript-eslint/require-await
'start': async (interaction) => startHunting(interaction.client),
'enable': (interaction) =>
addChannelInCache(interaction, 'Cookie Hunter', 'cookieHunterChannels'),
'disable': (interaction) =>
removeChannelFromChache(interaction, 'Cookie Hunter', 'cookieHunterChannels'),
'add-daily-log': (interaction) =>
addChannelInCache(
interaction,
'Cookie Hunter Daily logs',
'cookieHunterDailyLogChannels',
),
'remove-daily-log': (interaction) =>
removeChannelFromChache(
interaction,
'Cookie Hunter Daily logs',
'cookieHunterDailyLogChannels',
),
'scoreboard': async (interaction) => displayScoreboard(interaction),
},
},
],
eventHandlers: () => ({
// eslint-disable-next-line @typescript-eslint/require-await
ready: async (client) => startHunting(client),
messageReactionAdd: countCookies,
channelDelete: async (channel) => {
await cleanCacheOnChannelDelete(channel, 'cookieHunterChannels');
await cleanCacheOnChannelDelete(channel, 'cookieHunterDailyLogChannels');
},
}),
intents: ['Guilds'],
});
Loading