diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index f00c9b5..2b100a9 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.3.0] steps: - uses: actions/checkout@v2 diff --git a/package-lock.json b/package-lock.json index 83eb35d..6c6c4d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -183,9 +183,9 @@ } }, "@unicsmcr/hs_discord_bot_api_client": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@unicsmcr/hs_discord_bot_api_client/-/hs_discord_bot_api_client-0.0.2.tgz", - "integrity": "sha512-hLE0P/nx6e9O28eXuBlAt6nxRlYwb1AI+NMUlVX3wAfcDNrXn+ZhwsCCImotOVKlIkAz1fW4kciAU7MsHnG+3Q==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@unicsmcr/hs_discord_bot_api_client/-/hs_discord_bot_api_client-0.0.4.tgz", + "integrity": "sha512-UFHnFGpz5auQDj/Ubbh78EqrXUr67ys/HXxu7FadXQAJXl0i4iVmgw/JF25nu1rzv4iLPtzxOz5lM5PmGQKJPw==", "requires": { "axios": "^0.19.2" } @@ -950,6 +950,11 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" }, + "humanize-duration": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.22.0.tgz", + "integrity": "sha512-kq2Ncl1E8I7LJtjWhraQS8/LCsdt6fTQ+fwrGJ8dLSNFITW5YQpGWAgPgzjfIErAID7QHv0PA+HZBPfAf6f7IA==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index 1c03dbc..ca4ab73 100644 --- a/package.json +++ b/package.json @@ -51,13 +51,14 @@ }, "dependencies": { "@types/node-fetch": "^2.5.5", - "@unicsmcr/hs_discord_bot_api_client": "^0.0.2", + "@unicsmcr/hs_discord_bot_api_client": "^0.0.4", "bad-words": "^3.0.3", "canvas": "^2.6.1", "discord-akairo": "^8.0.0", "discord.js": "^12.2.0", "dotenv": "^8.2.0", "form-data": "^3.0.0", + "humanize-duration": "^3.22.0", "node-fetch": "^2.6.0", "pino": "^5.17.0" } diff --git a/src/HackathonClient.ts b/src/HackathonClient.ts index b543e0f..c1e7763 100644 --- a/src/HackathonClient.ts +++ b/src/HackathonClient.ts @@ -1,8 +1,9 @@ -import { AkairoClient, CommandHandler, ListenerHandler } from 'discord-akairo'; +import { AkairoClient, CommandHandler, ListenerHandler, Command } from 'discord-akairo'; import { join } from 'path'; import { Logger } from 'pino'; import MuteTracker from './util/MuteTracker'; import { Image } from 'canvas'; +import { Message } from 'discord.js'; export interface ApplicationConfig { discord: { @@ -54,6 +55,12 @@ export class HackathonClient extends AkairoClient { directory: join(__dirname, 'commands') }); + this.commandHandler.on('cooldown', (message: Message, command: Command, remaining: number) => { + message + .reply(`You can't use that command for another ${Math.ceil(remaining / 1000)} seconds.`) + .catch(err => this.loggers.bot.warn(err)); + }); + const listenerHandler = new ListenerHandler(this, { directory: join(__dirname, 'listeners') }); diff --git a/src/commands/id.ts b/src/commands/id.ts new file mode 100644 index 0000000..4842122 --- /dev/null +++ b/src/commands/id.ts @@ -0,0 +1,31 @@ +import { Message, TextChannel, DMChannel, User } from 'discord.js'; +import { Command } from 'discord-akairo'; +import { Task, TaskStatus } from '../util/task'; +import { HackathonClient } from '../HackathonClient'; + +export default class IdCommand extends Command { + public constructor() { + super('id', { + aliases: ['id'], + args: [ + { + 'id': 'target', + 'type': 'user', + 'default': (message: Message) => message.author + } + ] + }); + } + + public async exec(message: Message, args: { target: User }) { + const task = new Task({ + title: 'User ID', + issuer: message.author, + description: `${args.target.tag} - your ID is ${message.author.id}`, + status: TaskStatus.Completed + }); + await task.sendTo(message.channel as TextChannel | DMChannel).catch(error => { + (this.client as HackathonClient).config.loggers.bot.warn(error); + }); + } +} diff --git a/src/commands/mentor.ts b/src/commands/mentor.ts index e5e6e83..87850f7 100644 --- a/src/commands/mentor.ts +++ b/src/commands/mentor.ts @@ -2,6 +2,7 @@ import { Message, TextChannel, DMChannel } from 'discord.js'; import { Command } from 'discord-akairo'; import { modifyUserRoles, getUser, AuthLevel } from '@unicsmcr/hs_discord_bot_api_client'; import { Task, TaskStatus } from '../util/task'; +import { HackathonClient } from '../HackathonClient'; const MentorMappings = { 'python': 'role.languages.python', @@ -32,15 +33,16 @@ export default class MentorCommand extends Command { args: [ { id: 'roles', - type: 'lowercase', - match: 'content' + match: 'separate', + type: 'lowercase' } ], channel: 'guild' }); } - public async exec(message: Message, args: { roles: string }) { + public async exec(message: Message, args: { roles: string[] }) { + const client = this.client as HackathonClient; const task = new Task({ title: 'Update Mentor Roles', issuer: message.author, @@ -48,7 +50,7 @@ export default class MentorCommand extends Command { }); await task.sendTo(message.channel as TextChannel | DMChannel); - const roles = args.roles.split(' '); + const roles = args.roles || []; const langRoles = []; for (const [roleName, resourceName] of Object.entries(MentorMappings)) { if (roles.includes(roleName)) { @@ -72,11 +74,12 @@ export default class MentorCommand extends Command { roles: existingRoles.concat(langRoles) }); - task.update({ + await task.update({ status: TaskStatus.Completed, description: 'Your new mentor roles have been set!' }); } catch (err) { + client.loggers.bot.warn(err); task.update({ status: TaskStatus.Failed, description: `An error occurred processing your request. Please try again later.` diff --git a/src/commands/mute.ts b/src/commands/mute.ts index 8554451..f18d16b 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -14,8 +14,7 @@ export default class MuteCommand extends Command { type: 'member', prompt: { start: 'Who would you like to mute?', - retry: 'That\'s not a valid member! Try again.', - optional: true + retry: 'That\'s not a valid member! Try again.' } } ], @@ -62,6 +61,7 @@ export default class MuteCommand extends Command { description: `Muted **${args.target.user.tag}** (${args.target.id})` }); } catch (err) { + client.loggers.bot.warn(err); task.update({ status: TaskStatus.Failed, description: `An error occurred processing your request. Please try again later.` diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 2aca53f..b24674f 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -2,7 +2,8 @@ import { Message, TextChannel, DMChannel } from 'discord.js'; import { Command } from 'discord-akairo'; import { Task, TaskStatus } from '../util/task'; import { HackathonClient } from '../HackathonClient'; -import { getTeams, getUsers } from '@unicsmcr/hs_discord_bot_api_client'; +import { getTeams, getUsers, AuthLevel } from '@unicsmcr/hs_discord_bot_api_client'; +import humanizeDuration from 'humanize-duration'; export default class StatsCommand extends Command { public constructor() { @@ -31,23 +32,33 @@ export default class StatsCommand extends Command { task.status = TaskStatus.Completed; task.description = ''; const [users, teams] = await Promise.all([getUsers(), getTeams()]); + const participants = users.filter(user => user.authLevel === AuthLevel.Attendee).length; + const volunteers = users.filter(user => user.authLevel === AuthLevel.Volunteer).length; task.addFields( { name: 'Participants in Discord server', - value: users.length + value: participants + }, + { + name: 'Volunteers in Discord server', + value: volunteers }, { name: 'Teams', value: teams.length + }, + { + name: 'Uptime', + value: client.uptime ? humanizeDuration(client.uptime) : 'Undefined' } ); task.update({}); } catch (error) { + client.loggers.bot.warn(error); task.update({ status: TaskStatus.Failed, description: `An error occurred processing your request. Please try again later.` }); - console.log(error); } } } diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..f6ad43a --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,50 @@ +import { Message, TextChannel, DMChannel, User } from 'discord.js'; +import { Command } from 'discord-akairo'; +import { Task, TaskStatus } from '../util/task'; +import { syncAccount, getUser, AuthLevel } from '@unicsmcr/hs_discord_bot_api_client'; + +export default class SyncCommand extends Command { + public constructor() { + super('sync', { + aliases: ['sync'], + args: [ + { + 'id': 'target', + 'type': 'user', + 'default': (message: Message) => message.author + } + ], + // One use per minute to stop abuse of API + cooldown: 60e3, + ratelimit: 1 + }); + } + + public async exec(message: Message, args: { target: User }) { + const task = new Task({ + title: 'User sync', + issuer: message.author, + description: `Syncing account state` + }); + await task.sendTo(message.channel as TextChannel | DMChannel); + try { + let target = args.target; + if (target.id !== message.author.id) { + const issuer = await getUser(message.author.id); + if (issuer.authLevel < AuthLevel.Volunteer) { + target = message.author; + } + } + await syncAccount(target.id); + await task.update({ + status: TaskStatus.Completed, + description: `Synced state for ${target.tag}!` + }); + } catch (error) { + await task.update({ + status: TaskStatus.Failed, + description: `An error occurred processing your request. Try again later.\n\n${error.message}` + }); + } + } +} diff --git a/src/commands/unmute.ts b/src/commands/unmute.ts index df771d7..ac5458b 100644 --- a/src/commands/unmute.ts +++ b/src/commands/unmute.ts @@ -14,8 +14,7 @@ export default class MuteCommand extends Command { type: 'member', prompt: { start: 'Who would you like to unmute?', - retry: 'That\'s not a valid member! Try again.', - optional: true + retry: 'That\'s not a valid member! Try again.' } } ], @@ -64,6 +63,7 @@ export default class MuteCommand extends Command { description: `Unmuted **${args.target.user.tag}** (${args.target.id})` }); } catch (err) { + client.loggers.bot.warn(err); task.update({ status: TaskStatus.Failed, description: `An error occurred processing your request. Please try again later.` diff --git a/src/commands/whois.ts b/src/commands/whois.ts new file mode 100644 index 0000000..2f5bc5b --- /dev/null +++ b/src/commands/whois.ts @@ -0,0 +1,106 @@ +import { Message, TextChannel, DMChannel, User } from 'discord.js'; +import { Command } from 'discord-akairo'; +import { Task, TaskStatus } from '../util/task'; +import { getUser, getTeam, APITeam, AuthLevel } from '@unicsmcr/hs_discord_bot_api_client'; +import { HackathonClient } from '../HackathonClient'; + +export default class WhoIsCommand extends Command { + public constructor() { + super('whois', { + aliases: ['whois'], + args: [ + { + id: 'target', + type: 'user', + prompt: { + start: 'Who would you like to get the details of?', + retry: 'That\'s not a valid member! Try again.' + } + } + ] + }); + } + + public async exec(message: Message, args: { target: User }) { + const client = this.client as HackathonClient; + const task = new Task({ + title: 'User info', + issuer: message.author, + description: 'Fetching the info now...' + }); + await task.sendTo(message.channel as TextChannel | DMChannel); + try { + const issuer = await getUser(message.author.id); + if (issuer.authLevel < AuthLevel.Volunteer) { + return task.update({ + status: TaskStatus.Failed, + description: 'Sorry, you need to be a volunteer or organiser to use this command.' + }); + } + + if (message.guild) { + const channel = message.channel as TextChannel; + if (channel.permissionsFor(message.guild.id)?.has('VIEW_CHANNEL')) { + return task.update({ + status: TaskStatus.Failed, + description: 'Sorry, you need to run this in a private channel.' + }); + } + } + + task.status = TaskStatus.Completed; + task.description = ''; + const user = await getUser(args.target.id); + task.addFields( + { + name: 'Name', + value: user.name + }, + { + name: 'Auth ID', + value: user.authId + } + ); + let team: APITeam | undefined; + if (user.team) { + try { + team = await getTeam(user.team); + task.addFields( + { + name: 'Team Name', + value: team.name + }, + { + name: 'Team Auth ID', + value: team.authId + }, + { + name: 'Team Number', + value: team.teamNumber + } + ); + } catch (error) { + task.addFields({ + name: 'Team', + value: `Auth ID: ${user.team} (error fetching more info than this)` + }); + client.config.loggers.bot.warn(`Error fetching team ${user.team}`); + client.config.loggers.bot.warn(error); + } + } + task.update({}); + } catch (error) { + if (error.res?.statusCode === 404) { + task.update({ + status: TaskStatus.Failed, + description: `This account is not linked.` + }); + } else { + task.update({ + status: TaskStatus.Failed, + description: `An error occurred processing your request. Try again later.` + }); + } + } + } +} diff --git a/src/types/humanize-duration.d.ts b/src/types/humanize-duration.d.ts new file mode 100644 index 0000000..71047aa --- /dev/null +++ b/src/types/humanize-duration.d.ts @@ -0,0 +1,3 @@ +declare module 'humanize-duration' { + export default function humanizeDuration(duration: number): string; +}