├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docker-compose.yml ├── exampleSettings.json ├── index.js ├── lib ├── argsHelper.js ├── bot.js ├── bracketHelper.js ├── channelProvider.js ├── clientProvider.js ├── codes.js ├── commands │ ├── brackets.js │ ├── cancel.js │ ├── channel.js │ ├── help.js │ ├── list.js │ ├── me.js │ ├── queue.js │ ├── reroll.js │ ├── rules.js │ ├── start.js │ └── status.js ├── createGiveaway.js ├── daemon.js ├── gameInfo.js ├── getTime.js ├── giveawayMessageWriter.js ├── highlight.js ├── logger.js ├── messages.js ├── permissionHelper.js ├── reactionHelper.js ├── recordFetch.js ├── settings.js ├── shuffleArray.js ├── state.js ├── store.js ├── timeHelper.js ├── trace.js └── winnerSelector.js ├── package.json ├── start.js ├── tests ├── helpers │ ├── assert.js │ ├── collection.js │ ├── message.js │ ├── mockClient.js │ ├── mockGuildMember.js │ ├── mockLogger.js │ ├── mockMessage.js │ ├── mockSteamInfo.js │ ├── mockStore.js │ ├── testBase.js │ └── testTrace.js ├── manual.md ├── test.js └── tests │ ├── commands │ ├── brackets.js │ ├── cancel.js │ ├── channel.js │ ├── help.js │ ├── list.js │ ├── me.js │ ├── queue.js │ ├── reroll.js │ ├── start.js │ └── status.js │ └── rejection.js └── vagrant ├── Vagrantfile └── provision.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules 3 | /vagrant/.vagrant 4 | /yarn.lock 5 | /yarn-error.log 6 | /vagrant/ubuntu-xenial-16.04-cloudimg-console.log 7 | /__store 8 | /__logs 9 | /tests/__store 10 | /settings.json 11 | /tests/__logs 12 | /.vscode 13 | /discord-giveawaybot 14 | /ddagent-install.log 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | script: 5 | - "npm test" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Shukri Adams 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 | # discord-giveawaybot 2 | 3 | This project is no longer under active developement. 4 | 5 | [![Build Status](https://travis-ci.org/shukriadams/discord-giveawaybot.svg?branch=master)](https://travis-ci.org/shukriadams/discord-giveawaybot) 6 | 7 | A Discord bot that does automated game giveaways. Built-in integration for Steam titles, but can 8 | handle anything connected to a URL. Heavily inspired by https://github.com/jagrosh/GiveawayBot, differs from the 9 | original with : 10 | 11 | - bot commands are in private message, allowing for surprise giveaways, direct messaging of game keys to winners, 12 | detailed data queries and other quiet admin functions 13 | - queuing of future giveaways 14 | - anti-greed features automatically prevents a winner from entering another giveaway for a while 15 | - better Steam integration 16 | 17 | (Bot is limited to non-admin functions, I can't auto-assign admin rights to users. I'm not active on this Discord channel, it's for demo purposes, if you need help or 18 | found a bug, please use Github). 19 | 20 | ## Requirements 21 | 22 | - An online machine to host your bot on. Your machine doesn't need to be publicly visible to the internet. 23 | - Either Docker in a Linux environment, or NodeJS 7 or higher. 24 | 25 | If you're hosting on Windows : 26 | 27 | - first familiarize yourself with best practices for running NodeJS apps as stable and persistent services on Windows. 28 | - Windows is known to wipe and reset bot state after system crashes or restarts. This is a Windows issue, and will not 29 | be addressed. 30 | 31 | 32 | ## Create your bot on Discord first 33 | 34 | - go to https://discordapp.com/developers/applications/me 35 | - click on "new app" 36 | - follow the instructions and create your app - you need to add only a name 37 | - after creating your app scroll down the app page and click on "create a bot user" to convert your app to a bot 38 | - on the bot's config page, copy the bot's client id, you'll need this later. Also click on "click to reveal token", 39 | copy this too for the next step. 40 | 41 | ## Host your bot 42 | 43 | PLEASE READ THIS CAREFULLY - most setup issues are caused by incorrect folder structure. 44 | 45 | There are several ways to fetch the bot's code. Regardless of which you use, you need to : 46 | 47 | 1. Create a root folder for your bot. 48 | 49 | mkdir myBot 50 | 51 | This is where you'll put either docker-compose.yml, or the code from this project if you downloaded the bot code 52 | from github directly. If the latter, *you should see package.json in this folder*. 53 | 54 | 2. In the root folder create a *work folder* called "discord-giveawaybot" 55 | 56 | mkdir myBot/discord-giveawaybot 57 | 58 | This is where the bot writes its own volatile files. 59 | 60 | 3. In the *work folder*, create a settings file. If you're on Linux, you can use 61 | 62 | touch myBot/discord-giveawaybot/settings.json 63 | 64 | The bot will write to this file too. 65 | 66 | 4. In the root of this Github project you'll find exampleSettings.json, copy its contents to the settings file from 67 | the step above, and replace "ADD YOUR BOT TOKEN HERE" with the Discord bot token you copied in 68 | "Create your bot on Discord first" above. Remember to use the token, not the client id. 69 | 70 | 71 | ## Getting the bot code 72 | 73 | You can get the bot code in three different ways. 74 | 75 | ### 1) From Docker image 76 | 77 | This is the recommended method because it's easiest to setup and update. Create a docker-compose.yml file in 78 | your bot root folder and add the following to it 79 | 80 | version: "2" 81 | services: 82 | node: 83 | container_name: discordgiveawaybot 84 | image: shukriadams/discord-giveawaybot:latest 85 | restart: unless-stopped 86 | command: npm start 87 | volumes: 88 | - ./discord-giveawaybot/:/usr/giveawaybot/discord-giveawaybot/:rw 89 | 90 | In the root folder run 91 | 92 | docker-compose up -d 93 | 94 | These settings can of course be tweaked to suite your host setup, only npm start and the volume map are required. Bot 95 | state is in ./discord-giveawaybot, back this up if desired. 96 | 97 | ### 2) From NPM 98 | 99 | Install 100 | 101 | npm install discord-giveawaybot --save 102 | 103 | Run 104 | npm start 105 | 106 | ### 3) From source 107 | 108 | Clone this repo, then run 109 | 110 | npm install 111 | npm start 112 | 113 | **Keep-alive** 114 | 115 | If you're not hosting with Docker, you need to restart the bot process when it unexpectedly exits. [pm2] 116 | (http://pm2.keymetrics.io/) is an excellent option. 117 | 118 | You can also set the bot up as a service 119 | 120 | [Service] 121 | WorkingDirectory=/path/to/bot/package.json 122 | ExecStart=/usr/bin/npm start 123 | Restart=always 124 | StandardOutput=syslog 125 | StandardError=syslog 126 | SyslogIdentifier=giveaway 127 | User=YOURUSER 128 | Group=YOURGROUP 129 | Environment=NODE_ENV=production 130 | 131 | You can use whatever you prefer, just as long as you handle exits, as the bot _will_ exit 132 | periodically. 133 | 134 | ## Add your bot to your Discord server 135 | 136 | - back on your app's Discord config page (from the first section above), use the bot client id you copied and paste it 137 | into this url, replacing YOURCLIENTID 138 | 139 | https://discordapp.com/oauth2/authorize?&client_id=YOURCLIENTID&scope=bot&permissions=0 140 | 141 | - then navigate to that url in a browser. You'll be able to select which of your Discord servers you want to add it to. 142 | After doing this you should see your bot as a user on your server. 143 | - Your bot needs to know which channel you'll be broadcasting giveaways in. Go to the channel you want to use and write 144 | "@BOTNAME channel" where BOTNAME is whatever name you gave your bot. 145 | - That's it, you're set to go. 146 | 147 | ## Additional config 148 | 149 | By default, only admins can create and manage giveaways. If you want to delegate giveaway responsibilities to non-admins 150 | 151 | - go to your Discord server settings and select "roles" 152 | - on the roles page, add a role called "Giveaways". If you don't want to use this name, create any role you want, and 153 | add that name to settings.json (requires bot restart) 154 | - Assign the role "Giveaways" (or whatever you called it) to users who'll run giveaways. 155 | 156 | Your bot should always have the permission _Manage Messages_. It gets assigned this by default, so you don't have to 157 | set it, but do not disable it. 158 | 159 | ## Response Emoji 160 | 161 | When a giveaway starts, the bot will publish a message in the giveaway channel, along with an emote. Users should click the emote to join the giveaway. The emote is set in settings.json as the "joinGiveawayResponseCharacter" property. This value _must_ be a valid emote that Discord supports. You can get a list of emotes at any site that lists them, one example is 162 | 163 | https://getemoji.com 164 | 165 | Note that emotes are single ASCII emoji characters, so the emoji for smile must be "🎉", and not ":fanfare:" (or whatever that emoji is called) 166 | 167 | ## Commands 168 | 169 | One of the major differences between this bot and jagrosh's giveawaybot is that this bot uses direct communication - 170 | you don't talk to it in public chat. 171 | 172 | ### brackets 173 | 174 | Price brackets let you limit how often users can win a game within a price range. If you register a bracket of $0-100, 175 | and a user wins a game that costs $50, that user will automatically be prevented, for seven days, from entering another 176 | giveaway for any game costing between 0 and 100 USD. Brackets are optional - you can register none, one, or as many as 177 | you like. 178 | 179 | brackets -b 0-20-50-100 180 | 181 | sets 3 brackets, 1-20, 20-50 and 50-100. If a game costs 20 USD, it falls in the first bracket that it fits in, 0-20 in 182 | this case. You can start and brackets at any price range. For example 183 | 184 | brackets -b 20-30 185 | 186 | sets one bracket, and will catch only games that fall in its range, and a user will be allowed unlimited entry in games 187 | below 20USD or above 30USD. 188 | 189 | Prices are always in USD. 190 | 191 | For a list of current brackets, use 192 | 193 | brackets 194 | 195 | ### cancel 196 | 197 | Admins or the creator of a giveaway can cancel that giveaway if it hasn't started yet, or is in progress. 198 | 199 | ### channel 200 | 201 | This is the only bot command done in public chat. It registers the channel from which it is sent as the channel in which 202 | giveaways will be broadcast. 203 | 204 | ### help 205 | 206 | Gets a list of commands from the bot. 207 | 208 | ### list 209 | 210 | Lists all ongoing giveaways. 211 | 212 | list all 213 | 214 | Shows ongoing and complete and cancelled giveaways. 215 | 216 | Additionally, admins and users with giveaway rolls will be able to see pending giveaways. 217 | 218 | ### me 219 | 220 | Tell a user if they're on cooldown for a given bracket if they recently won a game in that bracket. 221 | 222 | ### queue 223 | 224 | Creates a giveaway to be started at some time in the future. Requires admin or giveaway roll. A game activation code 225 | can optionally be added to this command - the winner will receive this code in a private message. 226 | 227 | ### reroll 228 | 229 | An admin or user with giveaway role can reroll a winner on a finished giveaway if they so wish. Note this obviously 230 | doesn't have much meaning if the activation code is attached to the giveaway, as the previous winner will already have 231 | received the code, so use common sense. 232 | 233 | ### rules 234 | 235 | Simple rules text can be found using 236 | 237 | rules 238 | 239 | To set rules text, admin pemission is required. Use 240 | 241 | rules Your text here ... 242 | 243 | ### start 244 | 245 | Immediately starts a giveaway. 246 | 247 | ### status 248 | 249 | This command is currently disabled. 250 | 251 | ## Other 252 | 253 | The bot automatically cleans out completed/cancelled competitions after 14 days. 254 | 255 | Get participate emoji characters at http://emojipedia.org 256 | 257 | ## Known issues 258 | 259 | - The bot can handle a maximum of 100 participants per giveaway. Anyone above that 100 will be ignored - this is a 260 | limitation in Discord's API, and will be fixed when Discord fixes their API. As a workaround, a giveaway will 261 | automatically end when it reaches 100 participants. 262 | 263 | - The bot can lose giveaways on Windows systems after a system crash or reset. The exact cause isn't known but is 264 | assumed to be Windows system restore. 265 | 266 | 267 | ## Monitoring 268 | 269 | If you expose your bot process to HTTP traffic, it will reply to /status queries with an integer indicating how 270 | responsive/overloaded the daemon is. A healthy bot should return 0, if this number is greater than 0 your bot is in 271 | trouble. 272 | 273 | HTTP traffic is disabled by default, to enable it add the following to settings.json 274 | 275 | "enableHealthMonitor" : true 276 | 277 | The default port the bot listens on is 8080, set some other port with 278 | 279 | "healthMonitorPort" : 3000 280 | 281 | So using the settings above and assuming your bot is hosted at https://mybot.example.com, the status call would be 282 | 283 | https://mybot.example.com:3000/status 284 | 285 | 286 | ## Development 287 | 288 | See the setup procedure for standard deployment above to get your dev bot running - you'll need to create a /discord-giveawaybot work folder and settings.json file in that, get a valid discord bot user access token etc. 289 | 290 | The bot is basically two processes 291 | 292 | - a message handler that receives message instructions from Discord users and responds to them immediately. 293 | - a daemon which ticks at an interval, and which carries out instructions that are not directly driven by incoming user 294 | messages 295 | 296 | All other files are helpers for the above. Other stuff : 297 | 298 | - Giveaway data is persisted with a local Loki.js store, in /discord-giveawaybot/__store 299 | - Errors are logged out with Winston in /discord-giveawaybot/__logs 300 | - The daemon uses node-cron for its timer. 301 | 302 | If you use Vagrant, the included vagrant script will start an Ubuntu VM ready to run the bot (for development or 303 | testing). 304 | 305 | cd /vagrant 306 | vagrant up 307 | vagrant ssh 308 | 309 | Then in the VM run 310 | 311 | yarn --no-bin-links (flag needed only if your host machine is Windows) 312 | node start (or npm start) 313 | 314 | If you want to run the bot directly on your host system without yarn 315 | 316 | npm install 317 | node start (or npm start) 318 | 319 | ## Tests 320 | 321 | npm test 322 | 323 | or if you want to test with a debugger (Webstorm, VSCode etc), point your debugger to /tests/test.js and 324 | 325 | cd /tests 326 | node test 327 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # example docker-compose 2 | version: "2" 3 | services: 4 | node: 5 | container_name: discordgiveawaybot 6 | image: shukriadams/discord-giveawaybot:latest 7 | restart: unless-stopped 8 | command: npm start 9 | volumes: 10 | - ./discord-giveawaybot/:/usr/giveawaybot/discord-giveawaybot/:rw 11 | -------------------------------------------------------------------------------- /exampleSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "ADD YOUR BOT TOKEN HERE", 3 | "joinGiveawayResponseCharacter": "🎉", 4 | "giveawayRole": "Giveaways", 5 | "bracketsCurrencyZone": "us", 6 | "daemonInterval" : "*/10 * * * * *", 7 | "maxConcurrentGiveaways" : 5, 8 | "enableHealthMonitor" : false, 9 | "healthMonitorPort" : 8080, 10 | "healthMonitorThreshold" : null, 11 | "winningCooldownDays" : 3 12 | 13 | } 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * If you're doing a "require('discord-giveawaybot')" from your own code, this is where you'll get your bot from. 3 | * In most cases, if you're starting the bot from a command line, from docker or otherwise, you'll be going via 4 | * start.js in the same folder as this file. 5 | */ 6 | 7 | 'use strict'; 8 | 9 | module.exports = require('./lib/bot'); -------------------------------------------------------------------------------- /lib/argsHelper.js: -------------------------------------------------------------------------------- 1 | let argParser = require('minimist-string'); 2 | 3 | module.exports = { 4 | 5 | stringSplit : function(raw){ 6 | raw = this.preSanitize(raw); 7 | let args = raw.split(' '); 8 | return args.filter(function(arg){ 9 | return arg && arg.length? arg: null; 10 | }); 11 | }, 12 | 13 | // converts text into minimist-string args object 14 | toArgsObject : function(raw){ 15 | raw = this.preSanitize(raw); 16 | return argParser(raw); 17 | }, 18 | 19 | preSanitize : function (raw){ 20 | return raw.trim().replace(/\s+/g, ' '); 21 | } 22 | }; -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The main bot file. Handles incoming messages from users ( _onMessage() function ), and starts the daemon. See 3 | * ./daemon.js for autonomous bot behaviour, ie, behaviour not based on responding to user messages. 4 | */ 5 | let codes = require('./codes'), 6 | Logger = require('./logger'), 7 | Client = require('./clientProvider'), 8 | Daemon = require('./daemon'), 9 | Trace = require('./trace'), 10 | hi = require('./highlight'), 11 | process = require('process'), 12 | Store = require('./store'), 13 | http = require('http'), 14 | path = require('path'), 15 | fs = require('fs'), 16 | State = require('./state'), 17 | recordFetch = require('./recordFetch'), 18 | timeHelper = require('./timeHelper'), 19 | Settings = require('./settings'); 20 | 21 | class Bot{ 22 | 23 | constructor(options){ 24 | options = options || {}; 25 | 26 | // process name abbreviated somewhat to make it easier to read on linux systems. 27 | process.title = 'dc-giveawaybot'; 28 | 29 | this.cronEnabled = true; 30 | this.busyProcessingMessage = false; 31 | this.willShutdown = false; 32 | 33 | if(options.cronEnabled === false) 34 | this.cronEnabled = false; 35 | } 36 | 37 | 38 | /** 39 | * Starts the bot. 40 | */ 41 | async start(){ 42 | try 43 | { 44 | this.logger = Logger.instance(); 45 | this.settings = Settings.instance(); 46 | let client = await Client.instance(); 47 | this.state = State.instance(); 48 | this.trace = Trace.instance(); 49 | 50 | if (this.settings.failed){ 51 | console.log('settings.json not found'); 52 | return; 53 | } 54 | 55 | // load all files in 'commands' folder into this.commands object 56 | this.commands = {}; 57 | for (let commandFile of fs.readdirSync(path.join(__dirname, 'commands'))){ 58 | let name = path.basename(commandFile.slice(0, -3)); 59 | this.commands[name] = require(`./commands/${name}`); 60 | } 61 | 62 | client.on('message', async function(message) { 63 | return this._onMessage(message) 64 | }.bind(this)); 65 | 66 | process.on('unhandledRejection', function (reason){ 67 | this.logger.error.error(`Unhandled promise : ${reason}`); 68 | }.bind(this)); 69 | 70 | // start the daemon 71 | this.daemon = Daemon.instance(); 72 | this.daemon.onProcessExpired = async function(){ 73 | this.willShutdown = true; 74 | if (!this.busyProcessingMessage) 75 | await this.shutdown(); 76 | }.bind(this); 77 | 78 | if (this.cronEnabled) 79 | await this.daemon.start(); 80 | 81 | // if health monitor enabled, return time since last daemon sign of life. The daemon process will update this 82 | // SOL after a tick process. Daemon hanging is a common cause of the bot failing 83 | if (this.settings.values.enableHealthMonitor) { 84 | 85 | http.createServer(function (req, res) { 86 | 87 | res.writeHead(200, {'Content-Type': 'text/plain'}); 88 | 89 | if (req.url === '/status'){ 90 | let minutesSinceLastDaemonTick = timeHelper.secondsSince(this.daemon.lastUpdateTime); 91 | 92 | if (this.settings.values.healthMonitorThreshold === null || 93 | this.settings.values.healthMonitorThreshold === undefined || 94 | this.settings.values.healthMonitorThreshold >= minutesSinceLastDaemonTick) 95 | return res.end(minutesSinceLastDaemonTick.toString()); 96 | 97 | return; 98 | } 99 | 100 | res.end('unsupported request'); 101 | 102 | }.bind(this)).listen(this.settings.values.healthMonitorPort); 103 | 104 | console.log(`health monitor running on port ${this.settings.values.healthMonitorPort}`); 105 | } 106 | 107 | 108 | console.log('discord-giveawaybot is now running.'); 109 | } catch (ex){ 110 | await this._handleUnexpectedError(ex); 111 | } 112 | } 113 | 114 | 115 | /** 116 | * Stops the daemon - allows the bot process to exit. This is required by unit tests, which would otherwise hang. 117 | */ 118 | stop(){ 119 | if (this.daemon) 120 | this.daemon.stop(); 121 | } 122 | 123 | 124 | /** 125 | * Common handler for all unhandled exceptions derived from incoming user messages. Errors are logged to file and 126 | * reported to user - if these fail, they get logged to the OS console. Bot shuts down on error, keeping bot alive 127 | * after unexpected errors can cause the bot to hang. If you want to make the bot resilient to errors, do so outside 128 | * the bot, with docker, pm2 or systemd. 129 | */ 130 | async _handleUnexpectedError(ex, message){ 131 | try 132 | { 133 | console.log(ex); 134 | this.logger.error.error(ex); 135 | if (message) 136 | message.author.send('An unexpected error occurred and has been logged. Bot will exit.'); 137 | } catch (ex){ 138 | console.log('An unexpected error occurred, failed to return message to user.', ex); 139 | } finally { 140 | await this.shutdown(); 141 | } 142 | } 143 | 144 | 145 | /** 146 | * Shuts bot processes down gracefully. This is triggered triggered when the daemon has flagged the bot for a 147 | * shutdown. If a message is being processed, shutdown will pause until the message handler has exited. 148 | */ 149 | async shutdown(){ 150 | 151 | // close Loki down gracefully 152 | let store = await Store.instance(); 153 | await store.close(); 154 | 155 | console.log(`Bot doing controlled shut down.`); 156 | process.exitCode = 0; 157 | process.exit(); 158 | } 159 | 160 | 161 | /** 162 | * Entry point for all incoming user messages. This is where all user-driven interaction (as opposed to daemon) 163 | * starts. The message content is sanitized, passed down to one of the command processors in /lib/commands, and the 164 | * result returned as a Discord message reply. 165 | */ 166 | async _onMessage(message){ 167 | return new Promise(async function(resolve, reject){ 168 | try { 169 | this.busyProcessingMessage = true; 170 | 171 | // capture guild id 172 | if (!this.settings.values.guildId){ 173 | let guild = await recordFetch.fetchGuild(message.client); 174 | if (guild) { 175 | this.settings.values.guildId = guild.id; 176 | this.settings.save(); 177 | } else { 178 | await message.reply('Critical error - unable to resolve bot guild. Bot cannot function.'); 179 | return resolve(codes.MESSAGE_REJECTED_GUILDNOTRESOLVABLE); 180 | } 181 | } 182 | 183 | // reject messages @bot (these are public messages), unless message is to set "channel" 184 | if (message.content.indexOf(`<@${message.client.user.id}>`) === 0 && message.content.toLowerCase().trim() !== `<@${message.client.user.id}> channel`) { 185 | await message.reply('Please message me directly.'); 186 | return resolve(codes.MESSAGE_REJECTED_UNTARGETED); 187 | } 188 | 189 | // repeat of the above, only for dm 190 | if (message.content.toLowerCase().trim() !== `<@${message.client.user.id}> channel` && message.channel.type !== 'dm') 191 | return resolve(codes.MESSAGE_REJECTED_UNTARGETED); 192 | 193 | // ignore messages from other bots 194 | if (message.author.bot) 195 | return resolve(codes.MESSAGE_REJECTED_BOT); 196 | 197 | // ignore global messages 198 | if (message.mentions.everyone) 199 | return resolve(codes.MESSAGE_REJECTED_GLOBAL); 200 | 201 | // ignore group messages 202 | if (message.mentions.users.size > 1) 203 | return resolve(codes.MESSAGE_REJECTED_GROUPMESSAGE); 204 | 205 | // ignore messages not aimed specifically at me 206 | if (message.mentions.users.array().length > 0 && !message.mentions.users.find('id', message.client.user.id)) 207 | return resolve(codes.MESSAGE_REJECTED_UNTARGETED); 208 | 209 | // sanitize incoming message text 210 | let atBot = `<@${message.client.user.id}>`; 211 | let messageText = message.content; 212 | if (messageText.indexOf(atBot) === 0) 213 | messageText = messageText.substr(atBot.length); 214 | 215 | messageText = messageText.trim(); 216 | 217 | // get first word of incoming message, this will be the command the user is trying to execute 218 | let args = messageText.split(' '), 219 | requestedCommand = args.length ? args[0] : ''; 220 | 221 | let command = this.commands[requestedCommand.toLowerCase()]; 222 | if (!command){ 223 | await message.author.send(`Sorry, I don't understand that command. Try asking me ${hi('help')} maybe?`); 224 | return resolve(codes.MESSAGE_REJECTED_UNKNOWNCOMMAND); 225 | } 226 | 227 | // handle the command, return result 228 | let result = await command(message, messageText); 229 | resolve(result); 230 | 231 | // friendly alerts go here .... 232 | // if a channel has not been set, remind the user of it 233 | if (requestedCommand !== 'channel' && !this.settings.values.giveawayChannelId) 234 | await message.author.send(this.trace(`Giveaway channel not set - please go to your intended giveaway channel and ${hi('@me channel')} where 'me' is my botname.`, codes.ALERT_CHANNELNOTSET)); 235 | 236 | // if there are pending status messages, remind the user to check statuses. 237 | if (requestedCommand !== 'status' && this.state.length()) 238 | await message.author.send(`There are issues with the bot - use ${hi('status')} for more info, or ask an admin to do so.`); 239 | 240 | } catch (ex) { 241 | await this._handleUnexpectedError(ex, message); 242 | return reject(ex); 243 | } finally { 244 | this.busyProcessingMessage = false; 245 | if (this.willShutdown) 246 | await this.shutdown(); 247 | } 248 | }.bind(this)); 249 | } 250 | } 251 | 252 | module.exports = Bot; -------------------------------------------------------------------------------- /lib/bracketHelper.js: -------------------------------------------------------------------------------- 1 | let Settings = require('./settings'); 2 | 3 | module.exports = { 4 | 5 | toString : function toString(bracket){ 6 | return `${bracket.min}-${bracket.max}`; 7 | }, 8 | 9 | findBracketForPrice : function findBracketForPrice(price){ 10 | let settings = Settings.instance(); 11 | return settings.values.brackets ? settings.values.brackets.find(function(bracket){ 12 | return bracket.min <= price && bracket.max >= price; 13 | }) : null; 14 | }, 15 | 16 | fromString : function(string){ 17 | let settings = Settings.instance(); 18 | return settings.values.brackets ? settings.values.brackets.find(function(bracket){ 19 | return this.toString(bracket) === string; 20 | }.bind(this)) : null; 21 | } 22 | 23 | }; -------------------------------------------------------------------------------- /lib/channelProvider.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets a channel for the bot to write to. 3 | */ 4 | let Settings = require('./settings'), 5 | State = require('./state'); 6 | 7 | module.exports = function(client){ 8 | let settings = Settings.instance(), 9 | state = State.instance(); 10 | 11 | if (!settings.values.giveawayChannelId){ 12 | state.add('settings.giveawayChannelId', `The current giveaway channel ${settings.values.giveawayChannelId} does not exist. Please reset the channel using "@[botname] channel" in the channel you want to use.`); 13 | return null; 14 | } 15 | 16 | for (let channel of client.channels.array()){ 17 | if (channel.id === settings.values.giveawayChannelId) 18 | return channel; 19 | } 20 | 21 | state.add('settings.giveawayChannelId', `The current giveaway channel ${settings.values.giveawayChannelId} does not exist. Please reset the channel using "@[botname] channel" in the channel you want to use.`); 22 | return null; 23 | }; -------------------------------------------------------------------------------- /lib/clientProvider.js: -------------------------------------------------------------------------------- 1 | let _isTest = false, 2 | Settings = require('./settings'), 3 | timeHelper = require('./timeHelper'), 4 | _instance = null, 5 | _instanceDate = null, 6 | Discord = require('discord.js'); 7 | 8 | module.exports = { 9 | 10 | /** 11 | * Returns a client instance ready for communication with Discord. 12 | */ 13 | instance : async function(){ 14 | return new Promise(function(resolve, reject){ 15 | try { 16 | if (_isTest && _instance) 17 | return resolve(_instance); 18 | let settings = Settings.instance(); 19 | 20 | if (!_instance || (settings.values.processLifetime && timeHelper.minutesSince(_instanceDate) > settings.values.processLifetime)) { 21 | 22 | _instance = new Discord.Client(); 23 | _instanceDate= new Date(); 24 | _instance.login(settings.values.token); 25 | _instance.on('ready', function(){ 26 | return resolve(_instance); 27 | }); 28 | } else { 29 | resolve(_instance); 30 | } 31 | } catch(ex){ 32 | reject (ex); 33 | } 34 | }) 35 | }, 36 | 37 | /** 38 | * Force an instance for testing 39 | */ 40 | set : function(testingInstance){ 41 | _instance = testingInstance; 42 | _isTest = true; 43 | } 44 | }; -------------------------------------------------------------------------------- /lib/codes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | MESSAGE_REJECTED_BOT : 1, 3 | MESSAGE_REJECTED_UNTARGETED : 2, 4 | MESSAGE_REJECTED_GROUPMESSAGE: 3, 5 | MESSAGE_REJECTED_GLOBAL: 4, 6 | MESSAGE_REJECTED_UNKNOWNCOMMAND: 5, 7 | MESSAGE_REJECTED_CHANNELNOTSET: 6, 8 | MESSAGE_REJECTED_INVALIDARGUMENTS: 7, 9 | MESSAGE_REJECTED_INVALIDTIMEFORMAT: 8, 10 | MESSAGE_REJECTED_INVALIDINT: 9, 11 | MESSAGE_REJECTED_PERMISSION: 10, 12 | MESSAGE_REJECTED_INVALIDBRACKET: 11, 13 | MESSAGE_REJECTED_GIVEAWAYNOTFOUND: 12, 14 | MESSAGE_REJECTED_GIVEAWAYCLOSED: 13, 15 | MESSAGE_REJECTED_INVALIDCHANNEL:14, 16 | MESSAGE_REJECTED_INVALIDGAMEURL: 15, 17 | MESSAGE_REJECTED_NOPRICE : 16, 18 | MESSAGE_REJECTED_NOPARTICIPANTS: 17, 19 | MESSAGE_REJECTED_NOAVAILABLEPARTICIPANTS: 18, 20 | MESSAGE_REJECTED_NOTCLOSED: 19, 21 | MESSAGE_REJECTED_INVALIDUSERNAME : 20, 22 | MESSAGE_REJECTED_MAXCONCURRENTGIVEAWAYS: 21, 23 | MESSAGE_REJECTED_GUILDNOTRESOLVABLE: 22, 24 | MESSAGE_ACCEPTED: 23, 25 | MESSAGE_ACCEPTED_HELPRETURNED: 24, 26 | MESSAGE_ACCEPTED_BRACKETSLIST: 25, 27 | MESSAGE_ACCEPTED_STATUSCLEARED : 26, 28 | 29 | ALERT_CHANNELNOTSET: 26, 30 | 31 | DAEMON_FINISHED: 26 32 | }; -------------------------------------------------------------------------------- /lib/commands/brackets.js: -------------------------------------------------------------------------------- 1 | // brackets : 0-100-300 Number separated by dashes. This example creates two brackets, 0-100 and 100-300. Prices are always USD. 2 | 3 | let permissionHelper = require('../permissionHelper'), 4 | argsHelper = require('../argsHelper'), 5 | codes = require('../codes'), 6 | messages = require('../messages'), 7 | Logger = require('../logger'), 8 | hi = require('../highlight'), 9 | Settings = require('../settings'); 10 | 11 | module.exports = async function (message, messageText){ 12 | let settings = Settings.instance(), 13 | infoLog = Logger.instance().info, 14 | hasArgs = argsHelper.stringSplit(messageText).length > 1, 15 | args = argsHelper.toArgsObject(messageText), 16 | isAdmin = await permissionHelper.isAdmin(message.client, message.author); 17 | 18 | // merge args 19 | if (args.h) args.help = true; 20 | if (args.b) args.brackets = args.b; 21 | 22 | if (args.brackets){ 23 | if (!isAdmin){ 24 | await message.author.send(messages.permissionError); 25 | return codes.MESSAGE_REJECTED_PERMISSION; 26 | } 27 | 28 | if (!(typeof args.brackets === 'string')){ 29 | await message.author.send(`-b argument cannot be empty.`); 30 | return codes.MESSAGE_REJECTED_INVALIDBRACKET; 31 | } 32 | 33 | let bracketParts = args.brackets.split('-').filter(function(part){ return part.length ? part : null; }); 34 | if (bracketParts.length < 2){ 35 | await message.author.send(`You should specify at least one price range, ex, ${hi('brackets -b 0-100')}.`); 36 | return codes.MESSAGE_REJECTED_INVALIDBRACKET; 37 | } 38 | 39 | let brackets = []; 40 | for (let i = 0 ; i < bracketParts.length ; i ++){ 41 | let bracket = bracketParts[i]; 42 | 43 | if (isNaN(bracket)){ 44 | await message.author.send(`${hi(bracket)} is not number.`); 45 | return codes.MESSAGE_REJECTED_INVALIDBRACKET; 46 | } 47 | 48 | bracket = parseInt(bracket); 49 | if (brackets.length > 0) 50 | brackets[brackets.length - 1].max = bracket; 51 | if (i !== bracketParts.length - 1) 52 | brackets.push({min : bracket}); 53 | } 54 | 55 | settings.values.brackets = brackets; 56 | settings.save(); 57 | 58 | await message.author.send(`${hi(brackets.length)} brackets were set.`); 59 | infoLog.info(`User ${message.author.username} set brackets to ${args.brackets}.`); 60 | return codes.MESSAGE_ACCEPTED; 61 | } 62 | 63 | if (args.help){ 64 | if (!isAdmin){ 65 | await message.author.send(messages.permissionError); 66 | return codes.MESSAGE_REJECTED_PERMISSION; 67 | } 68 | 69 | await message.author.send( 70 | `${hi('brackets')} divides games up into price groups.\n\n` + 71 | `If someone wins a game, they will not be allowed to enter another giveaway for ${settings.values.winningCooldownDays} days. `+ 72 | `Brackets limits this lockout to only the price group they won in, allowing them to continue entering giveaways at other price points.\n\n` + 73 | `Expected : ${hi('brackets -b price-price..')} \n` + 74 | `Example ${hi('brackets -b 0-50-100-200')} creates 3 brackets 0-50, 50-100, & 100-200.`); 75 | 76 | return codes.MESSAGE_ACCEPTED_HELPRETURNED; 77 | } 78 | 79 | // if args supplied by none of the previous cases caught, assume args are invalid 80 | if (hasArgs){ 81 | await message.author.send(`Invalid command. Try ${hi('brackets -h' )} for help.`); 82 | return codes.MESSAGE_REJECTED_INVALIDARGUMENTS; 83 | } 84 | 85 | // fallthrough - return current bracket info 86 | let reply = ''; 87 | 88 | if (!settings.values.brackets || !settings.values.brackets.length){ 89 | reply = 'No brackets set.'; 90 | } else { 91 | reply += 'The current price brackets are :\n'; 92 | for (let bracket of settings.values.brackets) 93 | reply += `$${hi(bracket.min)} - $${hi(bracket.max)}\n`; 94 | } 95 | 96 | if (isAdmin) 97 | reply += `You can also try ${hi('brackets --help')} for more info.`; 98 | 99 | await message.author.send(reply); 100 | return codes.MESSAGE_ACCEPTED_BRACKETSLIST; 101 | 102 | }; -------------------------------------------------------------------------------- /lib/commands/cancel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * cancel an active giveaway if you are admin, or if competition hasn't started yet and you created it 3 | * 4 | * Command : 5 | * cancel {ID} 6 | */ 7 | let Store = require('../store'), 8 | argsHelper = require('../argsHelper'), 9 | codes = require('../codes'), 10 | recordFetch = require('../recordFetch'), 11 | channelProvider = require('../channelProvider'), 12 | Settings = require('../settings'), 13 | messages = require('../messages'), 14 | Logger = require('../logger'), 15 | hi = require('../highlight'), 16 | permissionHelper = require('../permissionHelper'); 17 | 18 | module.exports = async function (message, messageText){ 19 | 20 | let store = await Store.instance(), 21 | infoLog = Logger.instance().info, 22 | settings = Settings.instance(), 23 | args = argsHelper.toArgsObject(messageText); 24 | 25 | // merge args 26 | if (args.h) args.help = true; 27 | if (args.i) args.id = args.i; 28 | 29 | if (args.id){ 30 | 31 | // ensure int 32 | if (isNaN(args.id)){ 33 | await message.author.send(`${hi(args.id)} is not a valid id.`); 34 | return codes.MESSAGE_REJECTED_INVALIDINT; 35 | } 36 | 37 | let giveaway = store.get(args.id); 38 | if (!giveaway){ 39 | await message.author.send(`Giveaway with id ${hi(args.id)} does not exist.`); 40 | return codes.MESSAGE_REJECTED_GIVEAWAYNOTFOUND; 41 | } 42 | 43 | // if user is giveaway creator or an admin, allow cancel 44 | let isAdmin = await permissionHelper.isAdmin(message.client, message.author); 45 | if (giveaway.ownerId !== message.author.id && !isAdmin){ 46 | await message.author.send(messages.permissionError); 47 | return codes.MESSAGE_REJECTED_PERMISSION; 48 | } 49 | 50 | if (giveaway.status === 'cancelled'){ 51 | await message.author.send('Cancel failed - that giveaway is already cancelled.'); 52 | return codes.MESSAGE_REJECTED_GIVEAWAYCLOSED; 53 | } 54 | 55 | // if user is not admin, user must be owner. owners are not allowed to cancel giveaways that are already closed 56 | // as this wipes the game from winning history, and can be abused. only admins are allowed to do this. 57 | if (giveaway.status === 'closed' && !isAdmin){ 58 | await message.author.send('Cancel failed - only an admin can cancel a closed giveaway.'); 59 | return codes.MESSAGE_REJECTED_PERMISSION; 60 | } 61 | 62 | giveaway.status = 'cancelled'; 63 | giveaway.ended = new Date().getTime(); 64 | store.update(giveaway); 65 | 66 | // find and delete giveaway message. this won't be necessary if the giveaway hasn't started yet 67 | let channel = channelProvider(message.client, settings); 68 | if (channel && giveaway.startMessageId) { 69 | let giveAwayMessage = await recordFetch.fetchMessage(channel, giveaway.startMessageId); 70 | if (giveAwayMessage) 71 | giveAwayMessage.delete(); 72 | 73 | let urlMessage = await recordFetch.fetchMessage(channel, giveaway.urlMessageId); 74 | if (urlMessage) 75 | urlMessage.delete(); 76 | } 77 | 78 | await message.author.send('Giveaway cancelled.'); 79 | infoLog.info(`User ${message.author.username} cancelled giveaway ${args.id} - ${giveaway.gameName}.`); 80 | return codes.MESSAGE_ACCEPTED; 81 | } 82 | 83 | if (args.help){ 84 | await message.author.send( 85 | `${hi('cancel')} stops an ongoing or queued giveaway. Only admins or the giveaway creator can cancel a giveaway.\n\n` + 86 | `Expected : ${hi('cancel --id giveawayId')} \n`+ 87 | `Example : ${hi('cancel --id 5')} \n\n`+ 88 | `To get a giveaway id try the ${hi('list')} command` 89 | ); 90 | return codes.MESSAGE_ACCEPTED_HELPRETURNED; 91 | } 92 | 93 | await message.author.send(`Invalid command. Try ${hi('cancel --help')} for more info.`); 94 | return codes.MESSAGE_REJECTED_INVALIDARGUMENTS; 95 | }; -------------------------------------------------------------------------------- /lib/commands/channel.js: -------------------------------------------------------------------------------- 1 | // cancel ID : cancel an active giveaway if you are admin, or if competition hasn't started yet and you created it 2 | 3 | let Settings = require('./../settings'), 4 | argsHelper = require('../argsHelper'), 5 | codes = require('./../codes'), 6 | messages = require('./../messages'), 7 | State = require('./../state'), 8 | Logger = require('./../logger'), 9 | hi = require('./../highlight'), 10 | permissionHelper = require('./../permissionHelper'); 11 | 12 | module.exports = async function (message, messageText){ 13 | 14 | let settings = Settings.instance(), 15 | state = State.instance(), 16 | infoLog = Logger.instance().info, 17 | args = argsHelper.toArgsObject(messageText), 18 | isAdmin = await permissionHelper.isAdmin(message.client, message.author); 19 | 20 | if (!isAdmin){ 21 | message.author.send(messages.permissionError); 22 | return codes.MESSAGE_REJECTED_PERMISSION; 23 | } 24 | 25 | if (args.h) args.help = true; 26 | if (args.help) { 27 | await message.reply( 28 | `${hi('channel')} sets a channel as the one the bot will broadcast giveaways in.\n\n` + 29 | `Expected: ${hi('@bot channel')} from the channel you want to set. \n` + 30 | `Example: ${hi('@ourGiveAwayBot channel')} in #general, if your bot user is called ${hi('ourGiveAwayBot')}.`); 31 | 32 | return codes.MESSAGE_ACCEPTED_HELPRETURNED; 33 | } 34 | 35 | if (message.channel.type === 'dm'){ 36 | await message.author.send('Only a public channel can be used for giveaways.'); 37 | return codes.MESSAGE_REJECTED_INVALIDCHANNEL; 38 | } 39 | 40 | settings.values.giveawayChannelId = message.channel.id; 41 | settings.save(); 42 | 43 | await message.reply(`The channel ${hi(message.channel.name)} will now be used for giveaways.`); 44 | infoLog.info(`User ${message.author.username} set active giveaway channel to ${message.channel.name}.`); 45 | state.remove('channel_not_set'); 46 | state.remove('settings.giveawayChannelId'); 47 | 48 | return codes.MESSAGE_ACCEPTED; 49 | 50 | }; -------------------------------------------------------------------------------- /lib/commands/help.js: -------------------------------------------------------------------------------- 1 | // help : displays help info 2 | let codes = require('../codes'), 3 | Settings = require('../settings'), 4 | permissionHelper = require('../permissionHelper'), 5 | hi = require('../highlight'); 6 | 7 | module.exports = async function (message){ 8 | 9 | let settings = Settings.instance(), 10 | isAdmin = await permissionHelper.isAdmin(message.client, message.author), 11 | isAdminOrCanGiveaway = await permissionHelper.isAdminOrHasRole(message.client, message.author, settings.values.giveawayRole), 12 | text = []; 13 | 14 | // all users 15 | text.push({ seq : 'h', text : `${hi('help')} : displays this text.` }); 16 | text.push({ seq : 'l', text : `${hi('list')} : lists giveaways.`}); 17 | text.push({ seq : 'r', text : `${hi('rules')} : shows rules.`}); 18 | text.push({ seq : 'm', text : `${hi('me')} : Tells you if you're on cooldown.`}); 19 | text.push({ seq : '', text : `Hi, I'm giveaway bot. I give things away on your behalf. My commands are :`}); 20 | text.push({ seq : 'zzz', text : `Command --help or -h gets you detailed instructions, egs ${hi('list --help')}. All commands should be sent to me in direct chat (PM).` }); 21 | text.push({ seq : 'b', text : `${hi('brackets')} : price brackets for games.` }); 22 | 23 | // admin / giveaway creator users 24 | if (isAdminOrCanGiveaway){ 25 | text.push({ seq : 'c', text : `${hi('cancel')} : Cancels a giveaway.`}); 26 | text.push({ seq : 'r', text : `${hi('reroll')} : rerolls a winner on a finished giveaway.`}); 27 | text.push({ seq : 'start', text : `${hi('start')} : starts a giveaway immediately.`}); 28 | } 29 | 30 | //admins only 31 | if (isAdmin){ 32 | // my wife forced me to hide this here :( Please forgive me, I had no choice. 33 | text.push({ seq : 'q', text : `${hi('queue')} : queues a giveaway to start in the future. Start time is when it starts from now, duration how long it runs for. Activation key is optional.`}); 34 | text.push({ seq : 'stat', text : `${hi('status')} : gets bot status.`}); 35 | text.push({ seq : 'c', text : `${hi('channel')} : sets a channel as the one giveaways will happen in - *Note : this command must be typed in the channel you want to set*.`}); 36 | } 37 | 38 | // order alphabetically by 'seq' property 39 | text.sort(function(a, b) { 40 | return a.seq > b.seq ? 1 : 41 | a.seq < b.seq ? -1 : 42 | 0; 43 | }); 44 | 45 | let output = ''; 46 | for (let item of text) 47 | output += item.text + '\n'; 48 | 49 | await message.author.send(output); 50 | return codes.MESSAGE_ACCEPTED; 51 | 52 | }; -------------------------------------------------------------------------------- /lib/commands/list.js: -------------------------------------------------------------------------------- 1 | // list : list everything 2 | let dateFormat = require('dateformat'), 3 | permissionHelper = require('./../permissionHelper'), 4 | Settings = require('./../settings'), 5 | argsHelper = require('../argsHelper'), 6 | timeHelper = require('./../timeHelper'), 7 | Store = require('./../store'), 8 | codes = require('./../codes'), 9 | Trace = require('./../trace'), 10 | hi = require('./../highlight'); 11 | 12 | module.exports = async function (message, messageText){ 13 | let settings = Settings.instance(), 14 | args = argsHelper.toArgsObject(messageText), 15 | trace = Trace.instance(), 16 | store = await Store.instance(); 17 | 18 | // discord message size limit is 2000 chars, to prevent list from breaking this we split 19 | // reply into 2000-character chunks 20 | let giveaways = store.list(), 21 | chunks = []; 22 | 23 | if (args.h) args.help = true; 24 | 25 | if (args.help) 26 | return await message.reply(trace( 27 | `${hi('list')} returns a list of giveaways.\n\n` + 28 | `Expected: ${hi('list')} lists giveaways that are currently ongoing. \n` + 29 | `Expected: ${hi('list --all')} lists current and completed giveaways up to ${settings.values.deleteGiveawaysAfter} days ago. \n`, 30 | codes.MESSAGE_ACCEPTED_HELPRETURNED)); 31 | 32 | if (!args.all) 33 | giveaways = giveaways.filter(function(giveaway){ 34 | return giveaway.status === 'closed' || giveaway.status === 'cancelled' ? null: giveaway; 35 | }); 36 | 37 | let isAdmin = await permissionHelper.isAdmin(message.client, message.author); 38 | 39 | if (giveaways.length){ 40 | 41 | giveaways = giveaways.sort(function(a,b){ 42 | return a.created < b.created ? 1 : 43 | a.created > b.created ? -1 : 44 | 0; 45 | }); 46 | 47 | for (let giveaway of giveaways){ 48 | 49 | // pending giveaways are visible only to admins and authors 50 | if (giveaway.status === 'pending' && !giveaway.ownerId !== message.author.id && !isAdmin) 51 | continue; 52 | 53 | let chunk = '', 54 | created = new Date(giveaway.created), 55 | dateCreated = dateFormat(created, 'mmm dS h:MM'); 56 | 57 | chunk += 58 | `id: ${giveaway.id} ${giveaway.gameName} ${dateCreated}, ` + 59 | ` ${giveaway.participants.length} participants, `; 60 | 61 | if (giveaway.status === 'pending') { 62 | chunk += ' Starts at ' + timeHelper.timePlusMinutes(giveaway.created, giveaway.start); 63 | } else if (giveaway.status === 'open') { 64 | chunk += ' Ends at ' + timeHelper.timePlusMinutes(giveaway.started, giveaway.durationMinutes); 65 | } else if (giveaway.status === 'closed') { 66 | 67 | let span = timeHelper.timespan(created, giveaway.ended); 68 | created.setMinutes(created.getMinutes() + giveaway.ended); 69 | chunk +=` Ended ${dateCreated} ran for ${span}`; 70 | 71 | } else if (giveaway.status === 'cancelled') { 72 | let cancelledDate = timeHelper.toShortDateTimeString(giveaway.ended); 73 | chunk +=` Cancelled at ${cancelledDate}.`; 74 | } 75 | 76 | if (giveaway.winnerId) 77 | chunk += ` Winner: <@${giveaway.winnerId}>`; 78 | 79 | chunk += '\n\n'; 80 | 81 | // max discord message length is 2000 chars 82 | // add chunk to chunks array, either as new item, or appended to last item. 83 | if (!chunks.length || chunks[chunks.length - 1].length + chunk.length >= 2000){ 84 | chunks.push(chunk); 85 | } else 86 | chunks[chunks.length -1] += chunk; 87 | 88 | } // for 89 | 90 | } else { 91 | let reply = `No giveaways found - create one with the ${hi('start')} or ${hi('queue')} commands.`; 92 | if (!args.all) 93 | reply += ` You can also try ${hi('list --all')} to view ongoing and completed giveaways.`; 94 | 95 | chunks.push(reply); 96 | } 97 | 98 | for (let chunk of chunks) 99 | await message.author.send(chunk); 100 | 101 | trace(codes.MESSAGE_ACCEPTED); 102 | }; 103 | -------------------------------------------------------------------------------- /lib/commands/me.js: -------------------------------------------------------------------------------- 1 | // list : list everything 2 | let timeHelper = require('../timeHelper'), 3 | bracketHelper = require('../bracketHelper'), 4 | permissionHelper = require('../permissionHelper'), 5 | codes = require('../codes'), 6 | Store = require('../store'), 7 | Settings = require('../settings'); 8 | 9 | module.exports = async function (message){ 10 | 11 | let settings = Settings.instance(), 12 | store = await Store.instance(), 13 | winnings = store.getWinnings(message.author.id), 14 | isAdmin = await permissionHelper.isAdmin(message.client, message.author), 15 | hasGiveawayRole = await permissionHelper.hasRole(message.client, settings.values.giveawayRole), 16 | reply = ''; 17 | 18 | if (winnings.length ){ 19 | reply += 'You recently won the following game(s):\n'; 20 | for (let winning of winnings){ 21 | let daysSince = timeHelper.daysSince(winning.ended), 22 | coolDown = settings.values.winningCooldownDays - daysSince; 23 | 24 | reply += `${winning.gameName} ${daysSince} days ago. `; 25 | 26 | let bracket = bracketHelper.fromString(winning.bracket); 27 | if (bracket && coolDown >= 0){ 28 | reply += `You'll need to wait ${coolDown} days to try again for a game in the range ${bracket.min}-${bracket.max} ${settings.values.bracketsCurrencyZone}.`; 29 | } 30 | 31 | reply += '\n' 32 | } 33 | } else { 34 | reply = `You haven't won anything in the last ${settings.values.winningCooldownDays} days\n` 35 | } 36 | 37 | if (isAdmin){ 38 | reply += 'You have admin permissions.' 39 | } 40 | 41 | if (hasGiveawayRole){ 42 | reply += 'You can create giveaways' 43 | } 44 | 45 | reply += '\nYou have the following roles:\n'; 46 | reply += await permissionHelper.getRoles(message.client, message.author); 47 | 48 | await message.author.send(reply); 49 | return codes.MESSAGE_ACCEPTED; 50 | 51 | }; 52 | -------------------------------------------------------------------------------- /lib/commands/queue.js: -------------------------------------------------------------------------------- 1 | let argsHelper = require('../argsHelper'), 2 | getTime = require('../getTime'), 3 | createGiveaway = require('../createGiveaway'), 4 | GameInfo = require('../gameInfo'), 5 | messages = require('../messages'), 6 | codes = require('../codes'), 7 | hi = require('../highlight'); 8 | 9 | module.exports = async function (message, messageText){ 10 | 11 | // first check if start command is running in simple mode 12 | let args = argsHelper.stringSplit(messageText), 13 | gameInfoHelper = GameInfo.instance(), 14 | hasSwitches = args.some(function(arg) { return arg.indexOf('-') === 0; }); 15 | 16 | async function showHelp(){ 17 | await message.author.send( 18 | `${hi('queue')} creates a giveaway, but lets you specify a time in the future when the giveaway will publicly commence.\n\n` + 19 | `Simple : ${hi('queue startTime durationTime SteamUrl')}. This easy method doesn't use switches, but works only on standard Steam games. \n\n` + 20 | `More advanced : ${hi('queue -s startTime -d durationTime -u SteamUrl -k key')}. Works only for standard Steam games. Key is the code for activating the game, and is optional. The winner will automatically be messaged this key when the giveaway ends. \n\n` + 21 | `Very advanced : ${hi('queue -s startTime -d durationTime -u url -p price -k key')}. Works for any game, but you have to manually enter the price. Key is optional. \n\n`+ 22 | `Example : ${hi('queue -s 5m -d 1h -u http://store.steampowered.com/app/524220 -k 12345-abcde-12346')} queues a giveaway that starts in 5 minutes, runs for 1 hour, gives away Nier Automata, and messages the key 12345-abcde-12346 to the winner.\n` + 23 | `Use EITHER minutes, hours or days. If you want 2 days you can either enter 48h or 2d, and if you want 5,5 hours, you enter 330m.` + 24 | `${messages.timeFormat}`); 25 | return codes.MESSAGE_REJECTED_INVALIDARGUMENTS; 26 | } 27 | 28 | async function showStartError(){ 29 | await message.author.send(`Start time ${hi(args.start)} is invalid. ${messages.timeFormat}`); 30 | return codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT; 31 | } 32 | 33 | async function showDurationError(){ 34 | await message.author.send(`Duration time ${hi(args.duration)} is invalid. ${messages.timeFormat}`); 35 | return codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT; 36 | } 37 | 38 | if (hasSwitches){ 39 | 40 | args = argsHelper.toArgsObject(messageText); 41 | 42 | // merge args 43 | if (args.s) args.start = args.s; 44 | if (args.d) args.duration = args.d; 45 | if (args.k) args.key = args.k; 46 | if (args.h) args.help = true; 47 | if (args.u) args.url = args.u; 48 | if (args.p) args.price = args.p; 49 | 50 | if (args.help) 51 | return await showHelp(); 52 | 53 | // second and third arg must be time 54 | let start = getTime(args.start), 55 | duration = getTime(args.duration); 56 | 57 | if(!start) 58 | return await showStartError(); 59 | 60 | if (!duration) 61 | return await showDurationError(); 62 | 63 | let gameInfo = await gameInfoHelper.getInfo({ 64 | price : args.price, 65 | url : args.url 66 | }); 67 | 68 | let result = await createGiveaway(message, message.client, start, duration, args.key, gameInfo); 69 | return result; 70 | } 71 | 72 | // 4 args is the special "shorthand" queue that takes start, duration and url only, and the url has to be a valid steam app 73 | if (args.length === 4){ 74 | let start = getTime(args[1]), 75 | duration = getTime(args[2]); 76 | 77 | if(!start) 78 | return await showStartError(); 79 | 80 | if (!duration) 81 | return await showDurationError(); 82 | 83 | let gameInfo = await gameInfoHelper.getInfo({ 84 | url : args[3] 85 | }); 86 | 87 | let result = await createGiveaway(message, message.client, start, duration, null, gameInfo); 88 | return result; 89 | } 90 | 91 | return await showHelp(); 92 | }; 93 | -------------------------------------------------------------------------------- /lib/commands/reroll.js: -------------------------------------------------------------------------------- 1 | // reroll ID : rerolls the winner of a competition, cancelled winners cannot win again, only admins can reroll 2 | 3 | let Store = require('../store'), 4 | codes = require('../codes'), 5 | hi = require('../highlight'), 6 | recordFetch = require('../recordFetch'), 7 | Logger = require('../logger'), 8 | messages = require('../messages'), 9 | Settings = require('../settings'), 10 | argsHelper = require('../argsHelper'), 11 | permissionHelper = require('../permissionHelper'), 12 | channelProvider = require('../channelProvider'), 13 | giveawayMessageWriter = require('../giveawayMessageWriter'), 14 | winnerSelector = require('../winnerSelector'); 15 | 16 | module.exports = async function (message, messageText){ 17 | let settings = Settings.instance, 18 | infoLog = Logger.instance().info, 19 | store = await Store.instance(), 20 | args = argsHelper.toArgsObject(messageText); 21 | 22 | if (args.h) args.help = true; 23 | if (args.i) args.id = args.i; 24 | 25 | if (args.help){ 26 | await message.author.send( 27 | `${hi('reroll')} randomly selects another winner for a giveaway. It can be used only by admins or the giveaway creator.\n\n` + 28 | `Expected: ${hi('reroll --id giveawayid')}\n` + 29 | `Example: ${hi('reroll --id 5')}\n` + 30 | `To get a giveaway id try the ${hi('list')} command` 31 | ); 32 | return codes.MESSAGE_ACCEPTED_HELPRETURNED; 33 | } 34 | 35 | if (!args.id){ 36 | await message.author.send(`Invalid reroll command.`); 37 | return codes.MESSAGE_REJECTED_INVALIDARGUMENTS; 38 | } 39 | 40 | // ensure int 41 | if (isNaN(args.id)){ 42 | await message.author.send(`${hi(args.id)} is not a valid giveaway id - only numbers are allowed.`); 43 | return codes.MESSAGE_REJECTED_INVALIDINT; 44 | } 45 | 46 | let giveaway = store.get(args.id); 47 | if (!giveaway){ 48 | await message.author.send(`No giveaway with id ${hi(args.id)} could be found. Use ${hi('list')} to find giveaway id's.`); 49 | return codes.MESSAGE_REJECTED_GIVEAWAYNOTFOUND; 50 | } 51 | 52 | // if user is giveaway creator or an admin, allow cancel 53 | let isAdmin = await permissionHelper.isAdmin(message.client, message.author); 54 | if (giveaway.ownerId !== message.author.id && !isAdmin){ 55 | await message.author.send(messages.permissionError); 56 | return codes.MESSAGE_REJECTED_PERMISSION; 57 | } 58 | 59 | // ensures that giveaway is complete 60 | if (giveaway.status !== 'closed') { 61 | await message.author.send('Only a closed giveaway can be rerolled.'); 62 | return codes.MESSAGE_REJECTED_NOTCLOSED; 63 | } 64 | 65 | // pick a random winner 66 | if (!giveaway.participants.length){ 67 | await message.author.send('This giveaway has no participants, rerolling is not possible.'); 68 | return codes.MESSAGE_REJECTED_NOPARTICIPANTS; 69 | } 70 | 71 | if (giveaway.rejectedWinners.length === giveaway.participants.length){ 72 | await message.author.send('All participants in this giveaway have already been rejected, rerolling is not possible.'); 73 | return codes.MESSAGE_REJECTED_NOAVAILABLEPARTICIPANTS; 74 | } 75 | 76 | await winnerSelector(giveaway); 77 | 78 | store.update(giveaway); 79 | 80 | infoLog.info(`User ${message.author.username} rerolled on giveaway id ${giveaway.id} - ${giveaway.gameName}.`); 81 | 82 | if (giveaway.winnerId){ 83 | let user = await message.client.fetchUser(giveaway.winnerId); 84 | await message.author.send(`Giveaway winner is now ${hi(user.username)}.`); 85 | infoLog.info(`User ${user.username} won reroll for giveaway id ${giveaway.id} - ${giveaway.gameName}.`); 86 | 87 | // update giveaway message 88 | let channel = channelProvider(message.client, settings); 89 | let giveAwayMessage = await recordFetch.fetchMessage(channel, giveaway.startMessageId); 90 | await giveawayMessageWriter.writeWinner(giveAwayMessage, giveaway); 91 | 92 | // broadcast winning, send message to user 93 | await giveawayMessageWriter.sendWinnerMessages(message.client, giveaway, user); 94 | } else { 95 | await message.author.send('Failed to assign new winner to giveaway. This giveaway currently has no winner.'); 96 | } 97 | 98 | return codes.MESSAGE_ACCEPTED; 99 | 100 | }; -------------------------------------------------------------------------------- /lib/commands/rules.js: -------------------------------------------------------------------------------- 1 | // help : displays help info 2 | let codes = require('../codes'), 3 | argsHelper = require('../argsHelper'), 4 | Settings = require('../settings'), 5 | permissionHelper = require('../permissionHelper'), 6 | messages = require('../messages'), 7 | hi = require('../highlight'); 8 | 9 | module.exports = async function (message, messageText){ 10 | 11 | let settings = Settings.instance(), 12 | isAdmin = await permissionHelper.isAdmin(message.client, message.author); 13 | 14 | let args = argsHelper.toArgsObject(messageText); 15 | 16 | if (args.h) args.help = true; 17 | 18 | if (args.help) { 19 | let help = `${hi('rules')} displays a simple rules message.\n\n`; 20 | if (isAdmin) 21 | help += `${hi('rules --text "your rules text"')} sets the rules text.`; 22 | await message.author.send(help); 23 | return codes.MESSAGE_ACCEPTED; 24 | } 25 | 26 | if (args.text){ 27 | if (!isAdmin){ 28 | await message.author.send(messages.permissionError); 29 | return codes.MESSAGE_REJECTED_PERMISSION; 30 | } 31 | 32 | settings.values.ruleText = args.text; 33 | settings.save(); 34 | await message.author.send(`Rule text updated to : \n\n ${settings.values.ruleText}`); 35 | return codes.MESSAGE_ACCEPTED; 36 | } 37 | 38 | // display rules 39 | let text = settings.values.ruleText; 40 | if (!text) 41 | text = 'Rule text is not set. You might want to tell an admin about that.'; 42 | await message.author.send(text); 43 | 44 | return codes.MESSAGE_ACCEPTED; 45 | 46 | }; -------------------------------------------------------------------------------- /lib/commands/start.js: -------------------------------------------------------------------------------- 1 | let argsHelper = require('../argsHelper'), 2 | getTime = require('../getTime'), 3 | createGiveaway = require('../createGiveaway'), 4 | messages = require('../messages'), 5 | GameInfo = require('../gameInfo'), 6 | hi = require('../highlight'), 7 | codes = require('../codes'); 8 | 9 | module.exports = async function (message, messageText){ 10 | 11 | // first check if start command is running in simple mode 12 | let args = argsHelper.stringSplit(messageText), 13 | gameInfoHelper = GameInfo.instance(), 14 | hasSwitches = args.some(function(arg) { return arg.indexOf('-') === 0; }); 15 | 16 | async function showHelp(){ 17 | await message.author.send( 18 | `${hi('start')} begins a giveaway immediately. The giveaway runs for an amount of time, after which a random entrant is picked as the winner. \n\n` + 19 | `Simple : ${hi('start time SteamUrl')}. This easy method doesn't use switches, but works only on standard Steam games. \n`+ 20 | `Example: ${hi('start 5h htt://store.steampowered.com/app/593280/Cat_Quest/')} creates a giveaway for Cat Quest that runs for 5 hours.\n\n`+ 21 | `Advanced : You can also giveaway titles that are not standard Steam games (bundles, non-Steam games, special editions etc) with : ${hi('start -d time -u url -p price')}.\n`+ 22 | `Example: ${hi('start -d 5h -u http://store.steampowered.com/app/593280')} creates a giveaway for Cat Quest that runs for 5 hours.\n`+ 23 | `When entering prices for a game, user the full price, not current special or discount prices.\n` + 24 | `${messages.timeFormat}.`); 25 | return codes.MESSAGE_REJECTED_INVALIDARGUMENTS; 26 | } 27 | 28 | async function showDurationError(){ 29 | await message.author.send(`Duration time ${hi(args[1])} is invalid. ${messages.timeFormat}`); 30 | return codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT; 31 | } 32 | 33 | if (hasSwitches){ 34 | 35 | args = argsHelper.toArgsObject(messageText); 36 | 37 | // merge args 38 | if (args.d) args.duration = args.d; 39 | if (args.h) args.help = true; 40 | if (args.u) args.url = args.u; 41 | if (args.p) args.price = args.p; 42 | 43 | if (args.help) 44 | return await showHelp(); 45 | 46 | // second and third arg must be time 47 | let duration = getTime(args.duration); 48 | if(!duration) 49 | return await showDurationError(); 50 | 51 | let gameInfo = await gameInfoHelper.getInfo({ 52 | price : args.price, 53 | url : args.url 54 | }); 55 | 56 | let result = await createGiveaway(message, message.client, null, duration, null, gameInfo); 57 | return result; 58 | } 59 | 60 | // 3 args is the special "shorthand" start that takes a duration and url only, and the url has to be a valid steam app 61 | if (args.length === 3){ 62 | let duration = getTime(args[1]); 63 | 64 | if(!duration) 65 | return await showDurationError(); 66 | 67 | let gameInfo = await gameInfoHelper.getInfo({ 68 | url : args[2] 69 | }); 70 | 71 | let result = await createGiveaway(message, message.client, null, duration, null, gameInfo); 72 | return result; 73 | } 74 | 75 | return await showHelp(); 76 | }; 77 | -------------------------------------------------------------------------------- /lib/commands/status.js: -------------------------------------------------------------------------------- 1 | let State = require('../state'), 2 | argsHelper = require('../argsHelper'), 3 | permissionHelper = require('../permissionHelper'), 4 | messages = require('../messages'), 5 | recordFetch = require('../recordFetch'), 6 | Settings = require('../settings'), 7 | hi = require('../highlight'), 8 | codes = require('../codes'); 9 | 10 | module.exports = async function (message, messageText){ 11 | 12 | let state = State.instance(), 13 | settings = Settings.instance(), 14 | args = argsHelper.toArgsObject(messageText), 15 | isAdmin = await permissionHelper.isAdmin(message.client, message.author); 16 | 17 | if (!isAdmin){ 18 | await message.author.send(messages.permissionError); 19 | return codes.MESSAGE_REJECTED_PERMISSION; 20 | } 21 | 22 | if (args.h) args.help = true; 23 | if (args.c) args.clear = true; 24 | 25 | if (args.help) { 26 | await message.reply( 27 | `${hi('status')} lets you know if anything is seriously wrong with the bot. The bot will warn in replies or in posts to the giveaway channel that its in trouble.\n\n` + 28 | `You can clear current status warnings with : ${hi('status --clear')}. The warnings will reappear if the issues recur.`); 29 | 30 | return codes.MESSAGE_ACCEPTED_HELPRETURNED; 31 | } 32 | 33 | if (args.clear){ 34 | state.clear(); 35 | await message.author.send('Status cleared'); 36 | return codes.MESSAGE_ACCEPTED_STATUSCLEARED; 37 | } 38 | 39 | let guild = await recordFetch.fetchGuildById(message.client, settings.values.guildId); 40 | if (!guild){ 41 | await message.author.send(`Failed to retrieve guild ${settings.values.guildId}`); 42 | return codes.MESSAGE_REJECTED_GUILDNOTRESOLVABLE; 43 | } 44 | 45 | let reply = `Guild : ${guild.name} \n\n`, 46 | items = state.get(); 47 | 48 | if (items.length){ 49 | 50 | for (let item in items) 51 | reply += `${items[item]}\n`; 52 | 53 | reply += `\n\n You can dismiss these warnings with ${hi('status --clear')}`; 54 | 55 | } else { 56 | reply += 'Nothing to report!'; 57 | } 58 | 59 | await message.author.send(reply); 60 | return codes.MESSAGE_ACCEPTED; 61 | 62 | }; -------------------------------------------------------------------------------- /lib/createGiveaway.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common logic for start and queue commands. Creates a giveaway. 3 | */ 4 | let Settings = require('./settings'), 5 | permissionHelper = require('./permissionHelper'), 6 | bracketHelper = require('./bracketHelper'), 7 | hi = require('./highlight'), 8 | codes = require('./codes'), 9 | Logger = require('./logger'), 10 | Store = require('./store'); 11 | 12 | module.exports = async function create(message, client, start, duration, code, gameInfo){ 13 | let settings = Settings.instance(), 14 | store = await Store.instance(), 15 | infoLog = Logger.instance().info; 16 | 17 | // user needs to be admin or have giveaway permission 18 | let hasPermission = await permissionHelper.isAdminOrHasRole(client, message.author, settings.values.giveawayRole); 19 | if (!hasPermission){ 20 | message.author.send('You don\'t have permission to create a giveaway'); 21 | return codes.MESSAGE_REJECTED_PERMISSION; 22 | } 23 | 24 | if (!gameInfo.url){ 25 | message.author.send(`Error : Game url is required. Use the ${hi('--url')} or ${hi('-u')} switch.`); 26 | return codes.MESSAGE_REJECTED_INVALIDGAMEURL; 27 | } 28 | 29 | if (gameInfo.urlError){ 30 | message.author.send(`Error : '${gameInfo.url}' is not a valid url.`); 31 | return codes.MESSAGE_REJECTED_INVALIDGAMEURL; 32 | } 33 | 34 | if (!gameInfo.price){ 35 | message.author.send(`Error : Game price could not be read from the link you provided, you will need to provide the game's price with ${hi('--price')} or ${hi('-p')}. ` + 36 | `Price should be digits only, egs ${hi('--price 10 ')} or ${hi('--p 299')}.`); 37 | return codes.MESSAGE_REJECTED_NOPRICE; 38 | } 39 | 40 | let activeGiveaways = store.getActive(); 41 | if (activeGiveaways.length >= settings.values.maxConcurrentGiveaways){ 42 | 43 | // calc end time of giveaways 44 | let nextGiveawayToEnd = store.getNextGiveawayToEnd(); 45 | 46 | message.author.send(`Error : The maximum number of concurrent giveaways (${settings.values.maxConcurrentGiveaways}) has been reached. The next giveaway to end is ${nextGiveawayToEnd.giveaway.gameName} in ${nextGiveawayToEnd.endsIn}.`); 47 | return codes.MESSAGE_REJECTED_MAXCONCURRENTGIVEAWAYS; 48 | } 49 | 50 | // try to get bracket - this can be null if no brackets exist 51 | let bracket = bracketHelper.findBracketForPrice(gameInfo.price); 52 | 53 | let giveaway = { 54 | status : 'pending', 55 | ownerId : message.author.id, 56 | duration : duration.time, 57 | durationMinutes : duration.minutes, 58 | start : start ? start.time : null, 59 | startMinutes : start ? start.minutes : null, 60 | participants : [], 61 | rejectedWinners : [], 62 | cooldownUsers: [], 63 | code : code ? code : null, 64 | created : new Date().getTime(), 65 | lastUpdated : new Date().getTime(), 66 | channelId : message.channel.id, 67 | gameUrl: gameInfo.url, 68 | gameName: gameInfo.gameName, 69 | price : gameInfo.price, 70 | bracket : bracket ? bracketHelper.toString(bracket): null 71 | }; 72 | 73 | let queued = store.add(giveaway), 74 | verb = start? 'Queued' : 'Starting'; 75 | 76 | message.author.send(`${verb} giveaway id ${queued.id}, ${gameInfo.gameName}`); 77 | infoLog.info(`${message.author.username} created giveaway | id: ${queued.id} | verb: ${verb} | title: ${gameInfo.gameName} | start: ${(start?start.minutes:null)}| duration: ${(duration ? duration.minutes: null)}| code: ${code} `); 78 | return codes.MESSAGE_ACCEPTED; 79 | 80 | }; 81 | -------------------------------------------------------------------------------- /lib/daemon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The daemon is the background process of the bot which runs independent of user messages. The daemon starts and 3 | * ends giveaways, triggers data cleanups and other autonomous activity. 4 | * 5 | */ 6 | let winston = require('winston'), 7 | busy = false, 8 | _instance, 9 | Store = require('./store'), 10 | winnerSelector = require('./winnerSelector'), 11 | channelProvider = require('./channelProvider'), 12 | Client = require('./clientProvider'), 13 | giveawayMessageWriter = require ('./giveawayMessageWriter'), 14 | timeHelper = require('./timeHelper'), 15 | permissionHelper = require('./permissionHelper'), 16 | reactionHelper = require('./reactionHelper'), 17 | State = require('./state'), 18 | Settings = require('./settings'), 19 | Logger = require('./logger'), 20 | codes = require('./codes'), 21 | recordFetch = require('./recordFetch'), 22 | CronJob = require('cron').CronJob; 23 | 24 | class Daemon { 25 | 26 | constructor(){ 27 | this.started = new Date(); 28 | this.lastUpdateTime = new Date(); 29 | this.willShutdown = false; 30 | } 31 | 32 | /** 33 | * Starts the timer loop that calls .tick() 34 | */ 35 | start(){ 36 | 37 | this.infoLog = Logger.instance().info; 38 | 39 | let settings = Settings.instance(); 40 | 41 | this.cron = new CronJob(settings.values.daemonInterval, async function daemonCron() { 42 | 43 | try 44 | { 45 | // use busy flag to prevent the daemon from running over itself 46 | if (this.willShutdown) 47 | return this.onProcessExpired(); 48 | 49 | if (busy) 50 | return; 51 | 52 | busy = true; 53 | 54 | await this.tick(); 55 | 56 | } catch (ex){ 57 | winston.error(ex); 58 | console.log(ex); 59 | } finally { 60 | busy = false; 61 | } 62 | 63 | }.bind(this), null, true); 64 | } 65 | 66 | stop(){ 67 | if (this.cron){ 68 | this.cron.stop(); 69 | } 70 | } 71 | 72 | /** 73 | * Logic for a single pass of the daemon process. A pass 74 | * - starts giveaways if their start time is reached, 75 | * - processes participants in started giveaways 76 | * - closes elapsed giveaways and assigns winners 77 | * - misc cleanups 78 | */ 79 | async tick(){ 80 | 81 | let settings = Settings.instance(), 82 | client = await Client.instance(true), 83 | state = State.instance(), 84 | channel = channelProvider(client, settings), 85 | store = await Store.instance(); 86 | 87 | // channel not set, can't proceed, write a state warning and exit 88 | if (!channel){ 89 | state.add('channel_not_set', 'Giveaway channel not set, or invalid. Please reset channel.'); 90 | return codes.MESSAGE_REJECTED_CHANNELNOTSET; 91 | } 92 | 93 | // loop through all active (not closed) giveaways, and either start them, move them along, or close them 94 | let giveaways = store.getActive(); 95 | for (let giveaway of giveaways){ 96 | 97 | // Scenario 1) start pending giveaway, giveaway has no designated start time so start immedaitely, or, 98 | // giveaway's startup time has elapsed 99 | if (giveaway.status === 'pending' && 100 | (!giveaway.startMinutes || timeHelper.minutesSince(giveaway.created) >= giveaway.startMinutes)) 101 | { 102 | giveaway.started = new Date().getTime(); 103 | 104 | // broadcast start to channel- post url of game 105 | let urlMessageId = await channel.send(giveaway.gameUrl); 106 | 107 | let giveAwayMessage = await giveawayMessageWriter.writeNew(client, giveaway); 108 | 109 | giveaway.urlMessageId = urlMessageId.id; 110 | giveaway.startMessageId = giveAwayMessage.id; 111 | giveaway.status = 'open'; 112 | store.update(giveaway); 113 | 114 | // post first response 115 | await giveAwayMessage.react(settings.values.joinGiveawayResponseCharacter); 116 | continue; 117 | } 118 | 119 | // from this point on, we only care about open giveaways 120 | if (giveaway.status !== 'open') 121 | continue; 122 | 123 | 124 | // Scenario 2 ) the giveaway is running ... do something with it 125 | let giveAwayMessage = await recordFetch.fetchMessage(channel, giveaway.startMessageId); 126 | 127 | // if broadcast message no longer exists, close giveaway immediately 128 | if (!giveAwayMessage){ 129 | giveaway.status = 'cancelled'; 130 | giveaway.comment = 'Giveaway message not found'; 131 | giveaway.ended = new Date().getTime(); 132 | store.update(giveaway); 133 | 134 | let urlMessage = await recordFetch.fetchMessage(channel, giveaway.urlMessageId); 135 | if (urlMessage) 136 | await urlMessage.delete(); 137 | 138 | continue; 139 | } 140 | 141 | 142 | // gets the participation response 143 | let participateReaction = giveAwayMessage.reactions.array().find(function(reaction){ 144 | return reaction._emoji.name === settings.values.joinGiveawayResponseCharacter; 145 | }); 146 | 147 | // get participants from reaction, add them to participants array, reject if they are on cooldown for the game's 148 | // bracket. 149 | // WARNING : .users is unreliable, but fetchUsers() doesn't allow more than 100 users. replace this with better 150 | // call when discord updates API 151 | let participatingUsers = participateReaction ? await reactionHelper.getAllReactionUsers(participateReaction) : []; 152 | for (let user of participatingUsers){ 153 | 154 | // ignore bot's own reaction 155 | if (user.id === client.user.id) 156 | continue; 157 | 158 | // ignore existing participants 159 | if (giveaway.participants.includes(user.id)) 160 | continue; 161 | 162 | // remove users not eligible to enter 163 | let comparableWinning = store.getComparableWinning(user.id, giveaway.price); 164 | if (comparableWinning){ 165 | 166 | let canManageMessages = await permissionHelper.canManageMessages(client, client.user); 167 | 168 | // try to delete user response, this will fail if the bot doesn't permission to, if so 169 | // write a status message 170 | let deleteException; 171 | if (canManageMessages){ 172 | try{ 173 | await participateReaction.remove(user); 174 | state.remove('message_permission'); 175 | } catch(ex){ 176 | deleteException = ex.toString(); 177 | } 178 | } 179 | else { 180 | state.add('message_permission', 'Cannot delete user responses, pleased give me permission "Manage Messages".'); 181 | } 182 | 183 | // inform user of removal once only. This mechanism is purely for flooding protection in event of the removal failing 184 | // reaction 185 | if (giveaway.cooldownUsers.indexOf(user.id) === -1){ 186 | giveaway.cooldownUsers.push(user.id); 187 | let daysAgoWon = timeHelper.daysSince(comparableWinning.ended); 188 | let coolDownLeft = settings.values.winningCooldownDays - daysAgoWon; 189 | await user.send(`Sorry, but you can't enter a giveaway for ${giveaway.gameName} because you won ${comparableWinning.gameName} ${daysAgoWon} days ago. These games are in the same price range. You will have to wait ${coolDownLeft} more day(s) to enter this price range again, but you can still enter giveaways in other price ranges.`); 190 | 191 | // log exception here to prevent flooding 192 | if (deleteException) 193 | this.infoLog.info(`Failed to remove participation emote from user ${user.username} on ${giveaway.id} - ${giveaway.gameName} (this exception will be logged once per user per giveaway) : ${deleteException}`); 194 | } 195 | 196 | this.infoLog.info(`${user.username} was on cooldown, removed from giveaway ID ${giveaway.id} - ${giveaway.gameName}.`); 197 | continue; 198 | } 199 | 200 | giveaway.participants.push(user.id); 201 | this.infoLog.info(`${user.username} joined giveaway ID ${giveaway.id} - ${giveaway.gameName}.`); 202 | 203 | } // for users in join reaction 204 | 205 | // remove ids of users no longer in reaction list 206 | giveaway.participants = giveaway.participants.filter(function(userId){ 207 | return participatingUsers.some(function(user){ 208 | return userId === user.id; 209 | }); 210 | }); 211 | 212 | store.update(giveaway); 213 | 214 | 215 | // the giveaway time has elapsed, or maximum participant count is reached, close giveaway 216 | if (participatingUsers.length >= 100 || timeHelper.minutesSince( giveaway.started) >= giveaway.durationMinutes){ 217 | 218 | // if giveaway update failed, giveaway will be reprocessed. Do not reprocess winner in that case, 219 | // reprocesses will invoke reroll logic 220 | if (!giveaway.winnerId) 221 | await winnerSelector(giveaway); 222 | 223 | // Update original channel post 224 | await giveawayMessageWriter.writeWinner(giveAwayMessage, giveaway); 225 | 226 | // refetch message as proof of update. This is done because discord.js seems to hang/die on message updates 227 | giveAwayMessage = await recordFetch.fetchMessage(channel, giveaway.startMessageId); 228 | let hasUpdated = giveAwayMessage.embeds.some(function(embed){ return embed.footer && embed.footer.text.startsWith('Giveaway ended at')}); 229 | 230 | if (hasUpdated){ 231 | 232 | giveaway.status = 'closed'; 233 | giveaway.ended = new Date().getTime(); 234 | 235 | // get winner if there is one 236 | let winner = giveaway.winnerId ? await recordFetch.fetchUser(client, giveaway.winnerId) : null; 237 | 238 | // failed to get winner user object from discord, this should never happen 239 | if (giveaway.winnerId && !winner) 240 | this.infoLog.error(`${giveaway.winnerId} won giveaway ID ${giveaway.id} - ${giveaway.gameName}, failed to retrieve user from discord`); 241 | 242 | if (winner) 243 | giveawayMessageWriter.sendWinnerMessages(client, giveaway, winner); 244 | 245 | // send a message to game creator 246 | let owner = await recordFetch.fetchUser(client, giveaway.ownerId); 247 | if (owner){ 248 | let ownerMessage = `Giveaway for ${giveaway.gameName} ended.`; 249 | 250 | if (winner) 251 | ownerMessage += `The winner was <@${giveaway.winnerId}>.`; 252 | else if (giveaway.winnerId && !winner) 253 | ownerMessage += `Er, looks like we lost the winner (discord id: <@${giveaway.winnerId}>).`; 254 | else 255 | ownerMessage += 'No winner was found.'; 256 | 257 | await owner.send(ownerMessage); 258 | } 259 | 260 | store.update(giveaway); 261 | continue; 262 | } 263 | 264 | } 265 | 266 | // if reach here, giveaway is still active, update its timer 267 | let minutesSinceUpdate = timeHelper.minutesSince(giveaway.lastUpdated); 268 | if (minutesSinceUpdate >= 1){ 269 | 270 | await giveawayMessageWriter.writeUpdate(client, giveaway, giveAwayMessage); 271 | 272 | giveAwayMessage.lastUpdated = new Date().getTime(); 273 | store.update(giveaway); 274 | } 275 | 276 | } // for 277 | 278 | // clean old giveaways 279 | store.clean(); 280 | 281 | // update health monitor 282 | this.lastUpdateTime = new Date(); 283 | 284 | return codes.DAEMON_FINISHED; 285 | } 286 | 287 | } 288 | 289 | module.exports = { 290 | instance : function(){ 291 | if (!_instance) 292 | _instance = new Daemon(); 293 | 294 | return _instance; 295 | }, 296 | set : function(newInstance){ 297 | _instance = newInstance; 298 | } 299 | }; 300 | -------------------------------------------------------------------------------- /lib/gameInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tries to help out by inferring information about a game using only its url. 3 | * Information it can get : 4 | * - game name 5 | * - game price 6 | * 7 | * Name will always have a value, but the quality of that value varies. A Steam app's name can be read from the Steam 8 | * JSON API. If not a Steam app, we can usually get name from OpenGraph meta data in the page header (most pro sites do 9 | * this), failing that we'll use the standard HTML page title, even though this often has other unwanted text like site 10 | * title. If the page title is empty, we fall back to using the url as the game name. 11 | * 12 | * Price is available from Steam apps only, and is always read from the JSON API. Other platforms can be added later if 13 | * APIs are made available. 14 | * 15 | */ 16 | 17 | let _instance, 18 | Url = require('url'), 19 | cheerio = require('cheerio'), 20 | request = require('request-promise-native'); 21 | 22 | class GameInfo{ 23 | 24 | /** 25 | * Args contains unsanitized user input. We will try to return the best possible info we can from whatever the user 26 | * inputs, if we can't find anything we replace args properties with null, and let validation deal with that later. 27 | * 28 | * Args : { price , gameName, url } 29 | */ 30 | async getInfo(args){ 31 | 32 | // first, ensure url 33 | if (!args.url) 34 | return args; 35 | 36 | let testUrl = null; 37 | 38 | try { 39 | 40 | testUrl = Url.parse(args.url); 41 | 42 | // is url a steam app ? 43 | if (testUrl.host && testUrl.host.toLowerCase() === 'store.steampowered.com'){ 44 | args = await this._treatAsSteamApp(args); 45 | } 46 | 47 | // treat remainder, or steam pages that are not apps 48 | if (!args.success){ 49 | args = await this._treatAsRegularHtml(args) 50 | } 51 | } catch(ex) { 52 | // todo : this is a terrible workaround to passing along a url error. all url sanitization needs to be rethought 53 | args.urlError = 'Url is invalid'; 54 | } 55 | 56 | return args; 57 | } 58 | 59 | // body is optional, lets us recycle request from steam check 60 | async _treatAsRegularHtml(args, body){ 61 | if (!body) 62 | body = await request({ url : args.url }); 63 | 64 | let $ = cheerio.load(body), 65 | ogTitle = $('head meta[property="og:title"]').text(), 66 | rawTitle = $('head title').text(); 67 | 68 | if (ogTitle.length) 69 | args.gameName = ogTitle; 70 | else{ 71 | if (rawTitle.length) 72 | args.gameName = rawTitle; 73 | else 74 | args.gameName = args.url; 75 | } 76 | 77 | return args; 78 | } 79 | 80 | /** 81 | * Tries to convert a steam store url to a steam api url 82 | */ 83 | _toSteamApiUrl(url){ 84 | let pattern = /\/app\/([0-9]*)\/?/, 85 | matches = url.toLowerCase().match(pattern); 86 | 87 | if (matches && matches.length === 2) 88 | return `http://store.steampowered.com/api/appdetails?cc=us&appids=${matches[1]}`; 89 | 90 | return null; 91 | } 92 | 93 | async _treatAsSteamApp(args){ 94 | 95 | // try to get api url 96 | let apiUrl = this._toSteamApiUrl(args.url); 97 | if (!apiUrl) 98 | return args; 99 | 100 | let body = await request({ url : apiUrl }), 101 | bodyJson; 102 | 103 | try { 104 | bodyJson = JSON.parse(body); 105 | } catch (ex){ 106 | // failed to parse body, url is not a valid json api, treat as regular "dumb" url 107 | args = await this._treatAsRegularHtml(args, body); 108 | return args; 109 | } 110 | 111 | let bodyProperties = Object.keys(bodyJson); 112 | // steam returns first property in body as the game's steamId 113 | let steamId = bodyProperties.length ? bodyProperties[0] : ''; 114 | 115 | // if app, the first property returned should have a success=true property 116 | if (bodyJson[steamId].success){ 117 | // steam price is listed in cents of currency, /100 to convert to dollars or euros 118 | // note : free games have no price 119 | let price = bodyJson[steamId].data && bodyJson[steamId].data.price_overview && bodyJson[steamId].data.price_overview.initial ? bodyJson[steamId].data.price_overview.initial : 0; 120 | args.price = price / 100; 121 | args.gameName = bodyJson[steamId].data.name; 122 | args.success = true; 123 | } 124 | 125 | return args; 126 | } 127 | } 128 | 129 | module.exports = { 130 | 131 | instance(){ 132 | if (!_instance) 133 | _instance = new GameInfo(); 134 | 135 | return _instance; 136 | }, 137 | 138 | set(newInstance){ 139 | _instance = newInstance; 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /lib/getTime.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts shorthand time (eg 1m, 10D, 100h) into an object containing the original time value, that value converted 3 | * to minutes, and the unit (m, h or d). 4 | * 5 | * Returns null if input doesn't match the shorthand format. 6 | */ 7 | module.exports = function getTime(input){ 8 | if (input === null || input === undefined) 9 | return null; 10 | 11 | let time = input.match(/\d/g); 12 | if (!time) 13 | return null; 14 | 15 | time = time.join(''); 16 | 17 | let unit = input.replace(time, '').toLowerCase(); 18 | if (unit !== 'd' && unit !== 'h' && unit !== 'm') 19 | return null; 20 | 21 | let minutes = time; 22 | if (unit === 'd') 23 | minutes = time * 24 * 60; 24 | else if (unit === 'h') 25 | minutes = time * 60; 26 | 27 | return { 28 | unit : unit, 29 | time : time, 30 | minutes : minutes 31 | } 32 | }; -------------------------------------------------------------------------------- /lib/giveawayMessageWriter.js: -------------------------------------------------------------------------------- 1 | let Logger = require('./logger'), 2 | Settings = require('./settings'), 3 | channelProvider = require('./channelProvider'), 4 | timeHelper = require('./timeHelper'); 5 | 6 | module.exports = { 7 | /** 8 | * Constructs a rich embed for a giveaway message 9 | */ 10 | createGiveawayEmbed : function (client, giveaway){ 11 | let settings = Settings.instance(), 12 | ends = timeHelper.timePlusMinutesAsDate(giveaway.started, giveaway.durationMinutes), 13 | remaining = timeHelper.remaining(new Date().getTime(), ends); 14 | 15 | return {embed: { 16 | color: 3447003, 17 | author: { 18 | name: client.user.username, 19 | }, 20 | title: `:mega: Giveaway ${giveaway.gameName} :mega:`, 21 | description: `React with ${settings.values.joinGiveawayResponseCharacter} to enter, time remaining : ${remaining}`, 22 | fields: [ 23 | { 24 | name : 'Given away by', 25 | value : `<@${giveaway.ownerId}>` 26 | }], 27 | footer: { 28 | text: 'Giveaway ends at ' 29 | }, 30 | timestamp: ends 31 | }}; 32 | }, 33 | 34 | writeNew : async function(client, giveaway){ 35 | let settings = Settings.instance(), 36 | channel = channelProvider(client, settings), 37 | embed = this.createGiveawayEmbed(client, giveaway); 38 | 39 | return await channel.send( embed ); 40 | }, 41 | 42 | writeUpdate : async function(client, giveaway, giveAwayMessage){ 43 | let embed = this.createGiveawayEmbed(client, giveaway); 44 | await giveAwayMessage.edit(embed); 45 | }, 46 | 47 | /** 48 | * Writes the state of the giveaway back to its original message on discord 49 | */ 50 | writeWinner : async function(giveawayMessage, giveaway){ 51 | await giveawayMessage.edit( { embed : { 52 | title: `:mega: Giveaway ${giveaway.gameName} ended :mega:`, 53 | fields: [ 54 | { 55 | name : 'Given away by', 56 | value : `<@${giveaway.ownerId}>` 57 | }, 58 | { 59 | name : 'Results', 60 | value : giveaway.winnerId ? `<@${giveaway.winnerId}> won` : 'No winner found' 61 | }], 62 | footer: { 63 | // WARNING : this text is also used as a flag to indicate successful message update. If changed, check 64 | // daemon logic for update confirm 65 | text: 'Giveaway ended at ' 66 | }, 67 | timestamp: new Date() 68 | }}); 69 | 70 | let infoLog = Logger.instance().info; 71 | infoLog.info(`Giveaway closed - ID ${giveaway.id} - ${giveaway.gameName}.`); 72 | }, 73 | 74 | /** 75 | * Handles all messaging logic when a giveaway is won, or rerolled and won. 76 | */ 77 | sendWinnerMessages : async function(client, giveaway, winner){ 78 | let settings = Settings.instance(), 79 | channel = channelProvider(client, settings); 80 | 81 | // post public congrats message to winner in giveaway channel 82 | await channel.send(`Congratulations <@${giveaway.winnerId}>, you won the draw for ${giveaway.gameName}! `); 83 | 84 | // send direct message to winner 85 | let winnerMessage = `Congratulations, you just won ${giveaway.gameName}, courtesy of <@${giveaway.ownerId}>. `; 86 | 87 | if (giveaway.code) 88 | winnerMessage += `Your game key is ${giveaway.code}, be sure to thank <@${giveaway.ownerId}> for the giveaway!`; 89 | else 90 | winnerMessage += 'Contact them for your game key. '; 91 | 92 | await winner.send(winnerMessage); 93 | 94 | let infoLog = Logger.instance().info; 95 | infoLog.info(`${winner.username} won initial roll for giveaway ID ${giveaway.id} - ${giveaway.gameName}.`); 96 | } 97 | }; -------------------------------------------------------------------------------- /lib/highlight.js: -------------------------------------------------------------------------------- 1 | module.exports = function hi(text){ 2 | return `**${text}**`; 3 | }; -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | let winston = require('winston'), 2 | fs = require('fs'), 3 | _instance, 4 | process = require('process'), 5 | path = require('path'); 6 | 7 | 8 | class Logger { 9 | constructor(){ 10 | 11 | let logFolder = path.join(process.cwd(), 'discord-giveawaybot', '__logs'); 12 | 13 | if (!fs.existsSync(logFolder)) 14 | fs.mkdirSync(logFolder); 15 | 16 | // apply rotation override for winston 17 | require('winston-daily-rotate-file'); 18 | 19 | this.info = new (winston.Logger)({ 20 | transports: [ 21 | new (winston.transports.DailyRotateFile)({ 22 | filename: path.join(logFolder, '.log'), 23 | datePattern: 'info.yyyy-MM-dd', 24 | prepend: true, 25 | level: 'info' 26 | }) 27 | ] 28 | }); 29 | 30 | this.error = new (winston.Logger)({ 31 | transports: [ 32 | new (winston.transports.DailyRotateFile)({ 33 | filename: path.join(logFolder, '.log'), 34 | datePattern: 'error.yyyy-MM-dd', 35 | prepend: true, 36 | level: 'error' 37 | })] 38 | }); 39 | 40 | } 41 | } 42 | 43 | module.exports = { 44 | instance : function(){ 45 | if (!_instance) 46 | _instance = new Logger(); 47 | 48 | return _instance; 49 | }, 50 | set : function(newInstance){ 51 | _instance = newInstance; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/messages.js: -------------------------------------------------------------------------------- 1 | let hi = require('.//highlight'); 2 | 3 | module.exports = { 4 | permissionError : `You don't have permission to do this.`, 5 | timeFormat : 'Time should be a digit followed by m, h or d (minutes, hours or days). Egs., 1m is 1 minute, 10h is 10 hours, 4d is 4 days.', 6 | listArgumentsError : `Invalid command. Expected : ${hi('list')}, or ${hi('list all')}.` 7 | }; -------------------------------------------------------------------------------- /lib/permissionHelper.js: -------------------------------------------------------------------------------- 1 | let recordFetch = require('./recordFetch'), 2 | Discord = require('discord.js'); 3 | 4 | module.exports = { 5 | 6 | isAdmin : async function(client, discordUser){ 7 | let guildMember = await recordFetch.fetchGuildMember(client, discordUser); 8 | if (!guildMember) 9 | return false; 10 | 11 | return guildMember.hasPermission(Discord.Permissions.FLAGS.MANAGE_CHANNELS); 12 | }, 13 | 14 | canManageMessages : async function(client, discordUser){ 15 | let guildMember = await recordFetch.fetchGuildMember(client, discordUser); 16 | if (!guildMember) 17 | return false; 18 | 19 | return guildMember.hasPermission(Discord.Permissions.FLAGS.MANAGE_MESSAGES); 20 | }, 21 | 22 | isAdminOrHasRole : async function(client, discordUser, roleName){ 23 | let guildMember = await recordFetch.fetchGuildMember(client, discordUser); 24 | if (!guildMember) 25 | return false; 26 | 27 | let isAdmin = guildMember.hasPermission(Discord.Permissions.FLAGS.MANAGE_CHANNELS); 28 | if (isAdmin) 29 | return true; 30 | 31 | for (let role of guildMember.roles.array()){ 32 | if (role.name === roleName) 33 | return true; 34 | } 35 | 36 | return false; 37 | }, 38 | 39 | /**/ 40 | hasRole : async function(client, discordUser, roleName){ 41 | let guildMember = await recordFetch.fetchGuildMember(client, discordUser); 42 | if (!guildMember) 43 | return false; 44 | 45 | for (let role of guildMember.roles.array()){ 46 | if (role.name === roleName) 47 | return true; 48 | } 49 | 50 | return false; 51 | }, 52 | 53 | /**/ 54 | getRoles : async function(client, discordUser){ 55 | let guildMember = await recordFetch.fetchGuildMember(client, discordUser); 56 | if (!guildMember) 57 | return 'NO ROLES, NOT A GUILD MEMBER'; 58 | 59 | let roles = ''; 60 | for (let role of guildMember.roles.array()){ 61 | roles += `${role.name};`; 62 | } 63 | 64 | return roles; 65 | } 66 | }; -------------------------------------------------------------------------------- /lib/reactionHelper.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Returns an array of all users from messagereaction, spanning multiple queries if necessary. 4 | * 5 | */ 6 | let getAllReactionUsers = async function(messageReaction){ 7 | return new Promise(async function(resolve, reject){ 8 | try { 9 | let users = [], 10 | lastUserId; 11 | 12 | while(true){ 13 | let options = {}; 14 | if (lastUserId) 15 | options.after = lastUserId; 16 | let fetchedUsers = await messageReaction.fetchUsers(100, options ); 17 | fetchedUsers = fetchedUsers.array(); 18 | if (fetchedUsers.length === 0) 19 | break; 20 | 21 | for (let fetchedUser of fetchedUsers){ 22 | users.push(fetchedUser); 23 | lastUserId = fetchedUser.id; 24 | } 25 | // force break, discord's .after or .before argument still not working -_- 26 | break; 27 | } 28 | resolve(users); 29 | } catch(ex){ 30 | reject(ex); 31 | } 32 | }); 33 | }; 34 | 35 | module.exports = { 36 | getAllReactionUsers 37 | } -------------------------------------------------------------------------------- /lib/recordFetch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Traps unnecessary exceptions caused when querying non-existent objects 3 | */ 4 | let winston = require('winston'); 5 | 6 | module.exports = { 7 | 8 | fetchMessage : async function(channel, id){ 9 | try 10 | { 11 | return await channel.fetchMessage(id); 12 | } catch(ex){ 13 | if (ex.code === 10008) // unknown message exception , it's cool bro 14 | return null; 15 | 16 | // ugh 17 | throw ex; 18 | } 19 | }, 20 | 21 | fetchUser : async function(client, id){ 22 | try{ 23 | return await client.fetchUser(id); 24 | }catch(ex){ 25 | // this exception would occur only if a user account disappears from discord, not sure if this ever happens 26 | // and cannot test, but for safety, log exception so it doesn't die in silence, and then continue. 27 | winston.error(ex); 28 | return null; 29 | } 30 | }, 31 | 32 | /* tries to find guild from all channels attach to bot */ 33 | fetchGuild : async function(client){ 34 | try { 35 | let channels = client.channels.array(); 36 | for (let channel of channels) { 37 | if (channel.guild); 38 | return channel.guild; 39 | } 40 | }catch(ex){ 41 | winston.error(ex); 42 | return null; 43 | } 44 | }, 45 | 46 | fetchGuildById : async function(client, guildId){ 47 | try { 48 | return await client.guilds.get(guildId); 49 | } catch(ex) { 50 | winston.error(ex); 51 | return null; 52 | } 53 | }, 54 | 55 | fetchGuildMember : async function(client, user){ 56 | try{ 57 | return await client.channels.array()[0].guild.fetchMember(user) 58 | }catch(ex){ 59 | // this exception would occur only if a user account disappears from discord, not sure if this ever happens 60 | // and cannot test, but for safety, log exception so it doesn't die in silence, and then continue. 61 | winston.error(ex); 62 | return null; 63 | } 64 | } 65 | 66 | }; 67 | -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps the settings.json file so we have a single point of read/write 3 | */ 4 | let fs = require('fs'), 5 | path = require('path'), 6 | process = require('process'), 7 | jsonfile = require('jsonfile'), 8 | _instance; 9 | 10 | class Settings { 11 | 12 | constructor(){ 13 | this.failed = false; 14 | this.settingsFilePath = path.join(process.cwd(), 'discord-giveawaybot', 'settings.json'); 15 | 16 | if (!fs.existsSync(this.settingsFilePath)) 17 | throw new Error('\'settings.json\' not found in bot working folder.'); 18 | 19 | try { 20 | this.values = jsonfile.readFileSync(this.settingsFilePath); 21 | } catch (ex){ 22 | throw new Error(`'Failed to read settings.json : ${ex}`); 23 | } 24 | 25 | // assign default settings 26 | // nr of days after which old giveaways are automatically deleted 27 | if (this.values.deleteGiveawaysAfter === undefined) 28 | this.values.deleteGiveawaysAfter = 14; 29 | 30 | if (this.values.winningCooldownDays === undefined) 31 | this.values.winningCooldownDays = 3; 32 | 33 | if (this.values.maxConcurrentGiveaways === undefined) 34 | this.values.maxConcurrentGiveaways = 5; 35 | 36 | if (this.values.daemonInterval === undefined) 37 | this.values.daemonInterval = '*/10 * * * * *'; 38 | 39 | if (this.values.processLifetime === undefined) 40 | this.values.processLifetime = 10; // 10 minutes 41 | 42 | if (this.values.joinGiveawayResponseCharacter === undefined) 43 | this.values.joinGiveawayResponseCharacter = '🎉'; 44 | 45 | if (this.values.enableHealthMonitor === undefined) 46 | this.values.enableHealthMonitor = false; 47 | 48 | if (this.values.healthMonitorPort === undefined) 49 | this.values.healthMonitorPort = 8080; 50 | 51 | 52 | // int, and in minutes 53 | if (this.values.processLifetime && !Number.isInteger(this.values.processLifetime)) 54 | throw new Error ('settings.json processLifetime value must an integer'); 55 | } 56 | 57 | save(){ 58 | if (this.values) 59 | jsonfile.writeFileSync(this.settingsFilePath, this.values); 60 | } 61 | 62 | } 63 | 64 | module.exports = { 65 | instance : function(){ 66 | if (!_instance) 67 | _instance = new Settings(); 68 | 69 | return _instance; 70 | }, 71 | set : function (newInstance){ 72 | _instance = newInstance; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /lib/shuffleArray.js: -------------------------------------------------------------------------------- 1 | // from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array 2 | module.exports = function shuffle(inArray) { 3 | 4 | // clone array 5 | let array = inArray.slice(0), 6 | currentIndex = array.length; 7 | 8 | while (0 !== currentIndex) { 9 | 10 | // Pick a remaining element... 11 | let randomIndex = Math.floor(Math.random() * currentIndex); 12 | currentIndex -= 1; 13 | 14 | // And swap it with the current element. 15 | let temporaryValue = array[currentIndex]; 16 | array[currentIndex] = array[randomIndex]; 17 | array[randomIndex] = temporaryValue; 18 | } 19 | 20 | return array; 21 | }; 22 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * State is a singleton holder that the bot can write to. This is meant to serve as a public log of the bot's health. 3 | * State can be queried by channel users. The bot can also periodically broadcast it's health to warn of serious errors, 4 | * but without spamming. 5 | */ 6 | 7 | class State 8 | { 9 | constructor(){ 10 | this.content = {}; 11 | } 12 | 13 | add(category, message){ 14 | this.content[category] = message; 15 | } 16 | 17 | remove (category){ 18 | if (this.content[category]) 19 | delete this.content[category]; 20 | } 21 | 22 | clear(){ 23 | this.content = {}; 24 | } 25 | 26 | length(){ 27 | return Object.keys(this.content).length; 28 | } 29 | 30 | get(){ 31 | let state = []; 32 | for (let property in this.content) 33 | state.push(this.content[property]) 34 | 35 | return state; 36 | } 37 | 38 | } 39 | 40 | let _instance = null; 41 | 42 | module.exports = { 43 | instance(){ 44 | if (!_instance) 45 | _instance = new State(); 46 | 47 | return _instance; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /lib/store.js: -------------------------------------------------------------------------------- 1 | let lokijs = require('lokijs'), 2 | fs = require('fs-extra'), 3 | path = require('path'), 4 | Settings = require('./settings'), 5 | timeHelper = require('./timeHelper'), 6 | bracketHelper = require('./bracketHelper'), 7 | process = require('process'), 8 | _instance; 9 | 10 | class Store { 11 | 12 | constructor(onReady){ 13 | this.settings = Settings.instance(); 14 | 15 | let keys = { }, 16 | table = 'store'; 17 | 18 | // ensure path 19 | let saveDir = path.join(process.cwd(), 'discord-giveawaybot', '__store'); 20 | if (!fs.existsSync(saveDir)) 21 | fs.ensureDirSync(saveDir); 22 | 23 | let savePath = path.join(saveDir, 'giveaways.json'); 24 | 25 | this.database = new lokijs(savePath, { 26 | autosave : true, 27 | autosaveInterval : 3000 28 | }); 29 | 30 | if (fs.existsSync(savePath)){ 31 | this.database.loadDatabase({}, function(err){ 32 | 33 | if (err) 34 | throw new Error(err); 35 | 36 | this._table = this.database.getCollection(table); 37 | 38 | if (!this._table) 39 | this._table = this.database.addCollection(table, keys); 40 | 41 | if (onReady) 42 | onReady(this); 43 | }.bind(this)); 44 | } else { 45 | this._table = this.database.addCollection(table, keys); 46 | if (onReady) 47 | onReady(this); 48 | } 49 | } 50 | 51 | async close(){ 52 | return new Promise(async function(resolve, reject) { 53 | try { 54 | this.database.close(function(){ 55 | resolve(); 56 | }); 57 | } catch (ex){ 58 | reject(ex); 59 | } 60 | }.bind(this)); 61 | } 62 | 63 | static _convert (record){ 64 | return { 65 | id : record.$loki.toString(), 66 | comment : record.comment, 67 | ownerId : record.ownerId, // discord userid, creator of giveaway 68 | start : record.start, // 69 | startMinutes : record.startMinutes, // minutes after this.created when giveaway should start 70 | duration : record.duration, // TODO : refactor out 71 | durationMinutes : record.durationMinutes, // minutes after this.started that giveaway should close 72 | urlMessageId : record.urlMessageId,// 73 | startMessageId: record.startMessageId, // dischord messageid announcing giveaway 74 | participants : record.participants, // array of dischord userids 75 | status : record.status, // string : pending|open|closed|cancelled 76 | code : record.code, // steam activation code. used only for queued giveaways 77 | winnerId: record.winnerId, // discord userid, winner of giveaway 78 | rejectedWinners : record.rejectedWinners, // array of userids 79 | cooldownUsers : record.cooldownUsers, // array of users who are warned about cooldown. this is NOT canonical 80 | created : record.created, // javascript date in ms, when giveaway was created 81 | started : record.started, // javascript date in ms, when giveaway started 82 | gameUrl : record.gameUrl, // url at which game info can viewed. normally this is a steam game page 83 | ended : record.ended, // javascript date in ms, when giveaway ended (or was cancelled) 84 | gameName : record.gameName, // name of game being given away 85 | channelId : record.channelId, // discord channel id giveaway was created in 86 | price : record.price, // price of steam game at time giveaway created 87 | bracket : record.bracket, // bracket into which price falls 88 | lastUpdated : record.lastUpdated // javascriåt date in ms, used for active state only, last time "ends" time written to discord 89 | }; 90 | } 91 | 92 | static _convertAll(records){ 93 | let result = []; 94 | 95 | // convert loki objects to database-agnostic objects 96 | for (let i = 0 ; i < records.length ; i ++) 97 | result.push(Store._convert(records[i])); 98 | 99 | return result; 100 | } 101 | 102 | // gets all user winnings in last active period 103 | getWinnings(userId){ 104 | let daysAgo = timeHelper.daysAgo(this.settings.values.winningCooldownDays); 105 | 106 | let winningsRaw = this._table.find({ '$and' : [ 107 | { ended : { '$gt' : daysAgo.getTime() } }, 108 | { status : 'closed' }, 109 | { winnerId : userId } 110 | ]}); 111 | 112 | if (winningsRaw.length === 0) 113 | return []; 114 | 115 | let winnings = Store._convertAll(winningsRaw); 116 | winnings.sort(function(a,b){ 117 | return a.ended < b.ended ? 1 : 118 | a.ended > b.ended ? -1 : 119 | 0; 120 | }); 121 | 122 | return winnings; 123 | } 124 | 125 | // gets last winning in last active period for any game within the give price bracket range. 126 | // if two brackets overlap it rules in favor of enforcing a cooldown 127 | getComparableWinning(userId, gamePrice){ 128 | let daysAgo = timeHelper.daysAgo(this.settings.values.winningCooldownDays); 129 | 130 | // check if user won game in price range 131 | let bracket = bracketHelper.findBracketForPrice(gamePrice); 132 | if (!bracket) 133 | return null; 134 | 135 | let winningsRaw = this._table.find({ '$and' : [ 136 | { bracket : bracketHelper.toString(bracket) }, 137 | { ended : { '$gt' : daysAgo.getTime() } }, 138 | { status : 'closed' }, 139 | { winnerId : userId } 140 | ]}); 141 | if (winningsRaw.length === 0) 142 | return null; 143 | 144 | let winnings = Store._convertAll(winningsRaw); 145 | winnings.sort(function(a,b){ 146 | return a.ended < b.ended ? 1 : 147 | a.ended > b.ended ? -1 : 148 | 0; 149 | }); 150 | 151 | return winnings[0]; 152 | } 153 | 154 | add(object){ 155 | let record = this._table.insert(object); 156 | return Store._convert(record); 157 | } 158 | 159 | list(query){ 160 | query = query || { }; 161 | 162 | let records = this._table.find(query); 163 | 164 | // convert loki objects to database-agnostic objects 165 | return Store._convertAll(records); 166 | } 167 | 168 | getActive(){ 169 | return this.list({ '$or' : [ 170 | { status : 'pending' }, 171 | { status : 'open'} 172 | ]}); 173 | } 174 | 175 | getNextGiveawayToEnd(){ 176 | let soonest = null, 177 | giveaway = null, 178 | activeGiveaways = this.getActive(); 179 | 180 | for (let activeGiveaway of activeGiveaways){ 181 | let date = new Date(activeGiveaway.created); 182 | if (activeGiveaway.startMinutes) 183 | date = timeHelper.timePlusMinutesAsDate(date, activeGiveaway.startMinutes); 184 | 185 | date = timeHelper.timePlusMinutesAsDate(date, activeGiveaway.durationMinutes); 186 | if (!soonest || soonest.getTime() > date.getTime()) 187 | soonest = date; 188 | giveaway = activeGiveaway; 189 | } 190 | 191 | return giveaway ? { 192 | giveaway : giveaway, 193 | endsIn : timeHelper.remaining(new Date(), soonest) 194 | } : null; 195 | } 196 | 197 | clean(){ 198 | let date = new Date(); 199 | 200 | date.setDate(date.getDate() - this.settings.values.deleteGiveawaysAfter); 201 | 202 | let records = this._table.find({ '$and' : [ 203 | { ended : { '$gt' : 0 } }, 204 | { ended : { '$lt' : date.getTime() } } 205 | ]}); 206 | 207 | for (let record of records) 208 | this._table.remove(record); 209 | } 210 | 211 | get(id){ 212 | let existingRecord = this._table.get(parseInt(id)); 213 | if (!existingRecord) 214 | return null; 215 | 216 | return Store._convert(existingRecord); 217 | } 218 | 219 | update(object){ 220 | let existingRecord = this._table.get(parseInt(object.id)); 221 | if (!existingRecord) 222 | return; 223 | 224 | for (let property in object){ 225 | // id is an artificial property added on returned objects only, so do not persist it 226 | if (property === 'id') 227 | continue; 228 | 229 | existingRecord[property] = object[property]; 230 | } 231 | 232 | this._table.update(existingRecord); 233 | } 234 | } 235 | 236 | module.exports = { 237 | async instance (){ 238 | return new Promise(async function(resolve, reject) { 239 | try { 240 | if (_instance) 241 | return resolve(_instance); 242 | 243 | new Store(function(inst){ 244 | _instance = inst; 245 | resolve(inst) 246 | }); 247 | 248 | } catch (ex){ 249 | reject(ex); 250 | } 251 | }.bind(this)); 252 | }, 253 | 254 | set (newInstance){ 255 | _instance = newInstance; 256 | } 257 | } 258 | ; 259 | 260 | -------------------------------------------------------------------------------- /lib/timeHelper.js: -------------------------------------------------------------------------------- 1 | let dateFormat = require('dateformat'); 2 | 3 | module.exports = { 4 | timespan : function(before, after){ 5 | if (typeof before === 'number') 6 | before = new Date(before); 7 | 8 | if (typeof after === 'number') 9 | after = new Date(after); 10 | 11 | let diff = after.getTime() - before.getTime(); 12 | 13 | let days = Math.floor(diff / (1000 * 60 * 60 * 24)); 14 | diff -= days * (1000 * 60 * 60 * 24); 15 | 16 | let hours = Math.floor(diff / (1000 * 60 * 60)); 17 | diff -= hours * (1000 * 60 * 60); 18 | 19 | let mins = Math.floor(diff / (1000 * 60)); 20 | 21 | if (days >= 1) 22 | return days + 'd'; 23 | if (hours >= 1) 24 | return hours + 'h'; 25 | 26 | return mins + 'm'; 27 | }, 28 | 29 | remaining : function(before, after){ 30 | if (typeof before === 'number') 31 | before = new Date(before); 32 | 33 | if (typeof after === 'number') 34 | after = new Date(after); 35 | 36 | let diff = after.getTime() - before.getTime(); 37 | 38 | let days = Math.floor(diff / (1000 * 60 * 60 * 24)); 39 | diff -= days * (1000 * 60 * 60 * 24); 40 | 41 | let hours = Math.floor(diff / (1000 * 60 * 60)); 42 | diff -= hours * (1000 * 60 * 60); 43 | 44 | let mins = Math.floor(diff / (1000 * 60)); 45 | 46 | let string = ''; 47 | if (days > 1) 48 | string += days + ' days '; 49 | if (days === 1) 50 | string += days + ' day '; 51 | 52 | if (hours > 1) 53 | string += hours + ' hours '; 54 | if (hours === 1) 55 | string += hours + ' hour '; 56 | 57 | if (mins > 1) 58 | string += mins + ' minutes '; 59 | if (mins === 1) 60 | string += mins + ' minute '; 61 | 62 | if (days < 1 && mins < 1 && mins < 1) 63 | string = '< a minute'; 64 | 65 | return string; 66 | }, 67 | 68 | toShortDateTimeString : function(datetime){ 69 | if (typeof datetime === 'number') 70 | datetime = new Date(datetime); 71 | 72 | return `${datetime.getFullYear()}/${datetime.getMonth() + 1}/${datetime.getDate()} ${datetime.toLocaleTimeString()}`; 73 | }, 74 | 75 | // returns formatted date string for the time + minutes. 76 | // datetime can be date object, or milliseconds 77 | timePlusMinutes : function(datetime, minutes){ 78 | let time = this.timePlusMinutesAsDate(datetime, minutes); 79 | return dateFormat(time, 'mmm dS h:MM'); 80 | }, 81 | 82 | // adds minutes to a date. date can be a date object or milliseconds (as giveaways are persisted) 83 | timePlusMinutesAsDate : function(datetime, minutes){ 84 | if (typeof datetime === 'number') 85 | datetime = new Date(datetime); 86 | 87 | return new Date(datetime.getTime() + minutes * 60000); 88 | }, 89 | 90 | /// 91 | daysSince : function(datetime){ 92 | if (typeof datetime === 'number') 93 | datetime = new Date(datetime); 94 | 95 | let elapsed = new Date().getTime() - datetime.getTime(); 96 | return Math.floor(elapsed / (1000 * 60 * 60 * 24)); // convert elapsed to minutes 97 | }, 98 | 99 | // gets a date value of the date x days ago 100 | daysAgo: function(daysAgo){ 101 | 102 | let date = new Date(); 103 | date.setDate(date.getDate() - daysAgo); 104 | return date; 105 | }, 106 | 107 | // gets minutes (integer) since the given time. datetime can be a date object, or milliseconds 108 | minutesSince : function(datetime){ 109 | if (typeof datetime === 'number') 110 | datetime = new Date(datetime); 111 | 112 | let elapsed = new Date().getTime() - datetime.getTime(); 113 | return Math.floor(elapsed / (1000 * 60)); // convert elapsed to minutes 114 | }, 115 | 116 | // gets seconds (integer) since the given time. datetime can be a date object, or milliseconds 117 | secondsSince : function(datetime){ 118 | if (typeof datetime === 'number') 119 | datetime = new Date(datetime); 120 | 121 | let elapsed = new Date().getTime() - datetime.getTime(); 122 | return Math.floor(elapsed / (1000)); // convert elapsed to minutes 123 | }, 124 | 125 | // pauses for given milliseconds 126 | wait : function wait(milliseconds){ 127 | return new Promise((resolve)=>{ 128 | setTimeout(()=>{ 129 | resolve(); 130 | }, milliseconds) 131 | }) 132 | } 133 | 134 | } ; 135 | -------------------------------------------------------------------------------- /lib/trace.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Trace is used during testing to mark return points in production code. Marking lets us confirm that a particular 3 | * condition has been met. Tracing is silenced during production, and its code footprint in production must be as 4 | * unintrusive as possible. 5 | */ 6 | 7 | let _instance; 8 | 9 | /** 10 | * Production trace always returns the first arg and ignores everything else. The test version of trace will logic 11 | * to handle/store other args. 12 | */ 13 | function trace(arg1){ 14 | return arg1; 15 | } 16 | 17 | 18 | module.exports = { 19 | instance : function(){ 20 | if (!_instance) 21 | _instance = trace; 22 | 23 | return _instance; 24 | }, 25 | set : function(newInstance){ 26 | _instance = newInstance; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/winnerSelector.js: -------------------------------------------------------------------------------- 1 | let shuffleArray = require('./shuffleArray'); 2 | 3 | /** 4 | * Processes giveaway, attempts to find winner. A winner isn't guaranteed. 5 | */ 6 | module.exports = async function(giveaway){ 7 | 8 | // if giveaway already has a winner, push that winner to rejected list 9 | if (giveaway.winnerId) 10 | giveaway.rejectedWinners.push(giveaway.winnerId); 11 | 12 | giveaway.winnerId = null; 13 | 14 | // try to get user with strictest rules. if no winner found, start relaxing rules 15 | let winnerId = null, 16 | participants = shuffleArray(giveaway.participants); 17 | 18 | while(participants.length){ 19 | let userID = participants.splice([participants.length - 1], 1)[0]; 20 | 21 | // user can fail if previously rejected (if giveaway was manually rerolled) 22 | if (giveaway.rejectedWinners.indexOf(userID) !== -1) 23 | continue; 24 | 25 | winnerId = userID; 26 | break; 27 | } 28 | 29 | if (winnerId) 30 | giveaway.winnerId = winnerId; 31 | 32 | giveaway.winnerId = winnerId; 33 | if (!giveaway.winnerId) 34 | giveaway.comment = 'Unable to allocate winner'; 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-giveawaybot", 3 | "version": "0.1.10", 4 | "description": "A Discord bot that gives games away.", 5 | "private": false, 6 | "author": "shukri.adams@gmail.com", 7 | "license": "MIT", 8 | "engines" : { 9 | "node" : ">=0.7.x" 10 | }, 11 | "scripts" : { 12 | "start" : "node start", 13 | "test" : "node tests/test" 14 | }, 15 | "dependencies" : { 16 | "cron" : "1.2.1", 17 | "request": "2.81.0", 18 | "request-promise-native": "1.0.4", 19 | "fs-extra": "4.0.1", 20 | "lokijs": "1.4.1", 21 | "dateformat" : "2.0.0", 22 | "winston": "2.4.0", 23 | "minimist-string" : "1.0.2", 24 | "winston-daily-rotate-file" : "1.7.2", 25 | "jsonfile": "2.0.0", 26 | "cheerio" : "1.0.0-rc.2", 27 | "discord.js" : "11.3.2" 28 | }, 29 | "devDependencies": { 30 | "mocha": "3.2.0", 31 | "glob": "7.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this instead of index.js if you want to start the bot directly from the command line. If you're trying to debug 3 | * the bot, this is also the file you want to start your debugger on. 4 | */ 5 | 6 | (async function(){ 7 | let Bot = require('./lib/bot'), 8 | bot = new Bot(); 9 | 10 | await bot.start(); 11 | })(); 12 | -------------------------------------------------------------------------------- /tests/helpers/assert.js: -------------------------------------------------------------------------------- 1 | let assert = require('assert'); 2 | 3 | module.exports = { 4 | 5 | equal : function(a, b, message){ 6 | assert.equal(a, b, message); 7 | }, 8 | 9 | fail : function(message){ 10 | assert.equal(true, false, message); 11 | }, 12 | 13 | zero : function(a, message){ 14 | assert.equal(a, 0, message); 15 | }, 16 | 17 | true : function(a, message){ 18 | assert.equal(a, true, message); 19 | }, 20 | 21 | null : function(a, message){ 22 | assert.equal(a, null, message); 23 | }, 24 | 25 | notNull : function(a, message){ 26 | assert.equal(a === null, false, message); 27 | } 28 | }; -------------------------------------------------------------------------------- /tests/helpers/collection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Used for testing, shims discord's collection type. 3 | */ 4 | module.exports = class { 5 | constructor(startingArray){ 6 | if (!startingArray) 7 | startingArray = []; 8 | 9 | this._array = startingArray; 10 | this.size = startingArray.length; 11 | } 12 | 13 | find(propertyName, value){ 14 | return this._array.find(function(item){ 15 | return item.hasOwnProperty(propertyName) && item[propertyName] === value ? 16 | item[propertyName] : null; 17 | }); 18 | } 19 | 20 | add(object){ 21 | this._array.push(object); 22 | } 23 | 24 | array(){ 25 | return this._array; 26 | } 27 | 28 | }; -------------------------------------------------------------------------------- /tests/helpers/message.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns structure of a valid message 3 | */ 4 | let Collection = require('./../helpers/collection'), 5 | ClientProvider = require('./../../lib/clientProvider'); 6 | 7 | module.exports = async function(botId){ 8 | let client = await ClientProvider.instance(); 9 | 10 | return { 11 | author : { 12 | bot : false, 13 | 14 | // another shim function 15 | send : function(){} 16 | }, 17 | client: client, 18 | content : '', 19 | channel : { 20 | type : 'dm' 21 | }, 22 | mentions : { 23 | users : new Collection([ 24 | { id : botId } 25 | ]) 26 | }, 27 | 28 | // shim function, discord messages can be replied to, but for testing we don't care about what happens inside this 29 | reply : function(){ 30 | 31 | } 32 | } 33 | }; -------------------------------------------------------------------------------- /tests/helpers/mockClient.js: -------------------------------------------------------------------------------- 1 | let Collection = require('./collection') 2 | GuildMember = require('./mockGuildMember'); 3 | 4 | 5 | 6 | 7 | 8 | class Guild { 9 | 10 | constructor(){ 11 | this._nextMember = new GuildMember(); 12 | this._nextMember.permission = false; 13 | } 14 | 15 | setNextMember(guildMember){ 16 | this._nextMember = guildMember; 17 | } 18 | 19 | async fetchMember(){ 20 | return this._nextMember; 21 | } 22 | } 23 | 24 | class Channel { 25 | constructor(id){ 26 | this.id = id; 27 | this.guild = new Guild(); 28 | } 29 | 30 | setNextMessage(message){ 31 | this._nextMessage = message; 32 | } 33 | 34 | async fetchMessage(){ 35 | return this.send(); 36 | } 37 | 38 | async send(){ 39 | return this._nextMessage; 40 | } 41 | } 42 | 43 | class Client{ 44 | 45 | constructor(bot){ 46 | this.events = {}; 47 | this.channels = new Collection(); 48 | 49 | // always add giveawaychanel to client 50 | this.channels.add(new Channel('giveawaychannel')); 51 | 52 | // this is the bot user, with a hardcoded id of 1234 53 | this.user = { 54 | id : 1234 55 | }; 56 | 57 | this.users = { 58 | get : async function(){ 59 | return this._nextUser; 60 | }.bind(this) 61 | }; 62 | 63 | this.bot = bot; 64 | } 65 | 66 | setNextUser(user){ 67 | this._nextUser = user; 68 | } 69 | 70 | async fetchUser(){ 71 | return this._nextUser; 72 | } 73 | 74 | async raiseMessageEvent(message){ 75 | return await this.bot._onMessage(message); 76 | } 77 | 78 | on(){ 79 | 80 | } 81 | 82 | login(appkey){ 83 | 84 | } 85 | } 86 | 87 | module.exports = Client; 88 | 89 | -------------------------------------------------------------------------------- /tests/helpers/mockGuildMember.js: -------------------------------------------------------------------------------- 1 | let Collection = require('./collection'); 2 | 3 | class MockGuildMember{ 4 | constructor(){ 5 | this.permission = false; 6 | this.roles = new Collection(); 7 | } 8 | 9 | hasPermission(){ 10 | return this.permission; 11 | } 12 | } 13 | 14 | module.exports = MockGuildMember; -------------------------------------------------------------------------------- /tests/helpers/mockLogger.js: -------------------------------------------------------------------------------- 1 | class WinstonMock { 2 | info(content){ 3 | console.log('Mocklogger received info : ', content) 4 | } 5 | 6 | error(content){ 7 | console.log('Mocklogger received error : ', content) 8 | } 9 | } 10 | 11 | module.exports = class MockLogger { 12 | 13 | constructor(){ 14 | this.info = new WinstonMock(); 15 | this.error = new WinstonMock(); 16 | } 17 | 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /tests/helpers/mockMessage.js: -------------------------------------------------------------------------------- 1 | let ClientProvider = require('./../../lib/clientProvider'); 2 | 3 | class MockMessage { 4 | constructor(){ 5 | this.id = null; 6 | this.client = ClientProvider.instance(); 7 | } 8 | 9 | edit (){ 10 | 11 | } 12 | 13 | delete(){ 14 | 15 | } 16 | } 17 | 18 | module.exports = MockMessage; -------------------------------------------------------------------------------- /tests/helpers/mockSteamInfo.js: -------------------------------------------------------------------------------- 1 | class MockSteamInfo { 2 | 3 | setNextInfo(info){ 4 | this.info = info; 5 | } 6 | 7 | async getInfo(){ 8 | return this.info; 9 | } 10 | 11 | // is this still used?? 12 | async get(){ 13 | return this.info; 14 | } 15 | } 16 | 17 | module.exports = MockSteamInfo; 18 | 19 | -------------------------------------------------------------------------------- /tests/helpers/mockStore.js: -------------------------------------------------------------------------------- 1 | class MockStore { 2 | constructor(){ 3 | this._records = []; 4 | } 5 | 6 | // use this to force contents of store. all methods which return a list of records will return records passed in 7 | setRecords(records){ 8 | this._records = records; 9 | } 10 | 11 | getWinnings(){ 12 | return this.list(); 13 | } 14 | 15 | getComparableWinning(){ 16 | return this.list(); 17 | } 18 | 19 | getActive(){ 20 | return this.list(); 21 | } 22 | 23 | list(){ 24 | return this._records; 25 | } 26 | 27 | add(){ 28 | return { id : 'whatever'} 29 | } 30 | 31 | clean(){ 32 | } 33 | 34 | delete(){ } 35 | 36 | update(){ } 37 | 38 | get(){ 39 | let records = this.list(); 40 | if (records.length) 41 | return records[0]; 42 | return null; 43 | } 44 | 45 | flush(){ 46 | this._records = []; 47 | } 48 | } 49 | 50 | module.exports = MockStore; -------------------------------------------------------------------------------- /tests/helpers/testBase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple scaffold to run mocha tests on an express server instance. 3 | */ 4 | let MockClient = require('./mockClient'), 5 | MockSteamInfo = require('./mockSteamInfo'), 6 | MockLogger = require('./mockLogger'), 7 | TestTrace = require('./testTrace'), 8 | MockStore = require('./mockStore'); 9 | 10 | module.exports = function(testName, tests){ 11 | 12 | describe(testName, function() { 13 | this.timeout(5000); 14 | 15 | // inject test structures into singletons 16 | let Settings = require('./../../lib/settings'), 17 | Store = require('./../../lib/store'), 18 | Logger = require('./../../lib/logger'), 19 | Trace = require('./../../lib/trace'), 20 | GameInfo = require('./../../lib/gameInfo'); 21 | 22 | let store = new MockStore(), 23 | steamInfo = new MockSteamInfo(), 24 | testTrace = new TestTrace(), 25 | mockLogger = new MockLogger(), 26 | settings = { 27 | save : function(){ }, 28 | values : { 29 | token : 'whatever', 30 | giveawayChannelId : 'giveawaychannel', 31 | brackets : [] 32 | }}; 33 | 34 | // set shims before importing bot 35 | // trace set twice because its being overwritten somewhere ... 36 | Trace.set(function(){ 37 | testTrace.trace.apply(testTrace, arguments); 38 | }); 39 | Logger.set(mockLogger); 40 | GameInfo.set(steamInfo); 41 | Settings.set(settings); 42 | Store.set(store); 43 | 44 | let Bot = require('../../lib/bot'), 45 | Client = require('./../../lib/clientProvider'), 46 | bot = new Bot({nocron : true}), 47 | client = new MockClient(bot); 48 | 49 | Client.set(client); 50 | bot.start(); 51 | 52 | // forces comparinator to create new data files for testing, so we don't have to trash "real" data 53 | tests({ 54 | bot : bot, 55 | trace : testTrace, 56 | client : client, 57 | store : store, // refactor this out, it's unreliable 58 | steamInfo : steamInfo, 59 | settings : settings 60 | }); 61 | 62 | beforeEach(function(done) { 63 | (async ()=>{ 64 | 65 | let settings = { 66 | save : function(){ }, 67 | values : { 68 | token : 'whatever', 69 | giveawayChannelId : 'giveawaychannel', 70 | brackets : [] 71 | }}; 72 | 73 | Settings.set(settings); 74 | let store = await Store.instance(); 75 | store.flush(); 76 | testTrace.clear(); 77 | // trace set twice because its being overwritten somewhere ... 78 | Client.set(client); 79 | Trace.set(function(){ 80 | testTrace.trace.apply(testTrace, arguments); 81 | }); 82 | // setup 83 | done(); 84 | })(); 85 | }); 86 | 87 | afterEach(function(done){ 88 | // teardown 89 | done(); 90 | bot.stop(); 91 | }); 92 | }); 93 | }; 94 | 95 | -------------------------------------------------------------------------------- /tests/helpers/testTrace.js: -------------------------------------------------------------------------------- 1 | class TestTrace{ 2 | 3 | constructor(){ 4 | this._calls = {}; 5 | } 6 | 7 | trace(arg1, arg2){ 8 | let identifier = !arguments.length ? null : 9 | arguments.length === 1 ? arg1 : arg2; 10 | 11 | if (!identifier) 12 | throw new Error('TestTrace trace() called with no arguments'); 13 | 14 | this._calls[identifier] = { }; 15 | } 16 | 17 | has(identifier) { 18 | return this._calls[identifier] !== undefined; 19 | } 20 | 21 | clear(){ 22 | this._calls = {}; 23 | } 24 | 25 | }; 26 | 27 | module.exports = TestTrace; -------------------------------------------------------------------------------- /tests/manual.md: -------------------------------------------------------------------------------- 1 | Things that still need to be tested by hand : 2 | 3 | - view help output for standard users 4 | - view help output for admins + giveaway creators -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mocha tests expect to be run from the mocha cli, making debugging difficult if your debugger wants to run tests 3 | * directly from node. This file is a workaround for that - you can debug this file directly, it will in turn fire up 4 | * mocha and run all tests in the ./tests folder. 5 | * 6 | * Confirmed working in Webstorm, also with breakpoints in any test file or any server file hidden behind the API. 7 | */ 8 | 9 | let Mocha = require('mocha'), 10 | glob = require('glob'), 11 | tests = glob.sync('./tests/**/*.js'), 12 | process = require('process'), 13 | mocha = new Mocha({}); 14 | 15 | // set environment flag so bot knows we're running from test, this is needed to fix pathing etc 16 | process.env['isTesting'] = 1; 17 | 18 | // TIP. Want to debug just one file? Overwrite tests array variable with your one file like : 19 | // tests = [__dirname + '/tests/commands/status.js']; 20 | 21 | for (let i = 0 ; i < tests.length ; i ++){ 22 | // slice removes .js file extension, which mocha doesn't want 23 | mocha.addFile(tests[i].slice(0, -3)); 24 | } 25 | 26 | mocha.run() 27 | .on('test', function(test) { 28 | console.log('Test started: ' + test.title); 29 | }) 30 | .on('test end', function(test) { 31 | console.log('Test ended: ' + test.title); 32 | }) 33 | .on('pass', function(test) { 34 | console.log('Test passed'); 35 | }) 36 | .on('fail', function(test, err) { 37 | console.log('Test failed'); 38 | console.log(test); 39 | console.log(err); 40 | }) 41 | .on('end', function() { 42 | console.log('All tests done'); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/tests/commands/brackets.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | GuildMember = require('./../../helpers/mockGuildMember'), 3 | codes = require('./../../../lib/codes'), 4 | makeMessage = require('./../../helpers/message'), 5 | Settings = require('./../../../lib/settings'), 6 | test = require('./../../helpers/testBase'); 7 | 8 | test('bracket command', function(testBase){ 9 | 10 | it('should accept a brackets list command for any user', async function() { 11 | 12 | // set non-admin user 13 | let member = new GuildMember(); 14 | member.permission = false; 15 | testBase.client.channels.array()[0].guild.setNextMember(member); 16 | 17 | let message = await makeMessage(testBase.client.user.id); 18 | message.content = 'brackets'; 19 | 20 | let result = await testBase.client.raiseMessageEvent(message); 21 | assert.equal(codes.MESSAGE_ACCEPTED_BRACKETSLIST, result); 22 | }); 23 | 24 | it('should reject a bracket set command if user not administrator', async function() { 25 | 26 | // force user to not have admin permission 27 | let member = new GuildMember(); 28 | member.permission = false; 29 | testBase.client.channels.array()[0].guild.setNextMember(member); 30 | 31 | let message = await makeMessage(testBase.client.user.id); 32 | message.content = 'brackets -b 0-100-200'; 33 | 34 | let result = await testBase.client.raiseMessageEvent(message); 35 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 36 | }); 37 | 38 | it('should reject a brackets command if invalid args given', async function() { 39 | 40 | // make user admin 41 | let member = new GuildMember(); 42 | member.permission = true; 43 | testBase.client.channels.array()[0].guild.setNextMember(member); 44 | 45 | let message = await makeMessage(testBase.client.user.id); 46 | message.content = 'brackets abcd xwy'; 47 | 48 | let result = await testBase.client.raiseMessageEvent(message); 49 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 50 | }); 51 | 52 | it('should reject a brackets command if bracket contains only 1 bracket', async function() { 53 | 54 | // make user admin 55 | let member = new GuildMember(); 56 | member.permission = true; 57 | testBase.client.channels.array()[0].guild.setNextMember(member); 58 | 59 | let message = await makeMessage(testBase.client.user.id); 60 | message.content = 'brackets -b 0-'; 61 | 62 | let result = await testBase.client.raiseMessageEvent(message); 63 | assert.equal(codes.MESSAGE_REJECTED_INVALIDBRACKET, result); 64 | }); 65 | 66 | it('should reject a brackets command if bracket contains non numeric chars', async function() { 67 | 68 | // make user admin 69 | let member = new GuildMember(); 70 | member.permission = true; 71 | testBase.client.channels.array()[0].guild.setNextMember(member); 72 | 73 | let message = await makeMessage(testBase.client.user.id); 74 | message.content = 'brackets -b 0-a'; 75 | 76 | let result = await testBase.client.raiseMessageEvent(message); 77 | assert.equal(codes.MESSAGE_REJECTED_INVALIDBRACKET, result); 78 | }); 79 | 80 | it('should create two brackets ', async function() { 81 | 82 | // make user admin 83 | let member = new GuildMember(); 84 | member.permission = true; 85 | testBase.client.channels.array()[0].guild.setNextMember(member); 86 | 87 | let message = await makeMessage(testBase.client.user.id); 88 | message.content = 'brackets -b 0-100-200'; 89 | 90 | let result = await testBase.client.raiseMessageEvent(message); 91 | assert.equal(codes.MESSAGE_ACCEPTED, result); 92 | 93 | let settings = Settings.instance(); 94 | assert.equal(settings.values.brackets.length, 2); 95 | assert.equal(settings.values.brackets[0].min, 0); 96 | assert.equal(settings.values.brackets[0].max, 100); 97 | assert.equal(settings.values.brackets[1].min, 100); 98 | assert.equal(settings.values.brackets[1].max, 200); 99 | }); 100 | }); -------------------------------------------------------------------------------- /tests/tests/commands/cancel.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | GuildMember = require('./../../helpers/mockGuildMember'), 4 | makeMessage = require('./../../helpers/message'), 5 | Store = require('./../../../lib/store'), 6 | test = require('./../../helpers/testBase'); 7 | 8 | test('cancel command', async function(testBase){ 9 | 10 | it('should reject a cancel command with no arg', async function() { 11 | 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content = 'cancel'; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 17 | }); 18 | 19 | it('should reject a cancel command with two args', async function() { 20 | 21 | let message = await makeMessage(testBase.client.user.id); 22 | message.content = 'cancel abc 123'; 23 | 24 | let result = await testBase.client.raiseMessageEvent(message); 25 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 26 | }); 27 | 28 | it('should reject a cancel command if id not a number', async function() { 29 | 30 | let message = await makeMessage(testBase.client.user.id); 31 | message.content = 'cancel -i abc'; 32 | 33 | let result = await testBase.client.raiseMessageEvent(message); 34 | assert.equal(codes.MESSAGE_REJECTED_INVALIDINT, result); 35 | }); 36 | 37 | it('should reject a cancel command if giveaway does not exist', async function() { 38 | 39 | let message = await makeMessage(testBase.client.user.id); 40 | message.content = 'cancel -i 123'; 41 | 42 | let result = await testBase.client.raiseMessageEvent(message); 43 | assert.equal(codes.MESSAGE_REJECTED_GIVEAWAYNOTFOUND, result); 44 | }); 45 | 46 | it('should reject a cancel command if user not administrator and not owner', async function() { 47 | let store = await Store.instance(); 48 | 49 | // force user to not have admin permission 50 | let member = new GuildMember(); 51 | member.permission = false; 52 | testBase.client.channels.array()[0].guild.setNextMember(member); 53 | 54 | store.setRecords([{ 55 | id : 123, 56 | ownerId : 'zxy' 57 | }]); 58 | 59 | let message = await makeMessage(testBase.client.user.id); 60 | message.author.id = 'abc'; 61 | message.content = 'cancel -i 123'; 62 | 63 | let result = await testBase.client.raiseMessageEvent(message); 64 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 65 | }); 66 | 67 | it('should reject a cancel command if user not giveaway owner', async function() { 68 | let store = await Store.instance(); 69 | 70 | // set giveaway user to xyz, and message id to abc 71 | store.setRecords([{ 72 | id : 123, 73 | ownerId : 'zxy', 74 | }]); 75 | 76 | // 77 | let message = await makeMessage(testBase.client.user.id); 78 | message.author.id = 'abc'; 79 | message.content = ' cancel -i 123'; 80 | 81 | let result = await testBase.client.raiseMessageEvent(message); 82 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 83 | }); 84 | 85 | it('should reject a cancel command if giveaway is closed and user is not admin', async function() { 86 | let store = await Store.instance(); 87 | 88 | // set giveaway user to xyz, and message id to abc 89 | store.setRecords([{ 90 | id : 123, 91 | status : 'closed', 92 | ownerId : 'abc', 93 | }]); 94 | 95 | // 96 | let message = await makeMessage(testBase.client.user.id); 97 | message.author.id = 'abc'; 98 | message.content = 'cancel -i 123'; 99 | 100 | let result = await testBase.client.raiseMessageEvent(message); 101 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 102 | }); 103 | 104 | it('should accept a cancel command if giveaway is closed and user is admin', async function() { 105 | let store = await Store.instance(); 106 | 107 | let member = new GuildMember(); 108 | member.permission = true; 109 | testBase.client.channels.array()[0].guild.setNextMember(member); 110 | 111 | // set giveaway user to xyz, and message id to abc 112 | store.setRecords([{ 113 | id : 123, 114 | status : 'closed', 115 | ownerId : 'abc', 116 | }]); 117 | 118 | let message = await makeMessage(testBase.client.user.id); 119 | message.content = 'cancel -i 123'; 120 | 121 | let result = await testBase.client.raiseMessageEvent(message); 122 | assert.equal(codes.MESSAGE_ACCEPTED, result); 123 | }); 124 | 125 | }); -------------------------------------------------------------------------------- /tests/tests/commands/channel.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | GuildMember = require('./../../helpers/mockGuildMember'), 4 | makeMessage = require('./../../helpers/message'), 5 | test = require('./../../helpers/testBase'); 6 | 7 | test('channel command', function(testBase){ 8 | 9 | it('should reject a user that is not admin', async function() { 10 | 11 | // mimic structure of a valid discord, with invalid command 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content = `<@${testBase.client.user.id}> channel`; 14 | message.channel.type = ''; 15 | 16 | let result = await testBase.client.raiseMessageEvent(message); 17 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 18 | }); 19 | 20 | 21 | it('should reject a dm from an admin', async function() { 22 | // make caller an admin 23 | let member = new GuildMember(); 24 | member.permission = true; 25 | testBase.client.channels.array()[0].guild.setNextMember(member); 26 | 27 | // mimic structure of a valid discord, with invalid command 28 | let message = await makeMessage(testBase.client.user.id); 29 | message.content = 'channel'; 30 | message.channel.type = 'dm'; 31 | 32 | let result = await testBase.client.raiseMessageEvent(message); 33 | assert.equal(codes.MESSAGE_REJECTED_INVALIDCHANNEL, result); 34 | }); 35 | 36 | it('should accept a channel set from admin', async function() { 37 | // make caller an admin 38 | let member = new GuildMember(); 39 | member.permission = true; 40 | testBase.client.channels.array()[0].guild.setNextMember(member); 41 | 42 | // mimic structure of a valid discord, with invalid command 43 | let message = await makeMessage(testBase.client.user.id); 44 | message.content = `<@${testBase.client.user.id}> channel`; 45 | message.channel.type = ''; 46 | 47 | let result = await testBase.client.raiseMessageEvent(message); 48 | assert.equal(codes.MESSAGE_ACCEPTED, result); 49 | }); 50 | }); -------------------------------------------------------------------------------- /tests/tests/commands/help.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | makeMessage = require('./../../helpers/message'), 4 | test = require('./../../helpers/testBase'); 5 | 6 | test('help command', function(testBase){ 7 | 8 | it('should accept a help command', async function() { 9 | 10 | // mimic structure of a valid discord, with invalid command 11 | let message = await makeMessage(testBase.client.user.id); 12 | message.content = 'help'; 13 | 14 | let result = await testBase.client.raiseMessageEvent(message); 15 | assert.equal(codes.MESSAGE_ACCEPTED, result); 16 | }); 17 | 18 | }); -------------------------------------------------------------------------------- /tests/tests/commands/list.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | Store = require('./../../../lib/store'), 4 | makeMessage = require('./../../helpers/message'), 5 | test = require('./../../helpers/testBase'); 6 | 7 | /** 8 | * Note : we don't test visibility-by-permission logic because there currently isn't an easy way to pass info about 9 | * nr. of visible items back to our testing code. 10 | */ 11 | 12 | test('list command', function(testBase){ 13 | 14 | it('should accept a list command', async function() { 15 | let store = await Store.instance(); 16 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 17 | store.setRecords([{ 18 | created : new Date().getTime(), 19 | participants : [] 20 | }]); 21 | 22 | let message = await makeMessage(testBase.client.user.id); 23 | message.content = 'list'; 24 | 25 | await testBase.client.raiseMessageEvent(message); 26 | assert.true(testBase.trace.has(codes.MESSAGE_ACCEPTED)); 27 | }); 28 | 29 | it('should accept a list all command', async function() { 30 | let store = await Store.instance(); 31 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 32 | store.setRecords([{ 33 | created : new Date().getTime(), 34 | participants : [] 35 | }]); 36 | 37 | let message = await makeMessage(testBase.client.user.id); 38 | message.content = 'list all'; 39 | 40 | await testBase.client.raiseMessageEvent(message); 41 | assert.true(testBase.trace.has(codes.MESSAGE_ACCEPTED)); 42 | }); 43 | 44 | it('should accept a list help command', async function() { 45 | 46 | let message = await makeMessage(testBase.client.user.id); 47 | message.content = 'list -h'; 48 | 49 | await testBase.client.raiseMessageEvent(message); 50 | assert.true(testBase.trace.has(codes.MESSAGE_ACCEPTED_HELPRETURNED)); 51 | }); 52 | 53 | it('should accept a list help command', async function() { 54 | 55 | let message = await makeMessage(testBase.client.user.id); 56 | message.content = 'list --help'; 57 | 58 | await testBase.client.raiseMessageEvent(message); 59 | assert.true(testBase.trace.has(codes.MESSAGE_ACCEPTED_HELPRETURNED)); 60 | }); 61 | }); -------------------------------------------------------------------------------- /tests/tests/commands/me.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | Store = require('./../../../lib/store'), 4 | makeMessage = require('./../../helpers/message'), 5 | test = require('./../../helpers/testBase'); 6 | 7 | 8 | test('me command', function(testBase){ 9 | 10 | it('should accept a me command', async function() { 11 | let store = await Store.instance(); 12 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 13 | store.setRecords([{ 14 | ended : new Date().getTime() 15 | }]); 16 | 17 | let message = await makeMessage(testBase.client.user.id); 18 | message.content = 'me'; 19 | 20 | let result = await testBase.client.raiseMessageEvent(message); 21 | assert.equal(codes.MESSAGE_ACCEPTED, result); 22 | }); 23 | 24 | }); -------------------------------------------------------------------------------- /tests/tests/commands/queue.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | SteamInfo = require('./../../../lib/gameInfo'), 4 | GuildMember = require('./../../helpers/mockGuildMember'), 5 | makeMessage = require('./../../helpers/message'), 6 | test = require('./../../helpers/testBase'); 7 | 8 | 9 | test('queue command', function(testBase){ 10 | 11 | it('should reject a queue command with too few args', async function() { 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content = 'queue ab cd'; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 17 | }); 18 | 19 | it('should reject a queue command with too few args', async function() { 20 | let message = await makeMessage(testBase.client.user.id); 21 | message.content = 'queue ab cd ef gh ij'; 22 | 23 | let result = await testBase.client.raiseMessageEvent(message); 24 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 25 | }); 26 | 27 | it('should reject a queue command with invalid start time format', async function() { 28 | let message = await makeMessage(testBase.client.user.id); 29 | message.content = 'queue ab cd ef'; 30 | 31 | let result = await testBase.client.raiseMessageEvent(message); 32 | assert.equal(codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT, result); 33 | }); 34 | 35 | it('should reject a queue command with invalid duration time format', async function() { 36 | let message = await makeMessage(testBase.client.user.id); 37 | message.content = 'queue 1m cd ef'; 38 | 39 | let result = await testBase.client.raiseMessageEvent(message); 40 | assert.equal(codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT, result); 41 | }); 42 | 43 | it('should reject a queue command from user that is not channel admin', async function() { 44 | let message = await makeMessage(testBase.client.user.id); 45 | message.content = 'queue 1m 2d steamid'; 46 | 47 | // make caller an admin 48 | let member = new GuildMember(); 49 | member.permission = false; 50 | testBase.client.channels.array()[0].guild.setNextMember(member); 51 | 52 | let result = await testBase.client.raiseMessageEvent(message); 53 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 54 | }); 55 | 56 | it('should reject a queue command with an invalid steamID', async function() { 57 | let message = await makeMessage(testBase.client.user.id); 58 | message.content = 'queue 1m 2d steamid'; 59 | 60 | // make caller an admin 61 | let member = new GuildMember(); 62 | member.permission = true; 63 | testBase.client.channels.array()[0].guild.setNextMember(member); 64 | 65 | let steamInfo = SteamInfo.instance(); 66 | steamInfo.setNextInfo({ 67 | success : false 68 | }); 69 | 70 | let result = await testBase.client.raiseMessageEvent(message); 71 | assert.equal(codes.MESSAGE_REJECTED_INVALIDGAMEURL, result); 72 | }); 73 | 74 | it('should reject a queue command with no price steamID', async function() { 75 | let message = await makeMessage(testBase.client.user.id); 76 | message.content = 'queue 1m 2d steamid'; 77 | 78 | // make caller an admin 79 | let member = new GuildMember(); 80 | member.permission = true; 81 | testBase.client.channels.array()[0].guild.setNextMember(member); 82 | 83 | let steamInfo = SteamInfo.instance(); 84 | steamInfo.setNextInfo({ 85 | url : 'some valid url', 86 | success : true 87 | }); 88 | 89 | let result = await testBase.client.raiseMessageEvent(message); 90 | assert.equal(codes.MESSAGE_REJECTED_NOPRICE, result); 91 | }); 92 | 93 | it('should accept a queue command with a valid steamID', async function() { 94 | let message = await makeMessage(testBase.client.user.id); 95 | message.content = 'queue 1m 2d steamid'; 96 | 97 | // make caller an admin 98 | let member = new GuildMember(); 99 | member.permission = true; 100 | testBase.client.channels.array()[0].guild.setNextMember(member); 101 | 102 | let steamInfo = SteamInfo.instance(); 103 | steamInfo.setNextInfo({ 104 | url : 'some valid url', 105 | price : 10, 106 | success : true 107 | }); 108 | 109 | let result = await testBase.client.raiseMessageEvent(message); 110 | assert.equal(codes.MESSAGE_ACCEPTED, result); 111 | }); 112 | 113 | }); -------------------------------------------------------------------------------- /tests/tests/commands/reroll.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | Store = require('./../../../lib/store'), 4 | GuildMember = require('./../../helpers/mockGuildMember'), 5 | MockMessage = require('./../../helpers/mockMessage'), 6 | makeMessage = require('./../../helpers/message'), 7 | test = require('./../../helpers/testBase'); 8 | 9 | test('reroll command', function(testBase){ 10 | 11 | it('should reject a reroll command if too few args', async function() { 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content = 'reroll'; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 17 | }); 18 | 19 | it('should reject a reroll command if too many args', async function() { 20 | let message = await makeMessage(testBase.client.user.id); 21 | message.content = 'reroll thing stuff'; 22 | 23 | let result = await testBase.client.raiseMessageEvent(message); 24 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 25 | }); 26 | 27 | it('should reject a reroll command if id is not an int', async function() { 28 | let message = await makeMessage(testBase.client.user.id); 29 | message.content = 'reroll -i thing'; 30 | 31 | let result = await testBase.client.raiseMessageEvent(message); 32 | assert.equal(codes.MESSAGE_REJECTED_INVALIDINT, result); 33 | }); 34 | 35 | it('should reject a reroll command if giveaway does not exist', async function() { 36 | let message = await makeMessage(testBase.client.user.id); 37 | message.content = 'reroll -i 1'; 38 | 39 | let result = await testBase.client.raiseMessageEvent(message); 40 | assert.equal(codes.MESSAGE_REJECTED_GIVEAWAYNOTFOUND, result); 41 | }); 42 | 43 | it('should reject a reroll command if user is not admin', async function() { 44 | // make caller an admin 45 | let member = new GuildMember(); 46 | member.permission = false; 47 | testBase.client.channels.array()[0].guild.setNextMember(member); 48 | 49 | let message = await makeMessage(testBase.client.user.id); 50 | message.content = 'reroll -i 1'; 51 | message.author.id = 'abc'; 52 | 53 | let store = await Store.instance(); 54 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 55 | store.setRecords([{ 56 | created : new Date().getTime(), 57 | ownerId : 'xyz', 58 | participants : [] 59 | }]); 60 | 61 | let result = await testBase.client.raiseMessageEvent(message); 62 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 63 | }); 64 | 65 | it('should reject a reroll command if there are no participants in giveaway', async function() { 66 | // make caller an admin 67 | let member = new GuildMember(); 68 | member.permission = true; 69 | testBase.client.channels.array()[0].guild.setNextMember(member); 70 | 71 | let message = await makeMessage(testBase.client.user.id); 72 | message.content = 'reroll -i 1'; 73 | 74 | let store = await Store.instance(); 75 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 76 | store.setRecords([{ 77 | created : new Date().getTime(), 78 | status : 'closed', 79 | participants : [] 80 | }]); 81 | 82 | let result = await testBase.client.raiseMessageEvent(message); 83 | assert.equal(codes.MESSAGE_REJECTED_NOPARTICIPANTS, result); 84 | }); 85 | 86 | it('should reject a reroll command if there are no available participants in giveaway', async function() { 87 | // make caller an admin 88 | let member = new GuildMember(); 89 | member.permission = true; 90 | testBase.client.channels.array()[0].guild.setNextMember(member); 91 | 92 | let message = await makeMessage(testBase.client.user.id); 93 | message.content = 'reroll -i 1'; 94 | 95 | let store = await Store.instance(); 96 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 97 | store.setRecords([{ 98 | created : new Date().getTime(), 99 | participants : ['bbb'], 100 | status : 'closed', 101 | rejectedWinners : ['bbb'] 102 | }]); 103 | 104 | let result = await testBase.client.raiseMessageEvent(message); 105 | assert.equal(codes.MESSAGE_REJECTED_NOAVAILABLEPARTICIPANTS, result); 106 | }); 107 | 108 | it('should reject a reroll command if the giveaway is not closed', async function() { 109 | // make caller an admin 110 | let member = new GuildMember(); 111 | member.permission = true; 112 | testBase.client.channels.array()[0].guild.setNextMember(member); 113 | 114 | let message = await makeMessage(testBase.client.user.id); 115 | message.content = 'reroll -i 1'; 116 | 117 | let store = await Store.instance(); 118 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 119 | store.setRecords([{ 120 | created : new Date().getTime(), 121 | participants : [], 122 | status : 'open' 123 | }]); 124 | 125 | let result = await testBase.client.raiseMessageEvent(message); 126 | assert.equal(codes.MESSAGE_REJECTED_NOTCLOSED, result); 127 | }); 128 | 129 | it('should accept a reroll command', async function() { 130 | // make caller an admin 131 | let member = new GuildMember(); 132 | member.permission = true; 133 | testBase.client.channels.array()[0].guild.setNextMember(member); 134 | testBase.client.setNextUser({}); 135 | // add dummy message giveaway message 136 | testBase.client.channels.array()[0].setNextMessage(new MockMessage()); 137 | 138 | let message = await makeMessage(testBase.client.user.id); 139 | message.content = 'reroll -i 1'; 140 | 141 | let store = await Store.instance(); 142 | // force a give away to ensure we enter the per-giveaway loop and cover as much as code as possible 143 | let giveaway = { 144 | created : new Date().getTime(), 145 | status : 'closed', 146 | participants : ['towin'], 147 | winnerId : 'tolose', 148 | rejectedWinners : [] 149 | }; 150 | store.setRecords([giveaway]); 151 | 152 | testBase.client.setNextUser({ username : 'name', send : function(){} }); // todo : user a proper user object instead of tacking functions on anon objs 153 | 154 | let result = await testBase.client.raiseMessageEvent(message); 155 | assert.equal(codes.MESSAGE_ACCEPTED, result); 156 | assert.equal(giveaway.winnerId, 'towin'); 157 | assert.true(giveaway.rejectedWinners.indexOf('tolose') !== -1) 158 | }); 159 | 160 | }); -------------------------------------------------------------------------------- /tests/tests/commands/start.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | SteamInfo = require('./../../../lib/gameInfo'), 4 | GuildMember = require('./../../helpers/mockGuildMember'), 5 | makeMessage = require('./../../helpers/message'), 6 | test = require('./../../helpers/testBase'); 7 | 8 | 9 | test('start command', function(testBase){ 10 | 11 | it('should reject a start command with too few args', async function() { 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content = 'start ab'; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 17 | }); 18 | 19 | 20 | it('should reject a start command with too many args', async function() { 21 | let message = await makeMessage(testBase.client.user.id); 22 | message.content = 'start ab cd ef'; 23 | 24 | let result = await testBase.client.raiseMessageEvent(message); 25 | assert.equal(codes.MESSAGE_REJECTED_INVALIDARGUMENTS, result); 26 | }); 27 | 28 | it('should reject a start command with invalid duration time format', async function() { 29 | let message = await makeMessage(testBase.client.user.id); 30 | message.content = 'start ab steamlink '; 31 | 32 | let result = await testBase.client.raiseMessageEvent(message); 33 | assert.equal(codes.MESSAGE_REJECTED_INVALIDTIMEFORMAT, result); 34 | }); 35 | 36 | it('should reject a start command without a price', async function() { 37 | let message = await makeMessage(testBase.client.user.id); 38 | message.content = 'start 1m steamid'; 39 | 40 | // make caller an admin 41 | let member = new GuildMember(); 42 | member.permission = true; 43 | testBase.client.channels.array()[0].guild.setNextMember(member); 44 | 45 | let steamInfo = SteamInfo.instance(); 46 | steamInfo.setNextInfo({ 47 | url : 'some valid url', 48 | success : true 49 | }); 50 | 51 | let result = await testBase.client.raiseMessageEvent(message); 52 | assert.equal(codes.MESSAGE_REJECTED_NOPRICE, result); 53 | }); 54 | 55 | it('should accept a start command with a valid steamID', async function() { 56 | let message = await makeMessage(testBase.client.user.id); 57 | message.content = 'start 1m steamid'; 58 | 59 | // make caller an admin 60 | let member = new GuildMember(); 61 | member.permission = true; 62 | testBase.client.channels.array()[0].guild.setNextMember(member); 63 | 64 | let steamInfo = SteamInfo.instance(); 65 | steamInfo.setNextInfo({ 66 | url : 'some valid url', 67 | price : 10, 68 | success : true 69 | }); 70 | 71 | let result = await testBase.client.raiseMessageEvent(message); 72 | assert.equal(codes.MESSAGE_ACCEPTED, result); 73 | }); 74 | 75 | }); -------------------------------------------------------------------------------- /tests/tests/commands/status.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../../helpers/assert'), 2 | codes = require('./../../../lib/codes'), 3 | GuildMember = require('./../../helpers/mockGuildMember'), 4 | makeMessage = require('./../../helpers/message'), 5 | test = require('./../../helpers/testBase'); 6 | 7 | test('status command', function(testBase){ 8 | 9 | it('should reject a status command from non admin', async function() { 10 | 11 | // mnimic structure of a valid discord, with invalid command 12 | let message = await makeMessage(testBase.client.user.id); 13 | message.content += 'status'; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | assert.equal(codes.MESSAGE_REJECTED_PERMISSION, result); 17 | }); 18 | 19 | it('should accept a status command from admin', async function() { 20 | 21 | // make user admin 22 | let member = new GuildMember(); 23 | member.permission = true; 24 | testBase.client.channels.array()[0].guild.setNextMember(member); 25 | 26 | // mnimic structure of a valid discord, with invalid command 27 | let message = await makeMessage(testBase.client.user.id); 28 | message.content += 'status'; 29 | 30 | let result = await testBase.client.raiseMessageEvent(message); 31 | assert.equal(codes.MESSAGE_ACCEPTED, result); 32 | }); 33 | 34 | }); -------------------------------------------------------------------------------- /tests/tests/rejection.js: -------------------------------------------------------------------------------- 1 | let assert = require('./../helpers/assert'), 2 | codes = require('./../../lib/codes'), 3 | makeMessage = require('./../helpers/message'), 4 | Collection = require('./../helpers/collection'), 5 | test = require('./../helpers/testBase'); 6 | 7 | test('invalid input', function(testBase){ 8 | 9 | it('should reject a message from another bot', async function() { 10 | 11 | let message = await makeMessage('emptyid'); 12 | // set message to appear like it's from another bot 13 | message.author.bot = true; 14 | 15 | let result = await testBase.client.raiseMessageEvent(message); 16 | 17 | assert.equal(codes.MESSAGE_REJECTED_BOT, result); 18 | }); 19 | 20 | it('should reject global messages', async function() { 21 | 22 | let message = await makeMessage('emptyId'); 23 | // mimic structure of a discord group message 24 | message.mentions.everyone = {}; 25 | 26 | let result = await testBase.client.raiseMessageEvent(message); 27 | 28 | assert.equal(codes.MESSAGE_REJECTED_GLOBAL, result); 29 | }); 30 | 31 | it('should reject group messages that the bot is included in', async function() { 32 | 33 | let message = await makeMessage('emptyId'); 34 | // mimic structure of a discord group message with more than person in it 35 | message.mentions.users = new Collection([ {}, {} ]); 36 | 37 | let result = await testBase.client.raiseMessageEvent(message); 38 | 39 | assert.equal(codes.MESSAGE_REJECTED_GROUPMESSAGE, result); 40 | }); 41 | 42 | it('should reject message not aimed at the bot user', async function() { 43 | 44 | let message = await makeMessage(testBase.client.user.id); 45 | // mnimic structure of a discord message aimed at user other than bot 46 | message.mentions.users = new Collection([ { id : 4321} ]); 47 | 48 | let result = await testBase.client.raiseMessageEvent(message); 49 | 50 | assert.equal(codes.MESSAGE_REJECTED_UNTARGETED, result); 51 | }); 52 | 53 | it('should reject message with an unknown command', async function() { 54 | 55 | // mnimic structure of a valid discord, with invalid command 56 | let message = await makeMessage(testBase.client.user.id); 57 | let result = await testBase.client.raiseMessageEvent(message); 58 | 59 | assert.equal(codes.MESSAGE_REJECTED_UNKNOWNCOMMAND, result); 60 | }); 61 | 62 | it('should inform that giveaway channel not set', async function() { 63 | // force blank the giveawaychannelid 64 | testBase.settings.values.giveawayChannelId = null; 65 | 66 | let message = await makeMessage(testBase.client.user.id); 67 | // message should be valid 68 | message.content = 'help'; 69 | 70 | await testBase.client.raiseMessageEvent(message); 71 | assert.true(testBase.trace.has(codes.ALERT_CHANNELNOTSET)); 72 | }); 73 | 74 | }); -------------------------------------------------------------------------------- /vagrant/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | 6 | config.vm.box = "ubuntu/xenial64" 7 | config.vm.network "forwarded_port", guest: 9229, host: 9229 8 | config.vm.network "forwarded_port", guest: 9228, host: 9228 9 | 10 | config.vm.synced_folder "./..", "/vagrant" 11 | 12 | config.vm.provider :virtualbox do |v| 13 | v.customize ["modifyvm", :id, "--memory", 1048] 14 | end 15 | 16 | config.vm.provision :shell, path: "provision.sh" 17 | end 18 | -------------------------------------------------------------------------------- /vagrant/provision.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sudo apt-get update 3 | 4 | # install git 5 | sudo apt-get install git -y 6 | 7 | #install node js 8 | curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash - 9 | sudo apt-get install nodejs -y 10 | 11 | sudo npm install -g yarn 12 | 13 | # force startup folder to vagrant project 14 | echo "cd /vagrant" >> /home/vagrant/.bashrc 15 | 16 | --------------------------------------------------------------------------------