├── .env.template ├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── emojiMapping.json ├── main.js ├── package-lock.json ├── package.json └── src ├── backgrounds ├── 0.jpg ├── 1.jpg ├── 10.jpg ├── 11.jpg ├── 12.jpg ├── 13.jpg ├── 14.jpg ├── 15.jpg ├── 16.jpg ├── 17.jpg ├── 18.jpg ├── 19.jpg ├── 2.jpg ├── 20.jpg ├── 21.jpg ├── 22.jpg ├── 3.jpg ├── 4.jpg ├── 5.jpg ├── 6.jpg ├── 7.jpg ├── 8.jpg ├── 9.jpg └── bingoTemplate.png ├── commands.js ├── constants.js ├── discordAPI.js ├── fonts └── Quicksand.ttf ├── format.js ├── rating.js ├── redisApi.js ├── tmApi.js └── utils.js /.env.template: -------------------------------------------------------------------------------- 1 | DISCORD_TOKEN=[bot_token] 2 | USER_LOGIN=[uplay_email:uplay_password] 3 | DEPLOY_MODE=dev 4 | REDIS_URL=redis://[user]:[pass]@[domain]:[port]/ 5 | ADMIN_TAG=user#0001 6 | ADMIN_SERVER_ID=[server_id] 7 | ADMIN_CHANNEL_ID=[channel_id] 8 | 9 | OAUTH_ID=[OAuth app ID] 10 | OAUTH_SECRET=[OAuth app secret] 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 12 10 | }, 11 | "rules": { 12 | "quotes": [ 13 | "error", 14 | "backtick" 15 | ], 16 | "semi": [ 17 | "error", 18 | "always" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: davidbmaier 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # image cache 2 | /images/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # VS Code workspace file 110 | *.code-workspace 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David B. Maier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TOTD Discord Bot 2 | 3 | This is a Discord bot for displaying the daily [Trackmania](https://www.trackmania.com/) Track of the Day (and a few other things). 4 | 5 | **Disclaimer:** This bot uses undocumented APIs, so it may break at any time - potentially indefinitely if Nadeo/Ubisoft decide to close off those APIs. 6 | 7 | **Extra disclaimer:** This is just a pet project I've worked on for fun - don't expect super-tidy code or great documentation. If you encounter any issues with it, either open an issue on Github or talk to me on Discord (@tooInfinite). 8 | 9 | ## What can this bot do? 🤖 10 | 11 | **Public invite link:** [Click here!](https://discord.com/api/oauth2/authorize?client_id=807920588738920468&permissions=388160&scope=applications.commands%20bot) 12 | 13 | **Note:** The bot is now using slash commands instead of the `!totd` prefix - if you've used the bot before, you'll need to simply use the invite link above to update its permissions. 14 | 15 | - `/today` - Prints the current TOTD information to the current channel. 16 | - `/leaderboard` - Prints the current TOTD top 10 and the time needed for top 100 to the current channel. 17 | - `/ratings` - Prints the global ratings for a stored TOTD. 18 | - `/rankings [time frame]` - Prints the TOTD rankings for the given time frame. 19 | - `/bingo` - Prints this week's bingo board for the current server. 20 | - `/lastbingo` - Prints last week's bingo board for the current server. 21 | - `/votebingo [1-25]` - Starts a vote on that bingo field. All ongoing votes are resolved when the next TOTD is released. 22 | - `/enable` - Stores the current channel to the list the daily scheduled TOTD post gets sent to. One channel per server. Admin only. 23 | - `/disable` - Removes the current channel from the list the scheduled TOTD post gets sent to. Admin only. 24 | - `/enablepings [@role] [region]` - Adds the role to the list of roles it pings 10 minutes before COTD. Daily TOTD posts have to be set up already. Supported regions for pings are `Europe`, `America`, and `Asia` - one for each official COTD. Admin only. 25 | - `/disablepings [region]` - Removes the currently set role from the pings. Admin only. 26 | - `/help` - Displays some info about the bot. 27 | - `/invite` - Displays a link to invite the bot to your server. 28 | 29 | Debug (and bot admin) only: 30 | 31 | - `/refreshtotd` - Refreshes the internally cached TOTD information. 32 | - `/refreshleaderboard` - Refreshes the internally cached leaderboard information. 33 | - `/refreshratings` - Prints the current global ratings and a short verdict based on them (admin-only since it's not resolved yet). 34 | - `/refreshbingo` - Regenerates the current bingo board. 35 | - `/refreshbingocount` - Resolves the ongoing bingo field votes. 36 | - `/servers` - Prints the current number of servers the bot is in. Also logs the server details. 37 | 38 | ## Screenshots 📷 39 | 40 | ![Screenshot today](https://i.imgur.com/gTiFt3S.png) 41 | ![Screenshot bingo](https://i.imgur.com/QnSKOPC.png) 42 | 43 | ## Setup/Development 💻 44 | 45 | The bot is written in Node.js - so you'll need to install [Node](https://nodejs.org/en/), which automatically comes with NPM, the default package manager. 46 | 47 | Note that the bot is generally built to be generic enough to run anywhere - the only exception are custom emojis, so you'll need to change the mappings in `./emojiMapping.json` to point to emojis and their IDs that your bot will have access to. 48 | 49 | To run it, just run an `npm i` and an `npm start`. Make sure you've added a `.env` file (see the template for the format). 50 | 51 | - `DISCORD_TOKEN` is the Discord bot's auth token (see any tutorial for more info). 52 | - `USER_LOGIN` is your UPlay/Ubisoft Connect credentials - I suggest not using your main account. It doesn't have to own the game, so you can just create a new one for this bot. 53 | - `DEPLOY_MODE` should only be `prod` if it's supposed to be live. Everything else is interpreted as a development mode (which means that all commands will be registered only in the admin server, and the bot won't reload all the data on startup). 54 | - `REDIS_URL` is a Redis database - it's required for the scheduled messages and caching. If you're using an insecure instance for local development, you can omit the auth part. 55 | - `ADMIN_TAG` is the Discord tag of the bot admin - used for administration commands that only they should be allowed to run. It's using the `name#0` notation. 56 | - `ADMIN_SERVER_ID` and `ADMIN_CHANNEL_ID` are Discord IDs of the admin server and channel - used for all admin commands. 57 | - `OAUTH_ID` and `OAUTH_SECRET` are ID and secret from [api.trackmania.com](https://api.trackmania.com/manager) - create a new app if you don't have one yet (make sure to enable "Confidential"). 58 | 59 | The `emojiMapping.json` file contains hard-coded references to production emotes, which you won't be able to access in your own environment. You'll want to change their values to emotes your bot will have access to, or to some arbitrary strings for debugging. 60 | 61 | Other than that, there may be some commands that only work properly once the bot has collected a day or two of data - if you encounter breaking errors in this stage, feel free to report them. 62 | 63 | ## TODOs 📋 64 | 65 | I consider the bot to be pretty much finished, but if I get back to it, this is what I'd probably look at. 66 | 67 | - Add better error handling for when the bot doesn't have permissions to post an error message - probably add a global method for it. 68 | - Add some server-specific ratings breakdown (i.e. store ratings per server so the ratings message can show both local and global ratings). 69 | - This might require some extra thought regarding scaling - since every new server increases the database size. It's not much, but I'd like to keep the footprint as minimal as possible (especially if the use case isn't really needed). 70 | - Add a short info message when joining a new server (if there's a reliable way to find the "main" channel). 71 | - More data (currently uses TM and TMX when available) - there's probably more interesting metadata the bot could display. 72 | - How many TOTDs did the author have before? 73 | - Current WR (but I guess that's not very useful when it gets TOTD) 74 | - Summary of the last TOTD with top 5 (incl. player links to tm.io and maybe some aggregate data like player numbers) 75 | - Some general restructuring of the code and some more documentation of the existing functionality. 76 | - Handling of non-JPG image files. 77 | 78 | ## Special Thanks ❤️ 79 | 80 | Thanks go to: 81 | 82 | - Juice#7454 for the improved voting emojis. 83 | - The [TMX](https://trackmania.exchange/) team for their platform and well-documented API. 84 | - Miss ([@codecat](https://github.com/codecat)) for her work on [trackmania.io](https://trackmania.io). 85 | 86 | ## Terms of Service 87 | 88 | When you invite this bot to your server or interact with it on another server, you agree to the following terms: 89 | 90 | - You agree to the bot storing and logging information about your Discord account/server for debugging purposes. It will only be used to improve the bot's functionality and will never be shared with any third parties. 91 | - You agree that you will not attempt to misuse the bot in any way - spamming commands and attempting to flood the bot with requests will result in a ban of your account/server. 92 | - The bot owner has the right to deny you access to the bot's features for any reason. 93 | 94 | ## Privacy Policy 95 | 96 | The TOTD Bot only stores data that is required for its core functionality: 97 | 98 | - It stores server and channel IDs for all servers that set up scheduled posts with the bot - this data is removed as soon as the configuration is deleted by a server admin/whenever the bot can't access a given server anymore (i.e. when it was kicked). 99 | - It also stores user IDs and the corresponding rating that a user submitted for the current TOTD to prevent people from voting more than once - this data is removed after a day. 100 | - Apart from that, it only stores past TOTD data (map and rating information) to be able to display historical data to users. 101 | 102 | Whenever the bot gets removed from a server with any configuration, it automatically removes that server's information from its datastore within a day. 103 | 104 | For debugging purposes, the bot also logs user IDs, channel IDs and server IDs as well as the corresponding names when people interact with it. Logs are potentially stored indefinitely, but are of course also subject to deletion requests if a user requests that all their data is removed. 105 | 106 | If you want to request information about or deletion of any of your data (user, message, channel or server IDs that are directly related to you), please contact the bot owner on Discord (tooInfinite). 107 | -------------------------------------------------------------------------------- /emojiMapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "Loading": "", 3 | "Bronze": "<:MedalBronze:763718615764566016>", 4 | "Silver": "<:MedalSilver:763718615689330699>", 5 | "Gold": "<:MedalGold:763718328685559811>", 6 | "Author": "<:MedalAuthor:763718159714222100>", 7 | "MinusMinusMinus": "<:MinusMinusMinus:884093853618020413>", 8 | "MinusMinus": "<:MinusMinus:884093853622239273>", 9 | "Minus": "<:Minus:884093853622239334>", 10 | "Plus": "<:Plus:884093853655769188>", 11 | "PlusPlus": "<:PlusPlus:884093853731282944>", 12 | "PlusPlusPlus": "<:PlusPlusPlus:884093853777416212>", 13 | "VoteYes": "<:YEP:817878595862659102>", 14 | "VoteNo": "<:NOP:817878607606579200>", 15 | "Bingo": "<:POGGIES:820016832944537670>", 16 | "COTDPing": "<:POGGIES:820016832944537670>" 17 | } 18 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const Discord = require(`discord.js`); 2 | const client = new Discord.Client({ 3 | partials: [`MESSAGE`, `CHANNEL`, `REACTION`], 4 | intents: [ 5 | Discord.Intents.FLAGS.GUILDS, // for join and leave events 6 | Discord.Intents.FLAGS.GUILD_MESSAGES, // for receiving commands through messages 7 | Discord.Intents.FLAGS.GUILD_MESSAGE_REACTIONS // for receiving rating reactions 8 | ], 9 | retryLimit: 0 // prevent random 500s from failing requests immediately - currently disabled for testing 10 | }); 11 | const cron = require(`cron`).CronJob; 12 | require(`dotenv`).config(); 13 | 14 | const discordAPI = require(`./src/discordAPI`); 15 | const tmAPI = require(`./src/tmApi`); 16 | const redisAPI = require(`./src/redisApi`); 17 | const format = require(`./src/format`); 18 | const commands = require(`./src/commands`); 19 | const utils = require(`./src/utils`); 20 | const constants = require(`./src/constants`); 21 | 22 | const discordToken = process.env.DISCORD_TOKEN; 23 | const deployMode = process.env.DEPLOY_MODE; 24 | const adminServerID = process.env.ADMIN_SERVER_ID; 25 | const adminChannelID = process.env.ADMIN_CHANNEL_ID; 26 | 27 | const commandIDs = {}; 28 | 29 | // COTD pings for 7pm (Europe) 30 | new cron( 31 | `00 50 18 * * *`, 32 | async () => { 33 | await discordAPI.sendCOTDPings(client, constants.cupRegions.europe); 34 | }, 35 | null, 36 | true, 37 | `Europe/Paris` 38 | ); 39 | 40 | // COTD pings for 3am (America) 41 | new cron( 42 | `00 50 02 * * *`, 43 | async () => { 44 | await discordAPI.sendCOTDPings(client, constants.cupRegions.america); 45 | }, 46 | null, 47 | true, 48 | `Europe/Paris` 49 | ); 50 | 51 | // COTD pings for 11am (Asia) 52 | new cron( 53 | `00 50 10 * * *`, 54 | async () => { 55 | await discordAPI.sendCOTDPings(client, constants.cupRegions.asia); 56 | }, 57 | null, 58 | true, 59 | `Europe/Paris` 60 | ); 61 | 62 | // display the current totd every day at 19:00:20 63 | new cron( 64 | `20 00 19 * * *`, 65 | async () => { 66 | await discordAPI.distributeTOTDMessages(client); 67 | }, 68 | null, 69 | true, 70 | `Europe/Paris` 71 | ); 72 | 73 | // refresh bingo every week on Monday at 19:00:10 (just after the TOTD because that counts yesterday's bingo votes) 74 | new cron( 75 | `10 00 19 * * 1`, 76 | async () => { 77 | await discordAPI.archiveBingoBoards(); 78 | }, 79 | null, 80 | true, 81 | `Europe/Paris` 82 | ); 83 | 84 | const setupRedis = async () => { 85 | const redisClient = await redisAPI.login(); 86 | 87 | const bingoBoards = await redisAPI.getAllBingoBoards(redisClient); 88 | if (!bingoBoards) { 89 | await redisAPI.resetBingoBoards(redisClient); 90 | } 91 | 92 | redisAPI.logout(redisClient); 93 | } 94 | 95 | client.on(`ready`, async () => { 96 | console.log(`Ready as ${client.user.tag}!`); 97 | 98 | await tmAPI.login(); 99 | await tmAPI.loginOAuth(); 100 | 101 | // in production, refresh TOTD to make sure there is a thumbnail in the images for cached messages 102 | if (deployMode === `prod`) { 103 | await discordAPI.getTOTDMessage(true); 104 | } 105 | 106 | await setupRedis(); 107 | 108 | // register all the commands (this might take a minute due to Discord rate limits) 109 | const globalCommandConfigs = commands.globalCommands.map((commandConfig) => commandConfig.slashCommand); 110 | const adminCommandConfigs = commands.adminCommands.map((commandConfig) => commandConfig.slashCommand); 111 | 112 | const adminGuild = await client.guilds.fetch(adminServerID); 113 | let globalCommandManager; 114 | if (deployMode !== `prod`) { 115 | // use admin guild for global commands in dev mode 116 | globalCommandManager = adminGuild.commands; 117 | } else { 118 | globalCommandManager = client.application.commands; 119 | } 120 | const adminCommandManager = adminGuild.commands; 121 | 122 | for (const commandConfig of globalCommandConfigs) { 123 | if (commandConfig) { 124 | const commandRes = await globalCommandManager.create(commandConfig); 125 | commandIDs[commandConfig.name] = commandRes.id; 126 | console.log(`Registered global command: ${commandConfig.name}`); 127 | } 128 | } 129 | for (const commandConfig of adminCommandConfigs) { 130 | if (commandConfig) { 131 | await adminCommandManager.create(commandConfig); 132 | console.log(`Registered admin command: ${commandConfig.name}`); 133 | } 134 | } 135 | 136 | const existingCommands = await globalCommandManager.fetch(); 137 | const joinedCommands = commands.globalCommands.concat(commands.adminCommands); 138 | 139 | existingCommands.forEach((command) => { 140 | const foundCommand = joinedCommands.find((c) => c.slashCommand.name === command.name); 141 | if (!foundCommand) { 142 | console.log(`Removing command ${command.name}`); 143 | globalCommandManager.delete(command.id); 144 | } 145 | }); 146 | }); 147 | 148 | client.on(`interactionCreate`, async (interaction) => { 149 | if (interaction.isCommand()) { 150 | if (!interaction.guildId) { 151 | // bot doesn't support DMs for now, reply with an explanation 152 | return await interaction.reply(`Sorry, I don't support DMs. Please use my commands in a server that we share.`); 153 | } 154 | 155 | console.log(`Received command: ${interaction.commandName} (#${interaction.channel.name} in ${interaction.guild?.name})`); 156 | const joinedCommands = commands.globalCommands.concat(commands.adminCommands); 157 | const matchedCommand = joinedCommands.find((commandConfig) => commandConfig?.slashCommand?.name === interaction.commandName); 158 | if (matchedCommand) { 159 | try { 160 | await matchedCommand.action(interaction, client, commandIDs); 161 | } catch (e) { 162 | console.error(e); 163 | } 164 | } else { 165 | console.error(`No matching command found`); 166 | } 167 | } else if (interaction.isAutocomplete()) { 168 | console.log(`Received autocomplete for ${interaction.commandName} (#${interaction.channel.name} in ${interaction.guild.name} - @${interaction.user.username})): ${interaction.options.getFocused()}`); 169 | const joinedCommands = commands.globalCommands.concat(commands.adminCommands); 170 | const matchedCommand = joinedCommands.find((commandConfig) => commandConfig.slashCommand.name === interaction.commandName); 171 | if (matchedCommand) { 172 | try { 173 | await matchedCommand.action(interaction, client); 174 | } catch (e) { 175 | console.log(e); 176 | } 177 | } else { 178 | console.log(`No matching command found`); 179 | } 180 | } 181 | }); 182 | 183 | client.on(`messageCreate`, async (msg) => { 184 | if (msg.mentions.has(client.user.id, {ignoreEveryone: true})) { 185 | console.log(`Proxying mention to admin server...`); 186 | const adminChannel = await client.channels.fetch(adminChannelID); 187 | const proxyMessage = format.formatProxyMessage(msg); 188 | utils.sendMessage(adminChannel, proxyMessage); 189 | } 190 | }); 191 | 192 | const handleReaction = async (reaction, user, add) => { 193 | if (reaction.partial) { 194 | // if reaction is partial (not cached), try to fetch it fully 195 | try { 196 | await reaction.fetch(); 197 | } catch (error) { 198 | console.error(`Something went wrong when fetching the full reaction: `, error); 199 | return; 200 | } 201 | } 202 | 203 | if ( 204 | reaction.message.author && (reaction.message.author.id === client.user.id) // check the message was sent by the bot 205 | && user.id !== client.user.id // check the reaction was not sent by the bot 206 | ) { 207 | discordAPI.updateTOTDReactionCount(reaction, add, user); 208 | } 209 | }; 210 | 211 | client.on(`messageReactionAdd`, (reaction, user) => { 212 | handleReaction(reaction, user, true); 213 | }); 214 | 215 | client.on(`messageReactionRemove`, async (reaction, user) => { 216 | handleReaction(reaction, user, false); 217 | }); 218 | 219 | client.on(`guildCreate`, (guild) => { 220 | console.log(`Joined new server: ${guild.name} (${guild.id})`); 221 | }); 222 | 223 | client.on(`guildDelete`, (guild) => { 224 | console.log(`Left server: ${guild.name} (${guild.id})`); 225 | }); 226 | 227 | /* client.on(`rateLimit`, (rateLimit) => { 228 | console.warn(`Rate limit reached: ${rateLimit.limit} on ${rateLimit.method} ${rateLimit.path} (global: ${rateLimit.global}) - wait for ${rateLimit.timeout}`); 229 | }); */ 230 | 231 | client.login(discordToken); 232 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "totd-bot", 3 | "description": "Discord bot for displaying the daily Trackmania Track of the Day", 4 | "version": "1.0.0", 5 | "main": "main.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/davidbmaier/totd-bot.git" 9 | }, 10 | "author": "@davidbmaier", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/davidbmaier/totd-bot/issues" 14 | }, 15 | "homepage": "https://github.com/davidbmaier/totd-bot#readme", 16 | "keywords": [ 17 | "trackmania", 18 | "discord", 19 | "bot" 20 | ], 21 | "scripts": { 22 | "start": "node main.js" 23 | }, 24 | "dependencies": { 25 | "axios": ">=0.21.1", 26 | "bufferutil": "^4.0.3", 27 | "canvas": "^2.8.0", 28 | "cron": "^1.8.2", 29 | "discord.js": "13.6.0", 30 | "dotenv": "^8.2.0", 31 | "luxon": "^1.26.0", 32 | "redis": "^3.0.2", 33 | "utf-8-validate": "^5.0.4" 34 | }, 35 | "engines": { 36 | "node": "16.x" 37 | }, 38 | "devDependencies": { 39 | "eslint": "^7.20.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/backgrounds/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/0.jpg -------------------------------------------------------------------------------- /src/backgrounds/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/1.jpg -------------------------------------------------------------------------------- /src/backgrounds/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/10.jpg -------------------------------------------------------------------------------- /src/backgrounds/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/11.jpg -------------------------------------------------------------------------------- /src/backgrounds/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/12.jpg -------------------------------------------------------------------------------- /src/backgrounds/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/13.jpg -------------------------------------------------------------------------------- /src/backgrounds/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/14.jpg -------------------------------------------------------------------------------- /src/backgrounds/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/15.jpg -------------------------------------------------------------------------------- /src/backgrounds/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/16.jpg -------------------------------------------------------------------------------- /src/backgrounds/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/17.jpg -------------------------------------------------------------------------------- /src/backgrounds/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/18.jpg -------------------------------------------------------------------------------- /src/backgrounds/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/19.jpg -------------------------------------------------------------------------------- /src/backgrounds/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/2.jpg -------------------------------------------------------------------------------- /src/backgrounds/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/20.jpg -------------------------------------------------------------------------------- /src/backgrounds/21.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/21.jpg -------------------------------------------------------------------------------- /src/backgrounds/22.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/22.jpg -------------------------------------------------------------------------------- /src/backgrounds/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/3.jpg -------------------------------------------------------------------------------- /src/backgrounds/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/4.jpg -------------------------------------------------------------------------------- /src/backgrounds/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/5.jpg -------------------------------------------------------------------------------- /src/backgrounds/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/6.jpg -------------------------------------------------------------------------------- /src/backgrounds/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/7.jpg -------------------------------------------------------------------------------- /src/backgrounds/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/8.jpg -------------------------------------------------------------------------------- /src/backgrounds/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/9.jpg -------------------------------------------------------------------------------- /src/backgrounds/bingoTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/backgrounds/bingoTemplate.png -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | require(`dotenv`).config(); 2 | 3 | const discordAPI = require(`./discordAPI`); 4 | const redisAPI = require(`./redisApi`); 5 | const format = require(`./format`); 6 | const utils = require(`./utils`); 7 | const constants = require(`./constants`); 8 | 9 | const luxon = require(`luxon`); 10 | 11 | const adminTag = process.env.ADMIN_TAG; 12 | 13 | const today = { 14 | slashCommand: { 15 | name: `today`, 16 | description: `Display the current Track of the Day`, 17 | type: `CHAT_INPUT`, 18 | }, 19 | action: async (msg, client) => { 20 | try { 21 | await discordAPI.sendTOTDMessage(client, msg.channel, await discordAPI.getTOTDMessage(), msg); 22 | } catch (error) { 23 | discordAPI.sendErrorMessage(msg.channel); 24 | console.log(error); 25 | } 26 | } 27 | }; 28 | 29 | const leaderboard = { 30 | slashCommand: { 31 | name: `leaderboard`, 32 | description: `Display the current TOTD leaderboard and trophy thresholds.`, 33 | type: `CHAT_INPUT`, 34 | }, 35 | action: async (msg, client) => { 36 | try { 37 | await discordAPI.sendTOTDLeaderboard(client, msg.channel, msg); 38 | } catch (error) { 39 | discordAPI.sendErrorMessage(msg.channel); 40 | console.log(error); 41 | } 42 | } 43 | }; 44 | 45 | // command to see the current ratings 46 | const ratings = { 47 | slashCommand: { 48 | name: `ratings`, 49 | description: `Display a TOTD's ratings.`, 50 | type: `CHAT_INPUT`, 51 | options: [ 52 | { 53 | type: `STRING`, 54 | name: `totd`, 55 | description: `A Track of the Day (that there are ratings for).`, 56 | required: true, 57 | autocomplete: true 58 | }, 59 | ] 60 | }, 61 | action: async (msg, client) => { 62 | if (msg.isAutocomplete()) { 63 | try { 64 | const redisClient = await redisAPI.login(); 65 | 66 | const focusedValue = msg.options.getFocused(); 67 | if (focusedValue === ``) { 68 | const response = []; 69 | 70 | const today = await redisAPI.getCurrentTOTD(redisClient); 71 | if (today) { 72 | response.push({name: `Today's TOTD (${utils.removeNameFormatting(today.name)} by ${today.authorName})`, value: today.mapUid}); 73 | } 74 | 75 | const yesterday = await redisAPI.getPreviousTOTD(redisClient); 76 | if (yesterday) { 77 | response.push({name: `Yesterday's TOTD (${utils.removeNameFormatting(yesterday.name)} by ${yesterday.authorName})`, value: yesterday.mapUid}); 78 | } 79 | 80 | response.push({name: `Or just start typing to search for a previous TOTD...`, value: ``}); 81 | await msg.respond(response); 82 | } else { 83 | const storedTOTDs = await redisAPI.getAllStoredTOTDs(redisClient); 84 | if (!storedTOTDs) { 85 | return await msg.respond([]); 86 | } 87 | 88 | const options = Object.entries(storedTOTDs).reverse().map( 89 | ([mapUid, map]) => ({ name: `${utils.removeNameFormatting(map.name)} by ${map.authorName} (${map.month} ${utils.formatDay(map.day)} ${map.year})`, value: mapUid })).filter((option) => option.name.toLowerCase().includes(focusedValue.toLowerCase()) 90 | ); 91 | await msg.respond(options.slice(0, 25)); 92 | } 93 | redisAPI.logout(redisClient); 94 | } catch (error) { 95 | console.error(error); 96 | } 97 | } else { 98 | try { 99 | const mapUid = msg.options.get(`totd`).value; 100 | if (mapUid === ``) { 101 | utils.sendMessage(msg.channel, `You'll have to select an actual TOTD if you want to see some ratings.`, msg); 102 | return; 103 | } 104 | await discordAPI.sendTOTDRatings(client, msg.channel, mapUid, msg); 105 | } catch (error) { 106 | discordAPI.sendErrorMessage(msg.channel); 107 | console.log(error); 108 | } 109 | } 110 | } 111 | }; 112 | 113 | const enable = { 114 | slashCommand: { 115 | name: `enable`, 116 | description: `Enable daily posts in this channel when the new TOTD is released.`, 117 | type: `CHAT_INPUT`, 118 | }, 119 | action: async (msg) => { 120 | if (msg.member.permissions.has(`ADMINISTRATOR`) || utils.checkMessageAuthorForTag(msg, adminTag)) { 121 | try { 122 | const redisClient = await redisAPI.login(); 123 | await redisAPI.addConfig(redisClient, msg.guild.id, msg.channel.id); 124 | redisAPI.logout(redisClient); 125 | utils.sendMessage(msg.channel, `You got it, I'll post the TOTD every day just after it comes out.`, msg); 126 | } catch (error) { 127 | discordAPI.sendErrorMessage(msg.channel); 128 | console.log(error); 129 | } 130 | } else { 131 | utils.sendMessage(msg.channel, `You don't have \`ADMINISTRATOR\` permission, sorry.`, msg); 132 | } 133 | } 134 | }; 135 | 136 | const disable = { 137 | slashCommand: { 138 | name: `disable`, 139 | description: `Disable daily TOTD posts again.`, 140 | type: `CHAT_INPUT`, 141 | }, 142 | action: async (msg) => { 143 | if (msg.member.permissions.has(`ADMINISTRATOR`) || utils.checkMessageAuthorForTag(msg, adminTag)) { 144 | try { 145 | const redisClient = await redisAPI.login(); 146 | await redisAPI.removeConfig(redisClient, msg.guild.id); 147 | redisAPI.logout(redisClient); 148 | utils.sendMessage(msg.channel, `Alright, I'll stop posting from now on.`, msg); 149 | } catch (error) { 150 | discordAPI.sendErrorMessage(msg.channel); 151 | console.log(error); 152 | } 153 | } else { 154 | utils.sendMessage(msg.channel, `You don't have \`ADMINISTRATOR\` permissions, sorry.`, msg); 155 | } 156 | } 157 | }; 158 | 159 | const setRole = { 160 | slashCommand: { 161 | name: `enablepings`, 162 | description: `Enable reminder pings for a role ten minutes before COTD.`, 163 | type: `CHAT_INPUT`, 164 | options: [ 165 | { 166 | type: `STRING`, 167 | name: `role`, 168 | description: `The role that should be pinged. Format: @role`, 169 | required: true, 170 | }, 171 | { 172 | type: `STRING`, 173 | name: `region`, 174 | description: `The region that the role should be pinged for.`, 175 | required: true, 176 | choices: [ 177 | { 178 | name: constants.cupRegions.europe, 179 | value: constants.cupRegions.europe 180 | }, 181 | { 182 | name: constants.cupRegions.america, 183 | value: constants.cupRegions.america 184 | }, 185 | { 186 | name: constants.cupRegions.asia, 187 | value: constants.cupRegions.asia 188 | }, 189 | ] 190 | } 191 | ] 192 | }, 193 | action: async (msg, client, commandIDs) => { 194 | if (msg.member.permissions.has(`ADMINISTRATOR`) || utils.checkMessageAuthorForTag(msg, adminTag)) { 195 | try { 196 | const redisClient = await redisAPI.login(); 197 | const configs = await redisAPI.getAllConfigs(redisClient); 198 | // check if this server already has daily posts set up 199 | const matchingConfig = configs.find((config) => config.serverID === msg.guild.id); 200 | if (matchingConfig) { 201 | const role = msg.options.get(`role`).value; 202 | if (role.startsWith(`<@&`)) { 203 | const region = msg.options.get(`region`).value; 204 | // valid regions: "Europe" (7pm), "America" (3am), "Asia" (11am) 205 | if (region) { 206 | const regions = constants.cupRegions; 207 | if ( 208 | region === regions.europe 209 | || region === regions.america 210 | || region === regions.asia 211 | ) { 212 | await redisAPI.addRole(redisClient, msg.guild.id, role, region); 213 | utils.sendMessage(msg.channel, `Okay, from now on I'll ping that role ten minutes before the ${region} COTD starts.`, msg); 214 | } else { 215 | const message1 = `Sorry, I only know three regions: \`${regions.europe}\`, \`${regions.america}\`, and \`${regions.asia}\` - `; 216 | const message2 = `tell me to set up a role for a region by using ${utils.formatCommand(`enablepings`, commandIDs)}.\n`; 217 | const message3 = `If you just want to set up pings for the main Europe event, you can leave out the region.`; 218 | utils.sendMessage(msg.channel, `${message1}${message2}${message3}`, msg); 219 | } 220 | } else { 221 | await redisAPI.addRole(redisClient, msg.guild.id, role); 222 | utils.sendMessage(msg.channel, `Okay, from now on I'll ping that role ten minutes before the main COTD starts.`, msg); 223 | } 224 | } else { 225 | utils.sendMessage(msg.channel, `Sorry, I only understand roles that look like \`@role\` - and I obviously don't accept user IDs either.`, msg); 226 | } 227 | } else { 228 | utils.sendMessage(msg.channel, `Sorry, you'll need to enable the daily posts first.`, msg); 229 | } 230 | redisAPI.logout(redisClient); 231 | } catch (error) { 232 | discordAPI.sendErrorMessage(msg.channel); 233 | console.log(error); 234 | } 235 | } else { 236 | utils.sendMessage(msg.channel, `You don't have \`ADMINISTRATOR\` permissions, sorry.`, msg); 237 | } 238 | } 239 | }; 240 | 241 | const removeRole = { 242 | slashCommand: { 243 | name: `disablepings`, 244 | description: `Disable TOTD reminder pings for a role.`, 245 | type: `CHAT_INPUT`, 246 | options: [ 247 | { 248 | type: `STRING`, 249 | name: `region`, 250 | description: `The region that the role should be pinged for.`, 251 | required: true, 252 | choices: [ 253 | { 254 | name: constants.cupRegions.europe, 255 | value: constants.cupRegions.europe 256 | }, 257 | { 258 | name: constants.cupRegions.america, 259 | value: constants.cupRegions.america 260 | }, 261 | { 262 | name: constants.cupRegions.asia, 263 | value: constants.cupRegions.asia 264 | }, 265 | ] 266 | } 267 | ] 268 | }, 269 | action: async (msg, client, commandIDs) => { 270 | if (msg.member.permissions.has(`ADMINISTRATOR`) || utils.checkMessageAuthorForTag(msg, adminTag)) { 271 | try { 272 | const redisClient = await redisAPI.login(); 273 | const region = msg.options.get(`region`).value; 274 | if (region) { 275 | const regions = constants.cupRegions; 276 | if ( 277 | region === regions.europe 278 | || region === regions.america 279 | || region === regions.asia 280 | ) { 281 | await redisAPI.removeRole(redisClient, msg.guild.id, region); 282 | utils.sendMessage(msg.channel, `Okay, I'll stop the pings for the ${region} COTD.`, msg); 283 | } else { 284 | const message1 = `Sorry, I only know three regions: \`${regions.europe}\`, \`${regions.america}\`, and \`${regions.asia}\`.\n`; 285 | const message2 = `Tell me to remove a role for a region by using ${utils.formatCommand(`disablepings`, commandIDs)}.`; 286 | utils.sendMessage(msg.channel, `${message1}${message2}`, msg); 287 | } 288 | } else { 289 | await redisAPI.removeRole(redisClient, msg.guild.id); 290 | utils.sendMessage(msg.channel, `Okay, I'll stop the pings for the main COTD.`, msg); 291 | } 292 | redisAPI.logout(redisClient); 293 | } catch (error) { 294 | discordAPI.sendErrorMessage(msg.channel); 295 | console.log(error); 296 | } 297 | } else { 298 | utils.sendMessage(msg.channel, `You don't have \`ADMINISTRATOR\` permissions, sorry.`, msg); 299 | } 300 | } 301 | }; 302 | 303 | const bingo = { 304 | slashCommand: { 305 | name: `bingo`, 306 | description: `Display this week's bingo board.`, 307 | type: `CHAT_INPUT`, 308 | }, 309 | action: async (msg, client, commandIDs) => { 310 | try { 311 | await discordAPI.sendBingoBoard(msg.channel, false, msg, commandIDs); 312 | } catch (error) { 313 | discordAPI.sendErrorMessage(msg.channel); 314 | console.log(error); 315 | } 316 | } 317 | }; 318 | 319 | const lastBingo = { 320 | slashCommand: { 321 | name: `lastbingo`, 322 | description: `Display last week's bingo board.`, 323 | type: `CHAT_INPUT`, 324 | }, 325 | action: async (msg, client, commandIDs) => { 326 | try { 327 | await discordAPI.sendBingoBoard(msg.channel, true, msg, commandIDs); 328 | } catch (error) { 329 | discordAPI.sendErrorMessage(msg.channel); 330 | console.log(error); 331 | } 332 | } 333 | }; 334 | 335 | const bingoOptions = []; 336 | for (let i = 0; i < 25; i++) { 337 | bingoOptions.push({ 338 | name: `Field ${i + 1}`, 339 | value: `${i + 1}` 340 | }); 341 | } 342 | 343 | const bingoVote = { 344 | slashCommand: { 345 | name: `votebingo`, 346 | description: `Start a vote for a bingo field.`, 347 | type: `CHAT_INPUT`, 348 | options: [ 349 | { 350 | type: `STRING`, 351 | name: `field`, 352 | description: `The bingo field you want to start a vote for.`, 353 | required: true, 354 | choices: bingoOptions 355 | } 356 | ] 357 | }, 358 | action: async (msg, client, commandIDs) => { 359 | try { 360 | const bingoID = msg.options.get(`field`).value; 361 | if (!bingoID || Number.isNaN(parseInt(bingoID))) { 362 | utils.sendMessage(msg.channel, `I didn't catch that - to vote on a bingo field, use ${utils.formatCommand(`votebingo`, commandIDs)}.`, msg); 363 | } else { 364 | await discordAPI.sendBingoVote(msg.channel, parseInt(bingoID), msg); 365 | } 366 | } catch (error) { 367 | discordAPI.sendErrorMessage(msg.channel); 368 | console.error(error); 369 | } 370 | } 371 | }; 372 | 373 | const invite = { 374 | slashCommand: { 375 | name: `invite`, 376 | description: `Get an invite link for the TOTD Bot.`, 377 | type: `CHAT_INPUT`, 378 | }, 379 | action: async (msg) => { 380 | try { 381 | utils.sendMessage(msg.channel, format.formatInviteMessage(), msg); 382 | } catch (error) { 383 | discordAPI.sendErrorMessage(msg.channel); 384 | console.log(error); 385 | } 386 | } 387 | }; 388 | 389 | const rankings = { 390 | slashCommand: { 391 | name: `rankings`, 392 | description: `Display TOTD rankings based on bot ratings.`, 393 | type: `CHAT_INPUT`, 394 | options: [ 395 | { 396 | type: `STRING`, 397 | name: `timeframe`, 398 | description: `The time frame you want to see rankings for.`, 399 | required: true, 400 | autocomplete: true 401 | } 402 | ] 403 | }, 404 | action: async (msg) => { 405 | if (msg.isAutocomplete()) { 406 | try { 407 | const focusedValue = msg.options.getFocused(); 408 | 409 | const monthsBackwards = [...luxon.Info.months(`long`)].reverse(); 410 | // hard-coded year and month for the first TOTD entry in the DB 411 | const firstYear = 2021; 412 | const firstMonth = `July`; 413 | const currentYear = luxon.DateTime.now().year; 414 | const currentMonth = luxon.DateTime.now().monthLong; 415 | 416 | let options = []; 417 | 418 | let currentMonthReached = false; 419 | for (let year = currentYear; year >= firstYear; year--) { 420 | for (const month of monthsBackwards) { 421 | if (!currentMonthReached) { 422 | if (month === currentMonth) { 423 | currentMonthReached = true; 424 | } else { 425 | continue; 426 | } 427 | } 428 | 429 | options.push({name: `${month} ${year}`, value: `${month} ${year}`}); 430 | 431 | if (month === `January`) { 432 | options.push({name: `${year} (full year)`, value: `complete ${year}`}); 433 | } 434 | if (year === firstYear && month === firstMonth) { 435 | options.push({name: `${year} (full year)`, value: `complete ${year}`}); 436 | options.push({name: `All-time`, value: `all-time`}); 437 | break; 438 | } 439 | } 440 | } 441 | 442 | options = options.filter((option) => option.name.toLowerCase().includes(focusedValue.toLowerCase())); 443 | 444 | await msg.respond(options.slice(0, 25)); 445 | } catch (error) { 446 | console.error(error); 447 | } 448 | } else { 449 | let timeframe = ``; 450 | try { 451 | timeframe = msg.options.get(`timeframe`).value; 452 | 453 | const rankings = await discordAPI.calculateRankings(timeframe); 454 | const rankingMessage = format.formatRankingMessage(rankings, timeframe); 455 | utils.sendMessage(msg.channel, rankingMessage, msg); 456 | } catch (error) { 457 | utils.sendMessage(msg.channel, error.message, msg); 458 | console.warn(`Error during /rankings with timeframe "${timeframe}":`, error); 459 | } 460 | } 461 | } 462 | }; 463 | 464 | const help = { 465 | slashCommand: { 466 | name: `help`, 467 | description: `Get a list of all commands for the TOTD Bot.`, 468 | type: `CHAT_INPUT`, 469 | }, 470 | action: async (msg, client, commandIDs) => { 471 | let message = `${utils.formatCommand(`today`, commandIDs)} - Display the current TOTD information.\n \ 472 | ${utils.formatCommand(`leaderboard`, commandIDs)} - Display the current top 10 (and the time for top 100).\n \ 473 | ${utils.formatCommand(`ratings`, commandIDs)} - Display a stored TOTD's ratings.\n \ 474 | ${utils.formatCommand(`rankings`, commandIDs)} - Display TOTD rankings based on bot ratings.\n \ 475 | ${utils.formatCommand(`bingo`, commandIDs)} - Display this week's bingo board for this server.\n \ 476 | ${utils.formatCommand(`lastbingo`, commandIDs)} - Display last week's bingo board for this server.\n \ 477 | ${utils.formatCommand(`votebingo`, commandIDs)} - Start a vote to cross off that bingo field.`; 478 | 479 | let adminMessage; 480 | if (msg.member.permissions.has(`ADMINISTRATOR`) || utils.checkMessageAuthorForTag(msg, adminTag)) { 481 | adminMessage = `\n${utils.formatCommand(`enable`, commandIDs)} - Enable daily TOTD posts in this channel.\n \ 482 | ${utils.formatCommand(`disable`, commandIDs)} - Disable the daily posts again.\n \ 483 | ${utils.formatCommand(`enablepings`, commandIDs)} - Enable pings ten minutes before COTD.\n \ 484 | ${utils.formatCommand(`disablepings`, commandIDs)} - Disable daily pings again.`; 485 | } 486 | try { 487 | const formattedMessage = format.formatHelpMessage(message, adminMessage); 488 | formattedMessage.ephemeral = true; 489 | utils.sendMessage(msg.channel, formattedMessage, msg); 490 | } catch (error) { 491 | discordAPI.sendErrorMessage(msg.channel); 492 | console.log(error); 493 | } 494 | } 495 | }; 496 | 497 | const refresh = { 498 | slashCommand: { 499 | name: `refreshtotd`, 500 | description: `Manually refresh the current TOTD data.`, 501 | type: `CHAT_INPUT`, 502 | }, 503 | action: async (msg) => { 504 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 505 | try { 506 | const response = await utils.sendMessage(msg.channel, `Working on it... ${utils.getEmojiMapping(`Loading`)}`, msg); 507 | await discordAPI.getTOTDMessage(true); 508 | response.edit(`I've refreshed the current TOTD data!`); 509 | } catch (error) { 510 | discordAPI.sendErrorMessage(msg.channel); 511 | console.error(error); 512 | } 513 | } 514 | } 515 | }; 516 | 517 | const refreshLeaderboard = { 518 | slashCommand: { 519 | name: `refreshleaderboard`, 520 | description: `Manually refresh the current leaderboard data.`, 521 | type: `CHAT_INPUT`, 522 | }, 523 | action: async (msg) => { 524 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 525 | try { 526 | const response = await utils.sendMessage(msg.channel, `Working on it... ${utils.getEmojiMapping(`Loading`)}`, msg); 527 | await discordAPI.getTOTDLeaderboardMessage(true); 528 | response.edit(`I've refreshed the current leaderboard data!`); 529 | } catch (error) { 530 | discordAPI.sendErrorMessage(msg.channel); 531 | console.error(error); 532 | } 533 | } 534 | } 535 | }; 536 | 537 | const refreshRatings = { 538 | slashCommand: { 539 | name: `refreshratings`, 540 | description: `Manually refresh the current ratings data.`, 541 | type: `CHAT_INPUT`, 542 | }, 543 | action: async (msg) => { 544 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 545 | try { 546 | const response = await utils.sendMessage(msg.channel, `Working on it... ${utils.getEmojiMapping(`Loading`)}`, msg); 547 | const redisClient = await redisAPI.login(); 548 | await redisAPI.clearTOTDRatings(redisClient); 549 | redisAPI.logout(redisClient); 550 | response.edit(`I've refreshed the current rating data!`); 551 | } catch (error) { 552 | discordAPI.sendErrorMessage(msg.channel); 553 | console.error(error); 554 | } 555 | } 556 | } 557 | }; 558 | 559 | const refreshBingo = { 560 | slashCommand: { 561 | name: `refreshbingo`, 562 | description: `Manually refresh the current bingo board for a server.`, 563 | type: `CHAT_INPUT`, 564 | options: [ 565 | { 566 | type: `STRING`, 567 | name: `serverid`, 568 | description: `The server ID to refresh the bingo board for.`, 569 | required: true 570 | } 571 | ] 572 | }, 573 | action: async (msg, client, commandIDs) => { 574 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 575 | try { 576 | const serverID = msg.options.get(`serverid`).value; 577 | const response = await utils.sendMessage(msg.channel, `Working on it... ${utils.getEmojiMapping(`Loading`)}`, msg); 578 | await discordAPI.getBingoMessage(serverID, false, true, commandIDs); 579 | response.edit(`I've refreshed the current bingo board!`); 580 | } catch (error) { 581 | discordAPI.sendErrorMessage(msg.channel); 582 | console.error(error); 583 | } 584 | } 585 | } 586 | }; 587 | 588 | const refreshBingoCount = { 589 | slashCommand: { 590 | name: `refreshbingocount`, 591 | description: `Manually refresh the current bingo vote count.`, 592 | type: `CHAT_INPUT`, 593 | }, 594 | action: async (msg, client) => { 595 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 596 | try { 597 | const response = await utils.sendMessage(msg.channel, `Working on it... ${utils.getEmojiMapping(`Loading`)}`, msg); 598 | await discordAPI.countBingoVotes(client); 599 | response.edit(`I've counted and resolved the current bingo votes!`); 600 | } catch (error) { 601 | discordAPI.sendErrorMessage(msg.channel); 602 | console.error(error); 603 | } 604 | } 605 | } 606 | }; 607 | 608 | const serverInfo = { 609 | slashCommand: { 610 | name: `servers`, 611 | description: `Get information about the servers the TOTD bot is in.`, 612 | type: `CHAT_INPUT`, 613 | }, 614 | action: async (msg, client) => { 615 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 616 | try { 617 | const servers = []; 618 | let memberCount = 0; 619 | client.guilds.cache.forEach(async (guild) => { 620 | servers.push(guild); 621 | memberCount += guild.memberCount; 622 | }); 623 | utils.sendMessage(msg.channel, `I'm currently in ${servers.length} servers and counting - reaching an audience of ${memberCount}!`, msg); 624 | 625 | // fetch and log detailed infos asynchronously 626 | servers.forEach(async (server) => { 627 | const owner = await client.users.fetch(server.ownerId); // don't use fetchOwner since we need the owner user, not the guild member 628 | console.log(`Server: ${server.name} (${server.memberCount}) - Owner: ${owner.tag} - ID: ${server.id}`); 629 | }); 630 | } catch (error) { 631 | discordAPI.sendErrorMessage(msg.channel); 632 | console.error(error); 633 | } 634 | } 635 | } 636 | }; 637 | 638 | const distributeTOTDMessages = { 639 | slashCommand: { 640 | name: `distribute`, 641 | description: `Manually broadcast the current TOTD message to all subscribed channels.`, 642 | type: `CHAT_INPUT`, 643 | options: [ 644 | { 645 | type: `STRING`, 646 | name: `confirm`, 647 | description: `Send "yes" to confirm you really want to do this.`, 648 | required: true 649 | } 650 | ] 651 | }, 652 | action: async (msg, client) => { 653 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 654 | try { 655 | const confirmation = msg.options.get(`confirm`).value; 656 | if (confirmation !== `yes`) { 657 | utils.sendMessage(msg.channel, `Ignoring broadcast command because it was missing the required confirmation.`, msg); 658 | } else { 659 | // override the check for a new map since this is manual 660 | discordAPI.distributeTOTDMessages(client, true); 661 | } 662 | } catch (error) { 663 | discordAPI.sendErrorMessage(msg.channel); 664 | console.error(error); 665 | } 666 | } 667 | } 668 | }; 669 | 670 | const kem = { 671 | slashCommand: { 672 | name: `kem`, 673 | description: `Say the line, Bart!`, 674 | type: `CHAT_INPUT`, 675 | }, 676 | action: async (msg) => { 677 | try { 678 | utils.sendMessage(msg.channel, `Fix it, Kem!`, msg); 679 | } catch (error) { 680 | discordAPI.sendErrorMessage(msg.channel); 681 | console.log(error); 682 | } 683 | } 684 | }; 685 | 686 | const debug = { 687 | slashCommand: { 688 | name: `debug`, 689 | description: `Placeholder command for testing.`, 690 | type: `CHAT_INPUT`, 691 | }, 692 | action: async (msg) => { 693 | if (utils.checkMessageAuthorForTag(msg, adminTag)) { 694 | try { 695 | utils.sendMessage(msg.channel, `Debug me!`, msg); 696 | } catch (error) { 697 | discordAPI.sendErrorMessage(msg.channel); 698 | console.error(error); 699 | } 700 | } 701 | } 702 | }; 703 | 704 | module.exports = { 705 | globalCommands: [ 706 | help, 707 | invite, 708 | today, 709 | leaderboard, 710 | ratings, 711 | rankings, 712 | enable, 713 | disable, 714 | setRole, 715 | removeRole, 716 | bingo, 717 | lastBingo, 718 | bingoVote, 719 | kem 720 | ], 721 | adminCommands: [ 722 | refresh, 723 | refreshLeaderboard, 724 | refreshRatings, 725 | refreshBingo, 726 | refreshBingoCount, 727 | debug, 728 | serverInfo, 729 | distributeTOTDMessages 730 | ] 731 | }; 732 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const ratingEmojis = [ 2 | `MinusMinusMinus`, 3 | `MinusMinus`, 4 | `Minus`, 5 | `Plus`, 6 | `PlusPlus`, 7 | `PlusPlusPlus` 8 | ]; 9 | 10 | const cupRegions = { 11 | europe: `Europe`, 12 | america: `America`, 13 | asia: `Asia` 14 | }; 15 | 16 | const specialRankings = { 17 | allTime: `all-time`, 18 | completeYear: `complete` 19 | }; 20 | 21 | const bingoFields = [ 22 | // map styles 23 | `Tech of the Day`, 24 | `SpeedTech\nof the Day`, 25 | `Ice of the Day`, 26 | `Dirt of the Day`, 27 | `Grass\nof the Day`, 28 | `FullSpeed\nof the Day`, 29 | `Plastic\nof the Day`, 30 | // map characteristics 31 | `Scenery\nof the Day`, 32 | `Cut/Reroute\nof the Day`, 33 | `Fog of the Day`, 34 | `Restart\nsimulator`, 35 | `RGB lighting`, 36 | `Poor lighting`, 37 | `Night map`, 38 | `Map filled\nwith custom\nscenery items`, 39 | `Scenery\nin the middle\nof the road`, 40 | `Texture mod`, 41 | `Map outside\nof the stadium`, 42 | `Engine-off\nblock`, 43 | `Slowmo block`, 44 | `Cruise control`, 45 | `Reactor jump\nwith a zoop`, 46 | `Driving\nupside-down\n(e.g. loopings)`, 47 | `Speed slides`, 48 | `Jump into\nthe finish`, 49 | `Risky\nfinish`, 50 | `Narrow\nblocks`, 51 | `Poles on\nthe track`, 52 | `Multi-lap\ntrack`, 53 | `Questionable\ngears`, 54 | `Magnets`, 55 | `Moving items\non track`, 56 | `Water blocks\non track`, 57 | `Multiple\nroutes`, 58 | `Missable\nCP`, 59 | `Blind turn`, 60 | `Badly-placed\nCP`, 61 | // author 62 | `Author's\nfirst TOTD`, 63 | `Author already\nhad TOTD\nin the last 7 days`, 64 | `Multiple\nauthors`, 65 | // times 66 | `Author time\nover a minute`, 67 | `Author time\nunder 30s`, 68 | `Author medal\nthat's insanely\ndifficult`, 69 | `Free\nAuthor medal`, 70 | `No GPS` 71 | ]; 72 | 73 | module.exports = { 74 | ratingEmojis, 75 | cupRegions, 76 | bingoFields, 77 | specialRankings 78 | }; 79 | -------------------------------------------------------------------------------- /src/discordAPI.js: -------------------------------------------------------------------------------- 1 | const tmAPI = require(`./tmApi`); 2 | const format = require(`./format`); 3 | const redisAPI = require(`./redisApi`); 4 | const utils = require(`./utils`); 5 | const constants = require(`./constants`); 6 | const rating = require(`./rating`); 7 | 8 | const luxon = require(`luxon`); 9 | 10 | const adminChannelID = process.env.ADMIN_CHANNEL_ID; 11 | 12 | const errorMessage = `Oops, something went wrong here - please talk to <@141627532335251456> and let him know that didn't work.`; 13 | 14 | const getTOTDMessage = async (forceRefresh) => { 15 | if (!forceRefresh) { 16 | console.log(`Using cached TOTD...`); 17 | const redisClient = await redisAPI.login(); 18 | const totd = await redisAPI.getCurrentTOTD(redisClient); 19 | redisAPI.logout(redisClient); 20 | 21 | if (totd) { 22 | return format.formatTOTDMessage(totd); 23 | } 24 | // if there is no message yet, refresh 25 | console.log(`No cached TOTD exists yet, falling back to refresh`); 26 | } 27 | 28 | console.log(`Refreshing TOTD from API...`); 29 | const totd = await tmAPI.getCurrentTOTD(); 30 | const formattedMessage = format.formatTOTDMessage(totd); 31 | 32 | // save fresh TOTD to redis 33 | const redisClient = await redisAPI.login(); 34 | const oldTOTD = await redisAPI.getCurrentTOTD(redisClient); 35 | if (oldTOTD && oldTOTD.mapUid !== totd.mapUid) { 36 | // oldTOTD was a different map, so save that as yesterday's TOTD in Redis 37 | // note it could have been an earlier one if the bot was done for more than a day 38 | console.log(`Currently stored TOTD has different mapUid, storing that as yesterday's TOTD`); 39 | await redisAPI.savePreviousTOTD(redisClient, oldTOTD); 40 | } 41 | await redisAPI.saveCurrentTOTD(redisClient, totd); 42 | redisAPI.logout(redisClient); 43 | 44 | console.log(`Refreshed TOTD in Redis`); 45 | 46 | // also refresh the leaderboard 47 | getTOTDLeaderboardMessage(true); 48 | 49 | return formattedMessage; 50 | }; 51 | 52 | const getTOTDLeaderboardMessage = async (forceRefresh) => { 53 | const redisClient = await redisAPI.login(); 54 | const cachedleaderBoardMessage = await redisAPI.getCurrentLeaderboard(redisClient); 55 | let totd = await redisAPI.getCurrentTOTD(redisClient); 56 | if (!totd) { 57 | await getTOTDMessage(); 58 | totd = await redisAPI.getCurrentTOTD(redisClient); 59 | } 60 | redisAPI.logout(redisClient); 61 | 62 | if ( 63 | forceRefresh 64 | || !cachedleaderBoardMessage 65 | || !cachedleaderBoardMessage.date 66 | || cachedleaderBoardMessage.date < utils.convertToUNIXSeconds(new Date()) - 600 67 | ) { 68 | // if cached message does not exist or is older than ten minutes, refresh 69 | console.log(`Refreshing leaderboard from API...`); 70 | const top = await tmAPI.getTOTDLeaderboard(totd.seasonUid, totd.mapUid); 71 | // if top doesn't exist yet, fall back 72 | if (!top) { 73 | const fallbackMessage = `Hmm, either there's not enough records yet, or the leaderboard is being updated too fast - please check again in a couple minutes.`; 74 | 75 | // clear leaderboard message in redis 76 | const redisClient = await redisAPI.login(); 77 | await redisAPI.clearCurrentLeaderboard(redisClient); 78 | redisAPI.logout(redisClient); 79 | 80 | return fallbackMessage; 81 | } 82 | 83 | const formattedMessage = format.formatLeaderboardMessage(totd, top, utils.convertToUNIXSeconds(new Date())); 84 | 85 | // save fresh message to redis 86 | const redisClient = await redisAPI.login(); 87 | await redisAPI.saveCurrentLeaderboard(redisClient, formattedMessage); 88 | redisAPI.logout(redisClient); 89 | 90 | console.log(`Refreshed leaderboard in Redis`); 91 | return formattedMessage; 92 | } else { 93 | console.log(`Using cached leaderboard...`); 94 | return cachedleaderBoardMessage; 95 | } 96 | }; 97 | 98 | const getRatingMessage = async (mapInfo) => { 99 | try { 100 | if (mapInfo) { 101 | return format.formatRatingsMessage(mapInfo); 102 | } else { 103 | return `Hmm, I don't seem to remember that track. Sorry about that!`; 104 | } 105 | } catch (err) { 106 | console.log(`Error while getting rating message:`, err); 107 | return errorMessage; 108 | } 109 | }; 110 | 111 | const generateNewBingoBoard = async (serverID, redisClient) => { 112 | console.log(`Regenerating bingo board for server ${serverID}...`); 113 | 114 | const bingoFields = [...constants.bingoFields]; 115 | const pickedFields = []; 116 | while (pickedFields.length < 24) { 117 | const randomPick = Math.floor(Math.random() * bingoFields.length); 118 | const pickedField = bingoFields.splice(randomPick, 1)[0]; 119 | pickedFields.push({ 120 | text: pickedField, 121 | checked: false, 122 | voteActive: false 123 | }); 124 | } 125 | 126 | const board = pickedFields; 127 | await redisAPI.saveBingoBoard(redisClient, board, serverID); 128 | console.log(`Refreshed bingo board in Redis`); 129 | return board; 130 | }; 131 | 132 | const archiveBingoBoards = async () => { 133 | console.log(`Archiving old bingo boards...`); 134 | const redisClient = await redisAPI.login(); 135 | const allBoards = await redisAPI.getAllBingoBoards(redisClient); 136 | await redisAPI.archiveBingoBoards(redisClient, allBoards); 137 | await redisAPI.resetBingoBoards(redisClient); 138 | redisAPI.logout(redisClient); 139 | }; 140 | 141 | const getBingoMessage = async (serverID, lastWeek, forceRefresh, commandIDs) => { 142 | const redisClient = await redisAPI.login(); 143 | let board = await redisAPI.getBingoBoard(redisClient, serverID, lastWeek); 144 | 145 | if (lastWeek) { 146 | if (board) { 147 | console.log(`Using last week's board`); 148 | redisAPI.logout(redisClient); 149 | return await format.formatBingoBoard(board, lastWeek, commandIDs); 150 | } else { 151 | console.log(`Couldn't find last week's board`); 152 | return `Hmm, I don't remember last week's board. Sorry about that!`; 153 | } 154 | } else { 155 | if (!board || forceRefresh) { 156 | board = await generateNewBingoBoard(serverID, redisClient); 157 | } else { 158 | console.log(`Using cached bingo board...`); 159 | } 160 | 161 | redisAPI.logout(redisClient); 162 | return await format.formatBingoBoard(board, false, commandIDs); 163 | } 164 | }; 165 | 166 | const sendTOTDMessage = async (client, channel, message, commandMessage) => { 167 | try { 168 | console.log(`Sending current TOTD to #${channel.name} in ${channel.guild.name}`); 169 | const discordMessage = await utils.sendMessage(channel, message, commandMessage); 170 | // add rating emojis 171 | const emojis = []; 172 | for (let i = 0; i < constants.ratingEmojis.length; i++) { 173 | emojis.push(utils.getEmojiMapping(constants.ratingEmojis[i])); 174 | } 175 | for (const emoji of emojis) { 176 | discordMessage.react(emoji); 177 | } 178 | return Promise.resolve(discordMessage); 179 | } catch (error) { 180 | console.log(`Couldn't send TOTD message to #${channel.name} in ${channel.guild.name}, throwing error`); 181 | console.error(error); 182 | return Promise.reject(error); 183 | } 184 | }; 185 | 186 | const sendTOTDLeaderboard = async (client, channel, commandMessage) => { 187 | const discordMessage = await utils.sendMessage(channel, `Fetching current leaderboard, give me a second... ${utils.getEmojiMapping(`Loading`)}`, commandMessage); 188 | 189 | const leaderboardMessage = await getTOTDLeaderboardMessage(); 190 | 191 | console.log(`Sending current leaderboard to #${channel.name} in ${channel.guild.name}`); 192 | discordMessage.edit(leaderboardMessage); 193 | }; 194 | 195 | const sendTOTDRatings = async (client, channel, mapUid, commandMessage) => { 196 | const redisClient = await redisAPI.login(); 197 | const today = await redisAPI.getCurrentTOTD(redisClient); 198 | 199 | let mapInfo; 200 | if (mapUid === today.mapUid) { 201 | mapInfo = today; 202 | mapInfo.today = true; 203 | mapInfo.ratings = await redisAPI.getTOTDRatings(redisClient); 204 | } else { 205 | const storedTOTDs = await redisAPI.getAllStoredTOTDs(redisClient); 206 | mapInfo = storedTOTDs[mapUid]; 207 | } 208 | 209 | const ratingString = mapInfo?.today ? `current rating` : `previous verdict (${mapInfo?.day}/${mapInfo?.month}/${mapInfo?.year})`; 210 | console.log(`Sending ${ratingString} to #${channel.name} in ${channel.guild.name}`); 211 | const message = await getRatingMessage(mapInfo); 212 | await utils.sendMessage(channel, message, commandMessage); 213 | redisAPI.logout(redisClient); 214 | }; 215 | 216 | const sendBingoBoard = async (channel, lastWeek, commandMessage, commandIDs) => { 217 | const bingoString = lastWeek ? `last week's` : `current`; 218 | console.log(`Sending ${bingoString} bingo board to #${channel.name} in ${channel.guild.name}`); 219 | const message = await getBingoMessage(channel.guildId, lastWeek, false, commandIDs); 220 | await utils.sendMessage(channel, message, commandMessage); 221 | }; 222 | 223 | const sendBingoVote = async (channel, bingoID, commandMessage) => { 224 | const redisClient = await redisAPI.login(); 225 | let board = await redisAPI.getBingoBoard(redisClient, channel.guildId); 226 | 227 | if (!board) { 228 | return await utils.sendMessage(channel, `There's no board yet, why are you trying to vote? You can use \`/bingo\` to generate this server's board for this week.`); 229 | } 230 | 231 | // add free space to the center 232 | board.splice(12, 0, {text: `Free space`, checked: false}); 233 | 234 | if (bingoID < 1 || bingoID > 25) { 235 | return await utils.sendMessage(channel, `Hmm, that's not on the board. I only understand numbers from 1 to 25 I'm afraid.`, commandMessage); 236 | } else if (bingoID === 13) { 237 | return await utils.sendMessage(channel, `You want to vote on the free space? Are you okay?`, commandMessage); 238 | } 239 | 240 | const field = board[bingoID - 1]; 241 | if (field.checked) { 242 | return await utils.sendMessage(channel, `Looks like that field is already checked off for this week.`, commandMessage); 243 | } else if (field.voteActive) { 244 | return await utils.sendMessage(channel, `There's already a vote going on for that field, check again tomorrow.`, commandMessage); 245 | } 246 | 247 | const textWithoutBreaks = field.text.replace(/\n/g, ` `); 248 | const voteMessage = await utils.sendMessage( 249 | channel, 250 | `Bingo vote started: **${textWithoutBreaks}**\n` + 251 | `Does that sound like today's track?\n` + 252 | `Vote using the reactions below - I'll close the vote when the next TOTD comes out.`, 253 | commandMessage 254 | ); 255 | 256 | const voteYes = utils.getEmojiMapping(`VoteYes`); 257 | const voteNo = utils.getEmojiMapping(`VoteNo`); 258 | voteMessage.react(voteYes); 259 | voteMessage.react(voteNo); 260 | 261 | board[bingoID - 1].voteActive = true; 262 | board[bingoID - 1].voteMessageID = voteMessage.id; 263 | board[bingoID - 1].voteChannelID = voteMessage.channel.id; 264 | // remove free space so it doesn't get saved 265 | board.splice(12, 1); 266 | 267 | // save vote info to redis 268 | await redisAPI.saveBingoBoard(redisClient, board, channel.guildId); 269 | return redisAPI.logout(redisClient); 270 | }; 271 | 272 | const countBingoVotes = async (client) => { 273 | console.log(`Counting outstanding bingo votes...`); 274 | const redisClient = await redisAPI.login(); 275 | let boards = await redisAPI.getAllBingoBoards(redisClient); 276 | 277 | const updatedFields = []; 278 | 279 | for (const [serverID, board] of Object.entries(boards)) { 280 | console.log(`Counting votes for ${serverID}`); 281 | for (let i = 0; i < board.length; i++) { 282 | const field = board[i]; 283 | if (!field.checked && field.voteActive && field.voteMessageID && field.voteChannelID) { 284 | // vote found, resolving it 285 | let voteMessage; 286 | try { 287 | const voteChannel = await client.channels.fetch(field.voteChannelID); 288 | voteMessage = await voteChannel.messages.fetch(field.voteMessageID); 289 | if (!voteMessage) { 290 | throw new Error(`Message not found`); 291 | } 292 | 293 | const voteYes = utils.getEmojiMapping(`VoteYes`); 294 | const voteNo = utils.getEmojiMapping(`VoteNo`); 295 | 296 | let countYes = 0; 297 | let countNo = 0; 298 | voteMessage.reactions.cache.forEach((reaction, reactionID) => { 299 | // use count - 1 since the bot adds one initially 300 | if (voteYes.includes(reactionID)) { 301 | countYes = reaction.count - 1; 302 | } else if (voteNo.includes(reactionID)) { 303 | countNo = reaction.count - 1; 304 | } 305 | // if Yes and No both don't match, ignore the reaction 306 | }); 307 | 308 | if (countYes > countNo) { 309 | field.checked = true; 310 | delete field.voteActive; 311 | delete field.voteMessageID; 312 | delete field.voteChannelID; 313 | updatedFields.push(field.text); 314 | } else { 315 | field.voteActive = false; 316 | delete field.voteMessageID; 317 | delete field.voteChannelID; 318 | } 319 | } catch (error) { 320 | console.log(`Caught error fetching votes for field ${field.text}:`, error); 321 | // if message or channel can't be found, just remove the active vote 322 | field.voteActive = false; 323 | delete field.voteMessageID; 324 | delete field.voteChannelID; 325 | } 326 | } 327 | } 328 | 329 | if (updatedFields.length > 0) { 330 | console.log(`Vote check finished, newly checked fields:`, updatedFields); 331 | } else { 332 | console.log(`Vote check finished, no new bingo field checked`); 333 | } 334 | 335 | await redisAPI.saveBingoBoard(redisClient, board, serverID); 336 | } 337 | return redisAPI.logout(redisClient); 338 | }; 339 | 340 | const archiveRatings = async (client, oldTOTD) => { 341 | console.log(`Archiving existing ratings and clearing current ones...`); 342 | const redisClient = await redisAPI.login(); 343 | const ratings = await redisAPI.getTOTDRatings(redisClient); 344 | 345 | console.log(`Old ratings:`, ratings); 346 | 347 | if (ratings) { 348 | const totds = await redisAPI.getAllStoredTOTDs(redisClient); 349 | totds[oldTOTD.mapUid] = { 350 | name: oldTOTD.name, 351 | authorName: oldTOTD.authorName, 352 | day: oldTOTD.day, 353 | month: oldTOTD.month, 354 | year: oldTOTD.year, 355 | ratings: ratings 356 | }; 357 | await redisAPI.storeTOTDs(redisClient, totds); 358 | 359 | // send ratings to admin server 360 | console.log(`Sending verdict to admin server...`); 361 | const adminChannel = await client.channels.fetch(adminChannelID); 362 | utils.sendMessage(adminChannel, await getRatingMessage(totds[oldTOTD.mapUid])); 363 | } 364 | await redisAPI.clearTOTDRatings(redisClient); 365 | 366 | return await redisAPI.logout(redisClient); 367 | }; 368 | 369 | const calculateRankings = async (timeframe) => { 370 | // timeframe has to use format "Month YYYY", "complete YYYY" or "all-time" 371 | const allTimeMode = timeframe === constants.specialRankings.allTime; 372 | const timeframeRegex = new RegExp(`(${luxon.Info.months(`long`).join(`|`)}|${constants.specialRankings.completeYear}) ([0-9]{4})`); 373 | const regexResult = timeframe.match(timeframeRegex); 374 | if (!regexResult && !allTimeMode) { 375 | throw new Error(`Can't find that time frame, make sure you've picked one of the suggested options.`); 376 | } 377 | 378 | const month = regexResult?.[1]; 379 | const year = regexResult?.[2]; 380 | const yearMode = month === `complete`; 381 | 382 | // get all totds stats 383 | const redisClient = await redisAPI.login(); 384 | const ratings = await redisAPI.getAllStoredTOTDs(redisClient); 385 | redisAPI.logout(redisClient); 386 | 387 | if (!ratings) { 388 | return { 389 | top: [], 390 | bottom: [] 391 | }; 392 | } 393 | 394 | let topMax = 10; 395 | let bottomMax = 5; 396 | 397 | // go through them from top to bottom and collect the top 10 and bottom 5 398 | let topRanking = []; 399 | let bottomRanking = []; 400 | for (const [mapUid, ratingData] of Object.entries(ratings)) { 401 | if (!allTimeMode) { 402 | if (ratingData.year != year) { 403 | if (topRanking.length > 0) { 404 | // if data has been collected and we run into this case, we've gone through all relevant data 405 | break; 406 | } else { 407 | continue; 408 | } 409 | } else { 410 | if (!yearMode && ratingData.month != month) { 411 | if (topRanking.length > 0) { 412 | // if data has been collected and we run into this case, we've gone through all relevant data 413 | break; 414 | } else { 415 | continue; 416 | } 417 | } 418 | } 419 | } 420 | 421 | ratingData.mapUid = mapUid; 422 | const {averageRating} = rating.calculateRatingStats(ratingData.ratings); 423 | ratingData.averageRating = averageRating; 424 | 425 | // go through top array - insert map if rating is higher than existing one 426 | let topInserted = false; 427 | for (let i = 0; i < topRanking.length; i++) { 428 | const topItem = topRanking[i]; 429 | if (ratingData.averageRating > topItem.averageRating) { 430 | topRanking.splice(i, 0, ratingData); 431 | topInserted = true; 432 | break; 433 | } 434 | } 435 | // add to the back of the list if it hasn't been inserted yet 436 | if (!topInserted) { 437 | topRanking.push(ratingData); 438 | } 439 | // cut off excess rankings beyond the max 440 | if (topRanking.length > topMax) { 441 | topRanking = topRanking.slice(0, topMax); 442 | } 443 | 444 | // go through bottom array - insert map if rating is higher than existing one 445 | let bottomInserted = false; 446 | for (let i = 0; i < bottomRanking.length; i++) { 447 | const bottomItem = bottomRanking[i]; 448 | if (ratingData.averageRating > bottomItem.averageRating) { 449 | bottomRanking.splice(i, 0, ratingData); 450 | bottomInserted = true; 451 | break; 452 | } 453 | } 454 | // add to the back of the list if it hasn't been inserted yet 455 | if (!bottomInserted) { 456 | bottomRanking.push(ratingData); 457 | } 458 | // cut off excess rankings beyond the max 459 | if (bottomRanking.length > bottomMax) { 460 | bottomRanking = bottomRanking.slice(-bottomMax); 461 | } 462 | } 463 | 464 | return { 465 | top: topRanking, 466 | bottom: bottomRanking 467 | }; 468 | }; 469 | 470 | const distributeTOTDMessages = async (client, oldTOTDOverride) => { 471 | // get cached TOTD for ratings 472 | const redisClient = await redisAPI.login(); 473 | const oldTOTD = await redisAPI.getCurrentTOTD(redisClient); 474 | 475 | console.log(`Broadcasting TOTD message to subscribed channels`); 476 | let message; 477 | try { 478 | message = await getTOTDMessage(true); 479 | } catch (error) { 480 | console.error(`Failed to get TOTD message during distribution`, error); 481 | } 482 | 483 | let retryCount = 0; 484 | while ( 485 | retryCount < 3 486 | && (!message || (oldTOTD.mapUid === message.embeds[0].footer.text && !oldTOTDOverride)) 487 | ) { 488 | // retries left + either no message or the same UID as the existing one (manual override disabled) 489 | console.warn(`No new TOTD message available, refetching in a few seconds`); 490 | retryCount++; 491 | try { 492 | await new Promise((resolve) => setTimeout(resolve, 5000)); 493 | message = await getTOTDMessage(true); 494 | } catch (error) { 495 | console.error(`Failed refetch`, error); 496 | } 497 | } 498 | 499 | if (!oldTOTDOverride || !message) { 500 | // no override or no message at all, so check whether we need to fail distribution 501 | if (!message || oldTOTD.mapUid === message.embeds[0].footer.text) { 502 | // still the old map, fail the TOTD distribution 503 | console.error(`Retries failed, aborting TOTD distribution`); 504 | return; 505 | } 506 | } 507 | 508 | await archiveRatings(client, oldTOTD); 509 | await redisAPI.clearIndividualRatings(redisClient); 510 | countBingoVotes(client); 511 | 512 | const configs = await redisAPI.getAllConfigs(redisClient); 513 | 514 | const distributeTOTDMessage = async (config, initialMessage) => { 515 | try { 516 | const channel = await client.channels.fetch(config.channelID); 517 | const totdMessage = await sendTOTDMessage(client, channel, message); 518 | 519 | if (initialMessage && totdMessage.embeds[0]?.image?.url) { 520 | console.log(`Writing back initial message's thumbnail URL to Redis and future posts`); 521 | message.embeds[0].image.url = totdMessage.embeds[0]?.image?.url; 522 | delete message.files; 523 | 524 | const updatedTOTD = await redisAPI.getCurrentTOTD(redisClient); 525 | await redisAPI.saveCurrentTOTD(redisClient, {...updatedTOTD, thumbnailUrl: totdMessage.embeds[0]?.image?.url}); 526 | redisAPI.logout(redisClient); 527 | } 528 | } catch (error) { 529 | //let retryCount = 0; 530 | if (error.message === `Missing Access` || error.message === `Missing Permissions` || error.message === `Unknown Channel`) { 531 | console.log(`Missing access or permissions, bot was probably kicked from server ${config.serverID} - removing config`); 532 | const redisClientForRemoval = await redisAPI.login(); 533 | await redisAPI.removeConfig(redisClientForRemoval, config.serverID); 534 | redisAPI.logout(redisClientForRemoval); 535 | } else { 536 | // no need to log user abort errors, those are just Discord API problems 537 | if (error.message !== `The user aborted a request.`) { 538 | console.error(`Unexpected error during TOTD distribution for ${config.serverID}: ${error.message}`); 539 | } 540 | 541 | // always log the error and notify the admin 542 | console.error(error); 543 | const adminChannel = await client.channels.fetch(adminChannelID); 544 | utils.sendMessage(adminChannel, `Unexpected error during TOTD distribution, check logs.`); 545 | 546 | // disable retry logic in here for now 547 | /* while (retryCount < 3) { 548 | retryCount++; 549 | // Discord API error, retry sending the message 550 | console.warn(`Discord API error during TOTD distribution for ${config.serverID}, retrying... (${retryCount})`); 551 | 552 | try { 553 | const channel = await client.channels.fetch(config.channelID); 554 | await sendTOTDMessage(client, channel, message); 555 | return; 556 | } catch (retryError) { 557 | if (retryError.message !== `The user aborted a request.`) { 558 | console.error(`Unexpected error during TOTD distribution for ${config.serverID} (${retryCount}): ${retryError.message}`); 559 | console.error(retryError); 560 | } 561 | } 562 | } 563 | 564 | console.error(`Failed to send TOTD message after 3 retries, giving up`); */ 565 | return; 566 | } 567 | } 568 | }; 569 | 570 | // send the first message in a blocking way to store the image URL 571 | console.log(`Sending out the first TOTD message before posting the rest`); 572 | await distributeTOTDMessage(configs.shift(), true); 573 | console.log(`Initial TOTD message processed, continuing with the rest`); 574 | for (const [configIndex, config] of configs.entries()) { 575 | if (configIndex % 25 === 0 && configIndex !== 0) { 576 | // wait a few seconds before continuing to definitely avoid hitting Discord API rate limit 577 | await new Promise(resolve => setTimeout(resolve, 15000)); 578 | console.log(`Waiting 15 seconds before sending TOTD message to next batch of servers...`); 579 | } 580 | distributeTOTDMessage(config, false); 581 | } 582 | }; 583 | 584 | const sendCOTDPings = async (client, region) => { 585 | console.log(`Broadcasting COTD ping to subscribed servers with role config for ${region || `default`} region`); 586 | 587 | const redisClient = await redisAPI.login(); 588 | const configs = await redisAPI.getAllConfigs(redisClient); 589 | redisAPI.logout(redisClient); 590 | 591 | const roleProp = region && region !== constants.cupRegions.europe ? `roleName${region}` : `roleName`; 592 | 593 | configs.forEach(async (config) => { 594 | if (config[roleProp]) { 595 | try { 596 | const channel = await client.channels.fetch(config.channelID); 597 | console.log(`Pinging ${config[roleProp]} in #${channel.name} (${channel.guild.name})`); 598 | utils.sendMessage(channel, `${config[roleProp]} The Cup of the Day is about to begin! ${utils.getEmojiMapping(`COTDPing`)} Ten minutes to go!`); 599 | } catch (error) { 600 | if (error.message === `Missing Access`) { 601 | console.log(`Can't access server, bot was probably kicked.`); 602 | } else { 603 | console.error(error); 604 | } 605 | } 606 | } 607 | }); 608 | }; 609 | 610 | const updateTOTDReactionCount = async (reaction, add, user) => { 611 | // check that the message really is the current TOTD 612 | const redisClient = await redisAPI.login(); 613 | const totd = await redisAPI.getCurrentTOTD(redisClient); 614 | 615 | if (reaction.message.partial || reaction.message.embeds.length === 0) { 616 | console.log(`Reaction message is partial, fetching...`); 617 | try { 618 | await reaction.message.fetch(); 619 | } catch (error) { 620 | console.error(`Something went wrong when fetching the full reaction message: `, error); 621 | return; 622 | } 623 | } 624 | 625 | // use the mapUid to check if this is the current TOTD 626 | const currentMapUid = totd.mapUid; 627 | const reactionMapUid = utils.removeNameFormatting( 628 | reaction.message?.embeds[0]?.footer?.text?.trim() 629 | ); 630 | if (currentMapUid === reactionMapUid || reactionMapUid === undefined) { // weird edge case: embed might be missing, but it's still a valid reaction 631 | const ratingEmojis = constants.ratingEmojis; 632 | let ratingEmojiFound = false; 633 | for (let i = 0; i < ratingEmojis.length; i++) { 634 | const ratingIdentifier = utils.getEmojiMapping(ratingEmojis[i]); 635 | 636 | if (ratingIdentifier.includes(reaction.emoji.identifier)) { 637 | ratingEmojiFound = true; 638 | // check if this is a valid rating (i.e. not a duplicate from this user) 639 | const valid = await redisAPI.updateIndividualRatings(redisClient, reaction.emoji.name, add, user.id); 640 | const emojiInfo = `[${user.tag} ${add ? `added` : `removed`} ${reaction.emoji.name}]`; 641 | if (valid) { 642 | // update the reaction count 643 | console.log(`Rating reaction in #${reaction.message.channel.name} (${reaction.message.channel.guild.name}) ${emojiInfo}`); 644 | await redisAPI.updateTOTDRatings(redisClient, ratingEmojis[i], add); 645 | } else { 646 | console.log(`Rating reaction in #${reaction.message.channel.name} (${reaction.message.channel.guild.name}) ${emojiInfo} [duplicate]`); 647 | } 648 | break; 649 | } 650 | } 651 | if (!ratingEmojiFound) { 652 | await reaction.remove(); 653 | } 654 | redisAPI.logout(redisClient); 655 | } else { 656 | console.log(`Ignored reaction: (${reaction.emoji.identifier} ${add ? `added` : `removed`} for mapUid "${reactionMapUid}")`); 657 | // ignored, close the redis connection 658 | redisAPI.logout(redisClient); 659 | } 660 | }; 661 | 662 | const sendErrorMessage = (channel) => { 663 | utils.sendMessage(channel, errorMessage); 664 | }; 665 | 666 | module.exports = { 667 | sendTOTDMessage, 668 | getTOTDMessage, 669 | getTOTDLeaderboardMessage, 670 | getBingoMessage, 671 | archiveBingoBoards, 672 | sendErrorMessage, 673 | sendTOTDLeaderboard, 674 | sendTOTDRatings, 675 | sendBingoBoard, 676 | sendBingoVote, 677 | countBingoVotes, 678 | distributeTOTDMessages, 679 | sendCOTDPings, 680 | updateTOTDReactionCount, 681 | archiveRatings, 682 | calculateRankings 683 | }; 684 | -------------------------------------------------------------------------------- /src/fonts/Quicksand.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidbmaier/totd-bot/cd295519e625c34ba560d65d2f8d69b00a1cbabe/src/fonts/Quicksand.ttf -------------------------------------------------------------------------------- /src/format.js: -------------------------------------------------------------------------------- 1 | const Discord = require(`discord.js`); 2 | const Canvas = require(`canvas`); 3 | const luxon = require(`luxon`); 4 | const path = require(`path`); 5 | 6 | const utils = require(`./utils`); 7 | const rating = require(`./rating`); 8 | const constants = require(`./constants`); 9 | 10 | const formatTime = (time) => { 11 | const millisecs = time.substr(-3); 12 | let secs = time.substr(0, time.length - 3); 13 | // minutes/seconds need to be calculated 14 | const mins = Math.floor(secs / 60); 15 | secs = secs % 60; 16 | // pad the seconds if they're only a single digit 17 | if (secs < 10) { 18 | secs = secs.toString().padStart(2, `0`); 19 | } 20 | 21 | return `${mins}:${secs}.${millisecs}`; 22 | }; 23 | 24 | const formatTOTDMessage = (totd) => { 25 | // assemble title 26 | let trackLabel = `Track`; 27 | if (totd.tmxTags && totd.tmxTags.includes(`Scenery`)) { 28 | trackLabel = `Scenery`; 29 | } 30 | if (totd.tmxTags && totd.tmxTags.includes(`Nascar`)) { 31 | trackLabel = `Nascar`; 32 | } 33 | if (totd.tmxTags && totd.tmxTags.includes(`LOL`)) { 34 | trackLabel = `LOL`; 35 | } 36 | 37 | const title = `**Here's the ${totd.month} ${utils.formatDay(totd.day)} ${trackLabel} of the Day!**`; 38 | 39 | // assemble track info 40 | let trackName = totd.name; 41 | if (totd.tmxName) { 42 | trackName = totd.tmxName; 43 | } else { 44 | trackName = utils.removeNameFormatting(totd.name); 45 | } 46 | let trackAuthor = totd.author; 47 | if (totd.authorName) { 48 | trackAuthor = totd.authorName; 49 | } 50 | 51 | // assemble style info 52 | let styles; 53 | if (totd.tmxTags) { 54 | styles = `${totd.tmxTags.join(`, `)}`; 55 | } 56 | 57 | // assemble medal info 58 | const bronze = `${utils.getEmojiMapping(`Bronze`)} ${formatTime(totd.bronzeScore.toString())}`; 59 | const silver = `${utils.getEmojiMapping(`Silver`)} ${formatTime(totd.silverScore.toString())}`; 60 | const gold = `${utils.getEmojiMapping(`Gold`)} ${formatTime(totd.goldScore.toString())}`; 61 | const author = `${utils.getEmojiMapping(`Author`)} ${formatTime(totd.authorScore.toString())}`; 62 | 63 | // assemble links 64 | let links = `[TM.io](https://trackmania.io/#/totd/leaderboard/${totd.seasonUid}/${totd.mapUid}) `; 65 | 66 | if (totd.tmxTrackId) { 67 | links += `| [TMX](https://trackmania.exchange/mapshow/${totd.tmxTrackId})`; 68 | } 69 | 70 | const scoreNote = `React to this message to rate the TOTD!`; 71 | 72 | const embed = { 73 | title: title, 74 | type: `rich`, 75 | description: scoreNote, 76 | fields: [ 77 | { 78 | name: `Name`, 79 | value: trackName, 80 | inline: true 81 | }, 82 | { 83 | name: `Author`, 84 | value: trackAuthor, 85 | inline: true 86 | }, 87 | { 88 | name: `Medal Times`, 89 | value: `${author}\n${gold}\n${silver}\n${bronze}`, 90 | inline: true 91 | }, 92 | { 93 | name: `Links`, 94 | value: links 95 | } 96 | ], 97 | footer: { 98 | text: totd.mapUid 99 | } 100 | }; 101 | 102 | // always add the Nadeo timestamp 103 | embed.fields.splice(2, 0, { 104 | name: `Last uploaded to Nadeo servers`, 105 | // parse ISO 8601 to UNIX timestamp (since that's what Discord's formatting requires) 106 | value: ``, 107 | }); 108 | 109 | if (totd.tmxTimestamp) { 110 | // if TMX timestamp exists, add that as well (along with styles if they're available) 111 | embed.fields.splice(3, 0, { 112 | name: `Uploaded to TMX`, 113 | // parse ISO 8601 to UNIX timestamp (since that's what Discord's formatting requires) 114 | value: ``, 115 | }); 116 | 117 | if (styles) { 118 | embed.fields.splice(5, 0, { 119 | name: `Styles (on TMX)`, 120 | value: styles, 121 | inline: true 122 | }); 123 | } 124 | } 125 | 126 | const messageObject = { 127 | embeds: [embed] 128 | }; 129 | 130 | if (totd.thumbnailUrl?.includes(`discordapp.com`)) { 131 | // if the image is hosted on Discord, just use the link 132 | embed.image = { 133 | url: totd.thumbnailUrl 134 | }; 135 | } else { 136 | // if the image is external, upload the file itself 137 | const thumbnailAttachment = new Discord.MessageAttachment(totd.thumbnailUrl, `totd.png`); 138 | // to attach the image, it needs to be sent along as a file 139 | messageObject.files = [thumbnailAttachment]; 140 | embed.image = { 141 | url: `attachment://totd.png` 142 | }; 143 | } 144 | 145 | return messageObject; 146 | }; 147 | 148 | const formatLeaderboardMessage = (totd, records, date) => { 149 | const topTen = records.slice(0, 10); 150 | let top100 = records.find((record) => record.position === 100); 151 | let top1k = records.find((record) => record.position === 1000); 152 | let top10k = records.find((record) => record.position === 10000); 153 | 154 | const times = topTen.map((top) => formatTime(top.score.toString())); 155 | const positions = topTen.map((top) => top.position); 156 | const names = topTen.map((top) => top.playerName); 157 | 158 | // assemble makeshift table using spaces 159 | let topTenField = `\`\`\`\n Time Name\n`; 160 | for (let i = 0; i < topTen.length; i++) { 161 | const positionString = positions[i].toString().length > 1 ? ` ${positions[i]} ` : ` ${positions[i]} `; 162 | const timeString = `${times[i]} `; 163 | const nameString = names[i]; 164 | topTenField += `${positionString}${timeString}${nameString}\n`; 165 | } 166 | 167 | topTenField += `\n\`\`\``; 168 | 169 | const formattedMessage = { 170 | content: null, // remove placeholder content during load 171 | embeds:[{ 172 | title: `Here's today's TOTD leaderboard!`, 173 | description: `Data from `, 174 | type: `rich`, 175 | fields: [ 176 | { 177 | name: `Top 10`, 178 | value: topTenField 179 | }, 180 | { 181 | name: `Links`, 182 | value: `More detailed leaderboards on [TM.io](https://trackmania.io/#/totd/leaderboard/${totd.seasonUid}/${totd.mapUid})` 183 | } 184 | ] 185 | }], 186 | date: date // used for caching 187 | }; 188 | 189 | if (top100 || top1k || top10k) { 190 | let thresholdText = `\`\`\`\n`; 191 | if (top100) { 192 | thresholdText += `Top 100: ${formatTime(top100.score.toString())}\n`; 193 | } 194 | if (top1k) { 195 | thresholdText += `Top 1k: ${formatTime(top1k.score.toString())}\n`; 196 | } 197 | if (top10k) { 198 | thresholdText += `Top 10k: ${formatTime(top10k.score.toString())}\n`; 199 | } 200 | thresholdText += `\`\`\``; 201 | 202 | formattedMessage.embeds[0].fields.splice(1, 0, { 203 | name: `Trophy Thresholds`, 204 | value: thresholdText 205 | }); 206 | } 207 | 208 | return formattedMessage; 209 | }; 210 | 211 | const formatRatingsMessage = (mapInfo) => { 212 | let formattedRatings = ``; 213 | 214 | const orderedRatings = { 215 | MinusMinusMinus: mapInfo.ratings.MinusMinusMinus, 216 | MinusMinus: mapInfo.ratings.MinusMinus, 217 | Minus: mapInfo.ratings.Minus, 218 | Plus: mapInfo.ratings.Plus, 219 | PlusPlus: mapInfo.ratings.PlusPlus, 220 | PlusPlusPlus: mapInfo.ratings.PlusPlusPlus 221 | }; 222 | for (const item in orderedRatings) { 223 | const rating = mapInfo.ratings[item]; 224 | // add it to the front since the ratings go from --- to +++ 225 | formattedRatings = `${utils.getEmojiMapping(item)} - ${rating}\n${formattedRatings}`; 226 | } 227 | 228 | const stats = rating.calculateRatingStats(mapInfo.ratings); 229 | 230 | let verdict = `Total ratings: ${stats.totalVotes}\n`; 231 | if (stats.totalVotes > 0) { 232 | verdict += `Average rating: ${stats.averageRating}\n`; 233 | verdict += `Average karma: ${stats.averageKarma}\n`; 234 | } 235 | 236 | verdict += `\n`; 237 | 238 | // provisional check for how controversial the votes are (may need some adjustment down the road) 239 | const checkForControversialVotes = () => stats.averagePositive > 2 && stats.averageNegative > 2; 240 | 241 | if (stats.totalVotes === 0) { 242 | verdict += `Looks like I didn't get any votes for this map...`; 243 | } else if (checkForControversialVotes() && -0.5 <= stats.averageRating && stats.averageRating < 0.5) { 244 | verdict += `A bit of a controversial one - let's agree on *interesting*.`; 245 | } else if (stats.averageRating < -2) { 246 | verdict += `Best to just forget about this one, huh?`; 247 | } else if (stats.averageRating < -1) { 248 | verdict += `Not really well-liked, but it could still be worse.`; 249 | } else if (stats.averageRating < 0) { 250 | verdict += `Not great, not terrible.`; 251 | } else if (stats.averageRating < 1) { 252 | verdict += `An alright track, but there's some room for improvement.`; 253 | } else if (stats.averageRating < 2) { 254 | verdict += `Pretty good track, definitely worth playing.`; 255 | } else { 256 | verdict += `Absolutely fantastic track, definitely a highlight!`; 257 | } 258 | 259 | let description = ``; 260 | if (mapInfo?.name) { 261 | description = `**${utils.removeNameFormatting(mapInfo.name)}** by **${mapInfo.authorName}** (${mapInfo.month} ${utils.formatDay(mapInfo.day)} ${mapInfo.year})`; 262 | } 263 | 264 | return { 265 | embeds: [{ 266 | title: 267 | mapInfo.formatDay 268 | ? `Here are today's TOTD ratings!` 269 | : `Here are the TOTD ratings!`, 270 | type: `rich`, 271 | description: description, 272 | fields: [ 273 | { 274 | name: `Ratings`, 275 | value: formattedRatings, 276 | inline: true 277 | }, 278 | { 279 | name: `Verdict`, 280 | value: verdict, 281 | inline: true 282 | } 283 | ], 284 | footer: { 285 | text: mapInfo.today 286 | ? `This track is still being voted on, so take these numbers with a grain of salt.` 287 | : `These ratings aren't just from here - I collect feedback from a bunch of other servers as well!` 288 | } 289 | }] 290 | }; 291 | }; 292 | 293 | const resolveRatingToEmoji = (rating) => { 294 | const mappings = [ 295 | { value: 2.5, emoji: utils.getEmojiMapping(`PlusPlusPlus`) }, 296 | { value: 1.5, emoji: utils.getEmojiMapping(`PlusPlus`) }, 297 | { value: 0, emoji: utils.getEmojiMapping(`Plus`) }, 298 | { value: -1.5, emoji: utils.getEmojiMapping(`Minus`) }, 299 | { value: -2.5, emoji: utils.getEmojiMapping(`MinusMinus`) }, 300 | { value: -3, emoji: utils.getEmojiMapping(`MinusMinusMinus`) }, 301 | ]; 302 | 303 | for (let i = 0; i < mappings.length; i++) { 304 | const mapping = mappings[i]; 305 | if (rating >= mapping.value) { 306 | return mapping.emoji; 307 | } 308 | } 309 | }; 310 | 311 | const formatRankingMessage = (rankings, timeframe) => { 312 | // if top and bottom are empty, return a basic placeholder 313 | if (rankings?.top.length === 0 || rankings?.bottom.length === 0) { 314 | return `It seems I don't have any data for that timeframe yet, sorry!`; 315 | } 316 | 317 | const currentYear = luxon.DateTime.now().year; 318 | const currentMonth = luxon.DateTime.now().monthLong; 319 | 320 | const formatRatingNumber = (rating) => { 321 | let ratingString = `${rating}`; 322 | if (!ratingString.includes(`.`)) { 323 | ratingString += `.0`; 324 | } 325 | return ratingString; 326 | }; 327 | 328 | const formatRankingRows = (rankingItems) => { 329 | let section = ``; 330 | rankingItems.forEach((rankingItem) => { 331 | // resolve rating to emoji 332 | const rating = `${resolveRatingToEmoji(rankingItem.averageRating)} \`${formatRatingNumber(rankingItem.averageRating)}\``; 333 | let date = `${rankingItem.month} ${utils.formatDay(rankingItem.day)}`; 334 | if (timeframe === constants.specialRankings.allTime) { 335 | // add the year if the ranking goes across multiple years 336 | date += ` ${rankingItem.year}`; 337 | } 338 | const mapLink = `https://trackmania.io/#/leaderboard/${rankingItem.mapUid}`; 339 | // escape Discord formatting characters 340 | const authorName = rankingItem.authorName.replace(/[_>*~|`]/g, `\\$&`); 341 | const voteCount = Object.values(rankingItem.ratings).reduce((sum, value) => sum + value, 0); 342 | 343 | // ++ (rating) date - mapName (mapAuthor) 344 | const row = `${rating} ${date} - [${utils.removeNameFormatting(rankingItem.name)}](${mapLink}) by **${authorName}** (${voteCount} votes)\n`; 345 | section += row; 346 | }); 347 | return section; 348 | }; 349 | 350 | const embed = { 351 | // set title based on the type 352 | title: `Here are the ${timeframe} TOTD rankings!`, 353 | type: `rich`, 354 | fields: [ 355 | { 356 | name: `Top ${rankings.top.length}`, 357 | value: formatRankingRows(rankings.top.slice(0, 5)), 358 | }, 359 | { 360 | name: `Bottom ${rankings.bottom.length}`, 361 | value: formatRankingRows(rankings.bottom), 362 | } 363 | ] 364 | }; 365 | 366 | if (rankings.top.length > 5) { 367 | embed.fields[0].name = `Top ${rankings.top.length}`; 368 | embed.fields.splice(1, 0, { 369 | name: ` `, 370 | value: formatRankingRows(rankings.top.slice(5, rankings.top.length)), 371 | },); 372 | } 373 | 374 | // add disclaimer to footer that month isn't over yet 375 | if (timeframe.includes(currentYear) && timeframe.includes(currentMonth)) { 376 | const description1 = `The month isn't over yet, so these aren't final -`; 377 | const description2 = `check again when it's over to see the final rankings!`; 378 | embed.footer = { 379 | text: `${description1} ${description2}` 380 | }; 381 | } else if (timeframe === `${constants.specialRankings.completeYear} ${currentYear}`) { 382 | const description1 = `The year isn't over yet, so these aren't final -`; 383 | const description2 = `check again when it's over to see the final rankings!`; 384 | embed.footer = { 385 | text: `${description1} ${description2}` 386 | }; 387 | } 388 | // for allTime/year 2021 add disclaimer to footer 389 | if (timeframe === `all-time` || timeframe === `${constants.specialRankings.completeYear} 2021`) { 390 | const footer = `This ranking only displays data starting from July 2021.`; 391 | embed.footer = { 392 | text: `${footer}` 393 | }; 394 | } 395 | 396 | return {embeds: [embed]}; 397 | }; 398 | 399 | const formatBingoBoard = async (fields, lastWeek, commandIDs) => { 400 | // add free space to the center 401 | fields.splice(12, 0, {text: `Free space`, checked: true}); 402 | 403 | Canvas.registerFont(path.resolve(`./src/fonts/Quicksand.ttf`), {family: `Quicksand`}); 404 | const fontName = `Quicksand`; 405 | 406 | // 5x5 board, each field is 160x90 407 | // borders around the board and each field as 2px wide 408 | // inline padding is 10px on each side -> inside width is 140px 409 | const canvas = Canvas.createCanvas(812, 462); 410 | const ctx = canvas.getContext(`2d`); 411 | 412 | const board = await Canvas.loadImage(`./src/backgrounds/bingoTemplate.png`); 413 | ctx.drawImage(board, 0, 0, canvas.width, canvas.height); 414 | 415 | // set alpha to .5 only for the background image 416 | ctx.globalAlpha = 0.5; 417 | 418 | // get the current week number (current time in Europe/Paris) 419 | let date = luxon.DateTime.fromMillis(new Date().getTime(), { zone: `Europe/Paris` }); 420 | if (lastWeek) { 421 | // subtract 7 days for last week 422 | date = date.minus({ days: 7 }); 423 | } 424 | // subtract 19hrs for the correct week (offset by the Monday TOTD) 425 | date = date.minus({ hours: 19 }); 426 | const weekNumber = date.weekNumber; 427 | 428 | // translate the weekNumber into one of the 23 background images 429 | const backgroundNo = weekNumber % 23; 430 | const background = await Canvas.loadImage(`./src/backgrounds/${backgroundNo}.jpg`); 431 | ctx.drawImage(background, 2, 2, canvas.width - 4, canvas.height - 4); 432 | 433 | ctx.globalAlpha = 1; 434 | 435 | ctx.font = `18px ${fontName} medium`; 436 | ctx.textAlign = `center`; 437 | ctx.textBaseline = `middle`; 438 | ctx.fillStyle = `#FFFFFF`; 439 | 440 | let fieldCount = 0; 441 | for (let y = 0; y < 5; y++) { 442 | for (let x = 0; x < 5; x++) { 443 | if (ctx.measureText(fields[fieldCount].text || fields[fieldCount]).width > 140) { 444 | console.log(`Bingo warning: Field is too long for one line:`, fields[fieldCount].text || fields[fieldCount]); 445 | } 446 | 447 | const textPieces = (fields[fieldCount].text || fields[fieldCount]).split(`\n`); 448 | 449 | // skip x cells incl their left border, then move to the cell center (incl the border) 450 | const horizontalCenter = (x * 162) + 82; 451 | // skip y cells incl their upper border, then move to the cell center (incl the border) 452 | const verticalCenter = (y * 92) + 47; 453 | const cellRight = horizontalCenter + 80; 454 | const cellLeft = horizontalCenter - 80; 455 | const cellTop = verticalCenter - 45; 456 | const cellBottom = verticalCenter + 45; 457 | 458 | // add dark backgrounds and highlighted edges to checked fields 459 | if (fields[fieldCount].checked) { 460 | ctx.globalAlpha = 0.65; 461 | ctx.fillStyle = `#000000`; 462 | ctx.fillRect(cellLeft, cellTop, 160, 90); 463 | 464 | ctx.setLineDash([]); 465 | ctx.strokeStyle = `#a4eb34`; 466 | ctx.lineWidth = 3; 467 | ctx.beginPath(); 468 | ctx.moveTo(cellLeft, cellTop); 469 | ctx.lineTo(cellRight, cellTop); 470 | ctx.lineTo(cellRight, cellBottom); 471 | ctx.lineTo(cellLeft, cellBottom); 472 | ctx.lineTo(cellLeft, cellTop); 473 | ctx.stroke(); 474 | // add dashed edges to fields that are being voted on 475 | } else if (fields[fieldCount].voteActive) { 476 | ctx.globalAlpha = 0.65; 477 | ctx.fillStyle = `#000000`; 478 | ctx.fillRect(cellLeft, cellTop, 160, 90); 479 | 480 | ctx.strokeStyle = `#ebe834`; 481 | ctx.setLineDash([5, 5]); 482 | ctx.lineWidth = 3; 483 | ctx.beginPath(); 484 | ctx.moveTo(cellLeft, cellTop); 485 | ctx.lineTo(cellRight, cellTop); 486 | ctx.lineTo(cellRight, cellBottom); 487 | ctx.lineTo(cellLeft, cellBottom); 488 | ctx.lineTo(cellLeft, cellTop); 489 | ctx.stroke(); 490 | } 491 | 492 | ctx.globalAlpha = 1; 493 | ctx.lineWidth = 1; 494 | 495 | // write field text 496 | for (let i = 0; i < textPieces.length; i++) { 497 | // (full height / spaces between text pieces) * piece index = offset for this specific piece 498 | const pieceOffset = (90 / (textPieces.length + 1)) * (i + 1); 499 | 500 | const currentHeight = cellTop + pieceOffset - 2; // move height up to correct for the font's line-height 501 | ctx.font = `18px ${fontName} medium`; 502 | ctx.textAlign = `center`; 503 | ctx.textBaseline = `middle`; 504 | ctx.fillStyle = `#FFFFFF`; 505 | ctx.fillText(textPieces[i], horizontalCenter, currentHeight); 506 | } 507 | 508 | // write field numbers 509 | ctx.font = `14px ${fontName} medium`; 510 | ctx.textAlign = `end`; 511 | ctx.textBaseline = `top`; 512 | // color the field numbers if the field has been checked 513 | if (fields[fieldCount].checked) { 514 | ctx.fillStyle = `#a4eb34`; 515 | } else { 516 | ctx.fillStyle = `#FFFFFF`; 517 | } 518 | 519 | ctx.fillText(fieldCount + 1, cellRight - 3, cellTop); // move font right into the corner 520 | 521 | fieldCount++; 522 | } 523 | } 524 | 525 | const attachment = new Discord.MessageAttachment(canvas.toBuffer(), `bingo.png`); 526 | 527 | const embedDescription = 528 | lastWeek 529 | ? `This board is closed - use ${utils.formatCommand(`bingo`, commandIDs)} to see the current one.` 530 | : `If you think we should cross one of these off, you can start a vote using ${utils.formatCommand(`votebingo`, commandIDs)}.`; 531 | 532 | const embed = { 533 | title: `Here's your server's TOTD bingo board for week ${weekNumber}!`, 534 | description: embedDescription, 535 | type: `rich`, 536 | image: { 537 | url: `attachment://bingo.png` 538 | } 539 | }; 540 | 541 | const checkBingoWin = () => { 542 | for (let i = 0; i < 5; i++) { 543 | // column checks 544 | if ( 545 | // column checks (0+5, 1+5, 2+5, 3+5, 3+5) 546 | (fields[i].checked && fields[i + 5].checked && fields[i + 10].checked && fields[i + 15].checked && fields[i + 20].checked) 547 | // row checks (0-4, 5-9, 10-14, 15-19, 20-24) 548 | || (fields[i * 5].checked && fields[i * 5 + 1].checked && fields[i * 5 + 2].checked && fields[i * 5 + 3].checked && fields[i * 5 + 4].checked) 549 | // diagonal checks (0+6, 4+4) 550 | || (fields[0].checked && fields[6].checked && fields[12].checked && fields[18].checked && fields[24].checked) 551 | || (fields[4].checked && fields[8].checked && fields[12].checked && fields[16].checked && fields[20].checked) 552 | ) { 553 | return true; 554 | } 555 | } 556 | return false; 557 | }; 558 | 559 | if (checkBingoWin()) { 560 | embed.fields = [ 561 | { 562 | name: `:tada: Bingo! :tada:`, 563 | value: `You've done it! Congrats! ${utils.getEmojiMapping(`Bingo`)}` 564 | } 565 | ]; 566 | } 567 | 568 | return {embeds: [embed], files: [attachment]}; 569 | }; 570 | 571 | const formatHelpMessage = (commands, adminCommands) => { 572 | const embed = { 573 | title: `Hey, I'm the Track of the Day Bot!`, 574 | type: `rich`, 575 | description: `Here's what you can tell me to do:`, 576 | fields: [ 577 | { 578 | name: `Commands`, 579 | value: commands 580 | }, 581 | { 582 | name: `More Info`, 583 | value: 584 | `I've been developed by tooInfinite (<@141627532335251456>) - feel free to talk to him if you've got any feedback or ran into any issues with me. \ 585 | My code can be found [here](https://github.com/davidbmaier/totd-bot). \n\ 586 | If you want to, you can [help pay for my hosting](https://github.com/sponsors/davidbmaier) or [give a tip](https://ko-fi.com/tooinfinite). Never required, always appreciated! \n\ 587 | To invite me to your own server, click [here](https://discord.com/api/oauth2/authorize?client_id=807920588738920468&permissions=388160&scope=applications.commands%20bot).` 588 | } 589 | ] 590 | }; 591 | 592 | if (adminCommands) { 593 | embed.fields.splice(1, 0, { 594 | name: `Admin commands`, 595 | value: adminCommands 596 | }); 597 | } 598 | 599 | return {embeds: [embed]}; 600 | }; 601 | 602 | const formatInviteMessage = (title, message) => { 603 | const inviteLink = `For the invite link, click [here](https://discord.com/api/oauth2/authorize?client_id=807920588738920468&permissions=388160&scope=applications.commands%20bot)!`; 604 | return { 605 | embeds: [{ 606 | type: `rich`, 607 | title: title || `Want to invite me to your own server?`, 608 | description: `${message || ``}\n${inviteLink}`, 609 | }] 610 | }; 611 | }; 612 | 613 | const formatProxyMessage = (message) => { 614 | return { 615 | embeds: [{ 616 | title: `I just got mentioned!`, 617 | type: `rich`, 618 | fields: [ 619 | { 620 | name: `Author`, 621 | value: message.author.tag, 622 | inline: true 623 | }, 624 | { 625 | name: `Server`, 626 | value: message.guild.name, 627 | inline: true 628 | }, 629 | { 630 | name: `Content`, 631 | value: message.content 632 | }, 633 | { 634 | name: `Link`, 635 | value: `[Message](https://discord.com/channels/${message.guild.id}/${message.channel.id}/${message.id})` 636 | } 637 | ] 638 | }] 639 | }; 640 | }; 641 | 642 | module.exports = { 643 | formatTOTDMessage, 644 | formatLeaderboardMessage, 645 | formatRankingMessage, 646 | formatRatingsMessage, 647 | formatHelpMessage, 648 | formatBingoBoard, 649 | formatInviteMessage, 650 | formatProxyMessage 651 | }; 652 | -------------------------------------------------------------------------------- /src/rating.js: -------------------------------------------------------------------------------- 1 | const calculateRatingStats = (ratings) => { 2 | let totalPositive = 0; 3 | let totalNegative = 0; 4 | let weightedPositive = 0; 5 | let weightedNegative = 0; 6 | let karmaPositive = 0; 7 | let karmaNegative = 0; 8 | let averageRating; 9 | let averagePositive; 10 | let averageNegative; 11 | let averageKarma; 12 | 13 | for (const item in ratings) { 14 | const rating = ratings[item]; 15 | 16 | if (item.includes(`Plus`)) { 17 | const weight = (item.match(/Plus/g) || []).length; 18 | weightedPositive += weight * rating; 19 | // 60 / 80 / 100 20 | karmaPositive += (60 + (weight - 1) * 20) * rating; 21 | totalPositive += rating; 22 | } else { 23 | const weight = (item.match(/Minus/g) || []).length; 24 | weightedNegative += weight * rating; 25 | // 0 / 20 / 40 26 | karmaNegative += (60 - weight * 20) * rating; 27 | totalNegative += rating; 28 | } 29 | } 30 | 31 | const weightedVotes = weightedPositive - weightedNegative; 32 | const totalVotes = totalPositive + totalNegative; 33 | const totalKarma = karmaPositive + karmaNegative; 34 | 35 | if (totalVotes > 0) { 36 | averageRating = Math.round(weightedVotes / totalVotes * 10) / 10; 37 | averagePositive = Math.round(weightedPositive / totalPositive * 10) / 10; 38 | averageNegative = Math.round(weightedNegative / totalNegative * 10) / 10; 39 | averageKarma = Math.round(totalKarma / totalVotes * 10) / 10; 40 | } 41 | 42 | return { 43 | totalVotes, weightedVotes, totalKarma, averageRating, averageKarma, averagePositive, averageNegative 44 | }; 45 | }; 46 | 47 | module.exports = { 48 | calculateRatingStats, 49 | }; 50 | -------------------------------------------------------------------------------- /src/redisApi.js: -------------------------------------------------------------------------------- 1 | const url = require(`url`); 2 | const redis = require(`redis`); 3 | require(`dotenv`).config(); 4 | 5 | const constants = require(`./constants`); 6 | 7 | const redisURL = process.env.REDIS_URL; 8 | 9 | const login = () => { 10 | return new Promise((resolve) => { 11 | const parsedRedisURL = new url.URL(redisURL); 12 | const redisConn = redis.createClient(parsedRedisURL.port, parsedRedisURL.hostname); 13 | if (parsedRedisURL.password) { 14 | redisConn.auth(parsedRedisURL.password); 15 | } 16 | redisConn.on(`ready`, () => { 17 | resolve(redisConn); 18 | }); 19 | }); 20 | }; 21 | 22 | const logout = (redisClient) => { 23 | return new Promise((resolve) => { 24 | redisClient.quit(); 25 | resolve(); 26 | }); 27 | }; 28 | 29 | const getConfigs = (redisClient) => { 30 | return new Promise((resolve, reject) => { 31 | redisClient.get(`serverConfigs`, (err, configs) => { 32 | if (err) { 33 | reject(err); 34 | } else { 35 | resolve(JSON.parse(configs) || []); 36 | } 37 | }); 38 | }); 39 | }; 40 | 41 | const addConfig = async (redisClient, serverID, channelID) => { 42 | let configs = await getConfigs(redisClient); 43 | return new Promise((resolve, reject) => { 44 | // remove duplicate 45 | for (let i = 0; i < configs.length; i++) { 46 | if (configs[i].serverID === serverID){ 47 | configs.splice(i, 1); 48 | break; 49 | } 50 | } 51 | // add new config 52 | configs.push({serverID: serverID, channelID: channelID}); 53 | // save to redis 54 | redisClient.set(`serverConfigs`, JSON.stringify(configs), (err) => { 55 | if (err) { 56 | reject(err); 57 | } else { 58 | resolve(); 59 | } 60 | }); 61 | }); 62 | }; 63 | 64 | const removeConfig = async (redisClient, serverID) => { 65 | let configs = await getConfigs(redisClient); 66 | return new Promise((resolve, reject) => { 67 | // remove config with serverID 68 | for (let i = 0; i < configs.length; i++) { 69 | if (configs[i].serverID === serverID){ 70 | configs.splice(i, 1); 71 | break; 72 | } 73 | } 74 | // save to redis 75 | redisClient.set(`serverConfigs`, JSON.stringify(configs), (err) => { 76 | if (err) { 77 | reject(err); 78 | } else { 79 | resolve(); 80 | } 81 | }); 82 | }); 83 | }; 84 | 85 | const addRole = async (redisClient, serverID, role, region) => { 86 | let configs = await getConfigs(redisClient); 87 | return new Promise((resolve, reject) => { 88 | // set the config's roleName 89 | for (let i = 0; i < configs.length; i++) { 90 | if (configs[i].serverID === serverID) { 91 | // main/Europe region is stored in roleName, others in roleNameAmerica and roleNameAsia 92 | if (region && region !== constants.cupRegions.europe) { 93 | configs[i][`roleName${region}`] = role; 94 | } else { 95 | configs[i].roleName = role; 96 | } 97 | break; 98 | } 99 | } 100 | // save to redis 101 | redisClient.set(`serverConfigs`, JSON.stringify(configs), (err) => { 102 | if (err) { 103 | reject(err); 104 | } else { 105 | resolve(); 106 | } 107 | }); 108 | }); 109 | }; 110 | 111 | const removeRole = async (redisClient, serverID, region) => { 112 | let configs = await getConfigs(redisClient); 113 | return new Promise((resolve, reject) => { 114 | // remove the config's roleName 115 | for (let i = 0; i < configs.length; i++) { 116 | if (configs[i].serverID === serverID){ 117 | // main/Europe region is stored in roleName, others in roleNameAmerica and roleNameAsia 118 | if (region && region !== constants.cupRegions.europe) { 119 | delete configs[i][`roleName${region}`]; 120 | } else { 121 | delete configs[i].roleName; 122 | } 123 | 124 | break; 125 | } 126 | } 127 | // save to redis 128 | redisClient.set(`serverConfigs`, JSON.stringify(configs), (err) => { 129 | if (err) { 130 | reject(err); 131 | } else { 132 | resolve(); 133 | } 134 | }); 135 | }); 136 | }; 137 | 138 | const getAllConfigs = async (redisClient) => { 139 | const configs = await getConfigs(redisClient); 140 | return Promise.resolve(configs); 141 | }; 142 | 143 | const saveCurrentTOTD = async (redisClient, totd) => { 144 | return new Promise((resolve, reject) => { 145 | // save to redis 146 | redisClient.set(`totd`, JSON.stringify(totd), (err) => { 147 | if (err) { 148 | reject(err); 149 | } else { 150 | resolve(); 151 | } 152 | }); 153 | }); 154 | }; 155 | 156 | const getCurrentTOTD = async (redisClient) => { 157 | return new Promise((resolve, reject) => { 158 | redisClient.get(`totd`, (err, totd) => { 159 | if (err) { 160 | reject(err); 161 | } else { 162 | resolve(JSON.parse(totd) || undefined); 163 | } 164 | }); 165 | }); 166 | }; 167 | 168 | const savePreviousTOTD = async (redisClient, totd) => { 169 | return new Promise((resolve, reject) => { 170 | // save to redis 171 | redisClient.set(`totdYesterday`, JSON.stringify(totd), (err) => { 172 | if (err) { 173 | reject(err); 174 | } else { 175 | resolve(); 176 | } 177 | }); 178 | }); 179 | }; 180 | 181 | const getPreviousTOTD = async (redisClient) => { 182 | return new Promise((resolve, reject) => { 183 | redisClient.get(`totdYesterday`, (err, totd) => { 184 | if (err) { 185 | reject(err); 186 | } else { 187 | resolve(JSON.parse(totd) || undefined); 188 | } 189 | }); 190 | }); 191 | }; 192 | 193 | const saveCurrentLeaderboard = async (redisClient, leaderboard) => { 194 | return new Promise((resolve, reject) => { 195 | // save to redis 196 | redisClient.set(`leaderboard`, JSON.stringify(leaderboard), (err) => { 197 | if (err) { 198 | reject(err); 199 | } else { 200 | resolve(); 201 | } 202 | }); 203 | }); 204 | }; 205 | 206 | const getCurrentLeaderboard = async (redisClient) => { 207 | return new Promise((resolve, reject) => { 208 | // save to redis 209 | redisClient.get(`leaderboard`, (err, leaderboard) => { 210 | if (err) { 211 | reject(err); 212 | } else { 213 | resolve(JSON.parse(leaderboard) || undefined); 214 | } 215 | }); 216 | }); 217 | }; 218 | 219 | const clearCurrentLeaderboard = async (redisClient) => { 220 | return new Promise((resolve, reject) => { 221 | // clear in redis 222 | redisClient.del(`leaderboard`, (err, leaderboard) => { 223 | if (err) { 224 | reject(err); 225 | } else { 226 | resolve(JSON.parse(leaderboard) || undefined); 227 | } 228 | }); 229 | }); 230 | }; 231 | 232 | const getTOTDRatings = async (redisClient) => { 233 | return new Promise((resolve, reject) => { 234 | redisClient.get(`ratings`, async (err, ratings) => { 235 | if (err) { 236 | reject(err); 237 | } else { 238 | if (ratings) { 239 | try { 240 | const parsedRatings = JSON.parse(ratings); 241 | resolve(parsedRatings); 242 | } catch (error) { 243 | reject(`Unable to parse rating JSON`); 244 | } 245 | } else { 246 | const clearedRatings = await clearTOTDRatings(redisClient); 247 | resolve(clearedRatings); 248 | } 249 | } 250 | }); 251 | }); 252 | }; 253 | 254 | const clearTOTDRatings = async (redisClient) => { 255 | return new Promise((resolve, reject) => { 256 | const baseRating = {}; 257 | for (let i = 0; i < constants.ratingEmojis.length; i++) { 258 | baseRating[constants.ratingEmojis[i]] = 0; 259 | } 260 | 261 | redisClient.set(`ratings`, JSON.stringify(baseRating), (err) => { 262 | if (err) { 263 | reject(err); 264 | } else { 265 | resolve(baseRating); 266 | } 267 | }); 268 | }); 269 | }; 270 | 271 | const updateTOTDRatings = async (redisClient, emojiName, add) => { 272 | return new Promise((resolve, reject) => { 273 | redisClient.get(`ratings`, async (getErr, rating) => { 274 | if (getErr) { 275 | reject(getErr); 276 | } else { 277 | if (!rating) { 278 | rating = await clearTOTDRatings(redisClient); 279 | } else { 280 | rating = JSON.parse(rating); 281 | } 282 | 283 | if (add) { 284 | rating[emojiName] += 1; 285 | } else { 286 | rating[emojiName] -= 1; 287 | // check that it can't go below 0 288 | if (rating[emojiName] < 0) { 289 | rating[emojiName] = 0; 290 | } 291 | } 292 | 293 | redisClient.set(`ratings`, JSON.stringify(rating), (setErr) => { 294 | if (setErr) { 295 | reject(setErr); 296 | } else { 297 | resolve(rating); 298 | } 299 | }); 300 | } 301 | }); 302 | }); 303 | }; 304 | 305 | const clearIndividualRatings = async (redisClient) => { 306 | return new Promise((resolve, reject) => { 307 | redisClient.set(`individualRatings`, JSON.stringify([]), (err) => { 308 | if (err) { 309 | reject(err); 310 | } else { 311 | resolve([]); 312 | } 313 | }); 314 | }); 315 | }; 316 | 317 | const getIndividualRatings = async (redisClient) => { 318 | return new Promise((resolve, reject) => { 319 | redisClient.get(`individualRatings`, async (err, individualRatings) => { 320 | if (err) { 321 | reject(err); 322 | } else { 323 | if (individualRatings) { 324 | try { 325 | const parsedRatings = JSON.parse(individualRatings); 326 | resolve(parsedRatings); 327 | } catch (error) { 328 | reject(`Unable to parse individual ratings JSON`); 329 | } 330 | } else { 331 | return await clearIndividualRatings(redisClient); 332 | } 333 | } 334 | }); 335 | }); 336 | }; 337 | 338 | // return value determines if the update was valid 339 | const updateIndividualRatings = async (redisClient, emojiName, add, user) => { 340 | return new Promise((resolve, reject) => { 341 | redisClient.get(`individualRatings`, async (getErr, individualRatings) => { 342 | if (getErr) { 343 | reject(getErr); 344 | } else { 345 | if (!individualRatings) { 346 | individualRatings = await clearIndividualRatings(redisClient); 347 | } else { 348 | try { 349 | individualRatings = JSON.parse(individualRatings); 350 | } catch (error) { 351 | return reject(`Unable to parse individual ratings JSON`); 352 | } 353 | } 354 | 355 | // check if this user has already voted with this emoji 356 | const existingRating = individualRatings.find((rating) => rating.user === user && rating.vote === emojiName); 357 | 358 | if (existingRating && add) { 359 | // can't add the same vote again 360 | return resolve(false); 361 | } else if (existingRating && !add) { 362 | // removing the existing rating 363 | const index = individualRatings.indexOf(existingRating); 364 | individualRatings.splice(index, 1); 365 | } else if (!existingRating && add) { 366 | // adding a new rating 367 | individualRatings.push({ 368 | user, 369 | vote: emojiName, 370 | }); 371 | } else if (!existingRating && !add) { 372 | // can't remove a rating that doesn't exist, no-op 373 | } 374 | 375 | redisClient.set(`individualRatings`, JSON.stringify(individualRatings), (setErr) => { 376 | if (setErr) { 377 | reject(setErr); 378 | } else { 379 | resolve(true); 380 | } 381 | }); 382 | } 383 | }); 384 | }); 385 | }; 386 | 387 | const getBingoBoard = async (redisClient, serverID, lastWeek) => { 388 | return new Promise((resolve, reject) => { 389 | let bingoEntry = `bingo`; 390 | if (lastWeek) { 391 | bingoEntry = `lastBingo`; 392 | } 393 | redisClient.get(bingoEntry, (err, boards) => { 394 | if (err) { 395 | reject(err); 396 | } else { 397 | if (boards) { 398 | const parsedBoards = JSON.parse(boards); 399 | resolve(parsedBoards[serverID]); 400 | } else { 401 | resolve(); 402 | } 403 | } 404 | }); 405 | }); 406 | }; 407 | 408 | const getAllBingoBoards = async (redisClient) => { 409 | return new Promise((resolve, reject) => { 410 | redisClient.get(`bingo`, (err, boards) => { 411 | if (err) { 412 | reject(err); 413 | } else { 414 | if (boards) { 415 | resolve(JSON.parse(boards)); 416 | } else { 417 | resolve(); 418 | } 419 | } 420 | }); 421 | }); 422 | }; 423 | 424 | const saveBingoBoard = async (redisClient, board, serverID) => { 425 | return new Promise((resolve, reject) => { 426 | redisClient.get(`bingo`, (getErr, boards) => { 427 | if (getErr) { 428 | return reject(getErr); 429 | } 430 | const parsedBoards = JSON.parse(boards); 431 | parsedBoards[serverID] = board; 432 | redisClient.set(`bingo`, JSON.stringify(parsedBoards), (err) => { 433 | if (err) { 434 | reject(err); 435 | } else { 436 | resolve(board); 437 | } 438 | }); 439 | }); 440 | }); 441 | }; 442 | 443 | const resetBingoBoards = async (redisClient) => { 444 | return new Promise((resolve, reject) => { 445 | redisClient.set(`bingo`, JSON.stringify({}), (err) => { 446 | if (err) { 447 | reject(err); 448 | } else { 449 | resolve(); 450 | } 451 | }); 452 | }); 453 | }; 454 | 455 | const archiveBingoBoards = async (redisClient, boards) => { 456 | return new Promise((resolve, reject) => { 457 | redisClient.set(`lastBingo`, JSON.stringify(boards), (err) => { 458 | if (err) { 459 | reject(err); 460 | } else { 461 | resolve(boards); 462 | } 463 | }); 464 | }); 465 | }; 466 | 467 | const getAllStoredTOTDs = async (redisClient) => { 468 | return new Promise((resolve, reject) => { 469 | redisClient.get(`totds`, (err, totds) => { 470 | if (err) { 471 | reject(err); 472 | } else { 473 | if (totds) { 474 | resolve(JSON.parse(totds)); 475 | } else { 476 | resolve(); 477 | } 478 | } 479 | }); 480 | }); 481 | }; 482 | 483 | const storeTOTDs = async (redisClient, totds) => { 484 | return new Promise((resolve, reject) => { 485 | redisClient.set(`totds`, JSON.stringify(totds), (err) => { 486 | if (err) { 487 | reject(err); 488 | } else { 489 | resolve(totds); 490 | } 491 | }); 492 | }); 493 | }; 494 | 495 | module.exports = { 496 | login, 497 | logout, 498 | addConfig, 499 | removeConfig, 500 | addRole, 501 | removeRole, 502 | getAllConfigs, 503 | saveCurrentTOTD, 504 | saveCurrentLeaderboard, 505 | getCurrentTOTD, 506 | getCurrentLeaderboard, 507 | clearCurrentLeaderboard, 508 | getTOTDRatings, 509 | clearTOTDRatings, 510 | updateTOTDRatings, 511 | clearIndividualRatings, 512 | getIndividualRatings, 513 | updateIndividualRatings, 514 | getBingoBoard, 515 | saveBingoBoard, 516 | resetBingoBoards, 517 | getAllBingoBoards, 518 | archiveBingoBoards, 519 | savePreviousTOTD, 520 | getPreviousTOTD, 521 | getAllStoredTOTDs, 522 | storeTOTDs 523 | }; 524 | -------------------------------------------------------------------------------- /src/tmApi.js: -------------------------------------------------------------------------------- 1 | const axios = require(`axios`); 2 | const { DateTime } = require(`luxon`); 3 | 4 | const userLogin = process.env.USER_LOGIN; 5 | const oauthID = process.env.OAUTH_ID; 6 | const oauthSecret = process.env.OAUTH_SECRET; 7 | 8 | let coreToken; 9 | let liveToken; 10 | let oauthToken; 11 | 12 | let lastRequestSent; 13 | 14 | const sendRequest = async ({url, token, method = `get`, body = {}, headersOverride}) => { 15 | let authOverride; 16 | 17 | let tokenValue = coreToken; 18 | if (token === `live`) { 19 | tokenValue = liveToken; 20 | } else if (token === `oauth`) { 21 | authOverride = `Bearer ${oauthToken}`; 22 | } 23 | 24 | let headers = { 25 | 'Content-Type': `application/json`, 26 | 'User-Agent': `TOTD Discord Bot - tooInfinite`, 27 | 'Authorization': authOverride || `nadeo_v1 t=${tokenValue}`, 28 | ...headersOverride 29 | }; 30 | 31 | try { 32 | // make sure only two requests get sent per second max 33 | if (lastRequestSent) { 34 | const timeDiff = DateTime.now().diff(lastRequestSent, `seconds`); 35 | if (timeDiff.toObject().seconds < 1) { 36 | //console.log(`--- Delaying request for rate limit`); 37 | await new Promise(resolve => setTimeout(resolve, 500)); 38 | } 39 | } 40 | console.log(`--- Sending ${method} request to "${url}"`); 41 | lastRequestSent = DateTime.now(); 42 | const response = await axios({ 43 | url: url, 44 | method: method, 45 | data: body, 46 | headers: headers 47 | }); 48 | 49 | return response.data; 50 | } catch (error) { 51 | if (error.response?.status === 401) { 52 | console.log(`--- 401: Refresh tokens and call the endpoint again`); 53 | if (token === `oauth`) { 54 | await loginOAuth(); 55 | } else { 56 | await login(); 57 | } 58 | return await sendRequest({url, token, method, body, headersOverride}); 59 | } else { 60 | console.error(error); 61 | throw error; 62 | } 63 | } 64 | }; 65 | 66 | const login = async () => { 67 | const ticketResponse = await sendRequest({ 68 | url: `https://public-ubiservices.ubi.com/v3/profiles/sessions`, 69 | method: `post`, 70 | headersOverride: { 71 | 'Ubi-AppId': `86263886-327a-4328-ac69-527f0d20a237`, 72 | Authorization: `Basic ${Buffer.from(userLogin).toString(`base64`)}` 73 | } 74 | }); 75 | const ticket = ticketResponse.ticket; 76 | 77 | const coreTokenResponse = await sendRequest({ 78 | url: `https://prod.trackmania.core.nadeo.online/v2/authentication/token/ubiservices`, 79 | method: `post`, 80 | headersOverride: { 81 | Authorization: `ubi_v1 t=${ticket}` 82 | }, 83 | body: {audience: `NadeoServices`} 84 | }); 85 | coreToken = coreTokenResponse.accessToken; 86 | 87 | const liveTokenResponse = await sendRequest({ 88 | url: `https://prod.trackmania.core.nadeo.online/v2/authentication/token/ubiservices`, 89 | method: `post`, 90 | headersOverride: { 91 | Authorization: `ubi_v1 t=${ticket}` 92 | }, 93 | body: {audience: `NadeoLiveServices`} 94 | }); 95 | liveToken = liveTokenResponse.accessToken; 96 | 97 | console.log(`Game API login successful`); 98 | }; 99 | 100 | const loginOAuth = async () => { 101 | const oauthTokenResponse = await sendRequest({ 102 | url: `https://api.trackmania.com/api/access_token`, 103 | method: `post`, 104 | headersOverride: { 105 | 'Content-Type': `application/x-www-form-urlencoded`, 106 | Authorization: `` // no auth header for login 107 | }, 108 | body: `grant_type=client_credentials&client_id=${oauthID}&client_secret=${oauthSecret}` 109 | }); 110 | oauthToken = oauthTokenResponse.access_token; 111 | console.log(`OAuth login successful`); 112 | }; 113 | 114 | const getPlayerNames = async (accountIDs) => { 115 | // assemble accountIDs in correct format 116 | const accountIDList = accountIDs.map((accountID) => `accountId[]=${accountID}`).join(`&`); 117 | 118 | const accounts = await sendRequest({ 119 | url: `https://api.trackmania.com/api/display-names/?${accountIDList}`, 120 | token: `oauth` 121 | }); 122 | 123 | // reorganize account-name mappings into array of {accountId, displayName} for compatibility 124 | const names = Object.entries(accounts).map(([accountID, accountName]) => ({accountId: accountID, displayName: accountName})); 125 | return names; 126 | }; 127 | 128 | const getPlayerName = async (accountID) => { 129 | const account = await getPlayerNames([accountID]); 130 | return account[0].displayName; 131 | }; 132 | 133 | const getMaps = async (mapUids) => { 134 | const maps = await sendRequest({ 135 | url: `https://prod.trackmania.core.nadeo.online/maps/?mapUidList=${mapUids.join(`,`)}`, 136 | token: `core` 137 | }); 138 | return maps; 139 | }; 140 | 141 | const getTMXInfo = async (mapUid) => { 142 | try { 143 | const tmxResponse = await axios.get(`https://trackmania.exchange/api/maps/?uid=${mapUid}&fields=Tags,Name,HasImages,MapId,UpdatedAt`, { 144 | timeout: 5000 // timeout of 5s in case TMX is down 145 | }); 146 | if (tmxResponse.data.Results.length === 1) { 147 | const tmxResult = { ...tmxResponse.data.Results[0] }; 148 | 149 | // get available image 150 | let imageLink; 151 | if (tmxResult.HasImages) { 152 | imageLink = `https://trackmania.exchange/mapimage/${tmxResult.MapId}/1`; 153 | } 154 | 155 | if (!imageLink && tmxResult.HasThumbnail) { 156 | imageLink = `https://trackmania.exchange/mapimage/${tmxResult.MapId}/0`; 157 | } 158 | if (imageLink) { 159 | tmxResult.ImageLink = imageLink; 160 | } 161 | 162 | return tmxResult; 163 | } else { 164 | return; 165 | } 166 | } catch (e) { 167 | console.log(`getTMXInfo error:`); 168 | console.log(e); 169 | } 170 | }; 171 | 172 | const getCurrentTOTD = async () => { 173 | try { 174 | const totds = await sendRequest({ 175 | url: `https://live-services.trackmania.nadeo.live/api/token/campaign/month?length=5&offset=0&royal=false`, 176 | token: `live` 177 | }); 178 | 179 | let currentTOTDMeta; 180 | for (let i = 0; i < totds.monthList[0].days.length; i++) { 181 | const totd = totds.monthList[0].days[i]; 182 | 183 | if (totd.relativeStart < 0 && totd.relativeEnd > 0) { 184 | currentTOTDMeta = totd; 185 | break; 186 | } 187 | } 188 | 189 | if (!currentTOTDMeta) { 190 | console.error(JSON.stringify(totds)); 191 | throw `Couldn't find current TOTD, see above for retrieved maps`; 192 | } 193 | 194 | const currentTOTDArray = await getMaps([currentTOTDMeta.mapUid]); 195 | const currentTOTD = currentTOTDArray[0]; 196 | currentTOTD.seasonUid = currentTOTDMeta.seasonUid; 197 | 198 | currentTOTD.authorName = await getPlayerName([currentTOTD.author]); 199 | 200 | // get the current hour in Paris 201 | const currentHour = new Date().toLocaleString(`en-US`, {hour: `2-digit`, hour12: false, timeZone: `Europe/Paris`}); 202 | 203 | let totdDate; 204 | if (currentHour < 19) { 205 | // if it's yesterday's TOTD we need to know what yesterday's date was in Paris 206 | totdDate = new Date(); 207 | totdDate.setHours(totdDate.getHours() - 19); 208 | } else { 209 | // use the current date in Paris 210 | totdDate = new Date(); 211 | } 212 | 213 | currentTOTD.day = totdDate.toLocaleString(`en-US`, {day: `numeric`, timeZone: `Europe/Paris`}); 214 | currentTOTD.month = totdDate.toLocaleString(`en-US`, {month: `long`, timeZone: `Europe/Paris`}); 215 | currentTOTD.year = totdDate.toLocaleString(`en-US`, {year: `numeric`, timeZone: `Europe/Paris`}); 216 | 217 | const tmxInfo = await getTMXInfo(currentTOTD.mapUid); 218 | 219 | if (tmxInfo) { 220 | currentTOTD.tmxName = tmxInfo.Name; 221 | currentTOTD.tmxTrackId = tmxInfo.MapId; 222 | currentTOTD.tmxTags = tmxInfo.Tags.map((tag) => tag.Name); 223 | currentTOTD.tmxTimestamp = tmxInfo.UpdatedAt; 224 | if (!currentTOTD.tmxTimestamp.includes(`+`)) { 225 | // if there's no timezone information, assume UTC 226 | currentTOTD.tmxTimestamp = `${currentTOTD.tmxTimestamp}+00:00`; 227 | } 228 | if (tmxInfo.ImageLink) { 229 | currentTOTD.thumbnailUrl = tmxInfo.ImageLink; 230 | } 231 | } 232 | 233 | return currentTOTD; 234 | } catch (e) { 235 | console.log(`getCurrentTOTD error:`); 236 | console.log(e); 237 | } 238 | }; 239 | 240 | const getLeaderboardPosition = async (seasonUid, mapUid, position) => { 241 | const leaderboardPositionInfo = await sendRequest({ 242 | url: `https://live-services.trackmania.nadeo.live/api/token/leaderboard/group/${seasonUid}/map/${mapUid}/top?length=1&onlyWorld=true&offset=${position - 1}`, 243 | token: `live` 244 | }); 245 | 246 | const record = leaderboardPositionInfo?.tops[0]?.top[0]; 247 | if (record) { 248 | record.playerName = await getPlayerName([record.accountId]); 249 | record.position = position; 250 | } 251 | 252 | return record; 253 | }; 254 | 255 | const getTOTDLeaderboard = async (seasonUid, mapUid) => { 256 | try { 257 | const leaderboardInfo = await sendRequest({ 258 | url: `https://live-services.trackmania.nadeo.live/api/token/leaderboard/group/${seasonUid}/map/${mapUid}/top?length=10&onlyWorld=true`, 259 | token: `live` 260 | }); 261 | 262 | const records = leaderboardInfo.tops[0].top; 263 | 264 | console.log(`Fetching player names...`); 265 | const playerNames = await getPlayerNames(records.map((record) => record.accountId)); 266 | 267 | for (let i = 0; i < records.length; i++) { 268 | records[i].playerName = playerNames.find((player) => player.accountId === records[i].accountId).displayName; 269 | records[i].position = i + 1; 270 | } 271 | 272 | const top100 = await getLeaderboardPosition(seasonUid, mapUid, 100); 273 | const top1000 = await getLeaderboardPosition(seasonUid, mapUid, 1000); 274 | const top10000 = await getLeaderboardPosition(seasonUid, mapUid, 10000); 275 | 276 | if (top100) { 277 | records.push(top100); 278 | } 279 | if (top1000) { 280 | records.push(top1000); 281 | } 282 | if (top10000) { 283 | records.push(top10000); 284 | } 285 | 286 | return records; 287 | } catch (error) { 288 | console.log(`getTOTDLeaderboard error:`); 289 | console.log(error); 290 | } 291 | }; 292 | 293 | 294 | module.exports = { 295 | login, 296 | loginOAuth, 297 | getCurrentTOTD, 298 | getTOTDLeaderboard 299 | }; 300 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | require(`dotenv`).config(); 2 | 3 | const convertToUNIXSeconds = (date) => { 4 | return Math.round(date.getTime()/1000); 5 | }; 6 | 7 | const getMinutesAgo = (date) => { 8 | var seconds = Math.floor((new Date() - date) / 1000); 9 | var interval = seconds / 31536000; 10 | interval = seconds / 60; 11 | return Math.floor(interval); 12 | }; 13 | 14 | const getEmojiMapping = (emojiName) => { 15 | const mapping = require(`../emojiMapping.json`); 16 | return mapping[emojiName] || ``; 17 | }; 18 | 19 | const removeNameFormatting = (text = ``) => { 20 | // this should take care of all the possible options, see https://doc.maniaplanet.com/client/text-formatting for reference 21 | let cleanedText = text.replace(/(?\$+)\k)?((?<=\$)(?!\$)|(\$([a-f\d]{1,3}|[ionmwsztg<>]|[lhp](\[[^\]]+\])?)))/gmi, ``); 22 | return cleanedText; 23 | }; 24 | 25 | const formatDay = (day) => { 26 | const dayNum = parseInt(day); 27 | if (dayNum === 1 || dayNum === 21 || dayNum === 31) { 28 | return `${dayNum}st`; 29 | } else if (dayNum === 2 || dayNum === 22) { 30 | return `${dayNum}nd`; 31 | } else if (dayNum === 3 || dayNum === 23) { 32 | return `${dayNum}rd`; 33 | } else { 34 | return `${dayNum}th`; 35 | } 36 | }; 37 | 38 | const sendMessage = async (channel, message, commandMessage) => { 39 | if (commandMessage) { 40 | let messageObject = {}; 41 | // add fetchReply depending on the message format 42 | if (typeof message === `string`) { 43 | messageObject = { 44 | content: message, 45 | fetchReply: true 46 | }; 47 | } else { 48 | messageObject = {...message, fetchReply: true}; 49 | } 50 | return await commandMessage.reply(messageObject); 51 | } else { 52 | return await channel.send(message); 53 | } 54 | }; 55 | 56 | const checkMessageAuthorForTag = (msg, tag) => { 57 | const author = msg.author || msg.user; 58 | return author.tag === tag; 59 | }; 60 | 61 | const formatCommand = (name, commandIDs) => { 62 | if (commandIDs && commandIDs[name]) { 63 | return ``; 64 | } 65 | return `\`/${name}\``; 66 | }; 67 | 68 | module.exports = { 69 | convertToUNIXSeconds, 70 | getMinutesAgo, 71 | getEmojiMapping, 72 | removeNameFormatting, 73 | formatDay, 74 | sendMessage, 75 | checkMessageAuthorForTag, 76 | formatCommand 77 | }; 78 | --------------------------------------------------------------------------------