Discord-InterChat/InterChat

View on GitHub
src/managers/VoteManager.ts

Summary

Maintainability
A
0 mins
Test Coverage
import Constants, { emojis } from '#utils/Constants.js';
import UserDbManager from '#main/managers/UserDbManager.js';
import Scheduler from '#main/services/SchedulerService.js';
import Logger from '#utils/Logger.js';
import type { WebhookPayload } from '#types/TopGGPayload.d.ts';
import db from '#utils/Db.js';
import { getOrdinalSuffix } from '#utils/Utils.js';
import { stripIndents } from 'common-tags';
import {
  APIGuildMember,
  APIUser,
  EmbedBuilder,
  REST,
  Routes,
  time,
  userMention,
  WebhookClient,
} from 'discord.js';
import type { NextFunction, Request, Response } from 'express';
import ms from 'ms';

export class VoteManager {
  private scheduler: Scheduler;
  private readonly userDbManager = new UserDbManager();
  private readonly rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN as string);

  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) {
        await this.removeVoterRole(vote.id);
      }
    });
  }

  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.userDbManager.getUser(id);
    return user?.voteCount ?? 0;
  }

  async incrementUserVote(userId: string, username?: string) {
    const lastVoted = new Date();
    const user = await this.userDbManager.getUser(userId);
    return await this.userDbManager.upsertUser(userId, {
      username,
      lastVoted,
      voteCount: user?.voteCount ? user.voteCount + 1 : 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) {
    const voteCount = (await this.getUserVoteCount(vote.user)) + 1;
    const webhook = new WebhookClient({
      url: String(process.env.VOTE_WEBHOOK_URL),
    });
    const ordinalSuffix = getOrdinalSuffix(voteCount);
    const userMentionStr = userMention(vote.user);
    const username = await this.getUsername(vote.user);

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

    await webhook.send({
      content: `${userMentionStr} (**${username}**)`,
      embeds: [
        new EmbedBuilder()
          .setDescription(
            stripIndents`
            ### ${emojis.topggSparkles} Thank you for voting!
              
            You can vote again on [top.gg](${Constants.Links.Vote}) ${timeUntilNextVote}!

            -# ${isTestVote ? '⚠️ This is a test vote.' : `${emojis.tada} This is your **${voteCount}${ordinalSuffix}** time voting!`}
            `,
          )
          .setColor('#FB3265'),
      ],
    });
  }

  async modifyUserRole(
    type: 'add' | 'remove',
    { userId, roleId }: { userId: string; roleId: string },
  ) {
    const userInGuild = (await this.rest
      .get(Routes.guildMember(Constants.SupportServerId, userId))
      .catch(() => null)) as APIGuildMember | null;

    if (!userInGuild?.roles.includes(roleId)) return;

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

  async addVoterRole(userId: string) {
    await this.modifyUserRole('add', { userId, roleId: Constants.VoterRoleId });
  }
  async removeVoterRole(userId: string) {
    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;
  }
}