Skip to content

Commit

Permalink
refactor(api): simplify vote webhook handling
Browse files Browse the repository at this point in the history
This change adds a middleware function to the API that handles incoming
top.gg webhook requests. The middleware verifies the request
authorization header, checks the validity of the payload, and then
processes the vote event. This includes incrementing the user's vote
count, adding the voter role, and announcing the vote in the support
server.

The changes also remove the previously used `VoteManager` class, as its
functionality has been consolidated into the new middleware.
  • Loading branch information
dev-737 committed Oct 11, 2024
1 parent 01a81fa commit c5bfe82
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 115 deletions.
18 changes: 7 additions & 11 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import dblRouter from '#main/api/routes/dbl.js';
import { VoteManager } from '#main/managers/VoteManager.js';
import Logger from '#utils/Logger.js';
import express from 'express';

const app = express();

export const startApi = (voteManager: VoteManager) => {
app.use(express.json());
app.use(dblRouter(voteManager));
app.use(express.json());
app.get('/', (req, res) => res.redirect('https://interchat.fun'));
app.use('/dbl', dblRouter);

app.get('/', (req, res) => res.redirect('https://interchat.fun'));

// run da server
app.listen(process.env.PORT, () =>
Logger.info(`API listening on http://localhost:${process.env.PORT}`),
);
};
// run da server
app.listen(process.env.PORT, () =>
Logger.info(`API listening on http://localhost:${process.env.PORT}`),
);
41 changes: 4 additions & 37 deletions src/api/routes/dbl.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,9 @@
// express route boyeee
import { VoteManager } from '#main/managers/VoteManager.js';
import type { WebhookPayload } from '#types/topgg.d.ts';
import Logger from '#utils/Logger.js';
import { Router } from 'express';

const isValidVotePayload = (payload: WebhookPayload) => {
const payloadTypes = ['upvote', 'test'];
const isValidData =
typeof payload.user === 'string' &&
typeof payload.bot === 'string' &&
payloadTypes.includes(payload.type);
const dblRouter: Router = Router();
const voteManager = new VoteManager();

const isValidWeekendType =
typeof payload.isWeekend === 'boolean' || typeof payload.isWeekend === 'undefined';
dblRouter.post('/', voteManager.middleware.bind(voteManager));

return isValidData && isValidWeekendType;
};

const router: Router = Router();

export default (voteManager: VoteManager) => {
router.post('/dbl', (req, res) => {
const dblHeader = req.header('Authorization');
if (dblHeader !== process.env.TOPGG_WEBHOOK_SECRET) {
return res.status(401).json({ message: 'Unauthorized' });
}

const payload: WebhookPayload = req.body;

if (!isValidVotePayload(payload)) {
Logger.error('Invalid payload received from top.gg, possible untrusted request: %O', payload);
return res.status(400).json({ message: 'Invalid payload' });
}

voteManager.emit('vote', payload);

return res.status(204).send();
});

return router;
};
export default dblRouter;
8 changes: 6 additions & 2 deletions src/managers/UserDbManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default class UserDbManager {
}

async ban(id: string, reason: string, username?: string) {
return await db.userData.upsert({
const user = await db.userData.upsert({
where: { id },
create: {
id,
Expand All @@ -64,10 +64,12 @@ export default class UserDbManager {
},
update: { banMeta: { reason }, username },
});

await this.addToCache(user);
}

async unban(id: string, username?: string) {
return await db.userData.upsert({
const user = await db.userData.upsert({
where: { id },
create: {
id,
Expand All @@ -78,5 +80,7 @@ export default class UserDbManager {
},
update: { banMeta: { set: null }, username },
});

await this.addToCache(user);
}
}
121 changes: 81 additions & 40 deletions src/managers/VoteManager.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,79 @@
import Constants, { emojis } from '#main/config/Constants.js';
import UserDbManager from '#main/managers/UserDbManager.js';
import Scheduler from '#main/modules/SchedulerService.js';
import Logger from '#main/utils/Logger.js';
import type { WebhookPayload } from '#types/topgg.d.ts';
import { getCachedData } from '#utils/cache/cacheUtils.js';
import { stripIndents } from 'common-tags';
import { ClusterManager } from 'discord-hybrid-sharding';
import { EmbedBuilder, time, userMention, WebhookClient } from 'discord.js';
import EventEmitter from 'events';
import Constants, { emojis, RedisKeys } from '#main/config/Constants.js';
import db from '#utils/Db.js';
import { getOrdinalSuffix, getUsername, modifyUserRole } from '#utils/Utils.js';
import Scheduler from '#main/modules/SchedulerService.js';
import parse from 'parse-duration';

export type TopggEvents = {
vote: WebhookPayload;
voteExpired: string;
};
import { getOrdinalSuffix } from '#utils/Utils.js';
import { stripIndents } from 'common-tags';
import { APIUser, EmbedBuilder, REST, Routes, time, userMention, WebhookClient } from 'discord.js';
import type { NextFunction, Request, Response } from 'express';
import ms from 'ms';

export class VoteManager extends EventEmitter<{ [K in keyof TopggEvents]: TopggEvents[K][] }> {
export class VoteManager {
private scheduler: Scheduler;
private cluster: ClusterManager;
private userDbManager = new UserDbManager();
private rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN as string);

constructor(cluster: ClusterManager, scheduler = new Scheduler()) {
super();
this.cluster = cluster;
constructor(scheduler = new Scheduler()) {
this.scheduler = scheduler;
this.scheduler.addRecurringTask('removeVoterRole', 60 * 60 * 1_000, async () => {
const expiredVotes = await db.userData.findMany({ where: { lastVoted: { lt: new Date() } } });
for (const vote of expiredVotes) {
this.emit('voteExpired', vote.id);
await this.removeVoterRole(vote.id);
}
});
}

async getDbUser(id: string) {
return (
await getCachedData(
`${RedisKeys.userData}:${id}`,
async () => await db.userData.findFirst({ where: { id } }),
)
).data;
async middleware(req: Request, res: Response, next: NextFunction) {
const dblHeader = req.header('Authorization');
if (dblHeader !== process.env.TOPGG_WEBHOOK_SECRET) {
res.status(401).json({ message: 'Unauthorized' });
return;
}

const payload = req.body;

if (!this.isValidVotePayload(payload)) {
Logger.error('Invalid payload received from top.gg, possible untrusted request: %O', payload);
res.status(400).json({ message: 'Invalid payload' });
return;
}

res.status(204).send();

if (payload.type === 'upvote') {
await this.incrementUserVote(payload.user);
await this.addVoterRole(payload.user);
}

await this.announceVote(payload);

next();
}

async getUserVoteCount(id: string) {
const user = await this.getDbUser(id);
const user = await this.userDbManager.getUser(id);
return user?.voteCount ?? 0;
}

async incrementUserVote(userId: string, username?: string) {
const lastVoted = new Date();
return await db.userData.upsert({
where: { id: userId },
create: { id: userId, username, lastVoted, voteCount: 1 },
update: { lastVoted, voteCount: { increment: 1 } },
});
const user = await this.userDbManager.getUser(userId);
if (!user) {
return await this.userDbManager.createUser({ id: userId, username, lastVoted, voteCount: 1 });
}
return await this.userDbManager.updateUser(userId, { lastVoted, voteCount: { increment: 1 } });
}

async getAPIUser(userId: string) {
const user = await this.rest.get(Routes.user(userId)).catch(() => null);
return user as APIUser | null;
}

async getUsername(userId: string) {
const user = (await this.getAPIUser(userId)) ?? (await this.userDbManager.getUser(userId));
return user?.username ?? 'Unknown User';
}

async announceVote(vote: WebhookPayload) {
Expand All @@ -62,13 +83,10 @@ export class VoteManager extends EventEmitter<{ [K in keyof TopggEvents]: TopggE
});
const ordinalSuffix = getOrdinalSuffix(voteCount);
const userMentionStr = userMention(vote.user);
const username =
(await getUsername(this.cluster, vote.user)) ??
(await this.getDbUser(vote.user))?.username ??
'Unknown User';
const username = await this.getUsername(vote.user);

const isTestVote = vote.type === 'test';
const timeUntilNextVote = time(new Date(Date.now() + (parse('12h') ?? 0)), 'R');
const timeUntilNextVote = time(new Date(Date.now() + (ms('12h') ?? 0)), 'R');

await webhook.send({
content: `${userMentionStr} (**${username}**)`,
Expand All @@ -88,10 +106,33 @@ export class VoteManager extends EventEmitter<{ [K in keyof TopggEvents]: TopggE
});
}

async modifyUserRole(
type: 'add' | 'remove',
{ userId, roleId }: { userId: string; roleId: string },
) {
const method = type === 'add' ? 'put' : 'delete';
return await this.rest[method](
Routes.guildMemberRole(Constants.SupportServerId, userId, roleId),
);
}

async addVoterRole(userId: string) {
await modifyUserRole(this.cluster, 'add', { userId, roleId: Constants.VoterRoleId });
await this.modifyUserRole('add', { userId, roleId: Constants.VoterRoleId });
}
async removeVoterRole(userId: string) {
await modifyUserRole(this.cluster, 'remove', { userId, roleId: Constants.VoterRoleId });
await this.modifyUserRole('remove', { userId, roleId: Constants.VoterRoleId });
}

private isValidVotePayload(payload: WebhookPayload) {
const payloadTypes = ['upvote', 'test'];
const isValidData =
typeof payload.user === 'string' &&
typeof payload.bot === 'string' &&
payloadTypes.includes(payload.type);

const isValidWeekendType =
typeof payload.isWeekend === 'boolean' || typeof payload.isWeekend === 'undefined';

return isValidData && isValidWeekendType;
}
}
26 changes: 1 addition & 25 deletions src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export const getEmojiId = (emoji: string | undefined) => {
const res = parseEmoji(emoji || '');
return res?.id ?? emoji;
};

// get ordinal suffix for a number
export const getOrdinalSuffix = (num: number) => {
const j = num % 10;
Expand Down Expand Up @@ -217,31 +218,6 @@ export const getUsername = async (client: ClusterManager, userId: Snowflake) =>
return username;
};

export const modifyUserRole = async (
cluster: ClusterManager,
action: 'add' | 'remove',
{
userId,
roleId,
guildId = Constants.SupportServerId,
}: { userId: Snowflake; roleId: Snowflake; guildId?: Snowflake },
) => {
await cluster.broadcastEval(
async (client, ctx) => {
const guild = client.guilds.cache.get(ctx.guildId);
if (!guild) return;

const role = await guild.roles.fetch(ctx.roleId);
if (!role) return;

// add or remove role
const member = await guild.members.fetch(ctx.userId);
await member?.roles[ctx.action](role);
},
{ context: { userId, roleId, guildId, action } },
);
};

export const containsInviteLinks = (str: string) => {
const inviteLinks = ['discord.gg', 'discord.com/invite', 'dsc.gg'];
return inviteLinks.some((link) => str.includes(link));
Expand Down

0 comments on commit c5bfe82

Please sign in to comment.