Skip to content
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

Add ContextMenuReaction interface and update query cache initialization #72

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
139 changes: 134 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { sql } from '@vercel/postgres';
import { ChannelType, Client, type Message, Partials } from 'discord.js';
import {
ApplicationCommandType,
ChannelType,
Client,
ContextMenuCommandBuilder,
type Interaction,
type Message,
Partials,
REST,
Routes,
} from 'discord.js';
import dotenv from 'dotenv';

import type {
AutoReactionEmoji,
Command,
ContextMenuReaction,
QueryCache,
ReactionAgentEmoji,
ReactionData,
} from './types';
import { toFormatEmoji } from './utils';

dotenv.config();

const regexCache = new Map<string, RegExp>();
const commandToEmojiStringMap = new Map<string, string>();

const queryCache: QueryCache = {
autoReactionEmojis: [],
reactionAgentEmojis: [],
commands: [],
contextMenuReactions: [],
};

const getOrCreateRegExp = (
Expand Down Expand Up @@ -77,14 +91,85 @@
ORDER BY c.id ASC;
`;
queryCache.commands = commands.rows;

const contextMenuReactions = await sql<ContextMenuReaction>`
SELECT c.name, array_agg(e.value) as values
FROM context_menu_reactions c
JOIN context_menu_reactions_emojis cme ON c.id = cme."contextMenuReactionId"
JOIN emojis e ON e.id = cme."emojiId"
GROUP BY c.id, c.name
ORDER BY c.id ASC;
`;
queryCache.contextMenuReactions = contextMenuReactions.rows;
};

export const updateCommandToEmojiStringMap = async ({
commandToEmojiStringMap,
queryCache,
}: {
commandToEmojiStringMap: Map<string, string>;
queryCache: QueryCache;
}) => {
for (const row of queryCache.contextMenuReactions) {
const formattedEmojis = await Promise.all(
row.values.map(toFormatEmoji(rest, process.env.GUILD_ID as string)),
);
commandToEmojiStringMap.set(row.name, formattedEmojis.join(' '));
}
};

Check warning on line 119 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L107-L119

Added lines #L107 - L119 were not covered by tests

export const updateApplicationCommands = ({
rest,
queryCache,
}: { rest: REST; queryCache: QueryCache }) => {
const commands = queryCache.contextMenuReactions.map((row) => {
return new ContextMenuCommandBuilder()
.setName(row.name)
.setType(ApplicationCommandType.Message);
});

return rest.put(
Routes.applicationGuildCommands(
process.env.BOT_APPLICATION_ID as string,
process.env.GUILD_ID as string,
),
{
body: commands,
},
);

Check warning on line 139 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L122-L139

Added lines #L122 - L139 were not covered by tests
};

export const handleClientReady =
({
updateQueryCache,
}: { updateQueryCache: (queryCache: QueryCache) => Promise<void> }) =>
() => {
return updateQueryCache(queryCache);
updateApplicationCommands,
updateCommandToEmojiStringMap,
}: {
updateQueryCache: (queryCache: QueryCache) => Promise<void>;
updateApplicationCommands: ({
rest,
queryCache,
}: {
rest: REST;
queryCache: QueryCache;
}) => Promise<unknown>;
updateCommandToEmojiStringMap: ({
commandToEmojiStringMap,
queryCache,
}: {
commandToEmojiStringMap: Map<string, string>;
queryCache: QueryCache;
}) => Promise<void>;
}) =>
async () => {
await updateQueryCache(queryCache);
await Promise.all([
updateApplicationCommands({ rest, queryCache }),
updateCommandToEmojiStringMap({
commandToEmojiStringMap,
queryCache,
}),
]);
};

export const handleMessageCreate =
Expand Down Expand Up @@ -168,16 +253,60 @@
}
};

export const handleInteractionCreate =
({
commandToEmojiStringMap,
queryCache,
}: {
commandToEmojiStringMap: Map<string, string>;
queryCache: QueryCache;
}) =>
async (interaction: Interaction) => {
if (interaction.isContextMenuCommand() && interaction.channel) {
for (const row of queryCache.contextMenuReactions) {
if (interaction.commandName === row.name) {
const message = await interaction.channel.messages.fetch(
interaction.targetId,
);
messageReaction({ message, reactionData: row });
interaction.reply({
content: `Reacted to ${message.url} with ${commandToEmojiStringMap.get(row.name)}`,
ephemeral: true,
});
}
}
}
};

Check warning on line 279 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L265-L279

Added lines #L265 - L279 were not covered by tests

const rest = new REST({ version: '10' }).setToken(
process.env.DISCORD_BOT_TOKEN as string,
);

const client = new Client({
intents: ['DirectMessages', 'Guilds', 'GuildMessages', 'MessageContent'],
partials: [Partials.Channel],
});

client.on('ready', handleClientReady({ updateQueryCache }));
client.on(
'ready',
handleClientReady({
updateQueryCache,
updateApplicationCommands,
updateCommandToEmojiStringMap,
}),
);

client.on(
'messageCreate',
handleMessageCreate({ client, regexCache, queryCache, updateQueryCache }),
);

client.on(
'interactionCreate',
handleInteractionCreate({
commandToEmojiStringMap,
queryCache,
}),
);

client.login(process.env.DISCORD_BOT_TOKEN);
5 changes: 5 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ export interface Command extends ReactionData {
command: string;
}

export interface ContextMenuReaction extends ReactionData {
name: string;
}

export interface QueryCache {
autoReactionEmojis: AutoReactionEmoji[];
reactionAgentEmojis: ReactionAgentEmoji[];
commands: Command[];
contextMenuReactions: ContextMenuReaction[];
}
14 changes: 14 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type GuildEmoji, type REST, Routes } from 'discord.js';

export const toFormatEmoji =
(rest: REST, guildId: string) => async (emoji: string) => {
if (!/^[0-9]+$/.test(emoji)) {
return emoji;
}

const response = (await rest.get(
Routes.guildEmoji(guildId, emoji),
)) as GuildEmoji;

return `<${response.animated ? 'a' : ''}:${response.name}:${response.id}>`;
};
17 changes: 14 additions & 3 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,20 @@ const expectReactionsToHaveBeenCalled = (mockReact: jest.Mock) => {
};

describe('handleClientReady', () => {
it('should call updateQueryCache when invoked', async () => {
it('should call updateQueryCache, updateApplicationCommands, and updateCommandToEmojiStringMap when invoked', async () => {
const mockUpdateQueryCache = jest.fn();

await handleClientReady({ updateQueryCache: mockUpdateQueryCache })();
const mockUpdateApplicationCommands = jest.fn();
const mockUpdateCommandToEmojiStringMap = jest.fn();

await handleClientReady({
updateQueryCache: mockUpdateQueryCache,
updateApplicationCommands: mockUpdateApplicationCommands,
updateCommandToEmojiStringMap: mockUpdateCommandToEmojiStringMap,
})();

expect(mockUpdateQueryCache).toHaveBeenCalled();
expect(mockUpdateApplicationCommands).toHaveBeenCalled();
expect(mockUpdateCommandToEmojiStringMap).toHaveBeenCalled();
});
});

Expand All @@ -45,6 +55,7 @@ describe('handleMessageCreate', () => {
autoReactionEmojis: [],
reactionAgentEmojis: [],
commands: [],
contextMenuReactions: [],
};
const handleMessageCreateCurried = handleMessageCreate({
client,
Expand Down
50 changes: 50 additions & 0 deletions test/utils/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type REST, Routes } from 'discord.js';
import { toFormatEmoji } from '../../src/utils';

describe('toFormatEmoji', () => {
const mockGet = jest.fn();
const mockGuildId = '123456789';
const curriedToFormatEmoji = toFormatEmoji(
{ get: mockGet } as unknown as REST,
mockGuildId,
);

beforeEach(() => {
jest.clearAllMocks();
});

it('returns the original emoji if it is not a numeric string', async () => {
const result = await curriedToFormatEmoji('😊');
expect(result).toBe('😊');
});

it('formats a numeric emoji correctly for a non-animated emoji', async () => {
mockGet.mockResolvedValue({
animated: false,
name: 'test_emoji',
id: '987654321',
});

const result = await curriedToFormatEmoji('987654321');

expect(result).toBe('<:test_emoji:987654321>');
expect(mockGet).toHaveBeenCalledWith(
Routes.guildEmoji(mockGuildId, '987654321'),
);
});

it('formats a numeric emoji correctly for an animated emoji', async () => {
mockGet.mockResolvedValue({
animated: true,
name: 'animated_emoji',
id: '123456789',
});

const result = await curriedToFormatEmoji('123456789');

expect(result).toBe('<a:animated_emoji:123456789>');
expect(mockGet).toHaveBeenCalledWith(
Routes.guildEmoji(mockGuildId, '123456789'),
);
});
});