Skip to content
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
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 @@ export const updateQueryCache = async (queryCache: QueryCache) => {
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(' '));
}
};

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,
},
);
};

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 handleMessageCreate =
}
};

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,
});
}
}
}
};

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'),
);
});
});